feat: rename package to @basketikun/canvas-agent and update related configurations

This commit is contained in:
HouYunFei
2026-06-15 17:16:27 +08:00
parent e422285c59
commit b6d358d6f8
16 changed files with 1160 additions and 157 deletions
+10 -4
View File
@@ -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
```
本仓库开发时可以改成:
+1 -3
View File
@@ -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=="],
+4 -1
View File
@@ -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
View File
@@ -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));
}
+162 -8
View File
@@ -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 !== ""));
}
+49 -3
View File
@@ -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";
}
}
+52 -5
View File
@@ -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
View File
@@ -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: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
};