fix(agent): synchronize shared Codex sessions

This commit is contained in:
yu
2026-07-17 18:30:10 +08:00
parent 062e4569aa
commit 5e1fd7a825
8 changed files with 347 additions and 127 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
## Unreleased ## Unreleased
+ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。 + [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。
+ [修复] 修复多标签页同时连接本地 Agent 时 MCP 可能操作非当前页面的问题 + [修复] 本地 Agent 请求与发起标签页强绑定,校验工具结果归属,并在页面关闭后回退到最近聚焦页面
+ [修复] 多标签页共享 Codex 会话改由 Agent 广播同步,按线程过滤消息和状态,并禁止运行中切换会话。
+ [修复] 本地 Agent 统一广播 Codex 运行状态,新连接标签页会同步禁用发送,并明确区分工具完成与本轮完成。
+ [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。 + [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。
## v0.9.0 - 2026-07-17 ## v0.9.0 - 2026-07-17
+53 -29
View File
@@ -11,11 +11,12 @@ import type { AgentAttachment, AgentEmit } from "./types.js";
type Json = Record<string, unknown>; type Json = Record<string, unknown>;
type AgentEvent = Json & { type: string; usage?: unknown }; type AgentEvent = Json & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
type CodexRunOptions = { threadId?: string; cwd?: string }; type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string }; type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
let codexQueue: Promise<unknown> = Promise.resolve(); let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null; let codexApp: CodexAppClient | null = null;
let codexAppStart: Promise<CodexAppClient> | null = null;
let codexThreadId = ""; let codexThreadId = "";
const canvasAgentMcp = canvasAgentMcpCommand(); const canvasAgentMcp = canvasAgentMcpCommand();
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@@ -30,52 +31,56 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
await codexQueue; await codexQueue;
} }
export function interruptCodexTurn() { export function interruptCodexTurn(threadId?: string) {
if (!codexApp) return false; if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
return codexApp.interruptCurrentTurn(); return codexApp.interruptCurrentTurn();
} }
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) { async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = []; let files: string[] = [];
try { try {
options.onStart?.();
files = await writeAttachmentFiles(attachments); files = await writeAttachmentFiles(attachments);
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(options.appEmit || emit);
let threadId = await ensureCodexThread(codexApp, options, emit); let threadId = await ensureCodexThread(app, options, emit);
options.onThread?.(threadId);
try { try {
await codexApp.startTurn(threadId, prompt, files); await app.startTurn(threadId, prompt, files, options.onTurn);
} catch (error) { } catch (error) {
if (!isRecoverableThreadError(error)) throw error; if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
codexThreadId = ""; codexThreadId = "";
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit); threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
await codexApp.startTurn(threadId, prompt, files); options.onThread?.(threadId);
await app.startTurn(threadId, prompt, files, options.onTurn);
} }
} catch (error) { } catch (error) {
emit("agent_error", { message: errorMessage(error) }); emit("agent_error", { message: errorMessage(error) });
} finally { } finally {
options.onFinish?.();
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined))); await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
} }
} }
export async function startCodexThread(emit: AgentEmit, cwd?: string) { export async function startCodexThread(emit: AgentEmit, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
const thread = await codexApp.startThread(cwd); const thread = await app.startThread(cwd);
codexThreadId = String(field(thread, "id") || ""); codexThreadId = String(field(thread, "id") || "");
return thread; return thread;
} }
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false); await loadCodexThread(emit, threadId, cwd, false);
const thread = await codexApp.resumeThread(threadId, cwd); const thread = await app.resumeThread(threadId, cwd);
assertThreadWorkspace(thread, cwd); assertThreadWorkspace(thread, cwd);
codexThreadId = String(field(thread, "id") || threadId); codexThreadId = String(field(thread, "id") || threadId);
return { thread, messages: threadMessages(thread) }; return { thread, messages: threadMessages(thread) };
} }
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) { export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
const result = await codexApp.listThreads({ const result = await app.listThreads({
limit: options.limit || 40, limit: options.limit || 40,
sortKey: "updated_at", sortKey: "updated_at",
sortDirection: "desc", sortDirection: "desc",
@@ -97,9 +102,9 @@ export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: stri
} }
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false); await loadCodexThread(emit, threadId, cwd, false);
await codexApp.archiveThread(threadId); await app.archiveThread(threadId);
} }
export function runClaudeTurn(prompt: string, emit: AgentEmit) { export function runClaudeTurn(prompt: string, emit: AgentEmit) {
@@ -131,7 +136,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
return codexThreadId; return codexThreadId;
} }
function isRecoverableThreadError(error: unknown) { export function isRecoverableThreadError(error: unknown) {
return /thread not loaded|no rollout found/i.test(errorMessage(error)); return /thread not loaded|no rollout found/i.test(errorMessage(error));
} }
@@ -192,10 +197,11 @@ class CodexAppClient {
return this.request("thread/archive", { threadId }); return this.request("thread/archive", { threadId });
} }
async startTurn(threadId: string, prompt: string, images: string[]) { async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" }); const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
const turnId = String(field(field(result, "turn"), "id") || ""); const turnId = String(field(field(result, "turn"), "id") || "");
if (!turnId) throw new Error("Codex app-server 没有返回 turn id"); if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
onTurn?.(turnId);
const completed = this.completedTurns.get(turnId); const completed = this.completedTurns.get(turnId);
if (this.completedTurns.has(turnId)) { if (this.completedTurns.has(turnId)) {
this.completedTurns.delete(turnId); this.completedTurns.delete(turnId);
@@ -267,9 +273,9 @@ class CodexAppClient {
} else if (turnId) { } else if (turnId) {
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null); this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
} }
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount }); this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount, ...codexEventScope(params) });
this.deltaCount = 0; this.deltaCount = 0;
this.emit("agent_done", { agent: "codex", usage: event.usage }); this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
} }
} }
@@ -278,7 +284,7 @@ class CodexAppClient {
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`; const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
this.deltaCount += 1; this.deltaCount += 1;
this.textByItem.set(id, text); this.textByItem.set(id, text);
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } }); this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
} }
private answerServerRequest(message: Json) { private answerServerRequest(message: Json) {
@@ -321,23 +327,41 @@ function codexInput(prompt: string, images: string[]) {
} }
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null { function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") }; const scope = codexEventScope(params);
if (method === "turn/started") return { type: "turn.started" }; if (method === "thread/started") return { type: "thread.started", ...scope };
if (method === "turn/completed") return { type: "turn.completed", usage: null }; if (method === "turn/started") return { type: "turn.started", ...scope };
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) }; if (method === "turn/completed") return { type: "turn.completed", usage: null, ...scope };
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) }; if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
if (method === "error") return { type: "error", message: field(params, "message") }; if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
if (method === "error") return { type: "error", message: field(params, "message"), ...scope };
return null; return null;
} }
function codexEventScope(params: Json) {
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
}
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) { async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
const result = await codexApp.readThread(threadId, includeTurns); const result = await app.readThread(threadId, includeTurns);
const thread = field(result, "thread") || {}; const thread = field(result, "thread") || {};
assertThreadWorkspace(thread, cwd); assertThreadWorkspace(thread, cwd);
return thread; return thread;
} }
async function getCodexApp(emit: AgentEmit) {
if (codexApp) return codexApp;
codexAppStart ||= CodexAppClient.start(emit);
try {
codexApp = await codexAppStart;
return codexApp;
} finally {
codexAppStart = null;
}
}
function assertThreadWorkspace(thread: unknown, cwd?: string) { function assertThreadWorkspace(thread: unknown, cwd?: string) {
if (!cwd || threadInWorkspace(thread, cwd)) return; if (!cwd || threadInWorkspace(thread, cwd)) return;
throw new Error("该 Codex 会话不属于当前画布工作空间"); throw new Error("该 Codex 会话不属于当前画布工作空间");
+72
View File
@@ -132,6 +132,78 @@ test("closing a client rejects its pending tool requests", async () => {
assert.match(outcome, /断开/); assert.match(outcome, /断开/);
}); });
test("shared thread events are broadcast with the active thread id", (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.emitThread("workspace_changed", "thread-2", { activeThreadId: "thread-2" });
assert.deepEqual(first.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
assert.deepEqual(second.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
});
test("new clients receive the current Codex state and later updates", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
const client = connect(session, "first");
t.after(() => client.close());
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
session.setCodexState({ busy: false });
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
});
test("a bound client remains the tool target while focus changes", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.bindClient("first");
session.activateClient("second");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
const result = session.callTool("canvas_create_text_node", { text: "bound" });
const call = first.event("tool_call");
assert.equal(second.event("tool_call"), undefined);
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
session.releaseClient("first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
});
test("closing the bound client falls back to the active client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.bindClient("first");
session.activateClient("second");
first.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
const call = second.event("tool_call");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
});
function connect(session: CanvasSession, clientId: string) { function connect(session: CanvasSession, clientId: string) {
const response = new FakeSseResponse(); const response = new FakeSseResponse();
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse); session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
+34 -4
View File
@@ -6,6 +6,7 @@ import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInpu
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js"; import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void }; type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
export type CodexState = { busy: boolean; threadId: string; turnId: string };
const SITE_TOOLS = new Set<ToolName>([ const SITE_TOOLS = new Set<ToolName>([
"site_navigate", "site_navigate",
@@ -26,14 +27,29 @@ export class CanvasSession {
private pending = new Map<string, PendingRequest>(); private pending = new Map<string, PendingRequest>();
private canvasStates = new Map<string, CanvasSnapshot>(); private canvasStates = new Map<string, CanvasSnapshot>();
private activeClientId = ""; private activeClientId = "";
private boundClientId = "";
private focusSequence = 0; private focusSequence = 0;
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
private get canvasState() { private get canvasState() {
return this.canvasStates.get(this.activeClientId) || null; return this.canvasStates.get(this.targetClientId) || null;
}
private get targetClientId() {
return this.boundClientId || this.activeClientId;
} }
health() { health() {
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size }; return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
}
get codexBusy() {
return this.codexState.busy;
}
setCodexState(patch: Partial<CodexState>) {
this.codexState = { ...this.codexState, ...patch };
this.emitAll("codex_state", this.codexState);
} }
openEvents(url: URL, res: ServerResponse) { openEvents(url: URL, res: ServerResponse) {
@@ -48,7 +64,7 @@ export class CanvasSession {
this.clientFocusOrder.set(clientId, ++this.focusSequence); this.clientFocusOrder.set(clientId, ++this.focusSequence);
} }
} }
sendEvent(res, "hello", { ok: true, clientId }); sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000); const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
res.on("close", () => { res.on("close", () => {
clearInterval(timer); clearInterval(timer);
@@ -56,6 +72,7 @@ export class CanvasSession {
this.clients.delete(clientId); this.clients.delete(clientId);
this.clientFocusOrder.delete(clientId); this.clientFocusOrder.delete(clientId);
this.canvasStates.delete(clientId); this.canvasStates.delete(clientId);
if (this.boundClientId === clientId) this.boundClientId = "";
this.pending.forEach((item, requestId) => { this.pending.forEach((item, requestId) => {
if (item.clientId !== clientId) return; if (item.clientId !== clientId) return;
this.pending.delete(requestId); this.pending.delete(requestId);
@@ -77,6 +94,15 @@ export class CanvasSession {
this.clientFocusOrder.set(clientId, ++this.focusSequence); this.clientFocusOrder.set(clientId, ++this.focusSequence);
} }
bindClient(clientId: string) {
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
this.boundClientId = clientId;
}
releaseClient(clientId: string) {
if (this.boundClientId === clientId) this.boundClientId = "";
}
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) { resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
const item = body.requestId ? this.pending.get(body.requestId) : null; const item = body.requestId ? this.pending.get(body.requestId) : null;
if (!item || !body.requestId || item.clientId !== clientId) return false; if (!item || !body.requestId || item.clientId !== clientId) return false;
@@ -89,6 +115,10 @@ export class CanvasSession {
this.clients.forEach((client) => sendEvent(client, type, payload)); this.clients.forEach((client) => sendEvent(client, type, payload));
} }
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
this.emitAll(type, { ...payload, threadId });
}
async callTool(name: unknown, rawInput: unknown) { async callTool(name: unknown, rawInput: unknown) {
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`); if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
let tool: ToolName = name; let tool: ToolName = name;
@@ -200,7 +230,7 @@ export class CanvasSession {
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) { private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const clientId = this.activeClientId; const clientId = this.targetClientId;
const client = this.clients.get(clientId); const client = this.clients.get(clientId);
if (!client) throw new Error("当前没有已连接画布"); if (!client) throw new Error("当前没有已连接画布");
sendEvent(client, "tool_call", { requestId, name, input }); sendEvent(client, "tool_call", { requestId, name, input });
+73 -12
View File
@@ -2,7 +2,7 @@ import express, { type NextFunction, type Request, type Response } from "express
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js"; import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
import { CanvasSession } from "./canvas-session.js"; import { CanvasSession } from "./canvas-session.js";
import { archiveCodexThread, interruptCodexTurn, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js"; import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
import type { AgentAttachment } from "./types.js"; import type { AgentAttachment } from "./types.js";
export function startHttpServer() { export function startHttpServer() {
@@ -12,7 +12,16 @@ export function startHttpServer() {
saveConfig(config); saveConfig(config);
const session = new CanvasSession(); const session = new CanvasSession();
const emit = (type: string, payload: unknown) => session.emitAll(type, payload); const emit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
};
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
return workspace;
};
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
app.use(express.json({ limit: "30mb" })); app.use(express.json({ limit: "30mb" }));
@@ -52,48 +61,100 @@ export function startHttpServer() {
res.json({ ok: true, workspace, ...result }); res.json({ ok: true, workspace, ...result });
})); }));
app.post("/agent/codex/threads/new", route(async (_req, res) => { app.post("/agent/codex/threads/new", route(async (_req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const thread = await startCodexThread(emit, workspace.workspacePath); const thread = await startCodexThread(emit, workspace.workspacePath);
const activeThreadId = String((thread as Record<string, unknown>).id || ""); const activeThreadId = String((thread as Record<string, unknown>).id || "");
updateSiteWorkspace(config, { activeThreadId }); const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] }); res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
})); }));
app.get("/agent/codex/threads/:threadId", route(async (req, res) => { app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId); const threadId = routeParam(req.params.threadId);
try {
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) }); res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
} catch (error) {
if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error)) throw error;
res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
}
})); }));
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => { app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId); const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath); const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
updateSiteWorkspace(config, { activeThreadId: threadId }); const nextWorkspace = setActiveThread(threadId);
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result }); res.json({ ok: true, workspace: nextWorkspace, ...result });
})); }));
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => { app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId); const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath); await archiveCodexThread(emit, threadId, workspace.workspacePath);
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined }); setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
res.json({ ok: true }); res.json({ ok: true });
})); }));
app.post("/agent/codex/turn", route(async (req, res) => { app.post("/agent/codex/turn", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : []; const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const prompt = String(req.body?.prompt || "");
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
const clientId = String(req.body?.clientId || "");
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
try {
let threadId = String(req.body?.threadId || workspace.activeThreadId || ""); let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
let turnId = "";
if (!threadId) { if (!threadId) {
const thread = await startCodexThread(emit, workspace.workspacePath); const thread = await startCodexThread(emit, workspace.workspacePath);
threadId = String((thread as Record<string, unknown>).id || ""); threadId = String((thread as Record<string, unknown>).id || "");
updateSiteWorkspace(config, { activeThreadId: threadId }); setActiveThread(threadId, { emptyThread: true });
} else if (threadId !== workspace.activeThreadId) { } else if (threadId !== workspace.activeThreadId) {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath); await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
updateSiteWorkspace(config, { activeThreadId: threadId }); setActiveThread(threadId);
} }
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath }); const chatMessage = {
sourceClientId: clientId,
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
};
let chatThreadId = "";
const turnEmit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
session.emitThread(type, threadId, data);
};
void runCodexTurn(withAgentPrompt(prompt), turnEmit, attachments, {
threadId,
cwd: workspace.workspacePath,
appEmit: emit,
onStart: clientId ? () => session.bindClient(clientId) : undefined,
onThread: (actualThreadId) => {
if (actualThreadId !== threadId) {
threadId = actualThreadId;
setActiveThread(threadId, { emptyThread: true });
}
session.setCodexState({ busy: true, threadId, turnId: "" });
if (chatThreadId !== threadId) {
chatThreadId = threadId;
session.emitThread("chat_message", threadId, chatMessage);
}
},
onTurn: (actualTurnId) => {
turnId = actualTurnId;
session.setCodexState({ busy: true, threadId, turnId });
},
onFinish: () => {
if (clientId) session.releaseClient(clientId);
session.setCodexState({ busy: false, threadId, turnId });
},
});
res.json({ ok: true, threadId }); res.json({ ok: true, threadId });
} catch (error) {
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
throw error;
}
})); }));
app.post("/agent/codex/interrupt", (_req, res) => { app.post("/agent/codex/interrupt", (req, res) => {
const ok = interruptCodexTurn(); const ok = interruptCodexTurn(String(req.body?.threadId || ""));
res.json({ ok }); res.json({ ok });
}); });
app.post("/agent/claude/turn", (req, res) => { app.post("/agent/claude/turn", (req, res) => {
+3 -1
View File
@@ -6,4 +6,6 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试 # 待测试
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。 - 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;关闭当前页面后应自动回退到仍连接的页面。 - 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。
@@ -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 (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("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("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 }; return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
} }
@@ -27,7 +27,9 @@ const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
type AgentEventPayload = { type AgentEventPayload = {
agent?: string; agent?: string;
type?: string; type?: string;
threadId?: string;
thread_id?: string; thread_id?: string;
turn_id?: string;
item?: AgentEventItem; item?: AgentEventItem;
error?: { message?: string }; error?: { message?: string };
message?: string; message?: string;
@@ -40,6 +42,10 @@ type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] }; type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] }; type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean }; 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 }) { export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -88,28 +94,27 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const errorLoggedRef = useRef(false); const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>()); const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(randomId()); const clientIdRef = useRef(randomId());
const loadThreadsSequenceRef = useRef(0);
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]); const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken"); 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; if (!connectedRef.current && !useAgentStore.getState().connected) return;
const sequence = ++loadThreadsSequenceRef.current;
setAgentState({ loadingThreads: true }); setAgentState({ loadingThreads: true });
try { try {
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`); const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
const nextThreadId = data.workspace?.activeThreadId || ""; const nextThreadId = data.workspace?.activeThreadId || "";
setAgentState({ let nextMessages: AgentChatItem[] = [];
threads: data.data || [], if (nextThreadId && !skipHistory) {
workspacePath: data.workspace?.workspacePath || "",
activeThreadId: nextThreadId,
messages: [],
});
if (nextThreadId) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`); 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) { } catch (error) {
addEventLog("读取历史失败", error); addEventLog("读取历史失败", error);
} finally { } finally {
setAgentState({ loadingThreads: false }); if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false });
} }
}, [endpoint, setAgentState, token]); }, [endpoint, setAgentState, token]);
@@ -144,36 +149,69 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
localStorage.setItem("canvas-agent-url", endpoint); localStorage.setItem("canvas-agent-url", endpoint);
localStorage.setItem("canvas-agent-token", token); localStorage.setItem("canvas-agent-token", token);
const clientId = clientIdRef.current; 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)}`); 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; errorLoggedRef.current = false;
connectedRef.current = true; 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 已连接"); if (!headless) message.success("本地 Agent 已连接");
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null); void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId); 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) => { source.addEventListener("tool_call", (event) => {
const data = parseEventData<AgentPendingToolCall>(event); const data = parseEventData<AgentPendingToolCall>(event);
if (data) void handleToolCall(endpoint, token, data); if (data) void handleToolCall(endpoint, token, data);
}); });
source.addEventListener("agent_event", (event) => { source.addEventListener("agent_event", (event) => {
const data = parseEventData<AgentEventPayload>(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) => { source.addEventListener("agent_log", (event) => {
const text = parseEventData<{ text?: unknown }>(event)?.text; const text = parseEventData<{ text?: unknown }>(event)?.text;
addEventLog("日志", text, text); addEventLog("日志", text, text);
}); });
source.addEventListener("agent_error", (event) => { source.addEventListener("agent_error", (event) => {
const message = parseEventData<{ message?: unknown }>(event)?.message; const data = parseEventData<AgentEventPayload>(event);
setAgentState({ activity: "出错", waiting: false }); if (!data) return;
addMessage({ role: "error", title: "错误", text: normalizeText(message) }); enqueueEvent(() => {
addEventLog("错误", message, message); if (!isCurrentThreadEvent(data)) return;
addMessage({ role: "error", title: "错误", text: normalizeText(data.message) });
addEventLog("错误", data.message, data.message);
}); });
source.addEventListener("agent_done", () => {
setAgentState({ activity: "完成", waiting: false, sending: false });
void loadThreads();
}); });
source.onerror = () => { source.onerror = () => {
const wasConnected = connectedRef.current; const wasConnected = connectedRef.current;
@@ -194,6 +232,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
return () => { return () => {
source.close(); source.close();
connectedRef.current = false; connectedRef.current = false;
loadThreadsSequenceRef.current += 1;
}; };
}, [enabled, endpoint, loadThreads, message, setAgentState, token]); }, [enabled, endpoint, loadThreads, message, setAgentState, token]);
@@ -223,27 +262,35 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" }); addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
return; return;
} }
setAgentState({ activity: "发送中", sending: true, waiting: true }); setAgentState({ activity: "发送中", sending: true });
addMessage({ role: "user", text: text || "发送了图片", attachments: files }); const messageId = createId();
addMessage({ id: messageId, role: "user", text: text || "发送了图片", attachments: files });
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) }); addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
try { 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", method: "POST",
headers: { "content-type": "application/json" }, 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(({ 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 }); if (data.threadId) setAgentState({ activeThreadId: data.threadId });
addEventLog("本地 Agent 已接收", { status: res.status }); addEventLog("本地 Agent 已接收", { threadId: data.threadId });
files.forEach((item) => { files.forEach((item) => {
URL.revokeObjectURL(item.url); URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url); attachmentUrlsRef.current.delete(item.url);
}); });
setAgentState({ prompt: "", attachments: [] }); setAgentState({ prompt: "", attachments: [] });
} catch (error) { } catch (error) {
setAgentState({ activity: "发送失败", waiting: false }); const text = error instanceof Error ? error.message : "发送失败";
addMessage({ role: "error", title: "发送失败", text: error instanceof Error ? error.message : "发送失败" }); const busy = text.includes("Codex 正在运行");
setAgentState({ activity: busy ? "Codex 正在运行" : "发送失败" });
addMessage({ role: "error", title: busy ? "任务仍在运行" : "发送失败", text });
addEventLog("发送失败", error); addEventLog("发送失败", error);
} finally { } finally {
setAgentState({ sending: false }); setAgentState({ sending: false });
@@ -254,11 +301,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (!connected || (!sending && !waiting)) return; if (!connected || (!sending && !waiting)) return;
setAgentState({ activity: "停止中" }); setAgentState({ activity: "停止中" });
try { try {
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" } }); await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId: useAgentStore.getState().activeThreadId || undefined }) });
setAgentState({ activity: "已停止", sending: false, waiting: false });
addEventLog("用户停止", {}); addEventLog("用户停止", {});
} catch { } catch {
setAgentState({ activity: "就绪", sending: false, waiting: false }); setAgentState({ activity: "停止失败" });
} }
}; };
@@ -306,7 +352,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
return; return;
} }
pendingToolRef.current = payload; pendingToolRef.current = payload;
setAgentState({ pendingTool: payload, activity: "等待确认", waiting: false }); setAgentState({ pendingTool: payload });
addEventLog("等待确认", payload, payload); addEventLog("等待确认", payload, payload);
return; return;
} }
@@ -316,16 +362,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
if (isSiteTool(payload.name)) { if (isSiteTool(payload.name)) {
try { try {
setAgentState({ activity: SITE_TOOL_LABELS[payload.name], waiting: true });
addEventLog(toolName(payload.name), payload, payload); addEventLog(toolName(payload.name), payload, payload);
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null }); const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result); 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 } }); 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) { } catch (error) {
const message = error instanceof Error ? error.message : "工具执行失败"; const message = error instanceof Error ? error.message : "工具执行失败";
setAgentState({ activity: "工具失败", waiting: false });
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload }); addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
} }
@@ -333,7 +376,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
} }
try { try {
const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {}; 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); addEventLog(toolName(payload.name), payload, payload);
let result: unknown; let result: unknown;
if (payload.name === "site_navigate") { if (payload.name === "site_navigate") {
@@ -351,7 +393,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
result = snapshot; result = snapshot;
} }
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result); addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ addMessage({
role: "tool", role: "tool",
@@ -361,7 +402,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "画布操作失败"; const message = error instanceof Error ? error.message : "画布操作失败";
setAgentState({ activity: "工具失败", waiting: false });
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload }); addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
} }
@@ -370,7 +410,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const rejectPendingTool = async () => { const rejectPendingTool = async () => {
if (!pendingTool) return; if (!pendingTool) return;
await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" }); 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 } }); addMessage({ role: "tool", title: "拒绝执行", text: toolName(pendingTool.name), detail: { requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input } });
pendingToolRef.current = null; pendingToolRef.current = null;
setAgentState({ pendingTool: null }); setAgentState({ pendingTool: null });
@@ -436,6 +475,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}, [autoConnect, connected, enabled]); }, [autoConnect, connected, enabled]);
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) { function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
loadThreadsSequenceRef.current += 1;
setAgentState({ setAgentState({
messages: [], messages: [],
threads: [], threads: [],
@@ -451,12 +491,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
} }
const startNewThread = async () => { const startNewThread = async () => {
if (!connected) return; if (!connected || sending || waiting) return;
setAgentState({ loadingThreads: true }); setAgentState({ loadingThreads: true });
try { try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); 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: "新对话" }); setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" });
await loadThreads();
} catch (error) { } catch (error) {
addEventLog("新建对话失败", error); addEventLog("新建对话失败", error);
message.error(error instanceof Error ? error.message : "新建对话失败"); message.error(error instanceof Error ? error.message : "新建对话失败");
@@ -466,12 +505,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}; };
const resumeThread = async (threadId: string) => { const resumeThread = async (threadId: string) => {
if (!connected || !threadId) return; if (!connected || !threadId || sending || waiting) return;
setAgentState({ loadingThreads: true }); setAgentState({ loadingThreads: true });
try { try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); 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: "已恢复会话" }); setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" });
await loadThreads();
} catch (error) { } catch (error) {
addEventLog("恢复对话失败", error); addEventLog("恢复对话失败", error);
message.error(error instanceof Error ? error.message : "恢复对话失败"); message.error(error instanceof Error ? error.message : "恢复对话失败");
@@ -481,7 +519,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}; };
const deleteThread = async (threadId: string) => { const deleteThread = async (threadId: string) => {
if (!connected || !threadId) return; if (!connected || !threadId || sending || waiting) return;
setAgentState({ loadingThreads: true }); setAgentState({ loadingThreads: true });
try { try {
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
@@ -512,11 +550,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); const text = normalizeText(item.text);
if (!text && !item.attachments?.length) return; 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; const currentMessages = useAgentStore.getState().messages;
if (currentMessages.some((message) => message.id === next.id)) return;
if (next.streamId) { if (next.streamId) {
const index = currentMessages.findIndex((message) => message.streamId === next.streamId); const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
if (index >= 0) { if (index >= 0) {
@@ -541,15 +580,8 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const handleAgentEvent = (event: AgentEventPayload) => { const handleAgentEvent = (event: AgentEventPayload) => {
if (shouldLogAgentEvent(event)) addEventLog(eventTitle(event), event, event); if (shouldLogAgentEvent(event)) addEventLog(eventTitle(event), event, event);
if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id }); 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); const item = formatAgentEvent(event);
if (item) { if (item) addMessage(item);
if (item.role === "error") setAgentState({ waiting: false, sending: false });
addMessage(item);
}
}; };
const content = ( const content = (
@@ -569,7 +601,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}} }}
right={ 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> </Button>
</> </>
@@ -596,6 +628,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
activeThreadId={activeThreadId} activeThreadId={activeThreadId}
workspacePath={workspacePath} workspacePath={workspacePath}
loading={loadingThreads} loading={loadingThreads}
busy={sending || waiting}
connected={connected} connected={connected}
onRefresh={() => void loadThreads()} onRefresh={() => void loadThreads()}
onNewThread={() => void startNewThread()} onNewThread={() => void startNewThread()}
@@ -878,6 +911,7 @@ function AgentHistoryView({
activeThreadId, activeThreadId,
workspacePath, workspacePath,
loading, loading,
busy,
connected, connected,
onRefresh, onRefresh,
onNewThread, onNewThread,
@@ -889,6 +923,7 @@ function AgentHistoryView({
activeThreadId: string; activeThreadId: string;
workspacePath: string; workspacePath: string;
loading: boolean; loading: boolean;
busy: boolean;
connected: boolean; connected: boolean;
onRefresh: () => void; onRefresh: () => void;
onNewThread: () => void; onNewThread: () => void;
@@ -913,7 +948,7 @@ function AgentHistoryView({
<Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}> <Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}>
</Button> </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> </Button>
</div> </div>
@@ -937,11 +972,11 @@ function AgentHistoryView({
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<span className="text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span> <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> </Button>
<Tooltip title="删除记录"> <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> </Tooltip>
</div> </div>
</div> </div>
@@ -1005,6 +1040,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) { function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
const head = [ const head = [
"Infinite Canvas Agent 诊断日志", "Infinite Canvas Agent 诊断日志",
@@ -1045,17 +1085,6 @@ function usageText(event: AgentEventPayload) {
return undefined; 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) { function eventTitle(event: AgentEventPayload) {
const item = event.item; const item = event.item;
if (event.type === "thread.started") return "已创建 Codex 会话"; if (event.type === "thread.started") return "已创建 Codex 会话";