Merge remote-tracking branch 'origin/main'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
HouYunFei
2026-07-20 07:55:56 +08:00
18 changed files with 947 additions and 173 deletions
@@ -303,7 +303,7 @@ function toolCardState(title: string, text: string, detail?: unknown) {
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "工具完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
}
@@ -5,7 +5,11 @@ import copyToClipboard from "copy-to-clipboard";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, Square, Terminal, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { imageMetadata } from "@/lib/canvas/canvas-node-factory";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { readImageMeta } from "@/lib/image-utils";
import { randomId } from "@/lib/utils";
import { uploadImage } from "@/services/image-storage";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useShallow } from "zustand/react/shallow";
@@ -27,7 +31,9 @@ const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
type AgentEventPayload = {
agent?: string;
type?: string;
threadId?: string;
thread_id?: string;
turn_id?: string;
item?: AgentEventItem;
error?: { message?: string };
message?: string;
@@ -40,6 +46,10 @@ type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState };
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean };
type AgentChatEvent = { threadId?: string; sourceClientId?: string; message?: AgentChatItem };
export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -88,28 +98,27 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(randomId());
const loadThreadsSequenceRef = useRef(0);
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
const loadThreads = useCallback(async () => {
const loadThreads = useCallback(async (skipHistory = false) => {
if (!connectedRef.current && !useAgentStore.getState().connected) return;
const sequence = ++loadThreadsSequenceRef.current;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
const nextThreadId = data.workspace?.activeThreadId || "";
setAgentState({
threads: data.data || [],
workspacePath: data.workspace?.workspacePath || "",
activeThreadId: nextThreadId,
messages: [],
});
if (nextThreadId) {
let nextMessages: AgentChatItem[] = [];
if (nextThreadId && !skipHistory) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`);
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
nextMessages = normalizeHistoryMessages(thread.messages || []);
}
if (sequence !== loadThreadsSequenceRef.current) return;
setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", activeThreadId: nextThreadId, messages: nextMessages });
} catch (error) {
addEventLog("读取历史失败", error);
} finally {
setAgentState({ loadingThreads: false });
if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false });
}
}, [endpoint, setAgentState, token]);
@@ -144,13 +153,28 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
localStorage.setItem("canvas-agent-url", endpoint);
localStorage.setItem("canvas-agent-token", token);
const clientId = clientIdRef.current;
let eventQueue = Promise.resolve();
const enqueueEvent = (task: () => void | Promise<void>) => {
eventQueue = eventQueue.then(task).catch((error) => addEventLog("同步会话失败", error));
};
const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
source.addEventListener("hello", () => {
source.addEventListener("hello", (event) => {
const busy = Boolean(parseEventData<AgentHelloEvent>(event)?.codex?.busy);
errorLoggedRef.current = false;
connectedRef.current = true;
setAgentState({ connected: true, activity: "已连接", connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
setAgentState({ connected: true, activity: busy ? "Codex 正在运行" : "已连接", waiting: busy, sending: false, connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
if (!headless) message.success("本地 Agent 已连接");
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
});
source.addEventListener("codex_state", (event) => {
const data = parseEventData<AgentCodexState>(event);
if (!data) return;
enqueueEvent(async () => {
const busy = Boolean(data.busy);
setAgentState({ activity: busy ? "Codex 正在运行" : "完成", waiting: busy, ...(busy ? {} : { sending: false }) });
if (!busy) await loadThreads();
});
});
source.addEventListener("tool_call", (event) => {
const data = parseEventData<AgentPendingToolCall>(event);
@@ -158,21 +182,40 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
});
source.addEventListener("agent_event", (event) => {
const data = parseEventData<AgentEventPayload>(event);
if (data) handleAgentEvent(data);
if (data) enqueueEvent(() => {
if (isCurrentThreadEvent(data)) handleAgentEvent(data);
});
});
source.addEventListener("workspace_changed", (event) => {
const data = parseEventData<AgentWorkspaceEvent>(event);
if (!data) return;
enqueueEvent(async () => {
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
pendingToolRef.current = null;
setAgentState({ activeThreadId: nextThreadId, messages: [], pendingTool: null });
await loadThreads(data.emptyThread);
});
});
source.addEventListener("chat_message", (event) => {
const data = parseEventData<AgentChatEvent>(event);
if (!data?.message) return;
enqueueEvent(() => {
if (!isCurrentThreadEvent(data)) return;
addMessage(data.message!);
});
});
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();
const data = parseEventData<AgentEventPayload>(event);
if (!data) return;
enqueueEvent(() => {
if (!isCurrentThreadEvent(data)) return;
addMessage({ role: "error", title: "错误", text: normalizeText(data.message) });
addEventLog("错误", data.message, data.message);
});
});
source.onerror = () => {
const wasConnected = connectedRef.current;
@@ -193,6 +236,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
return () => {
source.close();
connectedRef.current = false;
loadThreadsSequenceRef.current += 1;
};
}, [enabled, endpoint, loadThreads, message, setAgentState, token]);
@@ -200,6 +244,19 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (connected) void loadThreads();
}, [connected, loadThreads]);
useEffect(() => {
if (!connected) return;
const activate = () => void activateAgentClient(endpoint, token, clientIdRef.current);
const activateVisible = () => {
if (document.visibilityState === "visible") activate();
};
window.addEventListener("focus", activate);
document.addEventListener("visibilitychange", activateVisible);
return () => {
window.removeEventListener("focus", activate);
document.removeEventListener("visibilitychange", activateVisible);
};
}, [connected, endpoint, token]);
const sendPrompt = async () => {
const text = prompt.trim();
const files = attachments;
@@ -209,27 +266,35 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
return;
}
setAgentState({ activity: "发送中", sending: true, waiting: true });
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
setAgentState({ activity: "发送中", sending: true });
const messageId = createId();
addMessage({ id: messageId, 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)}`, {
const data = await fetchAgentJson<{ threadId?: string }>(endpoint, token, "/agent/codex/turn", {
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 })) }),
body: JSON.stringify({
prompt: requestPrompt,
messageText: text || `发送了 ${files.length} 张图片`,
messageId,
clientId: clientIdRef.current,
threadId: useAgentStore.getState().activeThreadId || undefined,
attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, 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 });
addEventLog("本地 Agent 已接收", { threadId: data.threadId });
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 : "发送失败" });
const text = error instanceof Error ? error.message : "发送失败";
const busy = text.includes("Codex 正在运行");
setAgentState({ activity: busy ? "Codex 正在运行" : "发送失败" });
addMessage({ role: "error", title: busy ? "任务仍在运行" : "发送失败", text });
addEventLog("发送失败", error);
} finally {
setAgentState({ sending: false });
@@ -240,11 +305,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (!connected || (!sending && !waiting)) return;
setAgentState({ activity: "停止中" });
try {
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" } });
setAgentState({ activity: "已停止", sending: false, waiting: false });
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId: useAgentStore.getState().activeThreadId || undefined }) });
addEventLog("用户停止", {});
} catch {
setAgentState({ activity: "就绪", sending: false, waiting: false });
setAgentState({ activity: "停止失败" });
}
};
@@ -256,9 +320,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const next = await Promise.all(
images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
const dataUrl = await readDataUrl(file);
const meta = await readImageMeta(dataUrl);
const url = URL.createObjectURL(file);
attachmentUrlsRef.current.add(url);
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
return { id: createId(), name: file.name, type: file.type, size: file.size, width: meta.width, height: meta.height, url, dataUrl };
}),
);
const merged = [...prev, ...next];
@@ -286,13 +351,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
};
const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
if (confirmToolsRef.current && payload.name === "canvas_apply_ops") {
if (confirmToolsRef.current && isCanvasWriteTool(payload.name)) {
if (pendingToolRef.current) {
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" });
return;
}
pendingToolRef.current = payload;
setAgentState({ pendingTool: payload, activity: "等待确认", waiting: false });
setAgentState({ pendingTool: payload });
addEventLog("等待确认", payload, payload);
return;
}
@@ -302,16 +367,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
if (isSiteTool(payload.name)) {
try {
setAgentState({ activity: SITE_TOOL_LABELS[payload.name], waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result = await runSiteTool(payload.name, payload.input || {}, navigate);
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
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: siteToolSummary(payload.name, result), detail: { requestId: payload.requestId, name: payload.name, input: payload.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 });
}
@@ -319,9 +381,9 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}
try {
const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {};
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : payload.name === "site_navigate" ? "跳转页面" : "读取画布", waiting: true });
addEventLog(toolName(payload.name), payload, payload);
let result: unknown;
let appliedOps = input.ops || [];
if (payload.name === "site_navigate") {
const path = input.path || "/";
navigate(path);
@@ -329,25 +391,29 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
} else if (payload.name === "canvas_apply_ops") {
const context = canvasContextRef.current;
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
result = context.applyOps(input.ops || []);
result = context.applyOps(appliedOps);
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
} else if (payload.name === "canvas_create_attachment_nodes") {
const context = canvasContextRef.current;
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
appliedOps = await attachmentNodeOps(endpoint, token, clientIdRef.current, payload.input?.nodes);
result = context.applyOps(appliedOps);
await postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
} else {
const snapshot = canvasContextRef.current?.snapshot;
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
result = snapshot;
}
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 || "/"}` : "已完成",
text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "画布操作" : 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 });
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
}
@@ -356,7 +422,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
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 });
@@ -422,6 +487,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}, [autoConnect, connected, enabled]);
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
loadThreadsSequenceRef.current += 1;
setAgentState({
messages: [],
threads: [],
@@ -437,12 +503,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}
const startNewThread = async () => {
if (!connected) return;
if (!connected || sending || waiting) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
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 : "新建对话失败");
@@ -452,12 +517,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
};
const resumeThread = async (threadId: string) => {
if (!connected || !threadId) return;
if (!connected || !threadId || sending || waiting) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
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 : "恢复对话失败");
@@ -467,7 +531,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
};
const deleteThread = async (threadId: string) => {
if (!connected || !threadId) return;
if (!connected || !threadId || sending || waiting) 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({}) });
@@ -498,11 +562,12 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
});
};
const addMessage = (item: Omit<AgentChatItem, "id">) => {
const addMessage = (item: Omit<AgentChatItem, "id"> & { id?: string }) => {
const text = normalizeText(item.text);
if (!text && !item.attachments?.length) return;
const next = { ...item, id: `${Date.now()}-${Math.random()}`, text };
const next = { ...item, id: item.id || `${Date.now()}-${Math.random()}`, text } as AgentChatItem;
const currentMessages = useAgentStore.getState().messages;
if (currentMessages.some((message) => message.id === next.id)) return;
if (next.streamId) {
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
if (index >= 0) {
@@ -527,15 +592,8 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
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);
}
if (item) addMessage(item);
};
const content = (
@@ -555,7 +613,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}}
right={
<>
<Button size="small" type="text" disabled={!connected || loadingThreads} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
<Button size="small" type="text" disabled={!connected || loadingThreads || sending || waiting} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
</Button>
</>
@@ -582,6 +640,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
activeThreadId={activeThreadId}
workspacePath={workspacePath}
loading={loadingThreads}
busy={sending || waiting}
connected={connected}
onRefresh={() => void loadThreads()}
onNewThread={() => void startNewThread()}
@@ -864,6 +923,7 @@ function AgentHistoryView({
activeThreadId,
workspacePath,
loading,
busy,
connected,
onRefresh,
onNewThread,
@@ -875,6 +935,7 @@ function AgentHistoryView({
activeThreadId: string;
workspacePath: string;
loading: boolean;
busy: boolean;
connected: boolean;
onRefresh: () => void;
onNewThread: () => void;
@@ -899,7 +960,7 @@ function AgentHistoryView({
<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 size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading || busy} onClick={onNewThread}>
</Button>
</div>
@@ -923,11 +984,11 @@ function AgentHistoryView({
</div>
<div className="flex shrink-0 items-center gap-1">
<span className="text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
<Button size="small" className="!h-6 !px-2" disabled={loading} onClick={() => onResumeThread(thread.id)}>
<Button size="small" className="!h-6 !px-2" disabled={loading || busy} 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)} />
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" disabled={loading || busy} icon={<Trash2 className="size-3.5" />} onClick={() => onDeleteThread(thread)} />
</Tooltip>
</div>
</div>
@@ -955,6 +1016,12 @@ async function postState(endpoint: string, token: string, clientId: string, snap
} catch {}
}
async function activateAgentClient(endpoint: string, token: string, clientId: string) {
try {
await fetch(`${endpoint}/canvas/activate?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST" });
} catch {}
}
async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
}
@@ -985,6 +1052,11 @@ function parseEventData<T>(event: Event) {
}
}
function isCurrentThreadEvent(event: { threadId?: string; thread_id?: string }) {
const threadId = event.threadId || event.thread_id || "";
return Boolean(threadId) && threadId === useAgentStore.getState().activeThreadId;
}
function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
const head = [
"Infinite Canvas Agent 诊断日志",
@@ -1025,17 +1097,6 @@ function usageText(event: AgentEventPayload) {
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 会话";
@@ -1064,6 +1125,7 @@ function toolName(name: string) {
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_node") return "创建节点";
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_text_nodes") return "批量创建文本";
if (name === "canvas_create_config_node") return "创建生成配置";
@@ -1093,6 +1155,10 @@ function siteToolSummary(name: string, result: unknown) {
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
if (name === "assets_list") return `${numberField(data, "total")} 个资产`;
if (name === "assets_add") return "已加入我的资产";
if (name === "generation_get_status") {
const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {};
return `${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`;
}
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
return "已完成";
@@ -1167,9 +1233,7 @@ function mergeAgentText(prev: string, next: string) {
}
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");
return text || (attachments.length ? "请处理上传的图片附件。" : "");
}
function attachmentPayloadBytes(attachments: AgentAttachment[]) {
@@ -1180,6 +1244,41 @@ function formatBytes(bytes: number) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
}
function isCanvasWriteTool(name: string) {
return name === "canvas_apply_ops" || name === "canvas_create_attachment_nodes";
}
async function attachmentNodeOps(endpoint: string, token: string, clientId: string, value: unknown): Promise<CanvasAgentOp[]> {
const nodes = Array.isArray(value) ? value : [];
if (!nodes.length) throw new Error("没有可添加的图片附件");
return await Promise.all(
nodes.map(async (value) => {
const item = value as { id?: unknown; attachmentId?: unknown; title?: unknown; position?: unknown };
const id = String(item.id || "");
const attachmentId = String(item.attachmentId || "");
if (!id || !attachmentId) throw new Error("图片附件节点参数无效");
const res = await fetch(`${endpoint}/agent/attachments/${encodeURIComponent(attachmentId)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error || "读取图片附件失败");
}
const image = await uploadImage(await res.blob());
const size = fitNodeSize(image.width, image.height);
const position = item.position && typeof item.position === "object" ? (item.position as { x?: unknown; y?: unknown }) : {};
return {
type: "add_node" as const,
id,
nodeType: "image" as const,
title: String(item.title || "参考图"),
position: { x: Number(position.x) || 0, y: Number(position.y) || 0 },
width: size.width,
height: size.height,
metadata: imageMetadata(image),
};
}),
);
}
async function fetchAgentJson<T>(endpoint: string, token: string, path: string, init?: RequestInit) {
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
const res = await fetch(url, init);