import { agentCanvasActions, agentCanvasActionLabel } from "@/lib/canvas/agent-canvas-actions"; import { Button } from "antd"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode, type SyntheticEvent } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Eye, HelpCircle, List, ListChecks, LoaderCircle, Pencil, Plus, RotateCcw, Sparkles, Wrench, XCircle } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { Components } from "streamdown"; import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary, type AgentToolCategory } from "@/lib/canvas/agent-tool-presentation"; import { agentOperationCategory, agentOperationFailed, agentOperationSegmentLabel } from "@/lib/canvas/agent-operation-feed"; import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry"; import { agentErrorContent } from "@/lib/canvas/agent-error-presentation"; import { AgentImagePreview } from "./canvas-cloud-agent-composer"; // 输入区已拆到 canvas-cloud-agent-composer.tsx;附件类型与引用转换在 canvas-cloud-agent-attachments.ts。 // 这里保留再导出,既有 import 路径保持可用。 import { agentAttachmentReferences, type CloudAgentChatAttachment } from "./canvas-cloud-agent-attachments"; export { agentAttachmentReferences, type CloudAgentChatAttachment } from "./canvas-cloud-agent-attachments"; export { AGENT_SCENE_DEFS, AgentChatComposer, AgentImagePreview, AgentSceneCapsules, type AgentSceneBucket } from "./canvas-cloud-agent-composer"; type CloudAgentOperationImpact = { operationCount: number; affectedNodeCount: number; destructiveCount: number; generationCount: number; items: string[]; warning?: string; }; export type CloudAgentPlanItem = { id: string; title: string; status: "pending" | "doing" | "done" }; export type CloudAgentFormOption = { id?: string; label: string; detail?: string; recommended?: boolean }; export type CloudAgentFormField = { id: string; title: string; type: "single_select" | "segmented" | "text" | "textarea" | "model_picker"; options?: CloudAgentFormOption[]; defaultValue?: string; required?: boolean; allowCustom?: boolean; placeholder?: string; }; export type CloudAgentUserQuestion = { question: string; options: Array<{ label: string; detail?: string }>; fields?: CloudAgentFormField[]; kind?: "choice" | "form"; questionId?: string; allowFreeform?: boolean; round?: number; maxRounds?: number; }; export type CloudAgentFormAnswer = { type: "form_answer"; questionId?: string; answers: Record; displayAnswers?: Record; fieldTitles?: Record; skippedFields?: string[]; useRecommendedDefaults?: boolean; }; export type CloudAgentChatMessage = { id: string; role: "user" | "assistant" | "system" | "tool" | "error"; title?: string; text: string; errorSeverity?: "warning" | "error"; streaming?: boolean; reasoning?: boolean; /** * 本轮最终答复(服务端收尾闸门的结论)。false 表示这是过程正文——模型边做边说的那一段, * 不是结论:运行还在继续,或者这次收尾被闸门拦下了。缺省按 true 处理(老后端没有这个字段)。 * 界面不再为它单独出角标(用户口径),但标记仍如实保存。 */ final?: boolean; planItems?: CloudAgentPlanItem[]; /** 运行已进入终态,但计划项仍未全部完成;用于历史恢复时停止显示 loading。 */ planTerminal?: boolean; question?: CloudAgentUserQuestion; formAnswer?: CloudAgentFormAnswer; /** 上下文压缩状态;数值只展示 Pi SDK 实际提供的估计值。 */ compaction?: { status: "pending" | "completed" | "failed" | "aborted"; compactionId?: string; tokensBefore?: number; estimatedTokensAfter?: number; reason?: string; errorMessage?: string }; meta?: string; detail?: unknown; attachments?: CloudAgentChatAttachment[]; interjection?: "sent" | "undelivered"; }; /** * 助手正文的收尾语义(工作项 A):final=false 是过程说明,true / 缺省是结论。 * 缺省按"结论"处理是为了兼容升级前的后端事件——那时所有正文都按最终正文展示。 */ export function agentAssistantFinality(payload: Record): boolean { return payload.final !== false; } /** * 服务端控制消息(例如运行时的收尾闸门 `completion_blocked`)在时间线里的表示: * 它是服务端说明,不是用户发言,所以走 system 行——绝不能渲染成真人 user 气泡。 */ export function agentControlMessage(id: string, text: string, meta?: string): CloudAgentChatMessage { return { id, role: "system", text, meta }; } const AGENT_FORM_FIELD_LABELS: Record = { genre: "题材", aspectRatio: "画幅", style: "画风", visualStyle: "画风", comedyStyle: "喜剧风格", projectType: "项目类型", model: "模型", notes: "补充说明", }; const AGENT_FORM_VALUE_LABELS: Record = { costume_time_travel: "古装穿越", absurd: "荒诞", }; function formAnswerRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const entries = Object.entries(value).filter(([, item]) => typeof item === "string" && item.trim()); return entries.length ? Object.fromEntries(entries.map(([key, item]) => [key, String(item)])) : undefined; } export function parseCloudAgentFormAnswer(value: string): CloudAgentFormAnswer | undefined { try { const parsed: unknown = JSON.parse(value); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || (parsed as { type?: unknown }).type !== "form_answer") return undefined; const record = parsed as Record; if (!record.answers || typeof record.answers !== "object" || Array.isArray(record.answers)) return undefined; const answers = formAnswerRecord(record.answers) || {}; return { type: "form_answer", questionId: typeof record.questionId === "string" ? record.questionId : undefined, answers, displayAnswers: formAnswerRecord(record.displayAnswers), fieldTitles: formAnswerRecord(record.fieldTitles), skippedFields: Array.isArray(record.skippedFields) ? record.skippedFields.filter((item): item is string => typeof item === "string") : [], useRecommendedDefaults: record.useRecommendedDefaults === true, }; } catch { return undefined; } } function agentFormFieldLabel(key: string, fieldTitles?: Record) { return fieldTitles?.[key] || AGENT_FORM_FIELD_LABELS[key] || key.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " "); } function agentFormValueLabel(value: string) { return AGENT_FORM_VALUE_LABELS[value] || value.replace(/_/g, " "); } function AgentFormAnswerCard({ answer, theme }: { answer: CloudAgentFormAnswer; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const values = answer.displayAnswers && Object.keys(answer.displayAnswers).length ? answer.displayAnswers : answer.answers; const entries = Object.entries(values).filter(([, value]) => value.trim()); return (
已确认创作方向
{answer.useRecommendedDefaults ?
已采用推荐方案
: null} {entries.length ? (
{entries.map(([key, value]) => (
{agentFormFieldLabel(key, answer.fieldTitles)} {answer.displayAnswers?.[key] || agentFormValueLabel(value)}
))}
) : (
已确认,按推荐方案继续
)}
); } /** * 类别的图标语言:三档互不串味 —— 清单用 List(读到存在与规模)、画面用 Eye(图片字节真的 * 交给了模型)、写画布用 Pencil / 创建用 Plus;未登记的工具走中性的 Wrench。 */ function agentToolCategoryIcon(category: AgentToolCategory) { if (category === "vision") return ; if (category === "read") return ; if (category === "create") return ; if (category === "operate") return ; return ; } export type CloudAgentQuickAction = { label: string; prompt: string }; /** * Turn the short numbered choices the Agent already emits into real UI actions. * This deliberately stays conservative: only assistant messages with 1–4 * numbered lines are eligible, and code blocks are ignored. */ export function extractCloudAgentQuickActions(text: string): CloudAgentQuickAction[] { if (!text.trim() || text.includes("```")) return []; const actions: CloudAgentQuickAction[] = []; const seen = new Set(); for (const line of text.split(/\r?\n/u)) { const match = /^\s*(?:[-*]\s*)?(\d{1,2})[.)、]\s*(.+?)\s*$/u.exec(line); if (!match) continue; const label = match[2].replace(/^[*_\s]+|[*_\s]+$/gu, "").trim(); if (!label || label.length > 96 || seen.has(label)) continue; seen.add(label); actions.push({ label, prompt: label }); if (actions.length >= 4) break; } return actions; } const WORKING_TEXT = "正在处理"; const AGENT_NODE_LINK_PREFIX = "#agent-node:"; const AGENT_NODE_TYPE_LABELS: Record = { image: "图片节点", video: "视频节点", audio: "音频节点", text: "文本节点", script: "脚本节点", drawing: "绘图节点", config: "工作流节点", frame: "画框节点", markdown: "Markdown 节点", svg: "SVG 节点", html: "HTML 节点", panorama: "全景节点", compare: "对比节点", chart: "图表节点", colorgrade: "调色节点", "media-conversion": "转码节点", "batch-table": "批量表节点", }; const AGENT_NODE_TYPE_PREFIXES = Object.keys(AGENT_NODE_TYPE_LABELS).sort((left, right) => right.length - left.length); const BUILTIN_AGENT_NODE_ID_PATTERN = "(?:image|video|audio|text|script|drawing|config|frame|markdown|svg|html|panorama|compare|chart|colorgrade|media-conversion|batch-table)-[A-Za-z0-9][A-Za-z0-9_-]*"; function escapeAgentNodePattern(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } function agentNodeTypeLabel(nodeId: string, reference?: CanvasResourceReference) { // 角色卡底层是 text 节点,sourceType 会是 text;按资源 kind 先识别,避免报成"文本节点"。 if (reference?.kind === "character") return "角色卡"; const sourceType = String(reference?.sourceType || ""); if (sourceType && AGENT_NODE_TYPE_LABELS[sourceType]) return AGENT_NODE_TYPE_LABELS[sourceType]; const prefix = AGENT_NODE_TYPE_PREFIXES.find((candidate) => nodeId.startsWith(`${candidate}-`)); return (prefix && AGENT_NODE_TYPE_LABELS[prefix]) || (reference?.kind ? `${reference.kind} 节点` : "画布节点"); } function agentNodeLinkLabel(nodeId: string, reference?: CanvasResourceReference) { const kindLabel = agentNodeTypeLabel(nodeId, reference); const title = (reference?.title || reference?.label || "").replace(/\s+/gu, " ").trim(); return title ? `${kindLabel} · ${title}` : kindLabel; } function escapeAgentMarkdownLinkText(value: string) { return value.replace(/[\\[\]]/gu, "\\$&"); } function createAgentNodeLink(nodeId: string, reference?: CanvasResourceReference) { return `[${escapeAgentMarkdownLinkText(agentNodeLinkLabel(nodeId, reference))}](${AGENT_NODE_LINK_PREFIX}${encodeURIComponent(nodeId)})`; } /** * Replace internal canvas node IDs in assistant Markdown with semantic links. * Fenced code is left untouched, while the historical "新节点 ID" wording is * shortened so the assistant exposes a node card instead of an implementation ID. */ export function rewriteAgentNodeLinks(text: string, references: CanvasResourceReference[] = []) { if (!text) return text; const referenceByNodeId = new Map(references.filter((reference) => reference.nodeId).map((reference) => [reference.nodeId, reference])); const exactNodeIds = [...referenceByNodeId.keys()].sort((left, right) => right.length - left.length).map(escapeAgentNodePattern); const nodeIdPattern = exactNodeIds.length ? `(?:${exactNodeIds.join("|")}|${BUILTIN_AGENT_NODE_ID_PATTERN})` : BUILTIN_AGENT_NODE_ID_PATTERN; const nodeTokenPattern = new RegExp(`(? { if (/^\r?\n$/u.test(part)) return part; const fence = /^\s{0,3}(`{3,}|~{3,})/u.test(part); if (fence) { inFence = !inFence; return part; } if (inFence) return part; const normalized = part.replace(newNodePrefixPattern, "$1"); return normalized.replace(nodeTokenPattern, (match, backtickedId: string | undefined, plainId: string | undefined) => { const nodeId = backtickedId || plainId; return nodeId ? createAgentNodeLink(nodeId, referenceByNodeId.get(nodeId)) : match; }); }) .join(""); } function agentNodeIdFromHref(href?: string) { if (!href?.startsWith(AGENT_NODE_LINK_PREFIX)) return null; try { const nodeId = decodeURIComponent(href.slice(AGENT_NODE_LINK_PREFIX.length)); return nodeId || null; } catch { return null; } } function createAgentMessageMarkdownComponents(references: CanvasResourceReference[], onFocusNode?: (nodeId: string) => void): Components { const referenceByNodeId = new Map(references.filter((reference) => reference.nodeId).map((reference) => [reference.nodeId, reference])); return { a: ({ children, href, className, ...props }) => { const nodeId = agentNodeIdFromHref(href); if (!nodeId) { return ( {children} ); } const reference = referenceByNodeId.get(nodeId); const title = reference?.title || reference?.label || "画布节点"; const previewUrl = reference?.previewUrl; return ( { event.preventDefault(); onFocusNode?.(nodeId); }} > {previewUrl ? ( ) : ( )} {agentNodeTypeLabel(nodeId, reference)} {reference?.title || reference?.label || children} ); }, }; } export function AgentChatMessage({ item, theme, references = [], isStreaming = false, retrying = false, onRejectTool, onApproveTool, onRetry, onFocusNode, }: { item: CloudAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; references?: CanvasResourceReference[]; isStreaming?: boolean; retrying?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onRetry?: () => void; onFocusNode?: (nodeId: string) => void; }) { const isUser = item.role === "user"; const isSystem = item.role === "system"; const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming); const formAnswer = item.formAnswer ?? (isUser ? parseCloudAgentFormAnswer(item.text) : undefined); const markdownComponents = useMemo(() => createAgentMessageMarkdownComponents(references, onFocusNode), [onFocusNode, references]); const agentMarkdownText = rewriteAgentNodeLinks(displayedText, references); const errorTone = item.errorSeverity === "warning" ? "warning" : "error"; const color = theme.node.text; if (item.reasoning) { return ; } if (item.compaction) { const compactionIcon = item.compaction.status === "pending" ? ( ) : item.compaction.status === "completed" ? ( ) : item.compaction.status === "aborted" ? ( ) : ( ); return (
{item.compaction.status === "pending" ? ( <> 压缩中 上下文已超过服务端压缩阈值,已暂停本轮执行并压缩历史(保留用户提示摘要、操作记录、未完成任务、当前工作、设计决策、限制和偏好),压完会从压缩后的上下文继续。 ) : item.compaction.status === "completed" ? ( {item.text} ) : ( {item.text || item.compaction.errorMessage || (item.compaction.status === "aborted" ? "上下文压缩已取消" : "上下文压缩失败")} )}
); } if (isSystem) { return (
} />
{item.text} {item.meta ? {item.meta} : null}
); } if (item.role === "tool") { if (objectField(item.detail, "status") === "pending") return onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />; return (
); } if (item.role === "error") { const title = item.title || (errorTone === "warning" ? "Agent 正在重试" : "Agent 暂时无法继续"); const { description, diagnosticId } = agentErrorContent(item.text, title); return (
{title}
{description ?

{description}

: null}
{item.meta ?

{item.meta}

: null} {diagnosticId ? (

如反复出现,请将诊断号反馈给管理员。

{diagnosticId}
) : null} {onRetry ? ( ) : null}
); } return (
{item.interjection ? ( {item.interjection === "undelivered" ? "插话未送达" : "插话"} ) : null} {item.role === "assistant" ? ( {agentMarkdownText} ) : formAnswer ? ( ) : ( )} {item.attachments?.length ? : null} {item.meta ?
{item.meta}
: null}
); } /** * 推理是辅助信息,不应与正文和工具输出争夺主视觉。一个事件流里的连续摘要 * 合并成一个入口,默认收起;需要排查时再展开查看完整内容。 */ export function AgentReasoningFeed({ items, theme }: { items: CloudAgentChatMessage[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const streaming = items.some((item) => item.streaming); const text = items .map((item) => item.text.trim()) .filter(Boolean) .join("\n\n"); const countLabel = items.length > 1 ? `${items.length} 段 · ` : ""; return (
{streaming ? "模型正在思考" : "模型思考"} {streaming ? "实时整理 · 点击查看" : `${countLabel}点击查看`} {streaming ?
{text || (streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}
); } /** * Agent SSE events contain text chunks. Keep the full text in the message * state, but reveal one code point at a time so a chunk never appears as a * whole block. The loop continues briefly after the stream ends to drain any * text that was buffered by the network. */ function useTypewriterText(targetText: string, shouldAnimate: boolean) { const targetRef = useRef(targetText); const visibleRef = useRef(shouldAnimate ? "" : targetText); const hasAnimatedRef = useRef(shouldAnimate); const runningRef = useRef(false); const timerRef = useRef(null); const [visibleText, setVisibleText] = useState(visibleRef.current); targetRef.current = targetText; const startLoop = useCallback(() => { if (runningRef.current) return; runningRef.current = true; const step = () => { const target = targetRef.current; const targetCharacters = Array.from(target); const visibleCharacters = Array.from(visibleRef.current); const visibleIsPrefix = target.startsWith(visibleRef.current); if (!visibleIsPrefix || visibleCharacters.length > targetCharacters.length) { visibleRef.current = ""; setVisibleText(""); } const currentCharacters = Array.from(visibleRef.current); if (currentCharacters.length >= targetCharacters.length) { runningRef.current = false; timerRef.current = null; return; } const nextText = targetCharacters.slice(0, currentCharacters.length + 1).join(""); visibleRef.current = nextText; setVisibleText(nextText); timerRef.current = window.setTimeout(step, 16); }; step(); }, []); useEffect(() => { if (shouldAnimate) hasAnimatedRef.current = true; if (!hasAnimatedRef.current && !shouldAnimate) { visibleRef.current = targetText; setVisibleText(targetText); return; } startLoop(); }, [shouldAnimate, startLoop, targetText]); useEffect( () => () => { if (timerRef.current !== null) window.clearTimeout(timerRef.current); runningRef.current = false; }, [], ); return visibleText; } function AgentMessageText({ text, references }: { text: string; references: CanvasResourceReference[] }) { const parts = splitAgentMessageMentions(text, references); return (
{parts.map((part, index) => part.reference ? ( {part.reference.label} ) : ( {part.text} ), )}
); } type AgentMessageMentionPart = { text: string; token?: string; reference?: CanvasResourceReference }; function splitAgentMessageMentions(text: string, references: CanvasResourceReference[]): AgentMessageMentionPart[] { if (!text) return []; const byToken = new Map(); references.forEach((reference) => { byToken.set(canvasResourceMentionTokenForAgentMessage(reference), reference); if (reference.kind === "skill" && reference.skill?.skillName) byToken.set(`@${reference.skill.skillName}`, reference); }); return text.split(/(@\[[^\]]+\])/gu).map((part) => { const reference = byToken.get(part); if (reference) return { text: "", token: part, reference }; // Never expose an internal Skill ID in the chat bubble when an old // conversation references a removed/unloaded Skill. if (/^@\[skill:[^\]]+\]$/u.test(part)) return { text: "技能包" }; return { text: part }; }); } function canvasResourceMentionTokenForAgentMessage(reference: CanvasResourceReference) { if (reference.mentionToken) return reference.mentionToken; if (reference.kind === "skill" && reference.skill?.skillId) return `@[skill:${reference.skill.skillId}]`; if (reference.assetId) return `@[asset:${reference.assetId}]`; return `@[node:${reference.nodeId}]`; } export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) { const impact = agentImpactFromDetail(detail); const friendlySummary = friendlyAgentToolSummary(agentToolName("", detail), summary, detail, true); return (
} />
需要你的确认 等待确认
{friendlySummary}
{impact?.operationCount ? (
0} theme={theme} /> 0} theme={theme} />
{impact.items.length ? (
{impact.items.map((item, index) => (
{item}
))}
) : null} {impact.warning ?
{impact.warning}
: null}
) : null} {onReject || onApprove ? (
) : null}
); } function ImpactMetric({ label, value, attention = false, theme }: { label: string; value: number; attention?: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
{label}
{value}
); } function agentImpactFromDetail(detail: unknown) { const impact = objectField(detail, "impact"); if (!impact || typeof impact !== "object") return null; const value = impact as Partial; return { operationCount: Number(value.operationCount) || 0, affectedNodeCount: Number(value.affectedNodeCount) || 0, destructiveCount: Number(value.destructiveCount) || 0, generationCount: Number(value.generationCount) || 0, items: Array.isArray(value.items) ? value.items.filter((item): item is string => typeof item === "string") : [], warning: typeof value.warning === "string" ? value.warning : "", } satisfies CloudAgentOperationImpact; } export function AgentToolCard({ title, text, detail, theme, references = [], onFocusNode, }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; references?: CanvasResourceReference[]; onFocusNode?: (nodeId: string) => void; }) { const state = toolCardState(title, text, detail); const toolName = agentToolName(title, detail); const category = agentToolCategory(toolName, detail); const categoryLabel = agentToolCategoryLabel(toolName, category); const summary = friendlyAgentToolSummary(toolName, text, detail); const actions = agentCanvasActions(toolName, detail, references); const isNodeRead = toolName === "canvas_get_state" && category === "read"; const [readExpanded, setReadExpanded] = useState(false); const visibleActions = actions.slice(0, isNodeRead && !readExpanded ? 1 : 8); const collapsedReadNodeCount = isNodeRead ? Math.max(0, actions.length - visibleActions.length) : 0; const isPlain = !actions.length && !state.isError; const conciseError = text.length > 180 ? `${text.slice(0, 180)}…` : text; const categoryIcon = agentToolCategoryIcon(category); const retry = agentToolRetry(detail); const attempts = objectField(detail, "retryAttempts"); if (retry && Array.isArray(attempts)) { const label = agentToolRetryLabel(retry); return (
{retry.status === "recovered" ? `${label} · 已恢复` : `${label} · ${retry.attempt}/${retry.maxAttempts} 次尝试未通过`}
    {(attempts as AgentToolRetryAttempt[]).map((attempt, index) => (
  1. 第 {index + 1} 次:{attempt.text}
  2. ))}
); } return (
{categoryIcon} {categoryLabel} {summary} {state.label !== "已完成" ? ( {state.label} ) : null}
{actions.length ? (
{visibleActions.map((action) => ( ))} {isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? ( ) : null}
) : null} {state.isError && text && text !== summary ? ( {conciseError} ) : null} {state.isError && text.length > 180 ? (
查看完整错误详情

{text}

) : null} {state.isError && objectField(objectField(detail, "result"), "taskId") ? ( 任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))} ) : null}
{state.label === "已完成" ? 已完成 : null}
); } /** * Agent 的操作记录(连续的 `role === "tool"` 消息)折成一行:报最新一步,点击展开完整记录。 * * 语义要点:折叠态整段只有这一行可见,所以**类别徽标必须在折叠行上**(清单 / 查看画面 / * 修改画布),否则默认状态看不出刚才到底是读了清单还是真看了画面。 * * 展开状态用「用户覆盖 + 失败时默认展开」两段决定 —— 失败的操作不能被折进一行里看不见, * 但用户手动收起之后就不再自动弹开(跑动中新步骤只会追加,不会重置状态)。 * `live` 由面板按"这一段是不是对话末尾且在跑"给出:只有还在推进时才流光。 */ export function AgentOperationFeed({ items, theme, references = [], onFocusNode, live = false, }: { items: CloudAgentChatMessage[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; references?: CanvasResourceReference[]; onFocusNode?: (nodeId: string) => void; live?: boolean; }) { const [userExpanded, setUserExpanded] = useState(null); const reducedMotion = useReducedMotion(); const listId = useId(); const latest = items[items.length - 1]; if (!latest) return null; const category = agentOperationCategory(latest); const categoryLabel = agentToolCategoryLabel(agentToolName(latest.title || "工具执行", latest.detail), category); const categoryIcon = agentToolCategoryIcon(category); const label = agentOperationSegmentLabel(items); // 只看最新一步的状态。前面的参数错误如果已被自动纠正,不应让整段 // 操作流继续保持红色并默认展开,否则用户会误以为最终动作仍然失败。 const failed = agentOperationFailed(latest); const expanded = userExpanded ?? failed; const shimmering = live && !failed; return (
{expanded ? (
{items.map((item) => ( ))}
) : null}
); } export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) { return (
); } export function AgentPlanBar({ items, theme, minimized, onToggle, terminal: runEnded = false, waitingUser = false, }: { items: CloudAgentPlanItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; minimized: boolean; onToggle: () => void; terminal?: boolean; /** 本轮停在 ask_user 等用户拍板:清单是暂停,不是停止。 */ waitingUser?: boolean; }) { const doneCount = items.filter((entry) => entry.status === "done").length; const allDone = doneCount === items.length; const terminal = runEnded && !waitingUser; return (
{minimized ? null : (
    {items.map((entry) => { const done = entry.status === "done"; const doing = entry.status === "doing"; const Icon = done ? CheckCircle2 : terminal ? CircleAlert : doing ? LoaderCircle : CircleDot; return (
  • {entry.title}
  • ); })}
)}
); } export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }: { question: CloudAgentUserQuestion; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onAnswer: (answer: string) => void; disabled?: boolean }) { const fields = question.fields || []; const isForm = question.kind === "form" || fields.length > 0; const fieldDefaultValue = (field: CloudAgentFormField) => field.defaultValue || field.options?.find((option) => option.recommended)?.id || field.options?.find((option) => option.recommended)?.label || ""; const [values, setValues] = useState>(() => Object.fromEntries(fields.map((field) => [field.id, fieldDefaultValue(field)]))); const [customFields, setCustomFields] = useState>({}); const [validation, setValidation] = useState(""); useEffect(() => { setValues(Object.fromEntries(fields.map((field) => [field.id, fieldDefaultValue(field)]))); setCustomFields({}); setValidation(""); }, [question.questionId, fields]); const submitForm = (useDefaults = false) => { const answers = useDefaults ? Object.fromEntries(fields.map((field) => [field.id, field.defaultValue || ""])) : values; const missing = fields.find((field) => (field.required && !String(answers[field.id] || "").trim()) || (field.required && answers[field.id] === "other")); if (missing) { setValidation(`请填写“${missing.title}”`); return; } setValidation(""); const displayAnswers: Record = {}; for (const field of fields) { const value = String(answers[field.id] || "").trim(); if (!value || value === "other") continue; const option = field.options?.find((item) => (item.id || item.label) === value); displayAnswers[field.id] = option?.label || value; } const fieldTitles = Object.fromEntries(fields.map((field) => [field.id, field.title])); onAnswer( JSON.stringify({ type: "form_answer", questionId: question.questionId, answers, displayAnswers, fieldTitles, skippedFields: fields.filter((field) => !String(answers[field.id] || "").trim()).map((field) => field.id), useRecommendedDefaults: useDefaults, }), ); }; const stop = (event: SyntheticEvent) => { event.stopPropagation(); }; const updateValue = (field: CloudAgentFormField, value: string) => { setValues((current) => ({ ...current, [field.id]: value })); if (field.allowCustom && value === "other") setCustomFields((current) => ({ ...current, [field.id]: true })); }; if (isForm) { return (
{question.round && question.maxRounds ? `确认 ${question.round}/${question.maxRounds}` : "需求确认"}
{question.question}
已填入推荐值,可按需修改;不重要的字段可以留空。
{fields.map((field) => { const options = field.options || []; const custom = customFields[field.id] || values[field.id] === "other"; return (
{field.type === "textarea" ? (