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 (
{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 ? (
} onClick={() => onReject?.()}>
拒绝执行
} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
批准执行
) : null}
);
}
function ImpactMetric({ label, value, attention = false, theme }: { label: string; value: number; attention?: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return ;
}
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) => (

{onRemoveAttachment ? (
) : null}
))}
) : null}
{slash && availableSlashSkills.length ? (
event.preventDefault()}
>
{availableSlashSkills.map((skill, index) => (
))}
) : null}
{onAddFiles ? (
<>
{
void onAddFiles(event.target.files);
event.target.value = "";
}} />
} onClick={() => fileInputRef.current?.click()} />
>
) : null}
{left}
:
} onClick={() => void onSubmit()} aria-label="发送" />
);
}
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) => (

))}
);
}
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;
}