mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat: rename package to @basketikun/canvas-agent and update related configurations
This commit is contained in:
@@ -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."
|
||||
+10
-4
@@ -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
|
||||
```
|
||||
|
||||
本仓库开发时可以改成:
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+176
-9
@@ -11,6 +11,8 @@ import type { AgentAttachment, AgentEmit } from "./types.js";
|
||||
type Json = Record<string, unknown>;
|
||||
type AgentEvent = Json & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string };
|
||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
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<string, unknown>), mode: "image" }, this.canvasState) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_config_node") {
|
||||
const data = input as Record<string, unknown>;
|
||||
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<string, unknown>, 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<string, unknown>), 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<string, unknown>; metadata?: Record<string, unknown> };
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
|
||||
@@ -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<string, CanvasWorkspaceConfig> };
|
||||
|
||||
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<CanvasWorkspaceConfig>) {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>).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<unknown>) {
|
||||
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);
|
||||
}
|
||||
|
||||
+100
-7
@@ -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<ToolName, z.AnyZodObject>;
|
||||
|
||||
export const toolDescriptions: Record<ToolName, string> = {
|
||||
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: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||
};
|
||||
|
||||
@@ -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,并展示结构化回复。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,并完善工具队列体验。
|
||||
|
||||
@@ -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<void>) | 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<CanvasAgentOp, { type: "run_generation" }> => 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) => {
|
||||
|
||||
@@ -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<HTMLDivElement>(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<string>());
|
||||
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<AgentThreadsResponse>(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<AgentThreadResponse>(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<typeof setAgentState>[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<AgentThreadResponse>(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<AgentThreadResponse>(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<HTMLDivElement>) => {
|
||||
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
|
||||
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
|
||||
<header className="flex h-14 items-center justify-between border-b px-4" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-lg" style={{ background: theme.node.fill }}>
|
||||
<span className="grid size-8 place-items-center rounded-lg">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -329,30 +491,40 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
|
||||
Codex · {connected ? activity : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-1 rounded-full px-2 py-0.5 text-xs" style={{ background: connected ? "rgba(34,197,94,.14)" : theme.node.fill, color: connected ? "#16a34a" : theme.node.muted }}>
|
||||
<span className="ml-1 rounded-full border px-2 py-0.5 text-xs" style={{ borderColor: connected ? "#16a34a" : theme.node.stroke, color: connected ? "#16a34a" : theme.node.muted }}>
|
||||
{connected ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<Button type="text" icon={<X className="size-4" />} onClick={onCollapseStart} />
|
||||
</header>
|
||||
|
||||
<div className="grid gap-2 border-b p-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_88px] gap-2">
|
||||
<Input value={url} onChange={(event) => setAgentState({ url: event.target.value })} placeholder="本地 Agent 地址" />
|
||||
<Button type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={() => setAgentState({ enabled: !enabled })}>
|
||||
{enabled ? "断开" : "连接"}
|
||||
</Button>
|
||||
</div>
|
||||
<Input.Password value={token} onChange={(event) => setAgentState({ token: event.target.value })} placeholder="Agent token" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm" style={{ color: theme.node.muted }}>
|
||||
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
|
||||
工具确认
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="small" type="text" icon={<Terminal className="size-3.5" />} onClick={() => setAgentState({ logOpen: true })}>
|
||||
运行日志{eventLogs.length ? ` ${eventLogs.length}` : ""}
|
||||
</Button>
|
||||
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-h-11 items-center justify-between gap-3">
|
||||
<nav className="thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role="tablist" aria-label="Agent 面板">
|
||||
<button type="button" role="tab" aria-selected={activeTab === "setup"} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${activeTab === "setup" ? "font-medium" : "font-normal"}`} style={tabStyle("setup")} onClick={() => setAgentState({ activeTab: "setup" })}>
|
||||
<PlugZap className="size-3.5" />
|
||||
连接
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={activeTab === "chat"} className={`h-11 shrink-0 border-b-2 px-0.5 transition ${activeTab === "chat" ? "font-medium" : "font-normal"}`} style={tabStyle("chat")} onClick={() => setAgentState({ activeTab: "chat" })}>
|
||||
对话
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={activeTab === "history"} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${activeTab === "history" ? "font-medium" : "font-normal"}`} style={tabStyle("history")} onClick={() => {
|
||||
setAgentState({ activeTab: "history" });
|
||||
void loadThreads();
|
||||
}}>
|
||||
<History className="size-3.5" />
|
||||
历史{threads.length ? ` ${threads.length}` : ""}
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={activeTab === "log"} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${activeTab === "log" ? "font-medium" : "font-normal"}`} style={tabStyle("log")} onClick={() => setAgentState({ activeTab: "log" })}>
|
||||
<Terminal className="size-3.5" />
|
||||
日志{eventLogs.length ? ` ${eventLogs.length}` : ""}
|
||||
</button>
|
||||
</nav>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs" style={{ color: theme.node.muted }}>
|
||||
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
|
||||
工具确认
|
||||
</label>
|
||||
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
|
||||
撤销
|
||||
</Button>
|
||||
@@ -360,26 +532,53 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EventLogModal
|
||||
logs={eventLogs}
|
||||
open={logOpen}
|
||||
theme={theme}
|
||||
context={{ endpoint, connected, enabled, activity, waiting, sending, messages: messages.length, pendingTool: pendingTool?.name }}
|
||||
onClose={() => setAgentState({ logOpen: false })}
|
||||
onClear={clearEventLogs}
|
||||
onCopied={(text) => message.success(text)}
|
||||
onCopyBlocked={(text) => message.warning(text)}
|
||||
/>
|
||||
|
||||
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{messages.map((item) => (
|
||||
<ChatMessage key={item.id} item={item} theme={theme} user={user} />
|
||||
))}
|
||||
{pendingTool ? <PendingToolCard tool={pendingTool} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
|
||||
{waiting ? <WorkingMessage theme={theme} logs={eventLogs.length} onOpenLog={() => setAgentState({ logOpen: true })} /> : null}
|
||||
</div>
|
||||
|
||||
<AgentComposer prompt={prompt} attachments={attachments} connected={connected} sending={sending || waiting} theme={theme} onPromptChange={(prompt) => setAgentState({ prompt })} onSubmit={sendPrompt} onAddFiles={addAttachments} onRemoveAttachment={removeAttachment} />
|
||||
{activeTab === "setup" ? (
|
||||
<AgentConnectView
|
||||
theme={theme}
|
||||
url={url}
|
||||
token={token}
|
||||
enabled={enabled}
|
||||
connected={connected}
|
||||
activity={activity}
|
||||
connectError={connectError}
|
||||
onUrlChange={(url) => setAgentState({ url, connectError: "" })}
|
||||
onTokenChange={(token) => setAgentState({ token, connectError: "" })}
|
||||
onToggleEnabled={toggleAgentConnection}
|
||||
/>
|
||||
) : activeTab === "history" ? (
|
||||
<AgentHistoryView
|
||||
theme={theme}
|
||||
threads={threads}
|
||||
activeThreadId={activeThreadId}
|
||||
workspacePath={workspacePath}
|
||||
loading={loadingThreads}
|
||||
connected={connected}
|
||||
onRefresh={() => void loadThreads()}
|
||||
onNewThread={() => void startNewThread()}
|
||||
onResumeThread={(threadId) => void resumeThread(threadId)}
|
||||
onDeleteThread={confirmDeleteThread}
|
||||
/>
|
||||
) : activeTab === "log" ? (
|
||||
<AgentLogView
|
||||
logs={eventLogs}
|
||||
theme={theme}
|
||||
context={{ endpoint, connected, enabled, activity, waiting, sending, messages: messages.length, pendingTool: pendingTool?.name }}
|
||||
onClear={clearEventLogs}
|
||||
onCopied={(text) => message.success(text)}
|
||||
onCopyBlocked={(text) => message.warning(text)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{messages.map((item) => (
|
||||
<ChatMessage key={item.id} item={item} theme={theme} user={user} />
|
||||
))}
|
||||
{pendingTool ? <PendingToolCard tool={pendingTool} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
|
||||
{waiting && !pendingTool ? <WorkingMessage theme={theme} /> : null}
|
||||
</div>
|
||||
<AgentComposer prompt={prompt} attachments={attachments} connected={connected} sending={sending || waiting} theme={theme} onPromptChange={(prompt) => setAgentState({ prompt })} onSubmit={sendPrompt} onAddFiles={addAttachments} onRemoveAttachment={removeAttachment} />
|
||||
</>
|
||||
)}
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -395,7 +594,7 @@ function AgentComposer({ prompt, attachments, connected, sending, theme, onPromp
|
||||
{attachments.length ? (
|
||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{attachments.map((item) => (
|
||||
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} title={item.name}>
|
||||
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}>
|
||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
|
||||
<X className="size-3" />
|
||||
@@ -440,7 +639,7 @@ function AgentComposer({ prompt, attachments, connected, sending, theme, onPromp
|
||||
);
|
||||
}
|
||||
|
||||
function EventLogModal({ logs, open, theme, context, onClose, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; open: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClose: () => void; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
|
||||
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
|
||||
const [mode, setMode] = useState<"text" | "json">("text");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
||||
@@ -455,40 +654,172 @@ function EventLogModal({ logs, open, theme, context, onClose, onClear, onCopied,
|
||||
onCopyBlocked("已选中日志,请手动复制");
|
||||
};
|
||||
return (
|
||||
<Modal title="运行日志" open={open} onCancel={onClose} footer={null} width="min(920px, calc(100vw - 32px))" centered destroyOnHidden>
|
||||
<div className="space-y-3">
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="flex min-h-full flex-col gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-6">运行日志</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented size="small" value={mode} onChange={(value) => setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>{logs.length} 条</span>
|
||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>复制排查日志</Button>
|
||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>复制最近错误</Button>
|
||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>复制</Button>
|
||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>最近错误</Button>
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>清空</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs md:grid-cols-4">
|
||||
{[
|
||||
["连接", context.connected ? "在线" : context.enabled ? "连接中" : "未启用"],
|
||||
["状态", context.activity],
|
||||
["等待", context.waiting ? "是" : "否"],
|
||||
["工具", context.pendingTool ? toolName(context.pendingTool) : "无"],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg border px-3 py-2" style={{ borderColor: theme.node.stroke }}>
|
||||
<div style={{ color: theme.node.muted }}>{label}</div>
|
||||
<div className="mt-0.5 truncate" style={{ color: theme.node.text }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
readOnly
|
||||
value={content}
|
||||
className="thin-scrollbar h-[62vh] max-h-[620px] min-h-[360px] w-full resize-none rounded-lg border p-3 font-mono text-xs leading-5 outline-none"
|
||||
style={{ borderColor: theme.node.stroke, background: theme.node.fill, color: theme.node.text }}
|
||||
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentConnectView({ theme, url, token, enabled, connected, activity, connectError, onUrlChange, onTokenChange, onToggleEnabled }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; url: string; token: string; enabled: boolean; connected: boolean; activity: string; connectError: string; onUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onToggleEnabled: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||
const copyCommand = (command: string) => {
|
||||
copyToClipboard(command);
|
||||
message.success("命令已复制");
|
||||
};
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-6">连接本地 Agent</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
本地服务只监听 127.0.0.1,画布通过地址和 token 连接到你电脑上的 Codex。
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{AGENT_CONNECT_STEPS.map((step) => {
|
||||
const command = "command" in step ? step.command : "";
|
||||
return (
|
||||
<div key={step.title} className="rounded-lg px-3 py-2.5">
|
||||
<div className="text-sm font-medium leading-5">{step.title}</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
|
||||
{command ? (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
|
||||
<Tooltip title="复制命令">
|
||||
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-lg border p-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4" style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}>
|
||||
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
||||
<span className="truncate">{statusText}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
填入终端输出的 Local URL 和 Connect token。
|
||||
</div>
|
||||
</div>
|
||||
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
|
||||
{enabled ? "断开" : "连接"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<Link2 className="size-3.5" />
|
||||
本地地址
|
||||
<span className="font-normal opacity-70">Local URL</span>
|
||||
</span>
|
||||
<Input size="large" prefix={<Link2 className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="例如 http://127.0.0.1:17371" />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<KeyRound className="size-3.5" />
|
||||
连接 Token
|
||||
<span className="font-normal opacity-70">Connect token</span>
|
||||
</span>
|
||||
<Input.Password size="large" prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={token} onChange={(event) => onTokenChange(event.target.value)} placeholder="终端输出的 Connect token" />
|
||||
</label>
|
||||
{connectError ? (
|
||||
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
|
||||
{connectError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loading, connected, onRefresh, onNewThread, onResumeThread, onDeleteThread }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; threads: AgentThreadSummary[]; activeThreadId: string; workspacePath: string; loading: boolean; connected: boolean; onRefresh: () => void; onNewThread: () => void; onResumeThread: (threadId: string) => void; onDeleteThread: (thread: AgentThreadSummary) => void }) {
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0">工作空间</span>
|
||||
<span className="min-w-0 truncate" title={workspacePath}>{workspacePath || "默认画布目录"}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm" style={{ color: theme.node.muted }}>
|
||||
{threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading} onClick={onNewThread}>
|
||||
新对话
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{threads.map((thread) => {
|
||||
const active = thread.id === activeThreadId;
|
||||
return (
|
||||
<div key={thread.id} className="rounded-lg border px-2.5 py-1.5 transition" style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{active ? <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
|
||||
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
|
||||
<Button size="small" className="!h-6 !px-2" disabled={loading} onClick={() => onResumeThread(thread.id)}>
|
||||
进入
|
||||
</Button>
|
||||
<Tooltip title="删除记录">
|
||||
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" disabled={loading} icon={<Trash2 className="size-3.5" />} onClick={() => onDeleteThread(thread)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!threads.length ? (
|
||||
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||
{connected ? "当前工作空间还没有对话记录" : "连接本地 Agent 后显示历史记录"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -530,35 +861,39 @@ function ChatMessage({ item, theme, user }: { item: AgentChatItem; theme: (typeo
|
||||
}
|
||||
|
||||
function PendingToolCard({ tool, theme, onReject, onApprove }: { tool: AgentPendingToolCall; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject: () => void; onApprove: () => void }) {
|
||||
const summary = summarizeCanvasAgentOps(tool.input?.ops || []) || toolName(tool.name);
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<OpenAiAvatar theme={theme} />
|
||||
<div className="min-w-0 max-w-[82%] rounded-2xl border p-3" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<div className="min-w-0 flex-1 rounded-xl border p-4" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<details>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-full border" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
<Wrench className="size-3.5" />
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: "rgba(217,119,6,.24)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
|
||||
<CircleAlert className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm font-medium leading-5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span>确认工具调用</span>
|
||||
<span className="text-[11px] font-normal" style={{ color: theme.node.muted }}>详情</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: "rgba(217,119,6,.22)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
|
||||
等待确认
|
||||
</span>
|
||||
<span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
{summarizeCanvasAgentOps(tool.input?.ops || []) || toolName(tool.name)}
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<DetailBlock detail={{ requestId: tool.requestId, name: tool.name, input: tool.input }} theme={theme} />
|
||||
</details>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button size="small" onClick={() => void onReject()}>
|
||||
取消
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => void onReject()}>
|
||||
拒绝执行
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => void onApprove()}>
|
||||
执行
|
||||
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => void onApprove()}>
|
||||
批准执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,16 +902,26 @@ function PendingToolCard({ tool, theme, onReject, onApprove }: { tool: AgentPend
|
||||
}
|
||||
|
||||
function ToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const state = toolCardState(title, text, detail);
|
||||
return (
|
||||
<details className="min-w-0 max-w-[82%] rounded-2xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<details className="min-w-0 flex-1 rounded-xl border px-4 py-3.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<Wrench className="size-3.5" />
|
||||
<span className="min-w-0 flex-1 truncate">{title}</span>
|
||||
{detail ? <span className="font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
{text}
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
|
||||
{state.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
|
||||
{state.label}
|
||||
</span>
|
||||
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
{detail ? <DetailBlock detail={detail} theme={theme} /> : null}
|
||||
@@ -586,23 +931,41 @@ function ToolCard({ title, text, detail, theme }: { title: string; text: string;
|
||||
|
||||
function DetailBlock({ detail, theme }: { detail: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<pre className="thin-scrollbar mt-2 max-h-52 overflow-auto rounded-lg border p-2 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-lg border p-3 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, background: theme.toolbar.panel, color: theme.node.muted }}>
|
||||
{JSON.stringify(detail, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingMessage({ theme, logs, onOpenLog }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; logs: number; onOpenLog: () => void }) {
|
||||
function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
|
||||
const lower = raw.toLowerCase();
|
||||
const tool = String(objectField(detail, "name") || objectField(detail, "tool") || "");
|
||||
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) {
|
||||
return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
}
|
||||
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) {
|
||||
return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
}
|
||||
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) {
|
||||
const label = tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成";
|
||||
return { label, color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
}
|
||||
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
|
||||
}
|
||||
|
||||
function WorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [length, setLength] = useState(1);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<OpenAiAvatar theme={theme} />
|
||||
<div className="min-w-0 max-w-[82%]">
|
||||
<div className="inline-flex items-center gap-2 text-sm" style={{ color: theme.node.muted }}>
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
<span>working...</span>
|
||||
<button type="button" className="text-xs underline-offset-2 hover:underline" onClick={onOpenLog}>
|
||||
运行日志{logs ? ` ${logs}` : ""}
|
||||
</button>
|
||||
<div className="font-mono text-sm" style={{ color: theme.node.muted }} aria-label={WORKING_TEXT}>
|
||||
<span className="inline-block w-[76px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -731,13 +1094,34 @@ function shouldLogAgentEvent(event: AgentEventPayload) {
|
||||
return !["item.updated"].includes(event.type || "") && !["reasoning"].includes(itemType) && !(event.type === "item.started" && itemType === "agent_message");
|
||||
}
|
||||
|
||||
function isConnectionErrorMessage(item: AgentChatItem) {
|
||||
return item.role === "error" && /连接失败|无法连接本地 Agent|本地 Agent 连接失败/.test(item.text);
|
||||
}
|
||||
|
||||
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_node") return "创建节点";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_text_nodes") return "批量创建文本";
|
||||
if (name === "canvas_create_config_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_update_node") return "更新节点";
|
||||
if (name === "canvas_update_node_text") return "更新文本";
|
||||
if (name === "canvas_move_nodes") return "移动节点";
|
||||
if (name === "canvas_resize_node") return "调整节点尺寸";
|
||||
if (name === "canvas_delete_nodes") return "删除节点";
|
||||
if (name === "canvas_connect_nodes") return "连接节点";
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -818,6 +1202,29 @@ function formatBytes(bytes: number) {
|
||||
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
|
||||
}
|
||||
|
||||
async function fetchAgentJson<T>(endpoint: string, token: string, path: string, init?: RequestInit) {
|
||||
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
||||
const res = await fetch(url, init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string; msg?: string };
|
||||
if (!res.ok) throw new Error(data.error || data.msg || "本地 Agent 请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeHistoryMessages(messages: AgentChatItem[]) {
|
||||
return messages
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || `history-${index}`,
|
||||
text: normalizeText(item.text),
|
||||
}))
|
||||
.filter((item) => item.text);
|
||||
}
|
||||
|
||||
function formatThreadTime(value?: number) {
|
||||
if (!value) return "";
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function createId() {
|
||||
return typeof crypto === "undefined" ? `${Date.now()}-${Math.random()}` : crypto.randomUUID();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export type AgentAttachment = { id: string; name: string; type: string; size: nu
|
||||
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
|
||||
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
|
||||
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[] } };
|
||||
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
|
||||
export type AgentPanelTab = "chat" | "setup" | "history" | "log";
|
||||
|
||||
type CanvasAgentStore = {
|
||||
width: number;
|
||||
@@ -20,9 +22,14 @@ type CanvasAgentStore = {
|
||||
waiting: boolean;
|
||||
messages: AgentChatItem[];
|
||||
eventLogs: AgentEventLog[];
|
||||
threads: AgentThreadSummary[];
|
||||
activeThreadId: string;
|
||||
workspacePath: string;
|
||||
loadingThreads: boolean;
|
||||
activeTab: AgentPanelTab;
|
||||
confirmTools: boolean;
|
||||
logOpen: boolean;
|
||||
activity: string;
|
||||
connectError: string;
|
||||
pendingTool: AgentPendingToolCall | null;
|
||||
setAgentState: (patch: Partial<Omit<CanvasAgentStore, "setAgentState" | "addMessage" | "addEventLog" | "clearEventLogs">>) => void;
|
||||
addMessage: (item: AgentChatItem) => void;
|
||||
@@ -42,9 +49,14 @@ export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
|
||||
waiting: false,
|
||||
messages: [],
|
||||
eventLogs: [],
|
||||
threads: [],
|
||||
activeThreadId: "",
|
||||
workspacePath: "",
|
||||
loadingThreads: false,
|
||||
activeTab: "setup",
|
||||
confirmTools: true,
|
||||
logOpen: false,
|
||||
activity: "就绪",
|
||||
connectError: "",
|
||||
pendingTool: null,
|
||||
setAgentState: (patch) => set(patch),
|
||||
addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })),
|
||||
|
||||
@@ -9,7 +9,8 @@ export type CanvasAgentOp =
|
||||
| { type: "delete_node"; id?: string; ids?: string[] }
|
||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||
| { type: "select_nodes"; ids: string[] };
|
||||
| { type: "select_nodes"; ids: string[] }
|
||||
| { type: "run_generation"; nodeId: string; mode?: "text" | "image" | "video" | "audio"; prompt?: string };
|
||||
|
||||
export type CanvasAgentSnapshot = {
|
||||
projectId: string;
|
||||
@@ -80,5 +81,6 @@ function opLabel(type: string) {
|
||||
if (type === "connect_nodes") return "连接";
|
||||
if (type === "set_viewport") return "调整视图";
|
||||
if (type === "select_nodes") return "选择节点";
|
||||
if (type === "run_generation") return "触发生成";
|
||||
return type;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user