"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { App, Button, Input, Segmented, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react"; import { motion } from "motion/react"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "../stores/use-canvas-agent-store"; import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops"; import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui"; const PANEL_MOTION_SECONDS = 0.5; const MAX_ATTACHMENTS = 6; const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024; const AGENT_CONNECT_STEPS = [ { title: "1. 本机已安装并登录 Codex", text: "先确认本机终端里的 Codex 可以正常使用。", command: "codex --version" }, { title: "2. 安装 Canvas Agent", text: "推荐全局安装,后续可以直接运行 canvas-agent。", command: "npm i -g @basketikun/canvas-agent" }, { title: "3. 启动本地 Agent", text: "启动后终端会输出 Local URL 和 Connect token。", command: "canvas-agent" }, { title: "4. 回到网页连接", text: "把终端输出的地址和 token 填到下面,点击连接。" }, ]; type AgentEventPayload = { agent?: string; type?: string; thread_id?: string; item?: AgentEventItem; error?: { message?: string }; message?: string; usage?: Record; }; type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } }; type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string }; type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string }; type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] }; type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] }; export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedded, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed?: boolean; embedded?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const user = useUserStore((state) => state.user); const { message, modal } = App.useApp(); const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore(); const [resizing, setResizing] = useState(false); const listRef = useRef(null); const snapshotRef = useRef(snapshot); const confirmToolsRef = useRef(confirmTools); const pendingToolRef = useRef(null); const onApplyOpsRef = useRef(onApplyOps); const connectedRef = useRef(false); const errorLoggedRef = useRef(false); const attachmentUrlsRef = useRef(new Set()); const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID()); const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]); const loadThreads = useCallback(async () => { const projectId = snapshotRef.current.projectId; if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return; setAgentState({ loadingThreads: true }); try { const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`); const current = useCanvasAgentStore.getState(); setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || current.workspacePath, activeThreadId: data.workspace?.activeThreadId || current.activeThreadId, }); const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId; if (nextThreadId && !current.messages.length) { const thread = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`); setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) }); } } catch (error) { addEventLog("读取历史失败", error); } finally { setAgentState({ loadingThreads: false }); } }, [endpoint, setAgentState, token]); useEffect(() => { snapshotRef.current = snapshot; }, [snapshot]); useEffect(() => { confirmToolsRef.current = confirmTools; }, [confirmTools]); useEffect(() => { pendingToolRef.current = pendingTool; }, [pendingTool]); useEffect(() => { onApplyOpsRef.current = onApplyOps; }, [onApplyOps]); useEffect(() => { listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); }, [messages, pendingTool, waiting]); useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []); useEffect(() => { if (!enabled || !token.trim()) return; localStorage.setItem("canvas-agent-url", endpoint); localStorage.setItem("canvas-agent-token", token); const clientId = clientIdRef.current; const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`); source.addEventListener("hello", () => { errorLoggedRef.current = false; connectedRef.current = true; setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) }); message.success("本地 Agent 已连接"); void postState(endpoint, token, clientId, snapshotRef.current); }); source.addEventListener("tool_call", (event) => { const data = parseEventData(event); if (data) void handleToolCall(endpoint, token, data); }); source.addEventListener("agent_event", (event) => { const data = parseEventData(event); if (data) handleAgentEvent(data); }); source.addEventListener("agent_log", (event) => { const text = parseEventData<{ text?: unknown }>(event)?.text; addEventLog("日志", text, text); }); source.addEventListener("agent_error", (event) => { const message = parseEventData<{ message?: unknown }>(event)?.message; setAgentState({ activity: "出错", waiting: false }); addMessage({ role: "error", title: "错误", text: normalizeText(message) }); addEventLog("错误", message, message); }); source.addEventListener("agent_done", () => { setAgentState({ activity: "完成", waiting: false, sending: false }); void loadThreads(); }); source.onerror = () => { const wasConnected = connectedRef.current; const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token"; if (!errorLoggedRef.current || wasConnected) { addEventLog(wasConnected ? "连接断开" : "连接失败", { endpoint, error: text }); message.error(text); } errorLoggedRef.current = true; connectedRef.current = false; clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: text }); if (!wasConnected) { source.close(); setAgentState({ enabled: false }); } }; return () => { source.close(); connectedRef.current = false; setAgentState({ connected: false }); }; }, [enabled, endpoint, loadThreads, message, setAgentState, token]); useEffect(() => { if (connected) void loadThreads(); }, [connected, loadThreads, snapshot.projectId]); useEffect(() => { if (!connected) return; const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, snapshot), 300); return () => clearTimeout(timer); }, [connected, endpoint, snapshot, token]); const sendPrompt = async () => { const text = prompt.trim(); const files = attachments; const requestPrompt = promptWithAttachments(text, files); if (!connected || !requestPrompt || sending || waiting) return; if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) { addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" }); return; } setAgentState({ activity: "发送中", sending: true, waiting: true }); addMessage({ role: "user", text: text || "发送了图片", attachments: files }); addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) }); try { const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) }); if (!res.ok) throw new Error("本地 Agent 拒绝了请求"); const data = (await res.json()) as { threadId?: string }; if (data.threadId) setAgentState({ activeThreadId: data.threadId }); addEventLog("本地 Agent 已接收", { status: res.status }); files.forEach((item) => { URL.revokeObjectURL(item.url); attachmentUrlsRef.current.delete(item.url); }); setAgentState({ prompt: "", attachments: [] }); } catch (error) { setAgentState({ activity: "发送失败", waiting: false }); addMessage({ role: "error", title: "发送失败", text: error instanceof Error ? error.message : "发送失败" }); addEventLog("发送失败", error); } finally { setAgentState({ sending: false }); } }; const addAttachments = async (files: FileList | File[] | null) => { if (!files) return; const images = Array.from(files).filter((file) => file.type.startsWith("image/")); const prev = useCanvasAgentStore.getState().attachments; try { const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => { const dataUrl = await readDataUrl(file); const url = URL.createObjectURL(file); attachmentUrlsRef.current.add(url); return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl }; })); const merged = [...prev, ...next]; if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) { next.forEach((item) => { URL.revokeObjectURL(item.url); attachmentUrlsRef.current.delete(item.url); }); addMessage({ role: "error", title: "图片过大", text: "图片附件最多约 30MB。" }); return; } if (next.length) setAgentState({ attachments: merged }); } catch (error) { addMessage({ role: "error", title: "图片读取失败", text: error instanceof Error ? error.message : "图片读取失败" }); } }; const removeAttachment = (id: string) => { const removed = attachments.find((item) => item.id === id); if (removed) { URL.revokeObjectURL(removed.url); attachmentUrlsRef.current.delete(removed.url); } setAgentState({ attachments: attachments.filter((item) => item.id !== id) }); }; const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { if (confirmToolsRef.current && payload.name === "canvas_apply_ops") { if (pendingToolRef.current) { await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" }); return; } pendingToolRef.current = payload; setAgentState({ pendingTool: payload, activity: "等待确认", waiting: false }); addEventLog("等待确认", payload, payload); return; } await runToolCall(endpoint, token, payload); }; const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { try { const input: { ops?: CanvasAgentOp[] } = payload.input || {}; setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布", waiting: true }); addEventLog(toolName(payload.name), payload, payload); const result = payload.name === "canvas_apply_ops" ? onApplyOpsRef.current(input.ops || []) : snapshotRef.current; await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); if (payload.name === "canvas_apply_ops") void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot); setAgentState({ activity: "工具完成", waiting: true }); addEventLog(`${toolName(payload.name)}完成`, result, result); addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } }); } catch (error) { const message = error instanceof Error ? error.message : "画布操作失败"; setAgentState({ activity: "工具失败", waiting: false }); addMessage({ role: "tool", title: "工具失败", text: message, detail: payload }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); } }; const rejectPendingTool = async () => { if (!pendingTool) return; await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" }); setAgentState({ activity: "已取消", waiting: false }); addMessage({ role: "tool", title: "拒绝执行", text: toolName(pendingTool.name), detail: { requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input } }); pendingToolRef.current = null; setAgentState({ pendingTool: null }); }; const approvePendingTool = async () => { if (!pendingTool) return; const tool = pendingTool; pendingToolRef.current = null; setAgentState({ pendingTool: null }); await runToolCall(endpoint, token, tool); }; const undoLastTool = () => { const restored = onUndoOps(); if (!restored) return; setAgentState({ activity: "已撤销" }); addMessage({ role: "tool", title: "已撤销", text: "上一次工具操作", detail: restored }); if (connected) void postState(endpoint, token, clientIdRef.current, restored); }; const toggleAgentConnection = () => { if (enabled) { clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" }); return; } if (!endpoint) { const text = "请填写本地 Agent 地址"; setAgentState({ connectError: text }); message.warning(text); return; } if (!token.trim()) { const text = "请填写 Agent token"; setAgentState({ connectError: text }); message.warning(text); return; } try { const parsed = new URL(endpoint); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol"); } catch { const text = "本地 Agent 地址格式不正确"; setAgentState({ connectError: text }); message.warning(text); return; } errorLoggedRef.current = false; setAgentState({ url: endpoint, token: token.trim(), enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" }); }; function clearAgentSession(patch: Parameters[0] = {}) { setAgentState({ messages: [], threads: [], activeThreadId: "", workspacePath: "", loadingThreads: false, waiting: false, sending: false, pendingTool: null, ...patch, }); pendingToolRef.current = null; } const startNewThread = async () => { const projectId = snapshotRef.current.projectId; if (!connected || !projectId) return; setAgentState({ loadingThreads: true }); try { const data = await fetchAgentJson(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" }); await loadThreads(); } catch (error) { addEventLog("新建对话失败", error); message.error(error instanceof Error ? error.message : "新建对话失败"); } finally { setAgentState({ loadingThreads: false }); } }; const resumeThread = async (threadId: string) => { const projectId = snapshotRef.current.projectId; if (!connected || !projectId || !threadId) return; setAgentState({ loadingThreads: true }); try { const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" }); await loadThreads(); } catch (error) { addEventLog("恢复对话失败", error); message.error(error instanceof Error ? error.message : "恢复对话失败"); } finally { setAgentState({ loadingThreads: false }); } }; const deleteThread = async (threadId: string) => { const projectId = snapshotRef.current.projectId; if (!connected || !projectId || !threadId) return; setAgentState({ loadingThreads: true }); try { await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); const current = useCanvasAgentStore.getState(); setAgentState({ threads: current.threads.filter((thread) => thread.id !== threadId), activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId, messages: current.activeThreadId === threadId ? [] : current.messages, }); message.success("记录已删除"); } catch (error) { addEventLog("删除对话失败", error); message.error(error instanceof Error ? error.message : "删除对话失败"); } finally { setAgentState({ loadingThreads: false }); } }; const confirmDeleteThread = (thread: AgentThreadSummary) => { const label = thread.name || thread.preview || "未命名对话"; modal.confirm({ title: "删除对话记录", content: `确定删除「${label.length > 48 ? `${label.slice(0, 48)}...` : label}」吗?`, okText: "删除", okType: "danger", cancelText: "取消", onOk: () => deleteThread(thread.id), }); }; const startResize = (event: ReactPointerEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = width; let nextWidth = startWidth; const onMove = (moveEvent: PointerEvent) => { nextWidth = clamp(startWidth + startX - moveEvent.clientX, 360, 760); setAgentState({ width: nextWidth }); }; const onUp = () => { localStorage.setItem("canvas-agent-panel-width", String(nextWidth)); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); setResizing(false); }; setResizing(true); window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }; const addMessage = (item: Omit) => { const text = normalizeText(item.text); if (!text && !item.attachments?.length) return; const next = { ...item, id: `${Date.now()}-${Math.random()}`, text }; const currentMessages = useCanvasAgentStore.getState().messages; if (next.streamId) { const index = currentMessages.findIndex((message) => message.streamId === next.streamId); if (index >= 0) { setAgentState({ messages: currentMessages.map((message, i) => i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message) }); return; } } const last = currentMessages.at(-1); if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) { const merged = mergeAgentText(last.text, next.text); if (merged === last.text) return; setAgentState({ messages: [...useCanvasAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] }); return; } pushMessage(next); }; const addEventLog = (title: string, text: unknown, raw?: unknown) => { pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: new Date().toLocaleTimeString(), title, text: normalizeText(text) || title, raw }); }; const handleAgentEvent = (event: AgentEventPayload) => { if (shouldLogAgentEvent(event)) addEventLog(eventTitle(event), event, event); if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id }); const nextActivity = activityText(event); if (nextActivity) setAgentState({ activity: nextActivity }); if (event.type === "turn.started") setAgentState({ waiting: true }); if (event.type === "turn.completed" || event.type === "turn.failed" || event.type === "error") setAgentState({ waiting: false, sending: false }); const item = formatAgentEvent(event); if (item) { if (item.role === "error") setAgentState({ waiting: false, sending: false }); addMessage(item); } }; const content = ( <> }, { value: "chat", label: "对话" }, { value: "history", label: "历史", icon: , count: threads.length }, { value: "log", label: "日志", icon: , count: eventLogs.length }, ]} onChange={(activeTab) => { setAgentState({ activeTab }); if (activeTab === "history") void loadThreads(); }} right={ <> } /> {activeTab === "setup" ? ( setAgentState({ url, connectError: "" })} onTokenChange={(token) => setAgentState({ token, connectError: "" })} onToggleEnabled={toggleAgentConnection} /> ) : activeTab === "history" ? ( void loadThreads()} onNewThread={() => void startNewThread()} onResumeThread={(threadId) => void resumeThread(threadId)} onDeleteThread={confirmDeleteThread} /> ) : activeTab === "log" ? ( message.success(text)} onCopyBlocked={(text) => message.warning(text)} /> ) : ( <>
{messages.map((item) => ( ))} {pendingTool ? : null} {waiting && !pendingTool ? : null}
setAgentState({ prompt })} onSubmit={sendPrompt} onAddFiles={addAttachments} onRemoveAttachment={removeAttachment} left={attachments.length ? {formatBytes(attachmentPayloadBytes(attachments))} / 30MB : null} /> )} ); if (embedded) return content; return (
{content} ); } function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) { const [mode, setMode] = useState<"text" | "json">("text"); const textareaRef = useRef(null); const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context); const lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${item.text}`)); const copy = async (value = content, tip = "日志已复制") => { if (await copyToClipboard(value)) { onCopied(tip); return; } textareaRef.current?.focus(); textareaRef.current?.select(); onCopyBlocked("已选中日志,请手动复制"); }; return (
运行日志
setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
{logs.length} 条