diff --git a/.github/workflows/publish-canvas-agent.yml b/.github/workflows/publish-canvas-agent.yml new file mode 100644 index 0000000..a561d04 --- /dev/null +++ b/.github/workflows/publish-canvas-agent.yml @@ -0,0 +1,59 @@ +name: Publish Canvas Agent + +on: + push: + branches: + - main + paths: + - "canvas-agent/**" + - ".github/workflows/publish-canvas-agent.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish-canvas-agent + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: canvas-agent + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: npm install --no-audit --no-fund + + - name: Check package version + id: package + run: | + name=$(node -p "require('./package.json').name") + version=$(node -p "require('./package.json').version") + published=$(npm view "$name@$version" version 2>/dev/null || true) + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + if [ -n "$published" ]; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + else + echo "should_publish=true" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to npm + if: steps.package.outputs.should_publish == 'true' + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Skip existing version + if: steps.package.outputs.should_publish != 'true' + run: echo "${{ steps.package.outputs.name }}@${{ steps.package.outputs.version }} already exists on npm, skipping publish." diff --git a/canvas-agent/README.md b/canvas-agent/README.md index 1f815e2..0310426 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -5,7 +5,7 @@ ## 启动 ```bash -npx -y canvas-agent +npx -y @basketikun/canvas-agent ``` 本仓库开发时也可以直接运行: @@ -28,6 +28,12 @@ Connect token: xxxxxx Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接后,Canvas Agent 会记录该网页 Origin;之后其他 Origin 不能复用这个本地 Agent,除非用户清理 `~/.infinite-canvas/canvas-agent.json` 里的 `origins`。 +## 发布 + +`canvas-agent` 使用自己的 `package.json` 版本号,不跟仓库根目录 `VERSION` 绑定。推送到 `main` 后,GitHub Actions 会检查 npm 上是否已经存在当前包版本;不存在时才发布 `@basketikun/canvas-agent`。 + +发布前需要在 GitHub 仓库 Secrets 中配置 `NPM_TOKEN`。 + ## Codex MCP 如果希望 Codex 终端能直接操作画布,需要先把 Canvas Agent 注册成 Codex MCP。 @@ -35,7 +41,7 @@ Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接 Canvas Agent 启动后,给 Codex 添加 MCP: ```bash -codex mcp add infinite-canvas -- npx -y canvas-agent mcp +codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp ``` 本仓库开发时可以改成,实际使用建议替换为本机绝对路径: @@ -51,7 +57,7 @@ Canvas Agent 源码使用 TypeScript 编写,MCP 协议层使用官方 `@modelc ```toml [mcp_servers.infinite-canvas] command = "npx" -args = ["-y", "canvas-agent", "mcp"] +args = ["-y", "@basketikun/canvas-agent", "mcp"] default_tools_approval_mode = "approve" ``` @@ -95,7 +101,7 @@ Claude Code Adapter 代码暂时保留,但当前网页侧边栏只开放 Codex 如果希望 Claude Code 也能操作画布,需要给 Claude Code 添加同一个 MCP。建议用 user scope,避免 Canvas Agent 从不同目录启动时找不到配置: ```bash -claude mcp add --scope user --transport stdio infinite-canvas -- npx -y canvas-agent mcp +claude mcp add --scope user --transport stdio infinite-canvas -- npx -y @basketikun/canvas-agent mcp ``` 本仓库开发时可以改成: diff --git a/canvas-agent/bun.lock b/canvas-agent/bun.lock index a6049c7..da19660 100644 --- a/canvas-agent/bun.lock +++ b/canvas-agent/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "canvas-agent", + "name": "@basketikun/canvas-agent", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "@openai/codex": "^0.139.0", @@ -85,8 +85,6 @@ "@openai/codex-linux-x64": ["@openai/codex@0.139.0-linux-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-linux-x64.tgz", { "os": "linux", "cpu": "x64" }, "sha512-14UgzDS+X4crkvdt6S02A/ZZOrS8ZyWiuTRpguCtnhNamb7unSuDxy86BWgpAl3sqiTaN2CP8VLyp2ohQ8Nbzw=="], - "@openai/codex-sdk": ["@openai/codex-sdk@0.139.0", "https://registry.npmmirror.com/@openai/codex-sdk/-/codex-sdk-0.139.0.tgz", { "dependencies": { "@openai/codex": "0.139.0" } }, "sha512-r4lDckaVx4mVZy1v/7ykEhkeWjVfM/4oGJfhG0AP4+zN3Sa+jcf5hdY4EHfJasofBcp0tIF/7JCKHpv534R+tw=="], - "@openai/codex-win32-arm64": ["@openai/codex@0.139.0-win32-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-arm64.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-nlwRjsYotH1Rtqu/Q0VwQbIeO2UX1mkHK84Ov9qn/hl29QqqoBtno0tRyqIPbkXFIVQuWiAYXlV3ugLwH5fTrQ=="], "@openai/codex-win32-x64": ["@openai/codex@0.139.0-win32-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-x64.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ=="], diff --git a/canvas-agent/package.json b/canvas-agent/package.json index 8dc9952..06f2d4e 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -1,5 +1,5 @@ { - "name": "canvas-agent", + "name": "@basketikun/canvas-agent", "version": "0.1.0", "type": "module", "main": "./dist/index.js", @@ -31,5 +31,8 @@ }, "engines": { "node": ">=18" + }, + "publishConfig": { + "access": "public" } } diff --git a/canvas-agent/src/agents.ts b/canvas-agent/src/agents.ts index 6854138..b8cc414 100644 --- a/canvas-agent/src/agents.ts +++ b/canvas-agent/src/agents.ts @@ -11,6 +11,8 @@ import type { AgentAttachment, AgentEmit } from "./types.js"; type Json = Record; type AgentEvent = Json & { type: string; usage?: unknown }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; +type CodexRunOptions = { threadId?: string; cwd?: string }; +type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string }; let codexQueue: Promise = Promise.resolve(); let codexApp: CodexAppClient | null = null; @@ -22,19 +24,19 @@ export function withAgentPrompt(prompt: string) { return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : ""; } -export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = []) { +export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) { if (!prompt.trim()) return; - codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments)); + codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options)); await codexQueue; } -async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[]) { +async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) { let files: string[] = []; try { files = await writeAttachmentFiles(attachments); codexApp ||= await CodexAppClient.start(emit); - codexThreadId ||= await codexApp.startThread(); - await codexApp.startTurn(codexThreadId, prompt, files); + const threadId = await ensureCodexThread(codexApp, options); + await codexApp.startTurn(threadId, prompt, files); } catch (error) { emit("agent_error", { message: errorMessage(error) }); } finally { @@ -42,6 +44,46 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age } } +export async function startCodexThread(emit: AgentEmit, cwd?: string) { + codexApp ||= await CodexAppClient.start(emit); + const thread = await codexApp.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 thread = await codexApp.resumeThread(threadId, cwd); + codexThreadId = String(field(thread, "id") || threadId); + return { thread, messages: threadMessages(thread) }; +} + +export async function listCodexThreads(emit: AgentEmit, options: { cwd?: string; all?: boolean; searchTerm?: string; limit?: number }) { + codexApp ||= await CodexAppClient.start(emit); + const result = await codexApp.listThreads({ + limit: options.limit || 40, + sortKey: "updated_at", + sortDirection: "desc", + sourceKinds: ["cli", "vscode", "appServer", "exec"], + ...(options.all ? {} : { cwd: options.cwd }), + ...(options.searchTerm ? { searchTerm: options.searchTerm } : {}), + }); + const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread) : []; + return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null }; +} + +export async function readCodexThread(emit: AgentEmit, threadId: string) { + codexApp ||= await CodexAppClient.start(emit); + const result = await codexApp.readThread(threadId); + const thread = field(result, "thread") || {}; + return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) }; +} + +export async function archiveCodexThread(emit: AgentEmit, threadId: string) { + codexApp ||= await CodexAppClient.start(emit); + await codexApp.archiveThread(threadId); +} + export function runClaudeTurn(prompt: string, emit: AgentEmit) { if (!prompt.trim()) return; const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", prompt], ["ignore", "pipe", "pipe"], emit); @@ -49,6 +91,21 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) { pipeJsonLines(child, emit, "claude"); } +async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) { + if (options.threadId) { + if (codexThreadId !== options.threadId) { + const thread = await app.resumeThread(options.threadId, options.cwd); + codexThreadId = String(field(thread, "id") || options.threadId); + } + return codexThreadId; + } + if (!codexThreadId) { + const thread = await app.startThread(options.cwd); + codexThreadId = String(field(thread, "id") || ""); + } + return codexThreadId; +} + class CodexAppClient { private nextId = 1; private buffer = ""; @@ -78,11 +135,32 @@ class CodexAppClient { return client; } - async startThread() { - const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), threadSource: "user" }); - const id = String(field(field(result, "thread"), "id") || ""); + async startThread(cwd?: string) { + const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" }); + const thread = field(result, "thread") as Json | undefined; + const id = String(field(thread, "id") || ""); if (!id) throw new Error("Codex app-server 没有返回 thread id"); - return id; + return thread || {}; + } + + async resumeThread(threadId: string, cwd?: string) { + const result = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) }); + const thread = field(result, "thread") as Json | undefined; + const id = String(field(thread, "id") || ""); + if (!id) throw new Error("Codex app-server 没有返回 thread id"); + return thread || {}; + } + + listThreads(params: Json) { + return this.request("thread/list", params); + } + + readThread(threadId: string) { + return this.request("thread/read", { threadId, includeTurns: true }); + } + + archiveThread(threadId: string) { + return this.request("thread/archive", { threadId }); } async startTurn(threadId: string, prompt: string, images: string[]) { @@ -245,6 +323,95 @@ function field(value: unknown, key: string) { return value && typeof value === "object" ? (value as Json)[key] : undefined; } +export function summarizeCodexThread(thread: unknown) { + return { + id: String(field(thread, "id") || ""), + sessionId: String(field(thread, "sessionId") || ""), + preview: displayUserText(String(field(thread, "preview") || "")), + name: stringOrNull(field(thread, "name")), + cwd: String(field(thread, "cwd") || ""), + status: String(field(thread, "status") || ""), + source: field(thread, "source"), + threadSource: field(thread, "threadSource"), + createdAt: Number(field(thread, "createdAt") || 0), + updatedAt: Number(field(thread, "updatedAt") || 0), + }; +} + +function threadMessages(thread: unknown): AgentHistoryMessage[] { + const turns = arrayValue(field(thread, "turns")); + const messages: AgentHistoryMessage[] = []; + turns.forEach((turn, turnIndex) => { + arrayValue(field(turn, "items")).forEach((item, itemIndex) => { + const type = String(field(item, "type") || ""); + const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`); + if (type === "userMessage") { + const text = displayUserText(userInputText(field(item, "content"))); + if (text) messages.push({ id, role: "user", text }); + } + if (type === "agentMessage") { + const text = String(field(item, "text") || "").trim(); + if (text) messages.push({ id, role: "assistant", title: "Codex", text, streamId: id }); + } + if (type === "mcpToolCall") { + const tool = String(field(item, "tool") || "工具调用"); + const error = field(field(item, "error"), "message"); + messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item }); + } + if (type === "commandExecution") { + const command = String(field(item, "command") || "").trim(); + if (command) messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } }); + } + if (type === "fileChange") messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item }); + }); + }); + return messages.filter((item) => item.text).slice(-120); +} + +function userInputText(content: unknown) { + return arrayValue(content) + .map((item) => { + const type = String(field(item, "type") || ""); + if (type === "text") return String(field(item, "text") || ""); + if (type === "image" || type === "localImage") return "图片附件"; + if (type === "mention") return `@${String(field(item, "name") || "文件")}`; + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function displayUserText(text: string) { + const value = text.trim(); + const marker = "用户请求:"; + const index = value.lastIndexOf(marker); + return (index >= 0 ? value.slice(index + marker.length) : value).trim(); +} + +function arrayValue(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function stringOrNull(value: unknown) { + return typeof value === "string" && value.trim() ? value : null; +} + +function toolName(name: string) { + if (name === "canvas_apply_ops") return "画布操作"; + if (name === "canvas_get_state") return "读取画布"; + if (name === "canvas_get_selection") return "读取选区"; + if (name === "canvas_export_snapshot") return "导出快照"; + if (name === "canvas_create_text_node") return "创建文本"; + if (name === "canvas_create_image_prompt_flow") return "创建生图流程"; + if (name === "canvas_create_generation_flow") return "创建生成流程"; + if (name === "canvas_generate_text") return "生成文本"; + if (name === "canvas_generate_image") return "生成图片"; + if (name === "canvas_generate_video") return "生成视频"; + if (name === "canvas_generate_audio") return "生成音频"; + if (name === "canvas_run_generation") return "触发生成"; + return name; +} + async function writeAttachmentFiles(attachments: AgentAttachment[]) { return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile)); } diff --git a/canvas-agent/src/canvas-session.ts b/canvas-agent/src/canvas-session.ts index 256c1d2..754faf6 100644 --- a/canvas-agent/src/canvas-session.ts +++ b/canvas-agent/src/canvas-session.ts @@ -3,7 +3,7 @@ import type { ServerResponse } from "node:http"; import { type ToolName } from "./schemas.js"; import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js"; -import type { CanvasSnapshot } from "./types.js"; +import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js"; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; @@ -55,18 +55,93 @@ export class CanvasSession { const ids = new Set(this.canvasState?.selectedNodeIds || []); return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) }; } + if (tool === "canvas_create_node") { + const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record }; + input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] }; + tool = "canvas_apply_ops"; + } if (tool === "canvas_create_text_node") { - const text = input as { text?: string; x?: number; y?: number; title?: string }; - input = { ops: [{ type: "add_node", nodeType: "text", title: text.title, position: { x: text.x ?? 0, y: text.y ?? 0 }, metadata: { content: text.text || "" } }] }; + const text = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number }; + input = { ops: [textNodeOp(text, text.x ?? nextCanvasX(this.canvasState), text.y ?? 0)] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_create_text_nodes") { + const data = input as { items: Array<{ text: string; title?: string; x?: number; y?: number; width?: number; height?: number }>; x?: number; y?: number; gap?: number; direction?: "row" | "column" }; + const x = Number(data.x ?? nextCanvasX(this.canvasState)); + const y = Number(data.y ?? 0); + const gap = Number(data.gap ?? 40); + input = { + ops: data.items.map((item, index) => textNodeOp(item, item.x ?? (data.direction === "row" ? x + index * (340 + gap) : x), item.y ?? (data.direction === "row" ? y : y + index * (240 + gap)))), + }; tool = "canvas_apply_ops"; } if (tool === "canvas_create_image_prompt_flow") { - const flow = input as { prompt: string; x?: number; y?: number }; - const x = Number(flow.x ?? nextCanvasX(this.canvasState)); - const y = Number(flow.y ?? 0); - const textId = `text-${crypto.randomUUID()}`; + input = { ops: generationFlowOps({ ...(input as Record), mode: "image" }, this.canvasState) }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_create_config_node") { + const data = input as Record; + const x = Number(data.x ?? nextCanvasX(this.canvasState)); + const y = Number(data.y ?? 0); const configId = `config-${crypto.randomUUID()}`; - input = { ops: [{ type: "add_node", id: textId, nodeType: "text", title: "提示词", position: { x, y }, metadata: { content: flow.prompt } }, { type: "add_node", id: configId, nodeType: "config", title: "图片生成", position: { x: x + 420, y }, metadata: { generationMode: "image", composerContent: flow.prompt } }, { type: "connect_nodes", fromNodeId: textId, toNodeId: configId }, { type: "select_nodes", ids: [configId] }] }; + const mode = generationMode(data.mode); + const prompt = String(data.prompt || ""); + input = { ops: [configNodeOp(configId, data, x, y), ...(data.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_create_generation_flow") { + input = { ops: generationFlowOps(input as Record, this.canvasState) }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_generate_text" || tool === "canvas_generate_image" || tool === "canvas_generate_video" || tool === "canvas_generate_audio") { + input = { ops: generationFlowOps({ ...(input as Record), mode: tool.replace("canvas_generate_", ""), autoRun: true }, this.canvasState) }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_update_node") { + const data = input as { id: string; patch?: Record; metadata?: Record }; + input = { ops: [{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_update_node_text") { + const data = input as { id: string; text: string; title?: string }; + input = { ops: [{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_move_nodes") { + const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> }; + input = { + ops: data.items.map((item) => { + const current = findNode(this.canvasState, item.id); + return { type: "update_node", id: item.id, patch: { position: { x: item.x ?? ((current?.position.x || 0) + (item.dx || 0)), y: item.y ?? ((current?.position.y || 0) + (item.dy || 0)) } } }; + }), + }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_resize_node") { + const data = input as { id: string; width: number; height: number; freeResize?: boolean }; + input = { ops: [{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_delete_nodes") { + input = { ops: [{ type: "delete_node", ids: (input as { ids: string[] }).ids }] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_connect_nodes") { + const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> }; + input = { ops: data.connections.map((connection) => ({ type: "connect_nodes", ...connection })) }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_select_nodes") { + input = { ops: [{ type: "select_nodes", ids: (input as { ids: string[] }).ids }] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_set_viewport") { + input = { ops: [{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }] }; + tool = "canvas_apply_ops"; + } + if (tool === "canvas_run_generation") { + const data = input as { nodeId: string; mode?: string; prompt?: string }; + input = { ops: [runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)] }; tool = "canvas_apply_ops"; } if (tool !== "canvas_apply_ops") throw new Error(`未知工具:${tool}`); @@ -92,3 +167,82 @@ export class CanvasSession { function sendEvent(res: ServerResponse, type: string, payload: unknown) { res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`); } + +function textNodeOp(input: { id?: string; text?: string; title?: string; width?: number; height?: number }, x: number, y: number) { + return { type: "add_node", id: input.id, nodeType: "text", title: input.title, position: { x, y }, width: input.width, height: input.height, metadata: { content: input.text || "", status: "success", fontSize: 14 } }; +} + +function configNodeOp(id: string, input: Record, x: number, y: number) { + const mode = generationMode(input.mode); + const prompt = String(input.prompt || ""); + return { + type: "add_node", + id, + nodeType: "config", + title: String(input.title || generationTitle(mode)), + position: { x, y }, + width: typeof input.width === "number" ? input.width : undefined, + height: typeof input.height === "number" ? input.height : undefined, + metadata: cleanRecord({ + generationMode: mode, + composerContent: prompt, + prompt, + status: "idle", + model: input.model, + size: input.size, + quality: input.quality, + count: input.count, + seconds: input.seconds, + vquality: input.vquality, + generateAudio: input.generateAudio, + watermark: input.watermark, + audioVoice: input.audioVoice, + audioFormat: input.audioFormat, + audioSpeed: input.audioSpeed, + audioInstructions: input.audioInstructions, + }), + }; +} + +function generationFlowOps(input: Record, state: CanvasSnapshot | null) { + const mode = generationMode(input.mode); + const prompt = String(input.prompt || ""); + const x = Number(input.x ?? nextCanvasX(state)); + const y = Number(input.y ?? 0); + const textId = `text-${crypto.randomUUID()}`; + const configId = `config-${crypto.randomUUID()}`; + const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : []; + const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)]; + const configInput = { ...input, prompt: tokens.join("\n") }; + return [ + textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y), + configNodeOp(configId, configInput, x + 420, y), + { type: "connect_nodes", fromNodeId: textId, toNodeId: configId }, + ...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes", fromNodeId, toNodeId: configId })), + { type: "select_nodes", ids: [configId] }, + ...(input.autoRun ? [runGenerationOp(configId, mode, tokens.join("\n"))] : []), + ]; +} + +function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string) { + return { type: "run_generation", nodeId, mode, prompt }; +} + +function generationMode(value: unknown): "text" | "image" | "video" | "audio" { + return value === "text" || value === "video" || value === "audio" ? value : "image"; +} + +function generationTitle(mode: "text" | "image" | "video" | "audio") { + if (mode === "text") return "文本生成"; + if (mode === "video") return "视频生成"; + if (mode === "audio") return "音频生成"; + return "图片生成"; +} + +function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefined { + return (state?.nodes || []).find((node) => node.id === id); +} + +function cleanRecord(value: Record) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "")); +} diff --git a/canvas-agent/src/config.ts b/canvas-agent/src/config.ts index 256ec1d..e9db514 100644 --- a/canvas-agent/src/config.ts +++ b/canvas-agent/src/config.ts @@ -6,10 +6,11 @@ import path from "node:path"; export const DEFAULT_PORT = 17371; export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas"); export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json"); -export const VERSION = "0.1.0"; -export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再用 canvas_apply_ops 提交 JSON 操作。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; +export const VERSION = readPackageVersion(); +export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; -export type CanvasAgentConfig = { url: string; token: string; origins?: string[] }; +export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] }; +export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record }; export function loadConfig(create = false): CanvasAgentConfig { try { @@ -25,3 +26,48 @@ export function saveConfig(config: CanvasAgentConfig) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } + +export function ensureCanvasWorkspace(config: CanvasAgentConfig, canvasId: string) { + const id = safeSegment(canvasId || "default"); + config.canvases ||= {}; + const current = config.canvases[id]; + if (current?.workspacePath) { + fs.mkdirSync(resolveWorkspacePath(current.workspacePath), { recursive: true }); + return { canvasId: id, ...current, workspacePath: resolveWorkspacePath(current.workspacePath) }; + } + const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", id); + config.canvases[id] = { workspacePath }; + fs.mkdirSync(workspacePath, { recursive: true }); + saveConfig(config); + return { canvasId: id, workspacePath }; +} + +export function updateCanvasWorkspace(config: CanvasAgentConfig, canvasId: string, patch: Partial) { + const current = ensureCanvasWorkspace(config, canvasId); + const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath; + const next = { ...current, ...patch, workspacePath }; + config.canvases ||= {}; + config.canvases[current.canvasId] = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds }; + fs.mkdirSync(workspacePath, { recursive: true }); + saveConfig(config); + return { canvasId: current.canvasId, ...config.canvases[current.canvasId] }; +} + +function resolveWorkspacePath(value: string) { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2)); + return path.resolve(value); +} + +function safeSegment(value: string) { + return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120) || "default"; +} + +function readPackageVersion() { + try { + const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string }; + return pkg.version || "0.0.0"; + } catch { + return "0.0.0"; + } +} diff --git a/canvas-agent/src/http-server.ts b/canvas-agent/src/http-server.ts index 3b82fa4..a5df7ea 100644 --- a/canvas-agent/src/http-server.ts +++ b/canvas-agent/src/http-server.ts @@ -1,8 +1,8 @@ import express, { type NextFunction, type Request, type Response } from "express"; -import { DEFAULT_PORT, loadConfig, saveConfig, type CanvasAgentConfig } from "./config.js"; +import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js"; import { CanvasSession } from "./canvas-session.js"; -import { runClaudeTurn, runCodexTurn, withAgentPrompt } from "./agents.js"; +import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, withAgentPrompt } from "./agents.js"; import type { AgentAttachment } from "./types.js"; export function startHttpServer() { @@ -38,10 +38,53 @@ export function startHttpServer() { res.json({ ok: true }); }); 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 = ensureCanvasWorkspace(config, String(req.query.canvasId || "")); + res.json({ ok: true, workspace }); + }); + app.get("/agent/codex/threads", route(async (req, res) => { + const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || "")); + const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, all: req.query.all === "1", searchTerm: String(req.query.searchTerm || "") }); + res.json({ ok: true, workspace, ...result }); + })); + app.post("/agent/codex/threads/new", route(async (req, res) => { + const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + const thread = await startCodexThread(emit, workspace.workspacePath); + const activeThreadId = String((thread as Record).id || ""); + updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId }); + res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] }); + })); + app.get("/agent/codex/threads/:threadId", route(async (req, res) => { + const threadId = routeParam(req.params.threadId); + res.json({ ok: true, ...(await readCodexThread(emit, threadId)) }); + })); + app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => { + const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + const threadId = routeParam(req.params.threadId); + const result = await resumeCodexThread(emit, threadId, workspace.workspacePath); + updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); + res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result }); + })); + app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => { + const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + const threadId = routeParam(req.params.threadId); + await archiveCodexThread(emit, threadId); + if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined }); + res.json({ ok: true }); + })); app.post("/agent/codex/turn", route(async (req, res) => { const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : []; - void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments); - res.json({ ok: true }); + const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + let threadId = String(req.body?.threadId || workspace.activeThreadId || ""); + if (!threadId) { + const thread = await startCodexThread(emit, workspace.workspacePath); + threadId = String((thread as Record).id || ""); + updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); + } else if (threadId !== workspace.activeThreadId) { + updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); + } + void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath }); + res.json({ ok: true, threadId }); })); app.post("/agent/claude/turn", (req, res) => { runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit); @@ -54,7 +97,7 @@ export function startHttpServer() { console.log("Infinite Canvas Agent"); console.log(`Local URL: ${config.url}`); console.log(`Connect token: ${config.token}`); - console.log("Codex MCP: codex mcp add infinite-canvas -- npx -y canvas-agent mcp"); + console.log("Codex MCP: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp"); }); } @@ -62,6 +105,10 @@ function route(handler: (req: Request, res: Response) => Promise) { return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next); } +function routeParam(value: string | string[]) { + return Array.isArray(value) ? value[0] || "" : value; +} + function requestUrl(req: Request, config: CanvasAgentConfig) { return new URL(req.originalUrl || req.url || "/", config.url); } diff --git a/canvas-agent/src/schemas.ts b/canvas-agent/src/schemas.ts index 471f625..339ae46 100644 --- a/canvas-agent/src/schemas.ts +++ b/canvas-agent/src/schemas.ts @@ -3,33 +3,126 @@ import { z } from "zod"; const recordSchema = z.record(z.unknown()); const positionSchema = z.object({ x: z.number(), y: z.number() }); const viewportSchema = z.object({ x: z.number(), y: z.number(), k: z.number() }); +const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]); +const generationModeSchema = z.enum(["text", "image", "video", "audio"]); -export const toolNames = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot", "canvas_apply_ops", "canvas_create_text_node", "canvas_create_image_prompt_flow"] as const; +export const toolNames = [ + "canvas_get_state", + "canvas_get_selection", + "canvas_export_snapshot", + "canvas_apply_ops", + "canvas_create_node", + "canvas_create_text_node", + "canvas_create_text_nodes", + "canvas_create_config_node", + "canvas_create_image_prompt_flow", + "canvas_create_generation_flow", + "canvas_generate_text", + "canvas_generate_image", + "canvas_generate_video", + "canvas_generate_audio", + "canvas_update_node", + "canvas_update_node_text", + "canvas_move_nodes", + "canvas_resize_node", + "canvas_delete_nodes", + "canvas_connect_nodes", + "canvas_select_nodes", + "canvas_set_viewport", + "canvas_run_generation", +] as const; export type ToolName = (typeof toolNames)[number]; export const canvasOpSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("add_node"), nodeType: z.enum(["image", "text", "config", "video", "audio"]).optional(), id: z.string().optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), position: positionSchema.optional(), metadata: recordSchema.optional() }).passthrough(), + z.object({ type: z.literal("add_node"), nodeType: nodeTypeSchema.optional(), id: z.string().optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), position: positionSchema.optional(), metadata: recordSchema.optional() }).passthrough(), z.object({ type: z.literal("update_node"), id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }).passthrough(), z.object({ type: z.literal("delete_node"), id: z.string().optional(), ids: z.array(z.string()).optional() }).passthrough(), z.object({ type: z.literal("connect_nodes"), id: z.string().optional(), fromNodeId: z.string(), toNodeId: z.string() }).passthrough(), z.object({ type: z.literal("set_viewport"), viewport: viewportSchema }).passthrough(), z.object({ type: z.literal("select_nodes"), ids: z.array(z.string()) }).passthrough(), + z.object({ type: z.literal("run_generation"), nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }).passthrough(), ]); +const textNodeSchema = z.object({ + text: z.string(), + title: z.string().optional(), + x: z.number().optional(), + y: z.number().optional(), + width: z.number().optional(), + height: z.number().optional(), +}); + +const generationOptionsSchema = z.object({ + model: z.string().optional(), + size: z.string().optional(), + quality: z.string().optional(), + count: z.number().optional(), + seconds: z.string().optional(), + vquality: z.string().optional(), + generateAudio: z.string().optional(), + watermark: z.string().optional(), + audioVoice: z.string().optional(), + audioFormat: z.string().optional(), + audioSpeed: z.string().optional(), + audioInstructions: z.string().optional(), +}); + +const generationFlowSchema = z.object({ + prompt: z.string(), + title: z.string().optional(), + x: z.number().optional(), + y: z.number().optional(), + referenceNodeIds: z.array(z.string()).optional(), +}); + export const toolInputSchemas = { canvas_get_state: z.object({}).passthrough(), canvas_get_selection: z.object({}).passthrough(), canvas_export_snapshot: z.object({}).passthrough(), canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }), - canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional() }), - canvas_create_image_prompt_flow: z.object({ prompt: z.string(), x: z.number().optional(), y: z.number().optional() }), + canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }), + canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }), + canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }), + canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema), + canvas_create_image_prompt_flow: z.object({ prompt: z.string(), x: z.number().optional(), y: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema), + canvas_create_generation_flow: generationFlowSchema.extend({ mode: generationModeSchema.optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema), + canvas_generate_text: generationFlowSchema.merge(generationOptionsSchema), + canvas_generate_image: generationFlowSchema.merge(generationOptionsSchema), + canvas_generate_video: generationFlowSchema.merge(generationOptionsSchema), + canvas_generate_audio: generationFlowSchema.merge(generationOptionsSchema), + canvas_update_node: z.object({ id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }), + canvas_update_node_text: z.object({ id: z.string(), text: z.string(), title: z.string().optional() }), + canvas_move_nodes: z.object({ items: z.array(z.object({ id: z.string(), x: z.number().optional(), y: z.number().optional(), dx: z.number().optional(), dy: z.number().optional() })).min(1) }), + canvas_resize_node: z.object({ id: z.string(), width: z.number(), height: z.number(), freeResize: z.boolean().optional() }), + canvas_delete_nodes: z.object({ ids: z.array(z.string()).min(1) }), + canvas_connect_nodes: z.object({ connections: z.array(z.object({ fromNodeId: z.string(), toNodeId: z.string() })).min(1) }), + 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() }), } satisfies Record; export const toolDescriptions: Record = { canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。", canvas_get_selection: "读取当前网页画布选中的节点。", canvas_export_snapshot: "导出当前画布快照,用于理解布局。", - canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、connect_nodes、set_viewport、select_nodes。", - canvas_create_text_node: "在当前画布创建文本节点。", - canvas_create_image_prompt_flow: "创建提示词文本节点和图片生成配置节点,并自动连线。", + canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、connect_nodes、set_viewport、select_nodes、run_generation。", + canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。", + canvas_create_text_node: "在当前画布创建单个文本节点。", + canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。", + canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。", + canvas_create_image_prompt_flow: "创建提示词文本节点和图片生成配置节点,并自动连线,可选择立即触发生图。", + canvas_create_generation_flow: "创建通用生成流程:提示词文本节点、生成配置节点、参考节点连线,可用于文案、生图、视频或音频。", + canvas_generate_text: "创建通用文本生成流程并立即触发生成。", + canvas_generate_image: "创建通用图片生成流程并立即触发生成。", + canvas_generate_video: "创建通用视频生成流程并立即触发生成。", + canvas_generate_audio: "创建通用音频生成流程并立即触发生成。", + canvas_update_node: "更新节点基础字段或 metadata。", + canvas_update_node_text: "更新文本节点内容和标题。", + canvas_move_nodes: "移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。", + canvas_resize_node: "调整节点尺寸。", + canvas_delete_nodes: "删除指定节点及相关连线。", + canvas_connect_nodes: "批量连接节点。", + canvas_select_nodes: "设置当前选中节点。", + canvas_set_viewport: "调整画布视口。", + canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。", }; diff --git a/docs/content/docs/progress/local-agent-integration-plan.mdx b/docs/content/docs/progress/local-agent-integration-plan.mdx index 864c872..ac4c4e2 100644 --- a/docs/content/docs/progress/local-agent-integration-plan.mdx +++ b/docs/content/docs/progress/local-agent-integration-plan.mdx @@ -60,7 +60,7 @@ canvas-agent/ MVP 阶段优先提供 `npx`: ```bash -npx -y canvas-agent +npx -y @basketikun/canvas-agent ``` 在本仓库内开发调试时可直接运行: @@ -135,7 +135,7 @@ Codex CLI <-> canvas-agent MCP <-> 当前网页画布 用户本机 Codex 配置示例: ```bash -codex mcp add infinite-canvas -- npx -y canvas-agent mcp +codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp ``` 本仓库调试时可使用,实际配置建议替换为本机绝对路径: @@ -189,7 +189,7 @@ Claude Code 侧当前 MVP 先调用本机 Claude Code CLI 的流式 JSON 输出 如果希望 Claude Code 也能操作当前画布,需要给 Claude Code 配置同一个 MCP: ```bash -claude mcp add --scope user --transport stdio infinite-canvas -- npx -y canvas-agent mcp +claude mcp add --scope user --transport stdio infinite-canvas -- npx -y @basketikun/canvas-agent mcp ``` Canvas Agent 调用 Claude Code 时默认允许 `mcp__infinite-canvas__*`,避免 print 模式被工具审批卡住;真正修改画布仍由网页侧边栏确认。 @@ -240,7 +240,7 @@ Canvas Agent 调用 Claude Code 时默认允许 `mcp__infinite-canvas__*`,避 ## MVP 验收标准 -- 用户运行 `npx -y canvas-agent` 后,线上画布能显示已连接。 +- 用户运行 `npx -y @basketikun/canvas-agent` 后,线上画布能显示已连接。 - 用户在 Codex CLI 中可以创建文本节点、移动节点、连接节点。 - 网页侧边栏能发送一条消息给本地 Codex,并展示流式回复。 - 网页侧边栏默认只展示本地 Codex,并展示结构化回复。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index dd5c39a..fdfa655 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,7 +5,8 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 -- 本地 Agent 接入 MVP:新增 `canvas-agent/` 本机 Node 服务,源码使用 TypeScript,HTTP 路由使用 Express,MCP 协议层使用官方 `@modelcontextprotocol/sdk`,工具入参使用 `zod`;支持仓库内 `cd canvas-agent && npm install && npm run build && node dist/index.js` 或发布后 `npx -y canvas-agent` 启动并输出本机地址和 token;根 README 已增加本地 Canvas Agent 入口;Canvas Agent 默认监听 `127.0.0.1`,使用 token 连接并记录允许的网页 Origin;画布右上角新增 `Agent` 面板,默认只展示 Codex,可拖拽调整宽度,并与右侧画布助手一致支持打开/收起宽度动画;Agent 面板首次打开后收起不卸载,网页侧对话、日志、连接配置、等待状态和待确认工具调用会放在 Zustand 内存 store 中保留,暂不持久化且暂不读取 Codex 本地历史;以对话气泡展示用户消息、Codex 文本、工具调用和错误,前端按 Codex `type/item/usage` 事件格式读取 `agent_message`、工具结果和 `turn.completed` 状态,收到真实 `item.updated` 时用同一个消息 ID 流式更新 assistant 文本,未收到 `item.updated` 时不做伪流式,原始事件 JSON 收进独立“运行日志”弹窗,可切换排查日志和原始 JSON,排查日志会合并本地 Agent 地址、连接状态、等待状态、pending tool、发送/接收关键节点,支持复制排查日志和最近错误,`working...` 旁可直接打开日志;连接成功和本轮结束不再写入对话区,状态留在面板头部,运行日志不再记录 item.updated 和 reasoning 噪声事件;对话区去掉单独背景色并融入面板背景,消息内容裸文本展示,不再显示 `Codex`/`You` 名称,Codex 消息保留透明背景 OpenAI 图标,用户消息保留透明背景用户头像或默认用户图标,回复前显示 `working...` 等待状态;工具调用和授权状态改为对话流里的轻量小卡片,`canvas_apply_ops` 在执行前通过内联确认卡片让用户取消或执行,工具卡片会合并执行结果并可展开查看输入、输出和授权详情;底部输入区改为一体式对话框,图片上传入口和发送按钮都在框内,支持选择或粘贴图片、显示附件体积、发送前限制约 30MB,并在用户消息中显示缩略图,图片会通过本机 Canvas Agent 临时写入文件后作为 app-server `localImage` 输入传给 Codex,Canvas Agent 单次请求体限制为 30MB;Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 事件流,复用同一个 Codex thread,并在 app-server 配置中注入 `infinite-canvas` MCP 和 `default_tools_approval_mode = "approve"`,侧边栏无需用户预先全局 `codex mcp add` 也可调用画布工具;Claude Code Adapter 代码暂保留但前端入口先隐藏;画布可响应 `canvas_apply_ops` 工具调用来新增/更新/删除/连接节点、选中节点和调整视口,执行后可撤销最近一次工具操作;Canvas Agent 内置 MCP stdio 模式并向 Codex 返回画布工具使用说明,Codex 终端仍可通过 `codex mcp add infinite-canvas -- node /path/to/infinite-canvas/canvas-agent/dist/index.js mcp` 或发布后的 `codex mcp add infinite-canvas -- npx -y canvas-agent mcp` 调用 `canvas_get_state`、`canvas_get_selection`、`canvas_export_snapshot`、`canvas_apply_ops`、`canvas_create_text_node` 和 `canvas_create_image_prompt_flow`,Claude Code 可通过 `claude mcp add --scope user --transport stdio infinite-canvas -- npx -y canvas-agent mcp` 接入同一组工具;需要验证浏览器连接、侧边栏自动注入 MCP 后的 Codex 工具调用、终端 Codex MCP 工具调用、工具确认/取消/撤销、Codex thread 复用、运行日志弹窗复制、图片附件预览和断开重连提示。 +- 本地 Agent 对话流里的工具调用卡片改为更宽的状态卡:等待确认时显示橙色提示和“批准执行 / 拒绝执行”按钮,执行成功显示绿色“已批准执行/执行完成”,拒绝或失败显示红色状态,需要验证画布操作确认、拒绝、执行失败和读取类工具结果展示。 +- 本地 Agent 接入 MVP:新增 `canvas-agent/` 本机 Node 服务,源码使用 TypeScript,HTTP 路由使用 Express,MCP 协议层使用官方 `@modelcontextprotocol/sdk`,工具入参使用 `zod`;支持仓库内 `cd canvas-agent && npm install && npm run build && node dist/index.js` 或发布后 `npx -y @basketikun/canvas-agent` 启动并输出本机地址和 token;新增 GitHub Actions 自动发布 `@basketikun/canvas-agent`,发布判断只读取 `canvas-agent/package.json` 里的包版本,不跟根目录 `VERSION` 绑定;根 README 已增加本地 Canvas Agent 入口;Canvas Agent 默认监听 `127.0.0.1`,使用 token 连接并记录允许的网页 Origin;画布右上角新增 `Agent` 面板,默认只展示 Codex,顶部以连接、对话、历史、日志 tab 切换,进入时默认停在连接 tab,点击后直接高亮对应 tab,连接配置页展示 Codex 前置条件、npm 安装、启动和网页连接步骤,地址或 token 为空、地址格式错误、连接失败时会在连接页保留失败状态和原因,可拖拽调整宽度,并与右侧画布助手一致支持打开/收起宽度动画;Agent 面板首次打开后收起不卸载,网页侧对话、日志、连接配置、等待状态和待确认工具调用会放在 Zustand 内存 store 中保留,断开连接会清空当前对话和历史列表;Canvas Agent 会在本机配置中按画布记录 Codex 工作空间和 active thread,默认工作目录为 `~/.infinite-canvas/codex-workspaces/<画布ID>`,前端历史视图直接放在 Agent 面板内,只展示当前画布默认工作目录,可刷新该目录下 Codex thread 列表、新建对话、恢复旧对话、删除记录,并通过 `thread/read includeTurns` 回填消息,删除记录前会二次确认;以对话气泡展示用户消息、Codex 文本、工具调用和错误,前端按 Codex `type/item/usage` 事件格式读取 `agent_message`、工具结果和 `turn.completed` 状态,收到真实 `item.updated` 时用同一个消息 ID 流式更新 assistant 文本,未收到 `item.updated` 时不做伪流式,原始事件 JSON 收进“运行日志”tab,可切换排查日志和原始 JSON,排查日志会合并本地 Agent 地址、连接状态、等待状态、pending tool、发送/接收关键节点,支持复制排查日志和最近错误,日志只通过顶部日志 tab 查看;连接成功、连接失败和本轮结束不再写入对话区,连接成功时会清理之前残留的连接错误消息,状态留在面板头部和连接 tab,运行日志不再记录 item.updated 和 reasoning 噪声事件;对话区去掉单独背景色并融入面板背景,消息内容裸文本展示,不再显示 `Codex`/`You` 名称,Codex 消息保留透明背景 OpenAI 图标,用户消息保留透明背景用户头像或默认用户图标,回复前显示循环打字机式 `working...` 等待状态;工具调用和授权状态改为对话流里的轻量小卡片,`canvas_apply_ops` 在执行前通过内联确认卡片让用户取消或执行,工具卡片会合并执行结果并可展开查看输入、输出和授权详情;底部输入区改为一体式对话框,图片上传入口和发送按钮都在框内,支持选择或粘贴图片、显示附件体积、发送前限制约 30MB,并在用户消息中显示缩略图,图片会通过本机 Canvas Agent 临时写入文件后作为 app-server `localImage` 输入传给 Codex,Canvas Agent 单次请求体限制为 30MB;Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 事件流,按当前画布 active thread 发送消息,未绑定时新建 thread,恢复旧对话后继续复用该 thread,并在 app-server 配置中注入 `infinite-canvas` MCP 和 `default_tools_approval_mode = "approve"`,侧边栏无需用户预先全局 `codex mcp add` 也可调用画布工具;Claude Code Adapter 代码暂保留但前端入口先隐藏;画布可响应 `canvas_apply_ops` 工具调用来新增/更新/删除/连接节点、选中节点和调整视口,执行后可撤销最近一次工具操作;Canvas Agent 内置 MCP stdio 模式并向 Codex 返回画布工具使用说明,Codex 终端仍可通过 `codex mcp add infinite-canvas -- node /path/to/infinite-canvas/canvas-agent/dist/index.js mcp` 或发布后的 `codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp` 调用 `canvas_get_state`、`canvas_get_selection`、`canvas_export_snapshot`、`canvas_apply_ops`、`canvas_create_node`、`canvas_create_text_node`、`canvas_create_text_nodes`、`canvas_create_config_node`、`canvas_create_image_prompt_flow`、`canvas_create_generation_flow`、`canvas_generate_text`、`canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`、`canvas_run_generation`、节点更新、移动、缩放、删除、连线、选择和视口工具,Claude Code 可通过 `claude mcp add --scope user --transport stdio infinite-canvas -- npx -y @basketikun/canvas-agent mcp` 接入同一组工具;需要验证浏览器连接、侧边栏自动注入 MCP 后的 Codex 工具调用、终端 Codex MCP 工具调用、工具确认/取消/撤销、每画布工作空间隔离、历史列表读取、历史消息回填、历史记录删除确认、Codex thread 复用、运行日志 tab 复制、图片附件预览和断开重连提示。 - 视频创作台创建视频任务成功后会立即写入左侧生成记录,状态显示为“生成中”;刷新页面后会读取本地任务 ID 继续轮询,任务成功或失败后更新同一条记录,需要验证 OpenAI 视频接口和 Seedance 任务接口的刷新恢复。 - 修复画布文字节点编辑时双击进入编辑、全选文本出现重影和错位的问题;文字节点内容编辑框改为使用原生 textarea 文本渲染,仍保留 `@` 资源候选插入,需要验证编辑态选区、展示态文字换行和右上角“生图”按钮避让都正常。 - 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。 diff --git a/docs/content/docs/progress/todo.mdx b/docs/content/docs/progress/todo.mdx index 658fcb6..36ce080 100644 --- a/docs/content/docs/progress/todo.mdx +++ b/docs/content/docs/progress/todo.mdx @@ -7,4 +7,4 @@ description: 当前项目后续值得处理的事项 本文档用来记录当前项目后续比较值得处理的事项。 -- 本地 Agent 接入后续:按[本地 Agent 接入规划](/docs/progress/local-agent-integration-plan)把 Claude Code CLI Adapter 升级为 Claude Agent SDK Adapter,完善 Codex SDK thread 恢复入口、工具队列体验和正式 npm 发布流程。 +- 本地 Agent 接入后续:按[本地 Agent 接入规划](/docs/progress/local-agent-integration-plan)把 Claude Code CLI Adapter 升级为 Claude Agent SDK Adapter,并完善工具队列体验。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index a1d6374..4fa3aa1 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -306,6 +306,7 @@ function InfiniteCanvasPage() { const connectionsRef = useRef(connections); const selectedNodeIdsRef = useRef(selectedNodeIds); const viewportRef = useRef(viewport); + const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise) | null>(null); const connectingParamsRef = useRef(connectingParams); const connectionTargetNodeIdRef = useRef(connectionTargetNodeId); const selectionBoxRef = useRef(selectionBox); @@ -660,7 +661,8 @@ function InfiniteCanvasPage() { const applyAgentOps = useCallback( (ops: CanvasAgentOp[]) => { const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current }; - const next = applyCanvasAgentOps(before, ops); + const generationOps = ops.filter((op): op is Extract => op.type === "run_generation"); + const next = applyCanvasAgentOps(before, ops.filter((op) => op.type !== "run_generation")); nodesRef.current = next.nodes; connectionsRef.current = next.connections; selectedNodeIdsRef.current = new Set(next.selectedNodeIds); @@ -672,6 +674,9 @@ function InfiniteCanvasPage() { setSelectedConnectionId(null); setViewport(next.viewport); setContextMenu(null); + if (generationOps.length) { + queueMicrotask(() => generationOps.forEach((op) => void generateNodeRef.current?.(op.nodeId, op.mode || "image", op.prompt || ""))); + } return { ...next, projectId, title: currentProject?.title || "未命名画布" }; }, [currentProject?.title, projectId], @@ -2137,6 +2142,9 @@ function InfiniteCanvasPage() { }, [effectiveConfig, openConfigDialog], ); + useEffect(() => { + generateNodeRef.current = handleGenerateNode; + }, [handleGenerateNode]); const handleRetryNode = useCallback( async (node: CanvasNodeData) => { diff --git a/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx b/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx index 38b18d0..50e9c85 100644 --- a/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx @@ -1,21 +1,28 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { App, Button, Input, Modal, Segmented, Switch, Tooltip } from "antd"; +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { App, Button, Input, Segmented, Switch, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; -import { ArrowUp, Bot, Copy, ImagePlus, LoaderCircle, PlugZap, RotateCcw, Terminal, Trash2, UserRound, Wrench, X } from "lucide-react"; +import { ArrowUp, Bot, CheckCircle2, CircleAlert, Copy, FolderOpen, History, ImagePlus, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2, UserRound, Wrench, X, XCircle } from "lucide-react"; import { motion } from "motion/react"; import { canvasThemes } from "@/lib/canvas-theme"; import type { AuthUser } from "@/services/api/auth"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; -import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPendingToolCall } from "../stores/use-canvas-agent-store"; +import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "../stores/use-canvas-agent-store"; import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops"; const PANEL_MOTION_SECONDS = 0.5; const MAX_ATTACHMENTS = 6; const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024; +const WORKING_TEXT = "working..."; +const AGENT_CONNECT_STEPS = [ + { title: "1. 本机已安装并登录 Codex", text: "先确认本机终端里的 Codex 可以正常使用。", command: "codex --version" }, + { title: "2. 安装 Canvas Agent", text: "推荐全局安装,后续可以直接运行 canvas-agent。", command: "npm i -g @basketikun/canvas-agent" }, + { title: "3. 启动本地 Agent", text: "启动后终端会输出 Local URL 和 Connect token。", command: "canvas-agent" }, + { title: "4. 回到网页连接", text: "把终端输出的地址和 token 填到下面,点击连接。" }, +]; type AgentEventPayload = { agent?: string; @@ -29,12 +36,15 @@ type AgentEventPayload = { type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } }; type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string }; +type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string }; +type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] }; +type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] }; export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApplyOps, onUndoOps, onCollapseStart }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null; onCollapseStart: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const user = useUserStore((state) => state.user); - const { message } = App.useApp(); - const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, confirmTools, logOpen, activity, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore(); + const { message, modal } = App.useApp(); + const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore(); const [resizing, setResizing] = useState(false); const listRef = useRef(null); const snapshotRef = useRef(snapshot); @@ -45,7 +55,35 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply const errorLoggedRef = useRef(false); const attachmentUrlsRef = useRef(new Set()); const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID()); - const endpoint = useMemo(() => url.replace(/\/$/, ""), [url]); + const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]); + const tabStyle = (tab: AgentPanelTab) => ({ + borderColor: activeTab === tab ? theme.node.text : "transparent", + color: activeTab === tab ? theme.node.text : theme.node.muted, + }); + + const loadThreads = useCallback(async () => { + const projectId = snapshotRef.current.projectId; + if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return; + setAgentState({ loadingThreads: true }); + try { + const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`); + const current = useCanvasAgentStore.getState(); + setAgentState({ + threads: data.data || [], + workspacePath: data.workspace?.workspacePath || current.workspacePath, + activeThreadId: data.workspace?.activeThreadId || current.activeThreadId, + }); + const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId; + if (nextThreadId && !current.messages.length) { + const thread = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`); + setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) }); + } + } catch (error) { + addEventLog("读取历史失败", error); + } finally { + setAgentState({ loadingThreads: false }); + } + }, [endpoint, setAgentState, token]); useEffect(() => { snapshotRef.current = snapshot; @@ -73,7 +111,8 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply source.addEventListener("hello", () => { errorLoggedRef.current = false; connectedRef.current = true; - setAgentState({ connected: true, activity: "已连接" }); + setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) }); + message.success("本地 Agent 已连接"); void postState(endpoint, token, clientId, snapshotRef.current); }); source.addEventListener("tool_call", (event) => { @@ -96,13 +135,18 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply }); source.addEventListener("agent_done", () => { setAgentState({ activity: "完成", waiting: false, sending: false }); + void loadThreads(); }); source.onerror = () => { const wasConnected = connectedRef.current; - if (!errorLoggedRef.current || wasConnected) addMessage({ role: "error", text: wasConnected ? "本地 Agent 连接失败或已断开" : "无法连接本地 Agent,请检查地址和 token" }); + const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token"; + if (!errorLoggedRef.current || wasConnected) { + addEventLog(wasConnected ? "连接断开" : "连接失败", { endpoint, error: text }); + message.error(text); + } errorLoggedRef.current = true; connectedRef.current = false; - setAgentState({ waiting: false, activity: "离线", connected: false }); + clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: text }); if (!wasConnected) { source.close(); setAgentState({ enabled: false }); @@ -113,7 +157,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply connectedRef.current = false; setAgentState({ connected: false }); }; - }, [enabled, endpoint, setAgentState, token]); + }, [enabled, endpoint, loadThreads, message, setAgentState, token]); + + useEffect(() => { + if (connected) void loadThreads(); + }, [connected, loadThreads, snapshot.projectId]); useEffect(() => { if (!connected) return; @@ -134,8 +182,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply addMessage({ 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)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) }); + const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.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 }); files.forEach((item) => { URL.revokeObjectURL(item.url); @@ -203,7 +253,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { try { const input: { ops?: CanvasAgentOp[] } = payload.input || {}; - setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布" }); + setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布", waiting: true }); addEventLog(toolName(payload.name), payload, payload); const result = payload.name === "canvas_apply_ops" ? onApplyOpsRef.current(input.ops || []) : snapshotRef.current; await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); @@ -214,7 +264,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply } catch (error) { const message = error instanceof Error ? error.message : "画布操作失败"; setAgentState({ activity: "工具失败", waiting: false }); - addMessage({ role: "error", title: "工具失败", text: message, detail: payload }); + addMessage({ role: "tool", title: "工具失败", text: message, detail: payload }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); } }; @@ -223,7 +273,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply 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 } }); + addMessage({ role: "tool", title: "拒绝执行", text: toolName(pendingTool.name), detail: { requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input } }); pendingToolRef.current = null; setAgentState({ pendingTool: null }); }; @@ -244,6 +294,116 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply if (connected) void postState(endpoint, token, clientIdRef.current, restored); }; + const toggleAgentConnection = () => { + if (enabled) { + clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" }); + return; + } + if (!endpoint) { + const text = "请填写本地 Agent 地址"; + setAgentState({ connectError: text }); + message.warning(text); + return; + } + if (!token.trim()) { + const text = "请填写 Agent token"; + setAgentState({ connectError: text }); + message.warning(text); + return; + } + try { + const parsed = new URL(endpoint); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol"); + } catch { + const text = "本地 Agent 地址格式不正确"; + setAgentState({ connectError: text }); + message.warning(text); + return; + } + errorLoggedRef.current = false; + setAgentState({ url: endpoint, token: token.trim(), enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" }); + }; + + function clearAgentSession(patch: Parameters[0] = {}) { + setAgentState({ + messages: [], + threads: [], + activeThreadId: "", + workspacePath: "", + loadingThreads: false, + waiting: false, + sending: false, + pendingTool: null, + ...patch, + }); + pendingToolRef.current = null; + } + + const startNewThread = async () => { + const projectId = snapshotRef.current.projectId; + if (!connected || !projectId) return; + setAgentState({ loadingThreads: true }); + try { + const data = await fetchAgentJson(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); + setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" }); + await loadThreads(); + } catch (error) { + addEventLog("新建对话失败", error); + message.error(error instanceof Error ? error.message : "新建对话失败"); + } finally { + setAgentState({ loadingThreads: false }); + } + }; + + const resumeThread = async (threadId: string) => { + const projectId = snapshotRef.current.projectId; + if (!connected || !projectId || !threadId) return; + setAgentState({ loadingThreads: true }); + try { + const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); + setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" }); + await loadThreads(); + } catch (error) { + addEventLog("恢复对话失败", error); + message.error(error instanceof Error ? error.message : "恢复对话失败"); + } finally { + setAgentState({ loadingThreads: false }); + } + }; + + const deleteThread = async (threadId: string) => { + const projectId = snapshotRef.current.projectId; + if (!connected || !projectId || !threadId) return; + setAgentState({ loadingThreads: true }); + try { + await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); + const current = useCanvasAgentStore.getState(); + setAgentState({ + threads: current.threads.filter((thread) => thread.id !== threadId), + activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId, + messages: current.activeThreadId === threadId ? [] : current.messages, + }); + message.success("记录已删除"); + } catch (error) { + addEventLog("删除对话失败", error); + message.error(error instanceof Error ? error.message : "删除对话失败"); + } finally { + setAgentState({ loadingThreads: false }); + } + }; + + const confirmDeleteThread = (thread: AgentThreadSummary) => { + const label = thread.name || thread.preview || "未命名对话"; + modal.confirm({ + title: "删除对话记录", + content: `确定删除「${label.length > 48 ? `${label.slice(0, 48)}...` : label}」吗?`, + okText: "删除", + okType: "danger", + cancelText: "取消", + onOk: () => deleteThread(thread.id), + }); + }; + const startResize = (event: ReactPointerEvent) => { event.preventDefault(); const startX = event.clientX; @@ -292,12 +452,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply 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.completed" || event.type === "turn.failed") setAgentState({ waiting: false, sending: false }); + 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 !== "tool" && event.type !== "item.updated") setAgentState({ waiting: false }); + if (item.role === "error") setAgentState({ waiting: false, sending: false }); addMessage(item); } }; @@ -320,7 +482,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
- +
@@ -329,30 +491,40 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply Codex · {connected ? activity : "离线"}
- + {connected ? "在线" : "离线"}
- - setAgentState({ token: event.target.value })} placeholder="Agent token" /> -
- -
- +
+
+ +
+ @@ -360,26 +532,53 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
- setAgentState({ logOpen: false })} - onClear={clearEventLogs} - onCopied={(text) => message.success(text)} - onCopyBlocked={(text) => message.warning(text)} - /> - -
- {messages.map((item) => ( - - ))} - {pendingTool ? : null} - {waiting ? setAgentState({ logOpen: true })} /> : null} -
- - setAgentState({ prompt })} onSubmit={sendPrompt} onAddFiles={addAttachments} onRemoveAttachment={removeAttachment} /> + {activeTab === "setup" ? ( + setAgentState({ url, connectError: "" })} + onTokenChange={(token) => setAgentState({ token, connectError: "" })} + onToggleEnabled={toggleAgentConnection} + /> + ) : activeTab === "history" ? ( + void loadThreads()} + onNewThread={() => void startNewThread()} + onResumeThread={(threadId) => void resumeThread(threadId)} + onDeleteThread={confirmDeleteThread} + /> + ) : activeTab === "log" ? ( + message.success(text)} + onCopyBlocked={(text) => message.warning(text)} + /> + ) : ( + <> +
+ {messages.map((item) => ( + + ))} + {pendingTool ? : null} + {waiting && !pendingTool ? : null} +
+ setAgentState({ prompt })} onSubmit={sendPrompt} onAddFiles={addAttachments} onRemoveAttachment={removeAttachment} /> + + )} ); @@ -395,7 +594,7 @@ function AgentComposer({ prompt, attachments, connected, sending, theme, onPromp {attachments.length ? (
{attachments.map((item) => ( -
+
{item.name} - + +
-
- {[ - ["连接", context.connected ? "在线" : context.enabled ? "连接中" : "未启用"], - ["状态", context.activity], - ["等待", context.waiting ? "是" : "否"], - ["工具", context.pendingTool ? toolName(context.pendingTool) : "无"], - ].map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -