diff --git a/CHANGELOG.md b/CHANGELOG.md index de7e54d..60a478e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。 ++ [修复] 修复多标签页同时连接本地 Agent 时 MCP 可能操作非当前页面的问题。 + [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。 ## v0.9.0 - 2026-07-17 diff --git a/canvas-agent/package.json b/canvas-agent/package.json index 3c6287a..69ebc60 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -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" diff --git a/canvas-agent/src/canvas-session.test.ts b/canvas-agent/src/canvas-session.test.ts new file mode 100644 index 0000000..4188772 --- /dev/null +++ b/canvas-agent/src/canvas-session.test.ts @@ -0,0 +1,170 @@ +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((resolve) => setTimeout(() => resolve("pending"), 20)), + ]); + if (outcome === "pending") session.resolveResult("first", { requestId, result: null }); + assert.match(outcome, /断开/); +}); + +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)[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"); + } +} diff --git a/canvas-agent/src/canvas-session.ts b/canvas-agent/src/canvas-session.ts index 6bc480c..1efaeea 100644 --- a/canvas-agent/src/canvas-session.ts +++ b/canvas-agent/src/canvas-session.ts @@ -5,7 +5,7 @@ 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 }; const SITE_TOOLS = new Set([ "site_navigate", @@ -17,12 +17,20 @@ const SITE_TOOLS = new Set([ "prompts_search", "assets_list", "assets_add", + "generation_get_status", ]); export class CanvasSession { private clients = new Map(); + private clientFocusOrder = new Map(); private pending = new Map(); - private canvasState: CanvasSnapshot | null = null; + private canvasStates = new Map(); + private activeClientId = ""; + private focusSequence = 0; + + private get canvasState() { + return this.canvasStates.get(this.activeClientId) || null; + } health() { return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size }; @@ -32,25 +40,49 @@ export class CanvasSession { 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); + 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 }); 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); + 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), 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), 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); + } + + 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) { @@ -168,7 +200,8 @@ export class CanvasSession { private async requestCanvasTool(name: ToolName, input: Record) { const requestId = crypto.randomUUID(); - const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value; + const clientId = this.activeClientId; + 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 +209,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)) }); }); } } diff --git a/canvas-agent/src/http-server.ts b/canvas-agent/src/http-server.ts index eeab8cd..d886236 100644 --- a/canvas-agent/src/http-server.ts +++ b/canvas-agent/src/http-server.ts @@ -33,10 +33,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); diff --git a/canvas-agent/src/schemas.ts b/canvas-agent/src/schemas.ts index a07af63..479098b 100644 --- a/canvas-agent/src/schemas.ts +++ b/canvas-agent/src/schemas.ts @@ -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 = { 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。", diff --git a/canvas-agent/tsconfig.json b/canvas-agent/tsconfig.json index 543a8e2..d312216 100644 --- a/canvas-agent/tsconfig.json +++ b/canvas-agent/tsconfig.json @@ -10,5 +10,6 @@ "rootDir": "src", "skipLibCheck": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] } diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a781a33..875286e 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -4,3 +4,6 @@ description: 当前版本已实现但仍需人工验证的变更项 --- # 待测试 + +- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。 +- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;关闭当前页面后应自动回退到仍连接的页面。 diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index fb20de8..eca3544 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -151,6 +151,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb setAgentState({ connected: true, activity: "已连接", 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("tool_call", (event) => { const data = parseEventData(event); @@ -200,6 +201,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; @@ -304,7 +318,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb 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); @@ -955,6 +969,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) }); } @@ -1093,6 +1113,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) : {}; + 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 "已完成"; diff --git a/web/src/lib/agent/agent-site-tools.ts b/web/src/lib/agent/agent-site-tools.ts index 1c5b49b..e031351 100644 --- a/web/src/lib/agent/agent-site-tools.ts +++ b/web/src/lib/agent/agent-site-tools.ts @@ -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 = { 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 = { }; type SiteToolInput = Record; +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 { +export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction, context: SiteToolContext = {}): Promise { 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 = { 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) { diff --git a/web/src/pages/image/index.tsx b/web/src/pages/image/index.tsx index b95ef3f..7d3b642 100644 --- a/web/src/pages/image/index.tsx +++ b/web/src/pages/image/index.tsx @@ -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(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; diff --git a/web/src/pages/video/index.tsx b/web/src/pages/video/index.tsx index f88caa9..f154100 100644 --- a/web/src/pages/video/index.tsx +++ b/web/src/pages/video/index.tsx @@ -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(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 { diff --git a/web/src/stores/use-workbench-agent-store.ts b/web/src/stores/use-workbench-agent-store.ts index 02d4c43..cb30422 100644 --- a/web/src/stores/use-workbench-agent-store.ts +++ b/web/src/stores/use-workbench-agent-store.ts @@ -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) => void; - dispatchVideo: (command: Omit) => void; + tasks: WorkbenchGenerationTask[]; + dispatchImage: (command: Omit) => string | undefined; + dispatchVideo: (command: Omit) => string | undefined; + updateTask: (id: string, patch: Partial>) => void; clearImageCommand: () => void; clearVideoCommand: () => void; }; @@ -25,8 +40,25 @@ const nextNonce = () => (nonce += 1); export const useWorkbenchAgentStore = create((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 }; +}