diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 16b9e22..fb20de8 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -8,7 +8,8 @@ import { canvasThemes } from "@/lib/canvas-theme"; import { randomId } from "@/lib/utils"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; -import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store"; +import { useShallow } from "zustand/react/shallow"; +import { useAgentStore, type AgentAttachment, type AgentCanvasContext, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store"; import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools"; import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui"; @@ -46,9 +47,40 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb const { message, modal } = App.useApp(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, canvasContext, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useAgentStore(); + // 逐字段 selector + useShallow:只有这些字段变化时才重渲染。 + // 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入, + // 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅, + // 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。 + const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore( + useShallow((state) => ({ + width: state.width, + url: state.url, + token: state.token, + connected: state.connected, + enabled: state.enabled, + prompt: state.prompt, + attachments: state.attachments, + sending: state.sending, + waiting: state.waiting, + messages: state.messages, + eventLogs: state.eventLogs, + threads: state.threads, + activeThreadId: state.activeThreadId, + workspacePath: state.workspacePath, + loadingThreads: state.loadingThreads, + activeTab: state.activeTab, + confirmTools: state.confirmTools, + activity: state.activity, + connectError: state.connectError, + pendingTool: state.pendingTool, + })), + ); + const setAgentState = useAgentStore((state) => state.setAgentState); + const pushMessage = useAgentStore((state) => state.addMessage); + const pushEventLog = useAgentStore((state) => state.addEventLog); + const clearEventLogs = useAgentStore((state) => state.clearEventLogs); const listRef = useRef(null); - const canvasContextRef = useRef(canvasContext); + const canvasContextRef = useRef(useAgentStore.getState().canvasContext); const confirmToolsRef = useRef(confirmTools); const pendingToolRef = useRef(null); const autoConnectRef = useRef(false); @@ -81,9 +113,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb } }, [endpoint, setAgentState, token]); + // canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。 useEffect(() => { - canvasContextRef.current = canvasContext; - }, [canvasContext]); + let timer: ReturnType | null = null; + const unsubscribe = useAgentStore.subscribe((state) => { + if (state.canvasContext === canvasContextRef.current) return; + canvasContextRef.current = state.canvasContext; + if (!useAgentStore.getState().connected) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContextRef.current?.snapshot || null), 300); + }); + return () => { + unsubscribe(); + if (timer) clearTimeout(timer); + }; + }, [endpoint, token]); useEffect(() => { confirmToolsRef.current = confirmTools; }, [confirmTools]); @@ -156,12 +200,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb if (connected) void loadThreads(); }, [connected, loadThreads]); - useEffect(() => { - if (!connected) return; - const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContext?.snapshot || null), 300); - return () => clearTimeout(timer); - }, [canvasContext?.snapshot, connected, endpoint, token]); - const sendPrompt = async () => { const text = prompt.trim(); const files = attachments; @@ -175,7 +213,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb 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, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) }); + const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.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 }); @@ -211,12 +253,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb const images = Array.from(files).filter((file) => file.type.startsWith("image/")); const prev = useAgentStore.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 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) => { @@ -295,7 +339,12 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); 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 || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } }); + addMessage({ + role: "tool", + title: `${toolName(payload.name)}完成`, + text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", + detail: { requestId: payload.requestId, name: payload.name, input, result }, + }); } catch (error) { const message = error instanceof Error ? error.message : "画布操作失败"; setAgentState({ activity: "工具失败", waiting: false }); @@ -457,7 +506,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb 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) }); + setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) }); return; } } @@ -554,7 +603,15 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb {messages.map((item) => ( ))} - {pendingTool ? : null} + {pendingTool ? ( + + ) : null} {waiting && !pendingTool ? : null} {formatBytes(attachmentPayloadBytes(attachments))} / 30MB : null} + left={ + attachments.length ? ( + + {formatBytes(attachmentPayloadBytes(attachments))} / 30MB + + ) : null + } /> )} @@ -580,7 +643,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb return embedded ? content : null; } -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 }) { +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); @@ -601,12 +678,28 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
运行日志
- setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} /> + setMode(value as "text" | "json")} + options={[ + { label: "排查日志", value: "text" }, + { label: "原始 JSON", value: "json" }, + ]} + />
- {logs.length} 条 - - - + + {logs.length} 条 + + + +