import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; import { Button, Tooltip } from "antd"; import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react"; 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 { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { Skill } from "@/services/api/skills"; export type CanvasAgentChatAttachment = { id: string; name: string; url: string }; export type CanvasAgentMode = "online" | "local"; export type CanvasAgentChatMessage = { id: string; role: "user" | "assistant" | "system" | "tool" | "error"; title?: string; text: string; meta?: string; detail?: unknown; attachments?: CanvasAgentChatAttachment[]; }; const WORKING_TEXT = "正在推演..."; export function AgentChatMessage({ item, theme, user, isStreaming = false, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) { const isUser = item.role === "user"; const isSystem = item.role === "system"; const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text; 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 (
); } return (
{!isUser ? : null}
{item.role === "assistant" ? {item.text} :
{item.text}
} {item.attachments?.length ? : null} {item.meta ?
{item.meta}
: null}
{isUser ? : null}
); } 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); return (
确认工具调用 等待确认
{summary}
{impact?.operationCount ? (
0} theme={theme} /> 0} theme={theme} />
{impact.items.length ?
{impact.items.map((item, index) =>
{item}
)}
: null} {impact.warning ?
{impact.warning}
: null}
) : null} {detail ?
技术详情
: 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 CanvasAgentOperationImpact; } export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const state = toolCardState(title, text, detail); return (
{state.icon}
{title} {state.label} {detail ? 详情 : null}
{text}
{detail ? : null}
); } export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const [length, setLength] = useState(1); useEffect(() => { const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120); return () => window.clearInterval(timer); }, [setLength]); return (
{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}
); } export function AgentChatComposer({ prompt, attachments = [], disabled, sending, placeholder, theme, onPromptChange, onSubmit, onAddFiles, onRemoveAttachment, left, references = [], slashSkills, includeAssetLibrary, }: { prompt: string; attachments?: CanvasAgentChatAttachment[]; disabled?: boolean; sending?: boolean; placeholder: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onPromptChange: (value: string) => void; onSubmit: () => void; 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 availableSlashSkills = slashSkills ?? []; const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(availableSlashSkills.length - 1, 0)); // 在输入值末尾检测「/关键词」打开技能候选;选择后替换为 @[skill:xxx] 引用 token(保持在 prompt 文本里)。 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.skill_id}] `; const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 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 || !availableSlashSkills.length) return; if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "ArrowDown") { event.preventDefault(); event.stopPropagation(); setSlashIndex((index) => Math.min(index + 1, availableSlashSkills.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(availableSlashSkills[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); }; return (
event.stopPropagation()}>
{attachments.length ? (
{attachments.map((item) => (
{item.name} {onRemoveAttachment ? ( ) : null}
))}
) : null}
{slash && availableSlashSkills.length ? (
event.preventDefault()} > {availableSlashSkills.map((skill, index) => ( ))}
) : null}
{onAddFiles ? ( <> { void onAddFiles(event.target.files); event.target.value = ""; }} />
); } 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 AgentDetailBlock({ detail, theme }: { detail: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
            {JSON.stringify(detail, null, 2)}
        
); } function AgentAvatar({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return ( ); } function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const avatarUrl = user?.avatarUrl?.trim(); return ( {avatarUrl ? : } ); } function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChatAttachment[] }) { return (
{attachments.map((item) => ( {item.name} ))}
); } function toolCardState(title: string, text: string, detail?: unknown) { const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`; const lower = raw.toLowerCase(); const tool = String(objectField(detail, "name") || objectField(detail, "tool") || ""); if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBg: "rgba(217,119,6,.04)", icon: , isError: false }; if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: , isError: true }; if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: , isError: true }; if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBg: "rgba(22,163,74,.04)", icon: , isError: false }; return { label: "工具调用", color: "#2563eb", softBg: "rgba(37,99,235,.04)", icon: , isError: false }; } function normalizeText(value: unknown) { if (typeof value === "string") return value.trim(); if (value instanceof Error) return value.message; if (value == null) return ""; return JSON.stringify(value, null, 2); } function objectField(value: unknown, key: string) { return value && typeof value === "object" ? (value as Record)[key] : undefined; }