mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-29 03:34:28 +08:00
merge: 同步上游 Agent 会话更新
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。
|
||||
+ [修复] 本地 Agent 请求与发起标签页强绑定,校验工具结果归属,并在页面关闭后回退到最近聚焦页面。
|
||||
+ [修复] 多标签页共享 Codex 会话改由 Agent 广播同步,按线程过滤消息和状态,并禁止运行中切换会话。
|
||||
+ [修复] 本地 Agent 统一广播 Codex 运行状态,新连接标签页会同步禁用发送,并明确区分工具完成与本轮完成。
|
||||
+ [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。
|
||||
+ [优化] 图片遮罩、切图与裁剪编辑支持缩放、平移、局部撤回和常用快捷操作,并修复高分辨率图片预览闪烁。
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "tsx --test src/canvas-session.test.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"prepack": "npm run build"
|
||||
|
||||
+53
-29
@@ -11,11 +11,12 @@ import type { AgentAttachment, AgentEmit } from "./types.js";
|
||||
type Json = Record<string, unknown>;
|
||||
type AgentEvent = Json & { type: string; usage?: unknown };
|
||||
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 };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||
let codexThreadId = "";
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -30,52 +31,56 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
export function interruptCodexTurn() {
|
||||
if (!codexApp) return false;
|
||||
export function interruptCodexTurn(threadId?: string) {
|
||||
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
|
||||
return codexApp.interruptCurrentTurn();
|
||||
}
|
||||
|
||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
options.onStart?.();
|
||||
files = await writeAttachmentFiles(attachments);
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
let threadId = await ensureCodexThread(codexApp, options, emit);
|
||||
const app = await getCodexApp(options.appEmit || emit);
|
||||
let threadId = await ensureCodexThread(app, options, emit);
|
||||
options.onThread?.(threadId);
|
||||
try {
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
codexThreadId = "";
|
||||
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit);
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||
options.onThread?.(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
}
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
options.onFinish?.();
|
||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const thread = await codexApp.startThread(cwd);
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await app.startThread(cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
return thread;
|
||||
}
|
||||
|
||||
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);
|
||||
const thread = await codexApp.resumeThread(threadId, cwd);
|
||||
const thread = await app.resumeThread(threadId, cwd);
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
codexThreadId = String(field(thread, "id") || threadId);
|
||||
return { thread, messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.listThreads({
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.listThreads({
|
||||
limit: options.limit || 40,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
@@ -97,9 +102,9 @@ export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: stri
|
||||
}
|
||||
|
||||
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 codexApp.archiveThread(threadId);
|
||||
await app.archiveThread(threadId);
|
||||
}
|
||||
|
||||
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
@@ -131,7 +136,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
function isRecoverableThreadError(error: unknown) {
|
||||
export function isRecoverableThreadError(error: unknown) {
|
||||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||||
}
|
||||
|
||||
@@ -192,10 +197,11 @@ class CodexAppClient {
|
||||
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 turnId = String(field(field(result, "turn"), "id") || "");
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
onTurn?.(turnId);
|
||||
const completed = this.completedTurns.get(turnId);
|
||||
if (this.completedTurns.has(turnId)) {
|
||||
this.completedTurns.delete(turnId);
|
||||
@@ -267,9 +273,9 @@ class CodexAppClient {
|
||||
} else if (turnId) {
|
||||
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.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") || "")}`;
|
||||
this.deltaCount += 1;
|
||||
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) {
|
||||
@@ -321,23 +327,41 @@ function codexInput(prompt: string, images: string[]) {
|
||||
}
|
||||
|
||||
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
|
||||
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
|
||||
if (method === "turn/started") return { type: "turn.started" };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "error") return { type: "error", message: field(params, "message") };
|
||||
const scope = codexEventScope(params);
|
||||
if (method === "thread/started") return { type: "thread.started", ...scope };
|
||||
if (method === "turn/started") return { type: "turn.started", ...scope };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null, ...scope };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.readThread(threadId, includeTurns);
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.readThread(threadId, includeTurns);
|
||||
const thread = field(result, "thread") || {};
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
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) {
|
||||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
|
||||
test("MCP 读取当前激活网页的画布", 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.activateClient("first");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
|
||||
session.activateClient("second");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
});
|
||||
|
||||
test("画布写操作只发送给当前激活网页", 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.activateClient("second");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "canvas_apply_ops");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
test("tool result is accepted only from the request client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("first");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "first only" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
|
||||
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
|
||||
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
|
||||
assert.deepEqual(await result, { client: "first" });
|
||||
});
|
||||
|
||||
test("生成状态查询由当前激活网页返回", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("second");
|
||||
|
||||
const result = session.callTool("generation_get_status", { scope: "all" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "generation_get_status");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
|
||||
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
|
||||
});
|
||||
|
||||
test("活动网页关闭后回退到仍连接的画布", 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.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
});
|
||||
|
||||
test("closing the active client falls back to the most recently focused client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
const third = connect(session, "third");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
third.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.updateState(snapshot("canvas-third"), "third");
|
||||
session.activateClient("third");
|
||||
session.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
|
||||
});
|
||||
|
||||
test("closing a client rejects its pending tool requests", async () => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "pending" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
first.close();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
|
||||
]);
|
||||
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
|
||||
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) {
|
||||
const response = new FakeSseResponse();
|
||||
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
|
||||
return response;
|
||||
}
|
||||
|
||||
function snapshot(projectId: string) {
|
||||
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
}
|
||||
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
class FakeSseResponse extends EventEmitter {
|
||||
private chunks: string[] = [];
|
||||
|
||||
writeHead() {
|
||||
return this;
|
||||
}
|
||||
|
||||
write(chunk: string) {
|
||||
this.chunks.push(chunk);
|
||||
return true;
|
||||
}
|
||||
|
||||
event(type: string) {
|
||||
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
|
||||
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||
return data ? (JSON.parse(data) as unknown) : undefined;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { type ToolName } from "./schemas.js";
|
||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { 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>([
|
||||
"site_navigate",
|
||||
@@ -17,46 +18,107 @@ const SITE_TOOLS = new Set<ToolName>([
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
"generation_get_status",
|
||||
]);
|
||||
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private clientFocusOrder = new Map<string, number>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasState: CanvasSnapshot | null = null;
|
||||
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||
private activeClientId = "";
|
||||
private boundClientId = "";
|
||||
private focusSequence = 0;
|
||||
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||
|
||||
private get canvasState() {
|
||||
return this.canvasStates.get(this.targetClientId) || null;
|
||||
}
|
||||
|
||||
private get targetClientId() {
|
||||
return this.boundClientId || this.activeClientId;
|
||||
}
|
||||
|
||||
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) {
|
||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||
const statusOnly = url.searchParams.get("role") === "status";
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
if (!statusOnly) this.clients.set(clientId, res);
|
||||
sendEvent(res, "hello", { ok: true, clientId });
|
||||
if (!statusOnly) {
|
||||
this.clients.set(clientId, res);
|
||||
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
|
||||
if (!this.activeClientId) {
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
}
|
||||
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
if (!statusOnly) this.clients.delete(clientId);
|
||||
if (this.canvasState?.clientId === clientId) this.canvasState = null;
|
||||
if (statusOnly || this.clients.get(clientId) !== res) return;
|
||||
this.clients.delete(clientId);
|
||||
this.clientFocusOrder.delete(clientId);
|
||||
this.canvasStates.delete(clientId);
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
this.pending.forEach((item, requestId) => {
|
||||
if (item.clientId !== clientId) return;
|
||||
this.pending.delete(requestId);
|
||||
item.reject(new Error("请求页面已断开"));
|
||||
});
|
||||
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
|
||||
});
|
||||
}
|
||||
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot;
|
||||
const targetClientId = clientId || this.activeClientId;
|
||||
if (!targetClientId) return;
|
||||
this.canvasStates.set(targetClientId, { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot);
|
||||
}
|
||||
|
||||
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
activateClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.activeClientId = clientId;
|
||||
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 }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
if (!item || !body.requestId) return;
|
||||
if (!item || !body.requestId || item.clientId !== clientId) return false;
|
||||
this.pending.delete(body.requestId);
|
||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||
return true;
|
||||
}
|
||||
|
||||
emitAll(type: string, payload: unknown) {
|
||||
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) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
let tool: ToolName = name;
|
||||
@@ -168,7 +230,8 @@ export class CanvasSession {
|
||||
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value;
|
||||
const clientId = this.targetClientId;
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) throw new Error("当前没有已连接画布");
|
||||
sendEvent(client, "tool_call", { requestId, name, input });
|
||||
return await new Promise((resolve, reject) => {
|
||||
@@ -176,7 +239,7 @@ export class CanvasSession {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error("画布操作超时"));
|
||||
}, 30000);
|
||||
this.pending.set(requestId, { resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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";
|
||||
|
||||
export function startHttpServer() {
|
||||
@@ -12,7 +12,16 @@ export function startHttpServer() {
|
||||
saveConfig(config);
|
||||
|
||||
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();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "30mb" }));
|
||||
@@ -33,10 +42,14 @@ export function startHttpServer() {
|
||||
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
session.resolveResult(req.body);
|
||||
app.post("/canvas/activate", (req, res) => {
|
||||
session.activateClient(String(req.query.clientId || ""));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
|
||||
res.status(ok ? 200 : 409).json({ ok });
|
||||
});
|
||||
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
||||
app.get("/agent/codex/workspace", (_req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
@@ -48,48 +61,100 @@ export function startHttpServer() {
|
||||
res.json({ ok: true, workspace, ...result });
|
||||
}));
|
||||
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 thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateSiteWorkspace(config, { activeThreadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
||||
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
|
||||
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
try {
|
||||
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) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
|
||||
const nextWorkspace = setActiveThread(threadId);
|
||||
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
||||
}));
|
||||
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 threadId = routeParam(req.params.threadId);
|
||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined });
|
||||
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
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 workspace = ensureSiteWorkspace(config);
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
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 turnId = "";
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
setActiveThread(threadId, { emptyThread: true });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
setActiveThread(threadId);
|
||||
}
|
||||
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 });
|
||||
} catch (error) {
|
||||
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||
throw error;
|
||||
}
|
||||
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
||||
res.json({ ok: true, threadId });
|
||||
}));
|
||||
app.post("/agent/codex/interrupt", (_req, res) => {
|
||||
const ok = interruptCodexTurn();
|
||||
app.post("/agent/codex/interrupt", (req, res) => {
|
||||
const ok = interruptCodexTurn(String(req.body?.threadId || ""));
|
||||
res.json({ ok });
|
||||
});
|
||||
app.post("/agent/claude/turn", (req, res) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export const toolNames = [
|
||||
"canvas_select_nodes",
|
||||
"canvas_set_viewport",
|
||||
"canvas_run_generation",
|
||||
"generation_get_status",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
@@ -111,6 +112,7 @@ export const toolInputSchemas = {
|
||||
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
|
||||
canvas_set_viewport: z.object({ viewport: viewportSchema }),
|
||||
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
|
||||
generation_get_status: z.object({ scope: z.enum(["all", "canvas", "image", "video"]).optional(), taskId: z.string().optional(), nodeIds: z.array(z.string()).optional(), limit: z.number().optional() }),
|
||||
workbench_image_get_config: z.object({}).passthrough(),
|
||||
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
|
||||
workbench_video_get_config: z.object({}).passthrough(),
|
||||
@@ -146,10 +148,11 @@ export const toolDescriptions: Record<ToolName, string> = {
|
||||
canvas_select_nodes: "设置当前选中节点。",
|
||||
canvas_set_viewport: "调整画布视口。",
|
||||
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||
generation_get_status: "查询当前活动网页的生成任务状态。默认返回画布、生图工作台和视频工作台最近任务;可用 scope 过滤来源,用 taskId 查询工作台任务,用 nodeIds 查询画布节点。",
|
||||
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
|
||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
|
||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
||||
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
||||
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -6,3 +6,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
# 待测试
|
||||
|
||||
- 图片编辑弹窗:遮罩、切图和裁剪支持滚轮缩放、中键或空格加左键平移;聚焦弹窗按钮后按空格应只进入平移模式,不能误触关闭或其他按钮;遮罩支持撤回/重做、Alt 加左/右键横拖调整笔刷;切图支持选中线后 Delete 删除及撤回/重做;裁剪支持固定当前比例、原图、1:1、4:3、16:9、9:16,需验证高分辨率图片下无重影、闪烁或误触。
|
||||
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
|
||||
- 本地 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 (/拒绝|取消/.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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,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 +42,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 +94,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 +149,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 +178,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 +232,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 +240,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 +262,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(({ name, type, dataUrl }) => ({ name, type, dataUrl })),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
|
||||
const data = (await res.json()) as { threadId?: string };
|
||||
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
|
||||
addEventLog("本地 Agent 已接收", { status: res.status });
|
||||
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 +301,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: "停止失败" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,7 +352,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
return;
|
||||
}
|
||||
pendingToolRef.current = payload;
|
||||
setAgentState({ pendingTool: payload, activity: "等待确认", waiting: false });
|
||||
setAgentState({ pendingTool: payload });
|
||||
addEventLog("等待确认", payload, payload);
|
||||
return;
|
||||
}
|
||||
@@ -302,16 +362,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,7 +376,6 @@ 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;
|
||||
if (payload.name === "site_navigate") {
|
||||
@@ -337,7 +393,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
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",
|
||||
@@ -347,7 +402,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
});
|
||||
} 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 +410,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 +475,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 +491,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 +505,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 +519,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 +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);
|
||||
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 +580,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 +601,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 +628,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 +911,7 @@ function AgentHistoryView({
|
||||
activeThreadId,
|
||||
workspacePath,
|
||||
loading,
|
||||
busy,
|
||||
connected,
|
||||
onRefresh,
|
||||
onNewThread,
|
||||
@@ -875,6 +923,7 @@ function AgentHistoryView({
|
||||
activeThreadId: string;
|
||||
workspacePath: string;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
connected: boolean;
|
||||
onRefresh: () => void;
|
||||
onNewThread: () => void;
|
||||
@@ -899,7 +948,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 +972,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 +1004,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 +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) {
|
||||
const head = [
|
||||
"Infinite Canvas Agent 诊断日志",
|
||||
@@ -1025,17 +1085,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 会话";
|
||||
@@ -1093,6 +1142,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 "已完成";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetchPrompts } from "@/services/api/prompts";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel";
|
||||
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
|
||||
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
|
||||
@@ -14,6 +15,7 @@ import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
||||
|
||||
export const SITE_TOOL_NAMES = [
|
||||
"canvas_list_projects",
|
||||
"generation_get_status",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
@@ -31,6 +33,7 @@ export function isSiteTool(name: string): name is SiteToolName {
|
||||
|
||||
export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
|
||||
canvas_list_projects: "画布列表",
|
||||
generation_get_status: "生成任务状态",
|
||||
workbench_image_get_config: "生图配置",
|
||||
workbench_image_generate: "生图工作台生成",
|
||||
workbench_video_get_config: "视频配置",
|
||||
@@ -41,11 +44,16 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
|
||||
};
|
||||
|
||||
type SiteToolInput = Record<string, unknown>;
|
||||
type SiteToolContext = { canvasSnapshot?: CanvasAgentSnapshot | null };
|
||||
type GenerationStatus = "idle" | "queued" | "running" | "succeeded" | "failed";
|
||||
type GenerationStatusItem = { id: string; source: "canvas" | "image" | "video"; status: GenerationStatus; kind?: string; title?: string; prompt?: string; projectId?: string; createdAt?: string; updatedAt?: string; successCount?: number; failCount?: number; error?: string };
|
||||
|
||||
export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction): Promise<unknown> {
|
||||
export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction, context: SiteToolContext = {}): Promise<unknown> {
|
||||
switch (name) {
|
||||
case "canvas_list_projects":
|
||||
return listCanvasProjects(input);
|
||||
case "generation_get_status":
|
||||
return getGenerationStatus(input, context.canvasSnapshot);
|
||||
case "workbench_image_get_config":
|
||||
return getImageConfig();
|
||||
case "workbench_image_generate":
|
||||
@@ -65,6 +73,56 @@ export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navi
|
||||
}
|
||||
}
|
||||
|
||||
function getGenerationStatus(input: SiteToolInput, canvasSnapshot?: CanvasAgentSnapshot | null) {
|
||||
const scope = input.scope === "canvas" || input.scope === "image" || input.scope === "video" ? input.scope : "all";
|
||||
const taskId = typeof input.taskId === "string" ? input.taskId : "";
|
||||
const nodeIds = new Set(Array.isArray(input.nodeIds) ? input.nodeIds.filter((id): id is string => typeof id === "string") : []);
|
||||
const limit = Math.max(1, Math.min(100, Math.floor(Number(input.limit)) || 20));
|
||||
const tasks: GenerationStatusItem[] = [];
|
||||
const includeCanvas = (scope === "all" || scope === "canvas") && (!taskId || nodeIds.size > 0);
|
||||
const includeWorkbench = !nodeIds.size || Boolean(taskId);
|
||||
|
||||
if (includeCanvas && canvasSnapshot) {
|
||||
canvasSnapshot.nodes.forEach((node) => {
|
||||
const status = normalizeCanvasGenerationStatus(node.metadata?.status);
|
||||
if (!status || (nodeIds.size && !nodeIds.has(node.id))) return;
|
||||
const metadata = node.metadata || {};
|
||||
if (!nodeIds.size && node.type !== "config" && status !== "running" && status !== "failed" && !metadata.generationMode && !metadata.generationType && !metadata.model) return;
|
||||
tasks.push({ id: node.id, source: "canvas", status, kind: metadata.generationMode || node.type, title: node.title, prompt: compactPrompt(metadata.prompt || metadata.composerContent), projectId: canvasSnapshot.projectId, error: metadata.errorDetails });
|
||||
});
|
||||
}
|
||||
|
||||
if (includeWorkbench) {
|
||||
useWorkbenchAgentStore.getState().tasks.forEach((task) => {
|
||||
if ((scope === "image" || scope === "video") && task.kind !== scope) return;
|
||||
if (scope === "canvas" || (taskId && task.id !== taskId)) return;
|
||||
tasks.push({ ...task, source: task.kind, prompt: compactPrompt(task.prompt) });
|
||||
});
|
||||
}
|
||||
|
||||
tasks.sort((a, b) => generationStatusOrder(a.status) - generationStatusOrder(b.status) || (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
||||
const summary: Record<GenerationStatus, number> = { idle: 0, queued: 0, running: 0, succeeded: 0, failed: 0 };
|
||||
tasks.forEach((task) => (summary[task.status] += 1));
|
||||
return { total: tasks.length, summary, tasks: tasks.slice(0, limit) };
|
||||
}
|
||||
|
||||
function generationStatusOrder(status: GenerationStatus) {
|
||||
return status === "running" ? 0 : status === "queued" ? 1 : 2;
|
||||
}
|
||||
|
||||
function normalizeCanvasGenerationStatus(status: unknown): GenerationStatus | null {
|
||||
if (status === "idle") return "idle";
|
||||
if (status === "loading") return "running";
|
||||
if (status === "success") return "succeeded";
|
||||
if (status === "error") return "failed";
|
||||
return null;
|
||||
}
|
||||
|
||||
function compactPrompt(prompt: unknown) {
|
||||
const value = typeof prompt === "string" ? prompt.trim() : "";
|
||||
return value ? `${value.slice(0, 200)}${value.length > 200 ? "..." : ""}` : undefined;
|
||||
}
|
||||
|
||||
function listCanvasProjects(input: SiteToolInput) {
|
||||
const { projects, hydrated } = useCanvasStore.getState();
|
||||
if (!hydrated) throw new Error("画布还在加载中,请稍后重试");
|
||||
@@ -118,8 +176,8 @@ function runImageWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
|
||||
const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
|
||||
const run = input.run !== false;
|
||||
navigate("/image");
|
||||
useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
|
||||
return { ok: true, navigated: "/image", prompt, run, applied, note: run ? "已跳转生图工作台并触发生成,结果请稍后在工作台查看" : "已跳转生图工作台并填入参数,未触发生成" };
|
||||
const taskId = useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
|
||||
return { ok: true, navigated: "/image", prompt, run, taskId, applied, note: run ? "已跳转生图工作台并触发生成,可用 generation_get_status 查询任务" : "已跳转生图工作台并填入参数,未触发生成" };
|
||||
}
|
||||
|
||||
function getVideoConfig() {
|
||||
@@ -173,8 +231,8 @@ function runVideoWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
|
||||
const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
|
||||
const run = input.run !== false;
|
||||
navigate("/video");
|
||||
useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
|
||||
return { ok: true, navigated: "/video", prompt, run, applied, note: run ? "已跳转视频创作台并触发生成,结果请稍后在工作台查看" : "已跳转视频创作台并填入参数,未触发生成" };
|
||||
const taskId = useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
|
||||
return { ok: true, navigated: "/video", prompt, run, taskId, applied, note: run ? "已跳转视频创作台并触发生成,可用 generation_get_status 查询任务" : "已跳转视频创作台并填入参数,未触发生成" };
|
||||
}
|
||||
|
||||
async function searchPrompts(input: SiteToolInput) {
|
||||
|
||||
@@ -92,7 +92,9 @@ export default function ImagePage() {
|
||||
const [autoRunToken, setAutoRunToken] = useState(0);
|
||||
const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand);
|
||||
const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand);
|
||||
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
|
||||
const processedCommandRef = useRef(0);
|
||||
const agentTaskIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const model = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
const canGenerate = Boolean(prompt.trim());
|
||||
@@ -141,22 +143,30 @@ export default function ImagePage() {
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
const agentTaskId = agentTaskIdRef.current;
|
||||
agentTaskIdRef.current = undefined;
|
||||
const text = prompt.trim();
|
||||
if (!text) {
|
||||
message.error("请输入生图提示词");
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "请输入生图提示词" });
|
||||
return;
|
||||
}
|
||||
if (!isAiConfigReady(effectiveConfig, model)) {
|
||||
message.warning("请先完成配置");
|
||||
openConfigDialog(true);
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图配置不完整" });
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = buildRequestSnapshot();
|
||||
if (!snapshot) return;
|
||||
if (!snapshot) {
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图参数无效" });
|
||||
return;
|
||||
}
|
||||
|
||||
setElapsedMs(0);
|
||||
setRunning(true);
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
|
||||
setPreviewLog(null);
|
||||
setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" })));
|
||||
const batchStartedAt = performance.now();
|
||||
@@ -169,6 +179,8 @@ export default function ImagePage() {
|
||||
const successCount = successImages.length;
|
||||
const failCount = generationCount - successCount;
|
||||
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
||||
const error = failed?.reason instanceof Error ? failed.reason.message : failCount ? "生成失败" : undefined;
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: successCount ? "succeeded" : "failed", successCount, failCount, error: successCount ? undefined : error });
|
||||
|
||||
try {
|
||||
const logImages = await Promise.all(
|
||||
@@ -202,8 +214,15 @@ export default function ImagePage() {
|
||||
processedCommandRef.current = imageCommand.nonce;
|
||||
clearImageCommand();
|
||||
if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt);
|
||||
if (imageCommand.run && !running) setAutoRunToken((value) => value + 1);
|
||||
}, [imageCommand, clearImageCommand, running]);
|
||||
if (imageCommand.run && running) {
|
||||
if (imageCommand.taskId) updateAgentTask(imageCommand.taskId, { status: "failed", error: "生图工作台已有任务正在运行" });
|
||||
return;
|
||||
}
|
||||
if (imageCommand.run) {
|
||||
agentTaskIdRef.current = imageCommand.taskId;
|
||||
setAutoRunToken((value) => value + 1);
|
||||
}
|
||||
}, [imageCommand, clearImageCommand, running, updateAgentTask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRunToken) return;
|
||||
|
||||
@@ -97,7 +97,9 @@ export default function VideoPage() {
|
||||
const [autoRunToken, setAutoRunToken] = useState(0);
|
||||
const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand);
|
||||
const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand);
|
||||
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
|
||||
const processedCommandRef = useRef(0);
|
||||
const agentTaskIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const model = effectiveConfig.videoModel || effectiveConfig.model;
|
||||
const canGenerate = Boolean(prompt.trim());
|
||||
@@ -170,10 +172,16 @@ export default function VideoPage() {
|
||||
}
|
||||
};
|
||||
const generate = async () => {
|
||||
const agentTaskId = agentTaskIdRef.current;
|
||||
agentTaskIdRef.current = undefined;
|
||||
const snapshot = buildRequestSnapshot();
|
||||
if (!snapshot) return;
|
||||
if (!snapshot) {
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "视频生成参数无效" });
|
||||
return;
|
||||
}
|
||||
setElapsedMs(0);
|
||||
setRunning(true);
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
|
||||
setPreviewLog(null);
|
||||
setResults([{ id: nanoid(), status: "pending" }]);
|
||||
const batchStartedAt = performance.now();
|
||||
@@ -181,11 +189,12 @@ export default function VideoPage() {
|
||||
try {
|
||||
const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences);
|
||||
const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task });
|
||||
await saveLog(log);
|
||||
void pollGenerationLog(log, snapshot.config);
|
||||
await saveLog(log, false);
|
||||
void pollGenerationLog(log, snapshot.config, agentTaskId);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
||||
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
|
||||
await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
|
||||
message.error(errorMessage);
|
||||
setRunning(false);
|
||||
@@ -198,8 +207,15 @@ export default function VideoPage() {
|
||||
processedCommandRef.current = videoCommand.nonce;
|
||||
clearVideoCommand();
|
||||
if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt);
|
||||
if (videoCommand.run && !running) setAutoRunToken((value) => value + 1);
|
||||
}, [videoCommand, clearVideoCommand, running]);
|
||||
if (videoCommand.run && running) {
|
||||
if (videoCommand.taskId) updateAgentTask(videoCommand.taskId, { status: "failed", error: "视频工作台已有任务正在运行" });
|
||||
return;
|
||||
}
|
||||
if (videoCommand.run) {
|
||||
agentTaskIdRef.current = videoCommand.taskId;
|
||||
setAutoRunToken((value) => value + 1);
|
||||
}
|
||||
}, [videoCommand, clearVideoCommand, running, updateAgentTask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRunToken) return;
|
||||
@@ -276,7 +292,7 @@ export default function VideoPage() {
|
||||
.filter((log) => selectedLogIds.includes(log.id))
|
||||
.map((log) => log.video?.storageKey)
|
||||
.filter((key): key is string => Boolean(key));
|
||||
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs);
|
||||
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(() => refreshLogs());
|
||||
if (previewLog && selectedLogIds.includes(previewLog.id)) {
|
||||
setPreviewLog(null);
|
||||
setResults([]);
|
||||
@@ -285,15 +301,15 @@ export default function VideoPage() {
|
||||
setDeleteConfirmOpen(false);
|
||||
};
|
||||
|
||||
const saveLog = async (log: GenerationLog) => {
|
||||
const saveLog = async (log: GenerationLog, resumePending = true) => {
|
||||
await logStore.setItem(log.id, serializeLog(log));
|
||||
await refreshLogs();
|
||||
await refreshLogs(resumePending);
|
||||
};
|
||||
|
||||
const refreshLogs = async () => {
|
||||
const refreshLogs = async (resumePending = true) => {
|
||||
const nextLogs = await readStoredLogs();
|
||||
setLogs(nextLogs);
|
||||
resumePendingLogs(nextLogs);
|
||||
if (resumePending) resumePendingLogs(nextLogs);
|
||||
return nextLogs;
|
||||
};
|
||||
|
||||
@@ -303,7 +319,7 @@ export default function VideoPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig) => {
|
||||
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig, agentTaskId?: string) => {
|
||||
if (!log.task || activeLogIdsRef.current.has(log.id)) return;
|
||||
activeLogIdsRef.current.add(log.id);
|
||||
setRunning(true);
|
||||
@@ -326,6 +342,7 @@ export default function VideoPage() {
|
||||
mimeType: stored.mimeType,
|
||||
};
|
||||
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "succeeded", successCount: 1, failCount: 0, error: undefined });
|
||||
await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined });
|
||||
message.success("视频已生成");
|
||||
return;
|
||||
@@ -337,6 +354,7 @@ export default function VideoPage() {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
||||
setResults([{ id: log.id, status: "failed", error: errorMessage }]);
|
||||
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
|
||||
await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage });
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -6,15 +6,30 @@ import { create } from "zustand";
|
||||
|
||||
export type WorkbenchCommand = {
|
||||
nonce: number;
|
||||
taskId?: string;
|
||||
prompt?: string;
|
||||
run: boolean;
|
||||
};
|
||||
|
||||
export type WorkbenchGenerationTask = {
|
||||
id: string;
|
||||
kind: "image" | "video";
|
||||
status: "queued" | "running" | "succeeded" | "failed";
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
successCount?: number;
|
||||
failCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type WorkbenchAgentStore = {
|
||||
imageCommand: WorkbenchCommand | null;
|
||||
videoCommand: WorkbenchCommand | null;
|
||||
dispatchImage: (command: Omit<WorkbenchCommand, "nonce">) => void;
|
||||
dispatchVideo: (command: Omit<WorkbenchCommand, "nonce">) => void;
|
||||
tasks: WorkbenchGenerationTask[];
|
||||
dispatchImage: (command: Omit<WorkbenchCommand, "nonce" | "taskId">) => string | undefined;
|
||||
dispatchVideo: (command: Omit<WorkbenchCommand, "nonce" | "taskId">) => string | undefined;
|
||||
updateTask: (id: string, patch: Partial<Pick<WorkbenchGenerationTask, "status" | "successCount" | "failCount" | "error">>) => void;
|
||||
clearImageCommand: () => void;
|
||||
clearVideoCommand: () => void;
|
||||
};
|
||||
@@ -25,8 +40,25 @@ const nextNonce = () => (nonce += 1);
|
||||
export const useWorkbenchAgentStore = create<WorkbenchAgentStore>((set) => ({
|
||||
imageCommand: null,
|
||||
videoCommand: null,
|
||||
dispatchImage: (command) => set({ imageCommand: { ...command, nonce: nextNonce() } }),
|
||||
dispatchVideo: (command) => set({ videoCommand: { ...command, nonce: nextNonce() } }),
|
||||
tasks: [],
|
||||
dispatchImage: (command) => {
|
||||
const commandNonce = nextNonce();
|
||||
const task = command.run ? createTask("image", commandNonce, command.prompt) : undefined;
|
||||
set((state) => ({ imageCommand: { ...command, nonce: commandNonce, taskId: task?.id }, tasks: task ? [task, ...state.tasks].slice(0, 30) : state.tasks }));
|
||||
return task?.id;
|
||||
},
|
||||
dispatchVideo: (command) => {
|
||||
const commandNonce = nextNonce();
|
||||
const task = command.run ? createTask("video", commandNonce, command.prompt) : undefined;
|
||||
set((state) => ({ videoCommand: { ...command, nonce: commandNonce, taskId: task?.id }, tasks: task ? [task, ...state.tasks].slice(0, 30) : state.tasks }));
|
||||
return task?.id;
|
||||
},
|
||||
updateTask: (id, patch) => set((state) => ({ tasks: state.tasks.map((task) => (task.id === id ? { ...task, ...patch, updatedAt: new Date().toISOString() } : task)) })),
|
||||
clearImageCommand: () => set({ imageCommand: null }),
|
||||
clearVideoCommand: () => set({ videoCommand: null }),
|
||||
}));
|
||||
|
||||
function createTask(kind: "image" | "video", commandNonce: number, prompt?: string): WorkbenchGenerationTask {
|
||||
const now = new Date().toISOString();
|
||||
return { id: `${kind}-${commandNonce}`, kind, status: "queued", prompt, createdAt: now, updatedAt: now };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user