Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-local-agent-panel.tsx
T

1448 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Segmented } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import copyToClipboard from "copy-to-clipboard";
import { CheckCircle2, Copy, ExternalLink, FolderOpen, History, LoaderCircle, PlugZap, Plus, RefreshCw, Terminal, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes } from "@/lib/canvas-theme";
import { consumeLocalRuntimeEventStream, postCanvasRuntimeState, prepareCanvasRuntimeConnection, waitForCanvasRuntimeReconnect, type LocalRuntimeEvent } from "@/lib/canvas/local-runtime-connection";
import { createClientId } from "@/lib/client-id";
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import {
canvasAgentConnectionStatusText,
canvasAgentConnectionStartingPatch,
canvasAgentTransientDisconnectPatch,
useCanvasAgentStore,
type AgentAttachment,
type AgentChatItem,
type AgentEventLog,
type AgentPanelTab,
type AgentPendingToolCall,
type AgentThreadSummary,
} from "@/stores/canvas/use-canvas-agent-store";
import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { skillRuntime } from "@/services/skill-runtime";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
import { AgentChatComposer, AgentChatMessage, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const MAX_AGENT_TURN_PAYLOAD_BYTES = 56 * 1024 * 1024;
type AgentEventPayload = {
agent?: string;
type?: string;
thread_id?: string;
item?: AgentEventItem;
error?: { message?: string };
message?: string;
usage?: Record<string, unknown>;
};
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 = { 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[] };
type AgentTurnPayload = {
text: string;
prompt: string;
canvasId: string;
threadId?: string;
attachments: Array<Pick<AgentAttachment, "name" | "type" | "dataUrl">>;
skills: Array<{ skillId: string; name: string; description: string; version: string; files: Array<{ path: string; mimeType: string; contentBase64: string }> }>;
};
export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
snapshot,
canUndoOps,
undoOpsCount = 0,
collapsed,
embedded,
headless,
autoConnect,
onApplyOps,
onUndoOps,
}: {
snapshot: CanvasAgentSnapshot;
canUndoOps: boolean;
undoOpsCount?: number;
collapsed?: boolean;
embedded?: boolean;
headless?: boolean;
autoConnect?: boolean;
onApplyOps: (ops: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise<CanvasAgentSnapshot>;
onUndoOps: () => CanvasAgentSnapshot | null;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const { message, modal } = App.useApp();
const {
width,
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 [retryTurn, setRetryTurn] = useState<AgentTurnPayload | null>(null);
const [retryMessageId, setRetryMessageId] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
// 供 Agent 输入框「@」插入的画布节点引用候选(active 标记为可用,供「@」菜单列出),与「/」弹出的已加入技能候选
const composerReferences = useMemo(() => buildCanvasResourceReferences(snapshot.nodes, snapshot.connections).map((item) => ({ ...item, active: true })), [snapshot]);
const [composerSkills, setComposerSkills] = useState<Skill[]>([]);
useEffect(() => {
let cancelled = false;
listAddedSkills()
.then((result) => {
if (!cancelled) setComposerSkills(result?.skills ?? []);
})
.catch(() => {
// 技能列表加载失败只影响「/」菜单,不影响输入主功能
});
return () => {
cancelled = true;
};
}, []);
const snapshotRef = useRef(snapshot);
const confirmToolsRef = useRef(confirmTools);
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
const onApplyOpsRef = useRef(onApplyOps);
const autoConnectRef = useRef(false);
const connectedRef = useRef(false);
const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(createClientId());
const runtimeRevisionRef = useRef(0);
const runtimeStateHashRef = useRef("");
const runtimeCanonicalStateHashRef = useRef("");
const runtimeSyncQueueRef = useRef<Promise<void>>(Promise.resolve());
const connectionControllerRef = useRef<AbortController | null>(null);
const activeToolRequestIdsRef = useRef(new Set<string>());
const recoveredToolResultIdsRef = useRef(new Set<string>());
const activeTurnRef = useRef<AgentTurnPayload | null>(null);
const syncState = useCallback(
(clientId: string, nextSnapshot: CanvasAgentSnapshot) => {
const stateHash = hashCanvasAgentSnapshot(nextSnapshot);
if (runtimeStateHashRef.current !== stateHash) {
runtimeRevisionRef.current = runtimeStateHashRef.current ? runtimeRevisionRef.current + 1 : nextSnapshot.revision ?? 0;
runtimeStateHashRef.current = stateHash;
}
// Canvas Agent 的 canonical hash 是服务端 SHA-256;浏览器本地校验使用轻量 FNV hash。
// 不把浏览器 hash 当作服务端 hash 上送,避免跨运行时误判;服务端会根据完整快照重新计算。
const envelope = { ...nextSnapshot, revision: runtimeRevisionRef.current };
const queued = runtimeSyncQueueRef.current
.catch(() => undefined)
.then(async () => {
const result = await postCanvasRuntimeState(getLocalRuntimeSessionClient(), clientId, envelope);
runtimeRevisionRef.current = result.revision;
runtimeCanonicalStateHashRef.current = result.stateHash;
return result;
})
.catch(() => {
pushEventLog({
id: `${Date.now()}-${Math.random()}`,
time: new Date().toLocaleTimeString(),
title: "状态同步失败",
text: "本机 Runtime 暂未接收画布状态",
});
return undefined;
});
runtimeSyncQueueRef.current = queued.then(() => undefined);
return queued;
},
[pushEventLog],
);
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<AgentThreadsResponse>(`/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<AgentThreadResponse>(`/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`);
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
}
} catch (error) {
addEventLog("读取历史失败", error);
} finally {
setAgentState({ loadingThreads: false });
}
}, [setAgentState]);
useEffect(() => {
snapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
runtimeRevisionRef.current = 0;
runtimeStateHashRef.current = "";
runtimeCanonicalStateHashRef.current = "";
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
}, [snapshot.projectId]);
useEffect(() => {
if (!connected) return;
const clientId = clientIdRef.current;
snapshot.nodes.forEach((node) => {
const continuation = node.metadata?.agentGenerationContinuation;
const requestId = continuation?.source === "local" && continuation.status === "completed" ? continuation.messageId : undefined;
if (!requestId || activeToolRequestIdsRef.current.has(requestId) || recoveredToolResultIdsRef.current.has(requestId)) return;
recoveredToolResultIdsRef.current.add(requestId);
void postToolResult(clientId, { requestId, result: snapshot })
.then(() => {
syncState(clientId, snapshot);
})
.catch(() => {
recoveredToolResultIdsRef.current.delete(requestId);
});
});
}, [connected, snapshot, syncState]);
useEffect(() => {
confirmToolsRef.current = confirmTools;
}, [confirmTools]);
useEffect(() => {
pendingToolRef.current = pendingTool;
}, [pendingTool]);
useEffect(() => {
onApplyOpsRef.current = onApplyOps;
}, [onApplyOps]);
useEffect(() => {
if (activeTab !== "chat") return;
const frame = requestAnimationFrame(() => listRef.current?.scrollTo({ top: listRef.current.scrollHeight }));
return () => cancelAnimationFrame(frame);
}, [activeTab, activeThreadId, messages, pendingTool, waiting]);
useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
connectionControllerRef.current = controller;
const clientId = clientIdRef.current;
let lastEventId = "";
const receive = (event: LocalRuntimeEvent) => {
if (event.id) lastEventId = event.id;
if (event.type === "hello") {
errorLoggedRef.current = false;
connectedRef.current = true;
setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
if (!headless) message.success("本地 Agent 已连接");
syncState(clientId, snapshotRef.current);
return;
}
if (event.type === "tool_call") {
const data = parseEventJson<AgentPendingToolCall>(event.data);
if (data) void handleToolCall(data);
return;
}
if (event.type === "agent_event") {
const data = parseEventJson<AgentEventPayload>(event.data);
if (data) handleAgentEvent(data);
return;
}
if (event.type === "agent_log") {
const text = parseEventJson<{ text?: unknown }>(event.data)?.text;
addEventLog("日志", text, text);
return;
}
if (event.type === "agent_error") {
const errorMessage = parseEventJson<{ message?: unknown }>(event.data)?.message;
setAgentState({ activity: "出错", waiting: false, sending: false });
const messageId = addMessage({ role: "error", title: "错误", text: normalizeText(errorMessage) || "本地 Agent 执行失败,请重试本轮。" });
if (activeTurnRef.current) {
setRetryTurn(activeTurnRef.current);
setRetryMessageId(messageId || null);
}
addEventLog("错误", errorMessage, errorMessage);
return;
}
if (event.type === "agent_done") {
setAgentState({ activity: "完成", waiting: false, sending: false });
void loadThreads();
}
};
void (async () => {
while (!controller.signal.aborted) {
try {
await prepareCanvasRuntimeConnection(useLocalRuntimeStore, controller.signal);
await consumeLocalRuntimeEventStream(getLocalRuntimeSessionClient(), `/events?clientId=${encodeURIComponent(clientId)}`, { signal: controller.signal, lastEventId, onEvent: receive });
if (!controller.signal.aborted) throw new Error("Canvas stream closed");
} catch (error) {
if (controller.signal.aborted) return;
const wasConnected = connectedRef.current;
const text = wasConnected ? "本地 Agent 连接已断开,正在重连" : "本地 Agent 连接失败,请检查本机 Runtime";
if (!errorLoggedRef.current || wasConnected) {
addEventLog(wasConnected ? "连接断开" : "连接失败", text);
if (!headless) message.error(text);
}
errorLoggedRef.current = true;
connectedRef.current = false;
setAgentState(canvasAgentTransientDisconnectPatch(wasConnected ? "正在重连" : "连接失败", text));
await waitForCanvasRuntimeReconnect(controller.signal);
}
}
})();
return () => {
controller.abort();
if (connectionControllerRef.current === controller) connectionControllerRef.current = null;
connectedRef.current = false;
setAgentState({ connected: false });
};
}, [enabled, loadThreads, message, setAgentState, syncState]);
useEffect(() => {
if (connected) void loadThreads();
}, [connected, loadThreads, snapshot.projectId]);
useEffect(() => {
if (activeTab === "history" && connected) void loadThreads();
}, [activeTab, connected, loadThreads]);
useEffect(() => {
if (!connected) return;
const timer = setTimeout(() => syncState(clientIdRef.current, snapshot), 300);
return () => clearTimeout(timer);
}, [connected, snapshot, syncState]);
const submitTurn = async (payload: AgentTurnPayload, appendUserMessage: boolean) => {
const state = useCanvasAgentStore.getState();
if (!state.connected || state.sending || state.waiting) return;
activeTurnRef.current = payload;
if (appendUserMessage) {
setRetryTurn(null);
setRetryMessageId(null);
}
setAgentState({ activity: appendUserMessage ? "发送中" : "重试中", sending: true, waiting: true });
if (appendUserMessage) {
const files = state.attachments;
addMessage({ role: "user", text: payload.text || "发送了图片", attachments: files });
addEventLog("用户发送", { text: payload.text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
files.forEach((item) => {
URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url);
});
setAgentState({ prompt: "", attachments: [] });
} else {
addEventLog("重试本轮", { threadId: payload.threadId, attachments: payload.attachments.map(({ name, type }) => ({ name, type })) });
}
try {
const data = await fetchAgentJson<{ threadId?: string }>("/agent/codex/turn", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
prompt: payload.prompt,
canvasId: payload.canvasId,
threadId: payload.threadId,
attachments: payload.attachments,
skills: payload.skills,
}),
});
const acceptedPayload = data.threadId ? { ...payload, threadId: data.threadId } : payload;
activeTurnRef.current = acceptedPayload;
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
addEventLog(appendUserMessage ? "本地 Agent 已接收" : "本地 Agent 已接收重试", { accepted: true });
} catch (error) {
setRetryTurn(activeTurnRef.current);
setAgentState({ activity: "发送失败", waiting: false });
const messageId = addMessage({ role: "error", title: appendUserMessage ? "发送失败" : "重试失败", text: error instanceof Error ? error.message : "发送失败" });
setRetryMessageId(messageId || null);
addEventLog(appendUserMessage ? "发送失败" : "重试失败", error);
} finally {
setAgentState({ sending: false });
}
};
const sendPrompt = async (overrideText?: string) => {
const text = (overrideText ?? prompt).trim();
const files = attachments;
if (!connected || !promptWithAttachments(text, files) || sending || waiting) return;
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
return;
}
let skillExecution: Awaited<ReturnType<typeof skillRuntime.prepare<"localAgent">>>;
try {
skillExecution = await skillRuntime.prepare({ profile: "localAgent", prompt: text, skills: composerSkills });
} catch (error) {
addMessage({ role: "error", title: "技能加载失败", text: error instanceof Error ? error.message : "无法读取技能包" });
return;
}
const requestPrompt = promptWithAttachments(skillExecution.prompt, files);
const skillBundles: AgentTurnPayload["skills"] = skillExecution.skills;
if (attachmentPayloadBytes(files) + skillBundlePayloadBytes(skillBundles) > MAX_AGENT_TURN_PAYLOAD_BYTES) {
addMessage({ role: "error", title: "本轮内容过大", text: "图片与技能包合计超过本地 Agent 单轮限制,请减少附件或只选择一个技能。" });
return;
}
await submitTurn({
text,
prompt: requestPrompt,
canvasId: snapshotRef.current.projectId,
threadId: useCanvasAgentStore.getState().activeThreadId || undefined,
attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })),
skills: skillBundles,
}, true);
};
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 (payload: AgentPendingToolCall) => {
if (confirmToolsRef.current && (payload.name === "canvas_apply_ops" || (isProjectAgentToolName(payload.name) && !isProjectAgentReadTool(payload.name)))) {
if (pendingToolRef.current) {
await postToolResult(clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" });
return;
}
pendingToolRef.current = payload;
setAgentState({ pendingTool: payload, activity: "等待确认", waiting: false });
addEventLog("等待确认", payload, payload);
return;
}
await runToolCall(payload);
};
const runToolCall = async (payload: AgentPendingToolCall) => {
activeToolRequestIdsRef.current.add(payload.requestId);
try {
const input = (payload.input || {}) as Record<string, unknown>;
const projectToolName = isProjectAgentToolName(payload.name) ? payload.name : null;
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : projectToolName ? "执行项目工具" : "读取画布", waiting: true });
addEventLog(toolName(payload.name), payload, payload);
if (payload.name === "canvas_apply_ops") {
const currentSnapshot = snapshotRef.current;
if (typeof input.expectedRevision === "number" && input.expectedRevision !== runtimeRevisionRef.current) throw new Error(`画布 revision 已从 ${input.expectedRevision} 变为 ${runtimeRevisionRef.current},请重新读取 canvas_get_context 后再执行写操作`);
// expectedStateHash is the Canvas Agent Runtime's canonical
// SHA-256 hash. The browser intentionally uses a different,
// synchronous fingerprint for local bookkeeping, so the
// Runtime has already performed the authoritative check before
// emitting this tool call. Comparing the two algorithms here
// would reject every valid local write.
const validation = validateCanvasAgentOps(currentSnapshot, (input.ops || []) as CanvasAgentOp[]);
if (!validation.ok) throw new Error(`画布操作校验失败:${validation.issues.filter((item) => item.severity === "error").map((item) => item.message).join(";")}`);
}
const result =
payload.name === "canvas_apply_ops"
? await (async () => {
const before = snapshotRef.current;
const next = await onApplyOpsRef.current((input.ops || []) as CanvasAgentOp[], { source: "local", conversationId: activeThreadId || clientIdRef.current, messageId: payload.requestId });
const verification = verifyCanvasAgentOps(before, next, (input.ops || []) as CanvasAgentOp[]);
return {
ok: verification.ok,
message: canvasAgentPostconditionMessage(verification),
data: { verification, snapshot: next },
snapshot: next,
};
})()
: payload.name === "canvas_get_state" || payload.name === "canvas_export_snapshot"
? snapshotRef.current
: payload.name === "canvas_get_context"
? await readLocalCanvasContext()
: payload.name === "canvas_find_nodes"
? findCanvasAgentNodes(snapshotRef.current, input as Parameters<typeof findCanvasAgentNodes>[1])
: payload.name === "canvas_get_node"
? getCanvasAgentNode(snapshotRef.current, { id: requireString(input.id, "id") })
: payload.name === "canvas_get_connection"
? getCanvasAgentConnection(snapshotRef.current, { id: requireString(input.id, "id") })
: payload.name === "canvas_get_generation_tasks"
? getCanvasAgentGenerationTasks(snapshotRef.current, input as Parameters<typeof getCanvasAgentGenerationTasks>[1])
: payload.name === "canvas_get_resources"
? getCanvasAgentResources(snapshotRef.current, input as Parameters<typeof getCanvasAgentResources>[1])
: payload.name === "canvas_validate_ops"
? validateCanvasAgentOps(snapshotRef.current, (input.ops || []) as CanvasAgentOp[])
: payload.name === "canvas_get_selection"
? (() => {
const ids = new Set(snapshotRef.current.selectedNodeIds || []);
return { nodes: snapshotRef.current.nodes.filter((node) => ids.has(node.id)) };
})()
: projectToolName
? await runProjectAgentTool(projectToolName, input, snapshotRef.current.domainProjectId)
: snapshotRef.current;
await postToolResult(clientIdRef.current, { requestId: payload.requestId, result });
if (payload.name === "canvas_apply_ops") syncState(clientIdRef.current, (result as { snapshot?: CanvasAgentSnapshot }).snapshot || snapshotRef.current);
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({
role: "tool",
title: `${toolName(payload.name)}完成`,
text: payload.name === "canvas_apply_ops" ? (result as { message?: string }).message || summarizeCanvasAgentOps((input.ops || []) as CanvasAgentOp[]) || "画布操作" : "已完成",
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(clientIdRef.current, { requestId: payload.requestId, error: message });
} finally {
activeToolRequestIdsRef.current.delete(payload.requestId);
}
};
const readLocalCanvasContext = async () => {
// Flush the latest browser snapshot first, then expose the Runtime's
// canonical state hash to Codex. This keeps the local MCP path's
// read→write precondition compatible with the server-side path.
const synced = await syncState(clientIdRef.current, snapshotRef.current);
const stateHash = synced?.stateHash || runtimeCanonicalStateHashRef.current;
return buildCanvasAgentContext(snapshotRef.current, {
...(stateHash ? { stateHash, hashSource: "canvas-agent-server" as const } : {}),
});
};
const rejectPendingTool = async () => {
if (!pendingTool) return;
await postToolResult(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(tool);
};
const undoLastTool = () => {
const restored = onUndoOps();
if (!restored) return;
setAgentState({ activity: "已撤销" });
addMessage({ role: "tool", title: "已撤销 Agent 批次", text: "已恢复到本次写回前的画布状态", detail: restored });
if (connected) syncState(clientIdRef.current, restored);
};
const toggleAgentConnection = () => {
if (enabled) {
connectionControllerRef.current?.abort();
pendingToolRef.current = null;
setAgentState({ enabled: false, connected: false, activity: "离线", connectError: "", waiting: false, sending: false, pendingTool: null });
return;
}
errorLoggedRef.current = false;
setAgentState(canvasAgentConnectionStartingPatch());
};
useEffect(() => {
if (!autoConnect || autoConnectRef.current || enabled || connected) return;
autoConnectRef.current = true;
void toggleAgentConnection();
}, [autoConnect, connected, enabled]);
const startNewThread = async () => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>("/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
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<AgentThreadResponse>(`/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
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(`/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const current = useCanvasAgentStore.getState();
if (current.activeThreadId === threadId) {
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
}
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<HTMLDivElement>) => {
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<AgentChatItem, "id">) => {
const text = normalizeText(item.text);
if (!text && !item.attachments?.length) return undefined;
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 currentMessages[index].id;
}
}
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 last.id;
setAgentState({ messages: [...useCanvasAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
return last.id;
}
pushMessage(next);
return next.id;
};
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") {
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
setAgentState({ waiting: false, sending: false });
} else if (event.type === "turn.failed" || event.type === "error") {
if (activeTurnRef.current) setRetryTurn(activeTurnRef.current);
setAgentState({ waiting: false, sending: false });
}
const item = formatAgentEvent(event);
if (item) {
if (item.role === "error") setAgentState({ waiting: false, sending: false });
const messageId = addMessage(item);
if ((event.type === "turn.failed" || event.type === "error") && activeTurnRef.current && item.role === "error") setRetryMessageId(messageId || null);
}
};
const content = (
<>
<div className="flex min-h-8 shrink-0 items-center justify-end gap-1 px-3 pb-1">
<div className="mr-auto min-w-0 truncate px-1 text-[var(--fs-tiny)]" style={{ color: connected ? "#16a34a" : theme.node.muted }}>
{connected ? "本机 Agent 已连接" : canvasAgentConnectionStatusText({ enabled, connected, activity, connectError })}
</div>
{!connected ? (
<Button size="small" type={enabled ? "default" : "primary"} className="!h-7 !px-2.5" icon={<PlugZap className="size-3.5" />} onClick={toggleAgentConnection}>
{enabled ? "连接中" : "连接"}
</Button>
) : null}
<Tooltip title={threads.length ? `历史会话 · ${threads.length}` : "历史会话"}>
<Button type="text" className={`!h-7 !min-w-7 !px-1.5 ${activeTab === "history" ? "font-medium" : ""}`} style={{ color: activeTab === "history" ? theme.node.text : theme.node.muted, background: activeTab === "history" ? theme.spatial.surface : "transparent" }} icon={<History className="size-3.5" />} onClick={() => setAgentState({ activeTab: activeTab === "history" ? "chat" : "history" })} aria-label="打开历史会话">
{threads.length ? <span className="text-[var(--fs-tiny)] tabular-nums">{threads.length}</span> : null}
</Button>
</Tooltip>
<Tooltip title="新对话">
<Button type="text" shape="circle" className="!h-7 !w-7 !min-w-7" disabled={!connected || loadingThreads} style={{ color: theme.node.muted }} icon={<Plus className="size-3.5" />} onClick={() => void startNewThread()} aria-label="新建对话" />
</Tooltip>
</div>
{!connected ? (
<AgentConnectView
theme={theme}
enabled={enabled}
activity={activity}
connectError={connectError}
onToggleEnabled={toggleAgentConnection}
/>
) : activeTab === "history" ? (
<AgentHistoryView
theme={theme}
threads={threads}
activeThreadId={activeThreadId}
workspacePath={workspacePath}
loading={loadingThreads}
connected={connected}
onRefresh={() => void loadThreads()}
onNewThread={() => void startNewThread()}
onResumeThread={(threadId) => void resumeThread(threadId)}
onDeleteThread={confirmDeleteThread}
/>
) : (
<>
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{!messages.length && !pendingTool && !waiting ? (
<AgentChatEmptyState
theme={theme}
nodeCount={snapshot.nodes.length}
onSelect={(text) => {
setAgentState({ prompt: text });
void sendPrompt(text);
}}
/>
) : null}
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} isStreaming={(sending || waiting) && item.id === messages.at(-1)?.id && item.role === "assistant"} retrying={sending && item.id === retryMessageId} onRetry={item.id === retryMessageId && retryTurn ? () => void submitTurn(retryTurn, false) : undefined} onQuickAction={(text) => void sendPrompt(text)} />
))}
{pendingTool ? (
<AgentPendingToolCard
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input, impact: previewCanvasAgentOps(pendingTool.input?.ops || [], snapshot) }}
theme={theme}
onReject={rejectPendingTool}
onApprove={approvePendingTool}
/>
) : null}
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
</div>
<AgentChatComposer
prompt={prompt}
attachments={attachments.map(agentAttachmentToChatAttachment)}
disabled={!connected}
sending={sending || waiting}
placeholder="询问 Codex,或让它操作画布"
theme={theme}
references={composerReferences}
slashSkills={composerSkills}
includeAssetLibrary
onPromptChange={(prompt) => setAgentState({ prompt })}
onSubmit={sendPrompt}
onAddFiles={addAttachments}
onRemoveAttachment={removeAttachment}
left={
<>
<VoiceRecordingButton disabled={!connected || sending || waiting} onTranscribed={(text) => setAgentState({ prompt: prompt.trim() ? `${prompt} ${text}` : text })} />
{attachments.length ? (
<span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB
</span>
) : null}
</>
}
/>
</>
)}
</>
);
if (headless) return null;
if (embedded) return content;
return (
<motion.div
className="relative z-[var(--z-panel-floating)] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: collapsed ? 0 : width + 1, opacity: collapsed ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: collapsed ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: collapsed ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
{content}
</motion.aside>
</motion.div>
);
});
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<HTMLTextAreaElement>(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 (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="flex min-h-full flex-col gap-3">
<div>
<div className="text-base font-semibold leading-6">运行日志</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Segmented
size="small"
value={mode}
onChange={(value) => setMode(value as "text" | "json")}
options={[
{ label: "排查日志", value: "text" },
{ label: "原始 JSON", value: "json" },
]}
/>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: theme.node.muted }}>
{logs.length} 条
</span>
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
复制
</Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
最近错误
</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
清空
</Button>
</div>
</div>
<textarea
ref={textareaRef}
readOnly
value={content}
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-md border-0 p-3 font-mono text-xs leading-5 outline-none"
style={{ background: theme.spatial.surface, color: theme.node.text }}
onFocus={(event) => event.currentTarget.select()}
/>
</div>
</div>
);
}
function AgentConnectView({
theme,
enabled,
activity,
connectError,
onToggleEnabled,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
enabled: boolean;
activity: string;
connectError: string;
onToggleEnabled: () => void;
}) {
const { message } = App.useApp();
const [platform, setPlatform] = useState<LocalAgentSetupPlatform>(() => detectLocalAgentSetupPlatform());
const origin = typeof window === "undefined" ? "http://localhost:3000" : window.location.origin;
const commands = buildLocalAgentSetupCommands(origin, platform);
const statusText = canvasAgentConnectionStatusText({ enabled, connected: false, activity, connectError });
const statusColor = connectError ? "#dc2626" : enabled ? "#d97706" : theme.node.muted;
const copyCommand = async (value: string, label: string) => {
if (await copyToClipboard(value)) {
message.success(`${label}已复制`);
return;
}
message.warning("复制失败,请手动复制");
};
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="mx-auto w-full max-w-[620px] space-y-5 pb-4">
<div>
<div className="text-base font-semibold leading-6">连接本地 Agent</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
在这台电脑启动 Canvas Agent 后,网页会通过 127.0.0.1 建立本机安全连接。连接成功后,这里会自动恢复原对话。
</div>
</div>
<div className="rounded-md p-3" style={{ background: theme.spatial.surface }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[var(--fs-label)] leading-4" style={{ background: theme.node.fill, color: statusColor }}>
{enabled ? <LoaderCircle className="size-3 shrink-0 animate-spin" /> : <span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />}
<span className="truncate">{statusText}</span>
</span>
</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
{enabled ? "正在检测本机 127.0.0.1:17371;服务启动后会自动连接。" : "先完成下面两步,再点击连接。"}
</div>
</div>
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
{enabled ? "停止重试" : connectError ? "重新连接" : "开始连接"}
</Button>
</div>
<div className="mt-3 grid gap-2.5">
{connectError ? (
<div className="rounded-md px-2.5 py-2 text-xs leading-5" style={{ background: "rgba(220,38,38,.08)", color: "#dc2626" }}>
{connectError}
</div>
) : null}
</div>
</div>
<div>
<section className="border-b pb-4" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-start gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold" style={{ background: theme.node.fill, color: theme.node.text }}>1</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-medium leading-5">安装本机 Runtime</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>需要 Git、Node.js 18+。当前版本从 GitHub 源码安装。</div>
</div>
<Segmented
size="small"
value={platform}
options={[
{ label: "macOS / Linux", value: "unix" },
{ label: "Windows", value: "windows" },
]}
onChange={(value) => setPlatform(value as LocalAgentSetupPlatform)}
/>
</div>
<CommandBlock value={commands.install} theme={theme} onCopy={() => copyCommand(commands.install, "安装命令")} />
</div>
</div>
</section>
<section className="border-b py-4" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-start gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold" style={{ background: theme.node.fill, color: theme.node.text }}>2</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-5">启动并授权当前站点</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
命令已绑定 <span className="font-mono">{origin}</span>。保持终端运行,再回到上方开始连接。
</div>
<CommandBlock value={commands.start} theme={theme} onCopy={() => copyCommand(commands.start, "启动命令")} />
</div>
</div>
</section>
<section className="pt-4">
<div className="flex items-start gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.node.fill, color: theme.node.text }}><CheckCircle2 className="size-3.5" /></span>
<div className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
<div className="text-sm font-medium" style={{ color: theme.node.text }}>当前支持范围</div>
<div className="mt-1">网页侧边栏使用 Codex;外部 MCP 已提供 Codex 与 Claude Code 接入。Hermes、WorkBuddy 尚未验证。Codex 需要已登录或配置可用凭据。</div>
<Button type="link" size="small" className="mt-1 !h-7 !px-0" href="https://github.com/ddcat-ai/open-ai-canvas/tree/main/canvas-agent" target="_blank" rel="noreferrer" icon={<ExternalLink className="size-3.5" />}>
查看完整说明
</Button>
</div>
</div>
</section>
</div>
</div>
</div>
);
}
function CommandBlock({
value,
theme,
onCopy,
}: {
value: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onCopy: () => void | Promise<void>;
}) {
return (
<div className="mt-3 overflow-hidden rounded-md" style={{ background: "rgba(0,0,0,.22)" }}>
<div className="flex items-center justify-between gap-2 border-b px-3 py-1.5" style={{ borderColor: theme.node.stroke }}>
<span className="inline-flex items-center gap-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
<Terminal className="size-3.5" />
终端命令
</span>
<Button type="text" size="small" className="!h-6 !px-1.5" icon={<Copy className="size-3.5" />} onClick={() => void onCopy()} aria-label="复制终端命令">
复制
</Button>
</div>
<pre className="thin-scrollbar overflow-x-auto whitespace-pre p-3 font-mono text-[11px] leading-5" style={{ color: theme.node.text }}>{value}</pre>
</div>
);
}
function AgentHistoryView({
theme,
threads,
activeThreadId,
workspacePath,
loading,
connected,
onRefresh,
onNewThread,
onResumeThread,
onDeleteThread,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
threads: AgentThreadSummary[];
activeThreadId: string;
workspacePath: string;
loading: boolean;
connected: boolean;
onRefresh: () => void;
onNewThread: () => void;
onResumeThread: (threadId: string) => void;
onDeleteThread: (thread: AgentThreadSummary) => void;
}) {
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
<FolderOpen className="size-3.5 shrink-0" />
<span className="shrink-0">工作空间</span>
<span className="min-w-0 truncate" title={workspacePath}>
{workspacePath || "默认画布目录"}
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm" style={{ color: theme.node.muted }}>
{threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"}
</div>
<div className="flex items-center gap-2">
<Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}>
刷新
</Button>
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading} onClick={onNewThread}>
新对话
</Button>
</div>
</div>
<div className="space-y-2">
{threads.map((thread) => {
const active = thread.id === activeThreadId;
return (
<div key={thread.id} className="rounded-md px-2.5 py-2 transition-colors" style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text }}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{active ? (
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>
当前
</span>
) : null}
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
</div>
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{thread.preview || thread.id}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className="text-[var(--fs-tiny)] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
<Button size="small" className="!h-6 !px-2" disabled={loading} onClick={() => onResumeThread(thread.id)}>
进入
</Button>
<Tooltip title="删除记录">
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" disabled={loading} icon={<Trash2 className="size-3.5" />} onClick={() => onDeleteThread(thread)} />
</Tooltip>
</div>
</div>
</div>
);
})}
{!threads.length ? (
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
{connected ? "当前工作空间还没有对话记录" : "连接本地 Agent 后显示历史记录"}
</div>
) : null}
</div>
</div>
</div>
);
}
async function postToolResult(clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
const response = await getLocalRuntimeSessionClient().request(`/canvas/result?clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
if (!response.ok) throw new Error("Canvas Agent 工具结果写回失败");
}
function agentMessageToChatMessage(item: AgentChatItem) {
return { ...item, attachments: item.attachments?.map(agentAttachmentToChatAttachment) };
}
function agentAttachmentToChatAttachment(item: AgentAttachment): CanvasAgentChatAttachment {
return { id: item.id, name: item.name, url: item.dataUrl || item.url };
}
function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
const item = event.item;
if (event.type === "turn.failed" || event.type === "error") return { role: "error", title: "模型调用失败", text: normalizeText(event.error?.message || event.message) || "模型调用失败,请重试本轮。", detail: event };
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
if ((event.type === "item.updated" || event.type === "item.completed") && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text), meta: usageText(event), streamId: item.id };
if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) };
const text = eventText(event);
if (text) return { role: "assistant", title: "Codex", text, meta: usageText(event) };
return null;
}
function parseEventJson<T>(data: string) {
try {
return JSON.parse(data) as T;
} catch {
return null;
}
}
function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
const head = [
"站点 Canvas Agent 诊断日志",
`连接: ${context.connected ? "在线" : context.enabled ? "连接中" : "未启用"}`,
`状态: ${context.activity}`,
`waiting: ${context.waiting}`,
`sending: ${context.sending}`,
`messages: ${context.messages}`,
`pendingTool: ${context.pendingTool ? toolName(context.pendingTool) : "none"}`,
`logs: ${logs.length}`,
].join("\n");
const body = logs
.map((item, index) => {
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
})
.join("\n\n---\n\n");
return [head, body || "暂无事件日志"].join("\n\n");
}
function formatLogJson(logs: AgentEventLog[], context: AgentLogContext) {
return JSON.stringify({ context, logs: logs.map(({ time, title, text, raw }) => ({ time, title, text, raw })) }, null, 2);
}
function eventText(event: AgentEventPayload) {
return event.type === "item.completed" && event.item?.type === "agent_message" ? stringText(event.item.text) : "";
}
function usageText(event: AgentEventPayload) {
const usage = event.usage;
if (!usage || typeof usage !== "object") return undefined;
const total = numberField(usage, "total_tokens");
const input = numberField(usage, "input_tokens");
const output = numberField(usage, "output_tokens");
if (total) return `${total} tok`;
if (input || output) return `${input || 0}/${output || 0} tok`;
return undefined;
}
function activityText(event: AgentEventPayload) {
const name = event.type || "";
if (name === "thread.started") return "已创建会话";
if (name === "turn.started") return "思考中";
if (name === "turn.completed") return "完成";
if (name === "turn.failed" || name === "error") return "出错";
if (name === "item.started") return isMcpToolItem(event.item) ? `调用${toolName(String(event.item?.tool || ""))}` : "执行步骤";
if (name === "item.completed") return isMcpToolItem(event.item) ? "工具完成" : "更新消息";
return "";
}
function eventTitle(event: AgentEventPayload) {
const item = event.item;
if (event.type === "thread.started") return "已创建 Codex 会话";
if (event.type === "turn.started") return "开始处理";
if (event.type === "turn.completed") return "本轮完成";
if (event.type === "stream.summary") return "流式摘要";
if (event.type === "turn.failed" || event.type === "error") return "本轮失败";
if (event.type === "item.started" && isMcpToolItem(item)) return `调用工具:${toolName(String(item?.tool || ""))}`;
if (event.type === "item.completed" && isMcpToolItem(item)) return `工具完成:${toolName(String(item?.tool || ""))}`;
if (event.type === "item.completed" && item?.type === "agent_message") return "Codex 回复";
return event.type || "Codex 事件";
}
function shouldLogAgentEvent(event: AgentEventPayload) {
const itemType = event.item?.type || "";
return !["item.updated"].includes(event.type || "") && !["reasoning"].includes(itemType) && !(event.type === "item.started" && itemType === "agent_message");
}
function isConnectionErrorMessage(item: AgentChatItem) {
return item.role === "error" && /连接失败|无法连接本地 Agent|本地 Agent 连接失败/.test(item.text);
}
function toolName(name: string) {
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_context") return "读取上下文";
if (name === "canvas_find_nodes") return "检索节点";
if (name === "canvas_get_node") return "读取节点";
if (name === "canvas_get_connection") return "读取连线";
if (name === "canvas_get_generation_tasks") return "读取生成任务";
if (name === "canvas_get_resources") return "读取资源";
if (name === "canvas_validate_ops") return "校验操作";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_node") return "创建节点";
if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_text_nodes") return "批量创建文本";
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
if (name === "canvas_create_generation_flow") return "创建生成流程";
if (name === "canvas_generate_text") return "生成文本";
if (name === "canvas_generate_image") return "生成图片";
if (name === "canvas_generate_video") return "生成视频";
if (name === "canvas_generate_audio") return "生成音频";
if (name === "canvas_update_node") return "更新节点";
if (name === "canvas_update_node_text") return "更新文本";
if (name === "canvas_move_nodes") return "移动节点";
if (name === "canvas_resize_node") return "调整节点尺寸";
if (name === "canvas_delete_nodes") return "删除节点";
if (name === "canvas_connect_nodes") return "连接节点";
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
if (name === "project_get_context") return "读取项目上下文";
if (name === "project_list_units") return "读取项目章节";
if (name === "project_extract_asset_candidates") return "登记资产候选";
if (name === "project_confirm_asset_candidate") return "确认资产候选";
if (name === "project_create_or_update_shots") return "保存项目镜头";
if (name === "project_link_shot_asset") return "关联镜头素材";
if (name === "project_start_workflow_step") return "启动流程步骤";
if (name === "project_link_asset") return "引用项目资产";
if (name === "project_upsert_asset_version") return "创建资产版本";
if (name === "project_register_task_output") return "登记任务产物";
return name;
}
function isReadTool(name: string) {
return name === "canvas_get_state" || name === "canvas_get_context" || name === "canvas_find_nodes" || name === "canvas_get_node" || name === "canvas_get_connection" || name === "canvas_get_generation_tasks" || name === "canvas_get_resources" || name === "canvas_validate_ops" || name === "canvas_get_selection" || name === "canvas_export_snapshot" || isProjectAgentReadTool(name);
}
function isMcpToolItem(item?: AgentEventItem) {
return item?.type === "mcp_tool_call";
}
function toolDetail(item?: AgentEventItem) {
return { server: item?.server, tool: item?.tool, status: item?.status, arguments: item?.arguments, result: parseToolResult(item?.result), error: item?.error };
}
function toolSummary(item?: AgentEventItem) {
const result = parseToolResult(item?.result);
const nodeField = objectField(result, "nodes");
const connectionField = objectField(result, "connections");
const nodes = Array.isArray(nodeField) ? nodeField : [];
const connections = Array.isArray(connectionField) ? connectionField : [];
if (Array.isArray(nodeField) || Array.isArray(connectionField)) return `读取到 ${nodes.length} 个节点,${connections.length} 条连线`;
return "工具调用完成";
}
function parseToolResult(result: unknown) {
const content = objectField(result, "content");
const text = Array.isArray(content)
? content
.map((item) => objectField(item, "text"))
.filter((item): item is string => typeof item === "string")
.join("\n")
: "";
try {
return text ? JSON.parse(text) : result;
} catch {
return text || result;
}
}
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 stringText(value: unknown) {
return typeof value === "string" ? value : "";
}
function requireString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} 必须是非空字符串`);
return value;
}
function objectField(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
}
function numberField(value: unknown, key: string) {
const field = objectField(value, key);
return typeof field === "number" ? field : 0;
}
function mergeAgentText(prev: string, next: string) {
if (!next || prev === next || prev.endsWith(next)) return prev;
if (next.startsWith(prev)) return next;
for (let size = Math.min(prev.length, next.length); size > 0; size--) {
if (prev.endsWith(next.slice(0, size))) return `${prev}${next.slice(size)}`;
}
const half = Math.floor(prev.length / 2);
if (prev.length > 12 && next.length > 12 && prev.slice(half) === next.slice(0, prev.length - half)) return prev;
return `${prev}${next}`;
}
function promptWithAttachments(text: string, attachments: AgentAttachment[]) {
if (!attachments.length) return text;
const names = attachments.map((item) => item.name).join("、");
return [text, `用户上传了 ${attachments.length} 张图片附件:${names}。`].filter(Boolean).join("\n\n");
}
function attachmentPayloadBytes(attachments: AgentAttachment[]) {
return attachments.reduce((total, item) => total + item.dataUrl.length, 0);
}
function skillBundlePayloadBytes(skills: AgentTurnPayload["skills"]) {
return skills.reduce((total, skill) => total + skill.files.reduce((fileTotal, file) => fileTotal + file.contentBase64.length, 0), 0);
}
function formatBytes(bytes: number) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
}
async function fetchAgentJson<T>(path: string, init?: RequestInit) {
const res = await getLocalRuntimeSessionClient().request(path, init);
const data = (await res.json().catch(() => ({}))) as T;
if (!res.ok) throw new Error("本地 Agent 请求失败");
return data;
}
function normalizeHistoryMessages(messages: AgentChatItem[]) {
return messages
.map((item, index) => ({
...item,
id: item.id || `history-${index}`,
text: normalizeText(item.text),
}))
.filter((item) => item.text);
}
function formatThreadTime(value?: number) {
if (!value) return "";
return new Date(value * 1000).toLocaleString();
}
function createId() {
return createClientId();
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function readDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("读取图片失败"));
reader.readAsDataURL(file);
});
}