import { agentCanvasActions, agentCanvasActionLabel } from "@/lib/canvas/agent-canvas-actions"; import { Button } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; import { ArrowUp, AtSign, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Eye, HelpCircle, ImagePlus, ListChecks, LoaderCircle, Pencil, Plus, RotateCcw, Sparkles, Square, X, XCircle } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; import { 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"; import { buildSkillMentionReferences } from "@/services/skill-runtime"; import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; export type CloudAgentChatAttachment = { id: string; name: string; url: string }; 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 CloudAgentUserQuestion = { question: string; options: Array<{ label: string; detail?: string }>; allowFreeform?: boolean; }; export type CloudAgentChatMessage = { id: string; role: "user" | "assistant" | "system" | "tool" | "error"; title?: string; text: string; streaming?: boolean; reasoning?: boolean; planItems?: CloudAgentPlanItem[]; question?: CloudAgentUserQuestion; meta?: string; detail?: unknown; attachments?: CloudAgentChatAttachment[]; interjection?: "sent" | "undelivered"; }; 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 MIN_AGENT_PROMPT_HEIGHT = 60; const MAX_AGENT_PROMPT_HEIGHT = 240; function clampAgentPromptHeight(height: number) { return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height))); } 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 color = item.role === "error" ? "#ef4444" : theme.node.text; if (item.reasoning) { return (
{item.streaming ? "模型正在思考" : "模型思考"} {item.streaming ? "整理目标与下一步" : "推理摘要"} {item.streaming ?
{item.text || (item.streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}
); } 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") { return (
} />
{item.title || "Agent 暂时无法继续"}
{item.text}
{item.meta ?
{item.meta}
: null} {onRetry ? ( ) : null}
); } return (
{!isUser ? : null}
{item.interjection ? ( {item.interjection === "undelivered" ? "插话未送达" : "插话"} ) : null} {item.role === "assistant" ? ( {item.text} ) : ( )} {item.attachments?.length ? : null} {item.meta ?
{item.meta}
: null}
); } 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 = category === "read" ? : category === "create" ? : ; 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}
); } export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) { return (
); } export function AgentPlanBar({ items, theme, minimized, onToggle }: { items: CloudAgentPlanItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; minimized: boolean; onToggle: () => void; }) { const doneCount = items.filter((entry) => entry.status === "done").length; const allDone = doneCount === items.length; return (
{minimized ? null : (
    {items.map((entry) => { const done = entry.status === "done"; const doing = entry.status === "doing"; const Icon = done ? CheckCircle2 : doing ? LoaderCircle : CircleDot; return (
  • {entry.title}
  • ); })}
)}
); } export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }: { question: CloudAgentUserQuestion; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onAnswer: (label: string) => void; disabled?: boolean; }) { return (
{question.question}
{question.options.map((option) => ( ))}
{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}
); } export function AgentChatComposer({ prompt, attachments = [], disabled, sending, running, placeholder, theme, onPromptChange, onSubmit, onAddFiles, onRemoveAttachment, left, onStop, stopping, references = [], slashSkills, includeAssetLibrary, }: { prompt: string; attachments?: CloudAgentChatAttachment[]; disabled?: boolean; sending?: boolean; running?: boolean; placeholder: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onPromptChange: (value: string) => void; onSubmit: () => void; onStop?: () => void | Promise; stopping?: boolean; onAddFiles?: (files: FileList | File[] | null) => void | Promise; onRemoveAttachment?: (id: string) => void; left?: ReactNode; /** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */ references?: CanvasResourceReference[]; /** 供「/」弹出的技能候选(可选) */ slashSkills?: Skill[]; /** 是否在「@」候选里包含素材库资源 */ includeAssetLibrary?: boolean; }) { const fileInputRef = useRef(null); const [slash, setSlash] = useState<{ start: number; query: string } | null>(null); const [slashIndex, setSlashIndex] = useState(0); const [previewAttachment, setPreviewAttachment] = useState(null); const [promptHeight, setPromptHeight] = useState(MIN_AGENT_PROMPT_HEIGHT); const promptResizeRef = useRef<{ startY: number; startHeight: number } | null>(null); const manualPromptHeightRef = useRef(null); const availableSlashSkills = slashSkills ?? []; const slashCandidates = useMemo(() => { const query = slash?.query.trim().toLocaleLowerCase() || ""; if (!query) return availableSlashSkills; return availableSlashSkills.filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(query)); }, [availableSlashSkills, slash?.query]); const attachmentReferences = useMemo(() => agentAttachmentReferences(attachments), [attachments]); const skillReferences = useMemo(() => buildSkillMentionReferences(availableSlashSkills), [availableSlashSkills]); const composerReferences = useMemo(() => [...references, ...skillReferences, ...attachmentReferences].filter((reference, index, all) => all.findIndex((item) => item.id === reference.id) === index), [attachmentReferences, references, skillReferences]); const canStop = Boolean(running && onStop); const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); const reducedMotion = useReducedMotion(); const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(slashCandidates.length - 1, 0)); const handlePromptContentSizeChange = useCallback((naturalHeight: number) => { const nextHeight = clampAgentPromptHeight(naturalHeight); // 用户开始拖动后,面板高度由用户掌控;超出部分交给内部滚动区, // 避免输入新内容时又把手动缩小的面板强行撑开。 setPromptHeight((currentHeight) => manualPromptHeightRef.current === null ? nextHeight : currentHeight); }, []); useEffect(() => { if (prompt.trim()) return; manualPromptHeightRef.current = null; setPromptHeight(MIN_AGENT_PROMPT_HEIGHT); }, [prompt]); const startPromptResize = (event: ReactPointerEvent) => { if (disabled) return; event.preventDefault(); manualPromptHeightRef.current = promptHeight; promptResizeRef.current = { startY: event.clientY, startHeight: promptHeight }; event.currentTarget.setPointerCapture(event.pointerId); }; const resizePrompt = (event: ReactPointerEvent) => { const resize = promptResizeRef.current; if (!resize) return; setPromptHeight(clampAgentPromptHeight(resize.startHeight + resize.startY - event.clientY)); }; const finishPromptResize = (event?: ReactPointerEvent) => { promptResizeRef.current = null; if (event?.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; const resizePromptByKeyboard = (event: ReactKeyboardEvent) => { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; event.preventDefault(); manualPromptHeightRef.current = promptHeight; setPromptHeight(clampAgentPromptHeight(promptHeight + (event.key === "ArrowUp" ? 20 : -20))); }; // 在输入值末尾检测「/ 或 、+ 关键词」打开技能候选:中文输入法下 "/" 会打成 "、",两者等价,且都必须紧跟行首或空白,避免中文顿号误触发。选中后写入稳定 token,编辑器再把它渲染为技能 chip。 const handlePromptChange = (value: string) => { onPromptChange(value); const match = /(^|\s)[/、]([^\s/、]*)$/.exec(value); if (match && availableSlashSkills.length) { const next = { start: match.index + match[1].length, query: match[2] }; setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next)); setSlashIndex(0); } else if (slash) { setSlash(null); } }; const applySlashSkill = (skill: Skill) => { const token = `@[skill:${skill.skillId}] `; // 触发符 "/" 与 "、" 都是单字符,替换长度固定为 1。 const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 1 + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token; setSlash(null); setSlashIndex(0); onPromptChange(next); }; // slash 菜单的键盘控制在 capture 阶段拦截(contentEditable/textarea 内部先消费 Enter,外层冒泡拿不到) const handleSlashKeyCapture = (event: ReactKeyboardEvent) => { if (!slash || !slashCandidates.length) return; if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "ArrowDown") { event.preventDefault(); event.stopPropagation(); setSlashIndex((index) => Math.min(index + 1, slashCandidates.length - 1)); } else if (event.key === "ArrowUp") { event.preventDefault(); event.stopPropagation(); setSlashIndex((index) => Math.max(index - 1, 0)); } else if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); event.stopPropagation(); applySlashSkill(slashCandidates[activeSlashIndex]); } else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setSlash(null); } }; // 保留粘贴图片成附件(contentEditable 模式内部会把粘贴转纯文本,capture 阶段先拦截图片) const handlePasteCapture = (event: ReactClipboardEvent) => { if (!onAddFiles) return; const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); if (!images.length) return; event.preventDefault(); event.stopPropagation(); void onAddFiles(images); }; const insertAttachmentMention = (item: CloudAgentChatAttachment) => { const token = `@[attachment:${item.id}] `; if (prompt.includes(`@[attachment:${item.id}]`)) return; onPromptChange(prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token); }; return (
event.stopPropagation()}>
{sending && !reducedMotion ? : null} {attachments.length ? (
{attachments.map((item, index) => (
{onRemoveAttachment ? ( ) : null}
))}
) : null}
{ if (canSubmit) onSubmit(); }} onContentSizeChange={handlePromptContentSizeChange} className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45" containerClassName="min-h-[60px] h-full" style={{ color: theme.node.text }} placeholder={placeholder} aria-label="Agent 输入" />
{slash && slashCandidates.length ? (
event.preventDefault()} > {slashCandidates.map((skill, index) => ( ))}
) : null}
{onAddFiles ? ( <> { void onAddFiles(event.target.files); event.target.value = ""; }} />
{disabled ? null : ( {canStop ? "运行中:发送即插话,下一步生效" : "Enter 发送 · Shift+Enter 换行"} {canStop ? "运行中可插话" : "Enter 发送"} )} {canStop ? void onStop?.()} whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined} whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined} animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }} transition={{ type: "spring", stiffness: 420, damping: 24 }} className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" style={{ background: theme.accent.danger, color: theme.accent.onPrimary, boxShadow: `0 8px 20px ${theme.accent.danger}45` }} > {stopping ? : } : null} onSubmit()} whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined} whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined} animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }} transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }} className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" style={{ background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface, color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted, boxShadow: canSubmit || sending ? `0 8px 20px ${theme.accent.primary}45` : "none", }} > {sending ? : }
{previewAttachment ? setPreviewAttachment(null)} /> : null}
); } export function AgentPanelTabs({ value, items, theme, right, onChange, }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void; }) { return (
{right ?
{right}
: null}
); } function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "tool" | "approval" | "error"; icon?: ReactNode }) { const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "tool" ? "#4f7cff" : tone === "agent" ? theme.accent.primary : theme.node.muted; return (