feat(create): 创作首页会话化 - 工作态指示器/计划路线分离/单会话承载分镜/通知中心与空态 hero/历史抽屉分组/行操作 hover 菜单与重命名/任务按钮规范

This commit is contained in:
maomaochong-ai
2026-09-06 20:29:38 +08:00
parent 4e774e56f1
commit e9ab9cce51
8 changed files with 542 additions and 1003 deletions

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* WorkingDots: diagonal wave dot-matrix "busy" indicator.
*
* Form absorbed from boardui agent-thinking (wave variant) — no UI assets or
* extra dependencies; reduced-motion renders a static frame. The grid is drawn
* with `bg-current`, so it inherits whatever text color the caller provides
* (canvas theme accents, CSS vars, etc.).
*/
export function WorkingDots({
cols = 3,
rows = 3,
dotSize = 4,
gap = 2,
tickMs = 90,
minOpacity = 0.14,
trail = 0.32,
className,
}: {
cols?: number;
rows?: number;
dotSize?: number;
gap?: number;
tickMs?: number;
minOpacity?: number;
trail?: number;
className?: string;
}) {
const reducedMotion = useReducedMotion();
const [phase, setPhase] = useState(0);
useEffect(() => {
if (reducedMotion) return;
const timer = window.setInterval(() => setPhase((value) => (value + 1 / 8) % 1), tickMs);
return () => window.clearInterval(timer);
}, [reducedMotion, tickMs]);
const count = cols * rows;
return (
<span aria-hidden className={`grid shrink-0 ${className ?? ""}`} style={{ gridTemplateColumns: `repeat(${cols}, ${dotSize}px)`, gap }}>
{Array.from({ length: count }, (_, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
// Normalized diagonal position, scaled so the wave front turns
// back before reaching the far corner (3/4 for a 3x3 grid,
// matching the original canvas indicator pixel-for-pixel).
const n = Math.max(cols, rows);
const normalized = (col / Math.max(1, cols - 1) + row / Math.max(1, rows - 1)) / 2;
const scalar = normalized * (n / (n + 1));
const behind = (phase - scalar + 1) % 1;
const lit = Math.max(0, 1 - behind / trail) ** 1.5;
const opacity = minOpacity + (1 - minOpacity) * lit;
return <span key={index} className="rounded-[1px] bg-current" style={{ width: dotSize, height: dotSize, opacity }} />;
})}
</span>
);
}
/**
* WorkingGlow: soft breathing halo rendered over a "busy" control (send
* button, composer capsule). Color accepts a hex value or a CSS variable
* (e.g. `var(--creation-text)`); the blurred layer uses color-mix so both
* work. Disabled under prefers-reduced-motion. Caller positions it: wrap the
* control in a `relative` container and pass the matching border radius.
*/
export function WorkingGlow({
active,
color,
radius = 0,
className,
}: {
active: boolean;
color: string;
radius?: number | string;
className?: string;
}) {
const reducedMotion = useReducedMotion();
if (!active || reducedMotion) return null;
const soft = `color-mix(in srgb, ${color} 55%, transparent)`;
return (
<motion.div
aria-hidden
className={className}
style={{ position: "absolute", inset: 0, borderRadius: radius, pointerEvents: "none", boxShadow: `0 0 0 1px ${color}` }}
animate={{
opacity: [0.35, 1, 0.35],
boxShadow: [`0 0 0 1px ${color}`, `0 0 16px 1px ${soft}, 0 0 0 1px ${color}`, `0 0 0 1px ${color}`],
}}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
);
}

View File

@@ -9,6 +9,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
import type { LocalUser } from "@/stores/use-user-store";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill } from "@/services/api/skills";
@@ -50,37 +51,6 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc
const WORKING_TEXT = "正在推演...";
// Agent 工作指示器:3×3 点阵对角波光。吸收自 boardui agent-thinking 的
// wave 形态(不引 UI 资产,仅点阵节奏与降级规范);reduced-motion 下保持静态帧。
const WORKING_DOTS = 3;
const WORKING_DOTS_TICK_MS = 90;
const WORKING_DOTS_MIN_OPACITY = 0.14;
const WORKING_DOTS_PHASE_STEP = 1 / 8;
const WORKING_DOTS_TRAIL = 0.32;
function AgentWorkingDots() {
const reducedMotion = useReducedMotion();
const [phase, setPhase] = useState(0);
useEffect(() => {
if (reducedMotion) return;
const timer = window.setInterval(() => setPhase((value) => (value + WORKING_DOTS_PHASE_STEP) % 1), WORKING_DOTS_TICK_MS);
return () => window.clearInterval(timer);
}, [reducedMotion]);
return (
<span aria-hidden className="grid shrink-0" style={{ gridTemplateColumns: `repeat(${WORKING_DOTS}, 4px)`, gap: 2 }}>
{Array.from({ length: WORKING_DOTS * WORKING_DOTS }, (_, index) => {
const col = index % WORKING_DOTS;
const row = Math.floor(index / WORKING_DOTS);
// 对角波相位 0..<1:波前扫过点阵后折返,读作光在格内往返。
const scalar = ((col + row) / (2 * (WORKING_DOTS - 1))) * (WORKING_DOTS / (WORKING_DOTS + 1));
const behind = (phase - scalar + 1) % 1;
const lit = Math.max(0, 1 - behind / WORKING_DOTS_TRAIL) ** 1.5;
const opacity = WORKING_DOTS_MIN_OPACITY + (1 - WORKING_DOTS_MIN_OPACITY) * lit;
return <span key={index} className="rounded-[1px] bg-current" style={{ width: 4, height: 4, opacity }} />;
})}
</span>
);
}
function AgentWorkingElapsed() {
const [elapsed, setElapsed] = useState(0);
@@ -304,7 +274,7 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
return (
<div role="status" aria-live="polite" className="flex items-center gap-2.5" style={{ color: theme.node.muted }}>
<AgentWorkingDots />
<WorkingDots />
<span className="text-sm leading-5">{label}</span>
<AgentWorkingElapsed />
</div>
@@ -417,15 +387,7 @@ export function AgentChatComposer({
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
}}
>
{sending && !reducedMotion ? (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[22px]"
style={{ boxShadow: `0 0 0 1px ${theme.accent.primary}` }}
animate={{ opacity: [0.3, 1, 0.3], boxShadow: [`0 0 0 1px ${theme.accent.primary}66`, `0 0 18px 2px ${theme.accent.primary}55, 0 0 0 1px ${theme.accent.primary}`, `0 0 0 1px ${theme.accent.primary}66`] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
) : null}
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
{attachments.length ? (
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
{attachments.map((item) => (

View File

@@ -1651,8 +1651,23 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) };
}
// 上游兼容兜底:DeepSeek 等 OpenAI 兼容渠道的思考/推理模式不允许
// tool_choice=required(后端归类文案见 providerPayloadErrorCategory)。
// required 只用于对话首步把智能体拖入工具循环;遇到该类拒绝时降级为 auto
// 重试一次——auto 在思考模式下可用,对话与工具调用均不受影响。
// 语义与 image.ts 对非 auto tool_choice 的兼容降级策略一致。
const THINKING_MODE_TOOL_CHOICE_REJECTION = "不支持强制工具调用";
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void) {
return runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice, onDelta });
const submit = (choice: "auto" | "required") => runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice: choice, onDelta });
try {
return await submit(toolChoice);
} catch (error) {
if (toolChoice !== "required" || !(error instanceof Error) || !error.message.includes(THINKING_MODE_TOOL_CHOICE_REJECTION)) {
throw error;
}
return submit("auto");
}
}
function summarizeToolCalls(calls: ResponseToolCall[]) {