import { useEffect, useId, useState, type ReactNode } from "react"; import { App, Button, Image, Modal } from "antd"; import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react"; import { Streamdown, type LinkSafetyModalProps } from "streamdown"; import { useCopyText } from "@/hooks/use-copy-text"; import { canvasThemes } from "@/lib/canvas-theme"; import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store"; import { revealAgentLocalFile } from "@/services/api/canvas-agent"; const streamdownProps = { className: "agent-streamdown", controls: { code: { copy: true, download: false }, table: { copy: true, download: false, fullscreen: false } }, linkSafety: { enabled: true, renderModal: (props: LinkSafetyModalProps) => }, lineNumbers: false, translations: { close: "关闭", copied: "已复制", copyCode: "复制代码", copyLink: "复制链接", externalLinkWarning: "即将打开以下外部链接,请确认链接可信。", openExternalLink: "打开外部链接?", openLink: "继续打开", }, } as const; const streamdownAnimation = { duration: 20, stagger: 0, sep: "word" } as const; function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) { const { message } = App.useApp(); const copyText = useCopyText(); const localPath = localFilePath(url); const [opening, setOpening] = useState(false); const open = async () => { if (!localPath) return onConfirm(); const { url: endpoint, token } = useAgentStore.getState(); setOpening(true); try { await revealAgentLocalFile(endpoint, token, localPath); message.success("已在文件管理器中定位"); onClose(); } catch (error) { message.error(error instanceof Error ? error.message : "无法打开本地文件"); } finally { setOpening(false); } }; return (
{localPath ? "将在本机文件管理器中定位该路径,不会通过浏览器打开。" : "即将打开以下外部链接,请确认链接可信。"}
{localPath || url}
); } function localFilePath(value: string) { let decoded = value; try { decoded = decodeURI(value); } catch {} if (decoded.startsWith("file://")) { try { return decodeURIComponent(new URL(decoded).pathname); } catch { return ""; } } if (/^[A-Za-z]:[\\/]/.test(decoded)) return decoded; let pathname = decoded; if (decoded.startsWith("http://") || decoded.startsWith("https://")) { try { const parsed = new URL(decoded); if (!["localhost", "127.0.0.1"].includes(parsed.hostname)) return ""; pathname = parsed.pathname; } catch { return ""; } } return /^\/(?:Users|home|private|tmp|Volumes|var\/folders)\//.test(pathname) ? decodeURIComponent(pathname) : ""; } export type AgentChatAttachment = { id: string; name: string; url: string }; export type AgentChatMessageItem = { id: string; role: "user" | "assistant" | "system" | "tool" | "error"; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentChatAttachment[]; /** Present while the message is actively streaming; cleared on completion. */ streamId?: string; }; export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: { item: AgentChatMessageItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; 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 ? (
{item.text}
) : ( {item.text} )} {item.attachments?.length ? : null} {item.meta ?
{item.meta}
: null}
); } export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) { const view = userDetail(detail); return (
{ if (!view) event.preventDefault(); }}>
等待确认 {view ? : null}
{summary}
{view ?
: null}
{onReject || onApprove ? (
) : null}
); } export function AgentApprovalCard({ approval, theme, onDecision }: { approval: AgentPendingApproval; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onDecision: (decision: "accept" | "acceptForSession" | "decline") => void }) { const isFile = approval.method === "item/fileChange/requestApproval"; const isNetwork = Boolean(approval.networkApprovalContext); const title = isNetwork ? "请求网络访问" : isFile ? "请求编辑文件" : approval.method === "item/permissions/requestApproval" ? "请求扩展权限" : "请求执行命令"; const target = isNetwork ? approvalTarget(approval.networkApprovalContext) : isFile ? approval.grantRoot || approval.cwd : commandText(approval.command) || approval.cwd; return (
{title}
{approval.reason ?
{approval.reason}
: null} {target ?
{target}
: null}
); } export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const plan = planDetail(detail); if (plan) return ; const kind = String(objectField(detail, "kind") || ""); if (kind === "reasoning") return ; if (kind === "command") return ; const state = toolCardState(title, text, detail); const view = userDetail(detail); const showText = title !== "读取画布" || text !== "已读取当前画布内容"; const className = "group min-w-0 rounded-xl border px-3 py-2.5 text-left"; const style = { borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }; const content = ( <>
{toolIcon(kind, state.icon)} {title} {state.label} {view ? : null}
{showText ? (
{text}
) : null} ); if (!view) return
{content}
; return (
{content}
); } function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const status = String(objectField(detail, "status") || ""); const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status); return (
{running ? : } {running ? "正在思考" : "思考摘要"}
{text}
); } type AgentCommandItem = Pick; export function AgentCommandGroup({ items, theme }: { items: AgentCommandItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const states = items.map((item) => commandViewState(item.detail)); const running = states.some((state) => state.running); const failed = states.filter((state) => state.failed).length; const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail))); const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted; const label = running ? items.length > 1 ? `正在执行 ${items.length} 条命令` : "正在执行命令" : `已执行 ${items.length} 条命令${failed ? ` · ${failed} 条失败` : ""}`; const header = (
{running ? : } {label} {expandable ? : null}
); if (!expandable) return
{header}
; return (
{header} {items.length === 1 ? :
{items.map((item, index) => )}
}
); } function AgentSingleCommand({ item, theme }: { item: AgentCommandItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const view = userDetail(item.detail); return (
{item.text ?
{item.text}
: null} {view ? : null}
); } function AgentCommandEntry({ item, index, theme }: { item: AgentCommandItem; index: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const [open, setOpen] = useState(false); const detailId = useId(); const view = userDetail(item.detail); const state = commandViewState(item.detail); const status = state.failed ? "执行失败" : state.running ? "执行中" : "已完成"; const color = state.failed ? "#dc2626" : state.running ? "#d97706" : "#16a34a"; const content = ( <> {index + 1} {item.text || "命令"} {state.running ? : state.failed ? : } {view ? : null} ); return (
{view ? :
{content}
} {view && open ?
: null}
); } function commandViewState(detail: unknown) { const status = String(objectField(detail, "status") || "").toLowerCase(); return { running: ["inprogress", "in_progress", "running", "started", "pending"].includes(status), failed: ["failed", "error"].includes(status), }; } function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const [open, setOpen] = useState(true); const completed = plan.tasks.filter((item) => item.status === "completed").length; const state = planCardState(plan, completed); return (
setOpen(event.currentTarget.open)} className="group min-w-0 flex-1 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}> {title} {state.label} {completed}/{plan.tasks.length} {plan.explanation ?
{plan.explanation}
: null}
{plan.tasks.map((item, index) => { const task = planTaskState(item.status, theme.node.muted); return (
{task.icon} {item.step} {task.label}
); })}
); } export function AgentWorkingMessage({ text, detail, status = "running", mcpStatuses = [], activityKey, theme }: { text: string; detail?: string; status?: "running" | "ready" | "error"; mcpStatuses?: Array<{ name: string; status: "running" | "ready" | "error"; detail: string }>; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const [elapsed, setElapsed] = useState(0); useEffect(() => { const startedAt = Date.now(); setElapsed(0); const timer = window.setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000); return () => window.clearInterval(timer); }, [activityKey]); return (
{status === "running" ? : status === "ready" ? : } {text} {status === "running" && elapsed >= 5 ? {waitingTime(elapsed)} : null}
{detail ?
{detail}
: null} {mcpStatuses.length ? (
{mcpStatuses.map((item) => (
{item.status === "running" ? : item.status === "ready" ? : }
{item.name}
{item.detail}
))}
) : null} {status === "running" && elapsed >= 30 ?
响应时间较长,但任务仍在运行。可以继续等待,或点击输入框右侧的停止按钮结束本轮。
: null}
); } function waitingTime(seconds: number) { if (seconds < 60) return `已等待 ${seconds} 秒`; const minutes = Math.floor(seconds / 60); return `已等待 ${minutes} 分 ${seconds % 60} 秒`; } function commandText(value: unknown) { if (Array.isArray(value)) return value.map(String).join(" "); return typeof value === "string" ? value : ""; } function approvalTarget(value: unknown) { const host = String(objectField(value, "host") || ""); const protocol = String(objectField(value, "protocol") || ""); const port = String(objectField(value, "port") || ""); return host ? `${protocol ? `${protocol}://` : ""}${host}${port ? `:${port}` : ""}` : ""; } type PlanTask = { step: string; status: string }; type PlanDetail = { status: string; tasks: PlanTask[]; explanation?: string }; type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> }; function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
{detail.rows?.length ? (
{detail.rows.map((row) => (
{row.label}
{row.value}
))}
) : null} {detail.files?.length ? (
涉及文件
{detail.files.map((file) => (
{file.path} {file.action ? {file.action} : null}
))}
) : null} {detail.output ? (
{detail.status === "failed" || detail.status === "error" ? "错误信息" : "运行输出"}
{detail.output}
) : null}
); } function AgentMessageAttachments({ attachments, alignRight }: { attachments: AgentChatAttachment[]; alignRight?: boolean }) { const [previewUrl, setPreviewUrl] = useState(null); return ( <>
{attachments.map((item) => ( {item.name} setPreviewUrl(item.url)} /> ))}
{previewUrl ? (
图片附件预览 !visible && setPreviewUrl(null) }} />
) : null} ); } function toolCardState(title: string, text: string, detail?: unknown) { const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`; const lower = raw.toLowerCase(); const status = String(objectField(detail, "status") || "").toLowerCase(); if (status === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", icon: , isError: false }; if (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) return { label: "已取消", color: "#dc2626", icon: , isError: true }; if (["failed", "error"].includes(status) || /失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", icon: , isError: true }; if (["inprogress", "in_progress", "running", "started", "pending"].includes(status)) return { label: "进行中", color: "#d97706", icon: , isError: false }; if (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: "已完成", color: "#16a34a", icon: , isError: false }; return { label: "已记录", color: "#2563eb", icon: , isError: false }; } function toolIcon(kind: string | undefined, fallback: ReactNode) { if (kind === "search") return ; if (kind === "file") return ; if (kind === "plan") return ; return fallback; } function planCardState(plan: PlanDetail, completed: number) { if (plan.status === "failed") return { label: "执行失败", color: "#dc2626" }; if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: "已停止", color: "#d97706" }; if (completed === plan.tasks.length) return { label: "已完成", color: "#16a34a" }; if (plan.status === "finished") return { label: "已结束", color: "#2563eb" }; return { label: "进行中", color: "#d97706" }; } function planTaskState(status: string, muted: string) { if (status === "completed") return { label: "已完成", color: "#16a34a", icon: }; if (status === "inProgress") return { label: "进行中", color: "#d97706", icon: }; return { label: "待处理", color: muted, icon: }; } function planDetail(value: unknown): PlanDetail | null { if (!value || typeof value !== "object" || objectField(value, "kind") !== "todo") return null; const tasks = Array.isArray(objectField(value, "tasks")) ? (objectField(value, "tasks") as unknown[]).flatMap((item) => { const step = String(objectField(item, "step") || "").trim(); return step ? [{ step, status: String(objectField(item, "status") || "pending") }] : []; }) : []; if (!tasks.length) return null; const explanation = String(objectField(value, "explanation") || "").trim(); return { status: String(objectField(value, "status") || "inProgress"), tasks, ...(explanation ? { explanation } : {}) }; } function userDetail(value: unknown): UserDetail | null { if (!value || typeof value !== "object") return null; const detail = value as Record; const rows = Array.isArray(detail.rows) ? detail.rows.flatMap((row) => { if (!row || typeof row !== "object") return []; const label = String((row as Record).label || ""); const value = String((row as Record).value || ""); return label && value ? [{ label, value }] : []; }) : []; const files = Array.isArray(detail.files) ? detail.files.flatMap((file) => { if (!file || typeof file !== "object") return []; const path = String((file as Record).path || ""); return path ? [{ path, action: String((file as Record).action || "") || undefined }] : []; }) : []; const error = objectField(detail.error, "message"); const output = typeof detail.output === "string" ? detail.output.trim() : typeof error === "string" ? error : ""; if (!rows.length && !files.length && !output) return null; return { kind: typeof detail.kind === "string" ? detail.kind : undefined, status: typeof detail.status === "string" ? detail.status : undefined, rows, files, output }; } function normalizeText(value: unknown) { if (typeof value === "string") return value.trim(); if (value instanceof Error) return value.message; if (value == null) return ""; return String(objectField(value, "message") || ""); } function objectField(value: unknown, key: string) { return value && typeof value === "object" ? (value as Record)[key] : undefined; }