mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 08:54:23 +08:00
feat(agent): implement local Canvas Agent with MCP integration and HTTP server
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { AGENT_PROMPT, VERSION } from "./config.js";
|
||||
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 };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexThreadId = "";
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function withAgentPrompt(prompt: string) {
|
||||
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
||||
}
|
||||
|
||||
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = []) {
|
||||
if (!prompt.trim()) return;
|
||||
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments));
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[]) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await writeAttachmentFiles(attachments);
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
codexThreadId ||= await codexApp.startThread();
|
||||
await codexApp.startTurn(codexThreadId, prompt, files);
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (!child) return;
|
||||
pipeJsonLines(child, emit, "claude");
|
||||
}
|
||||
|
||||
class CodexAppClient {
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
private textByItem = new Map<string, string>();
|
||||
private deltaCount = 0;
|
||||
private lastUsage: unknown = null;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private activeTurns = new Map<string, PendingRequest>();
|
||||
private completedTurns = new Map<string, Error | null>();
|
||||
|
||||
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
|
||||
|
||||
static async start(emit: AgentEmit) {
|
||||
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
||||
const client = new CodexAppClient(child, emit);
|
||||
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
||||
child.on("exit", (code) => {
|
||||
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
||||
codexApp = null;
|
||||
codexThreadId = "";
|
||||
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
||||
});
|
||||
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
||||
client.notify("initialized");
|
||||
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") || "");
|
||||
if (!id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return id;
|
||||
}
|
||||
|
||||
async startTurn(threadId: string, prompt: string, images: string[]) {
|
||||
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
|
||||
const turnId = String(field(field(result, "turn"), "id") || "");
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
const completed = this.completedTurns.get(turnId);
|
||||
if (this.completedTurns.has(turnId)) {
|
||||
this.completedTurns.delete(turnId);
|
||||
if (completed) throw completed;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
||||
}
|
||||
|
||||
private request(method: string, params: unknown) {
|
||||
const id = this.nextId++;
|
||||
this.write({ id, method, params });
|
||||
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
||||
}
|
||||
|
||||
private notify(method: string, params?: unknown) {
|
||||
this.write(params === undefined ? { method } : { method, params });
|
||||
}
|
||||
|
||||
private write(value: unknown) {
|
||||
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
private read(chunk: string) {
|
||||
this.buffer += chunk;
|
||||
const lines = this.buffer.split(/\r?\n/);
|
||||
this.buffer = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
this.handle(JSON.parse(line) as Json);
|
||||
} catch {
|
||||
this.emit("agent_log", { text: line });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handle(message: Json) {
|
||||
const id = Number(message.id);
|
||||
if (message.error && this.pending.has(id)) return this.reject(id, String(field(message.error, "message") || "Codex request failed"));
|
||||
if (this.pending.has(id)) return this.resolve(id, message.result);
|
||||
if (typeof message.method === "string" && "id" in message) return this.answerServerRequest(message);
|
||||
if (typeof message.method === "string") this.handleNotification(message.method, (message.params || {}) as Json);
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: Json) {
|
||||
if (method === "item/agentMessage/delta") return this.emitDelta(params);
|
||||
if (method === "thread/tokenUsage/updated") this.lastUsage = normalizeUsage(params);
|
||||
const event = normalizeCodexNotification(method, params);
|
||||
if (!event) return;
|
||||
if (event.type === "turn.completed") event.usage = this.lastUsage;
|
||||
this.emit("agent_event", { agent: "codex", ...event });
|
||||
if (event.type === "turn.completed") {
|
||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||
const pending = this.activeTurns.get(turnId);
|
||||
const error = field(field(params, "turn"), "error");
|
||||
if (pending) {
|
||||
this.activeTurns.delete(turnId);
|
||||
error ? pending.reject(new Error(String(field(error, "message") || "Codex turn failed"))) : pending.resolve(event);
|
||||
} else if (turnId) {
|
||||
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
|
||||
}
|
||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount });
|
||||
this.deltaCount = 0;
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage });
|
||||
}
|
||||
}
|
||||
|
||||
private emitDelta(params: Json) {
|
||||
const id = String(field(params, "itemId") || "");
|
||||
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
|
||||
this.deltaCount += 1;
|
||||
this.textByItem.set(id, text);
|
||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } });
|
||||
}
|
||||
|
||||
private answerServerRequest(message: Json) {
|
||||
const method = String(message.method);
|
||||
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
||||
this.write({ id: message.id, result });
|
||||
this.emit("agent_event", { agent: "codex", type: "server.request", method, params: message.params, result });
|
||||
}
|
||||
|
||||
private resolve(id: number, result: unknown) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.resolve(result));
|
||||
}
|
||||
|
||||
private reject(id: number, message: string) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.reject(new Error(message)));
|
||||
}
|
||||
|
||||
failAll(message: string) {
|
||||
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
||||
this.pending.clear();
|
||||
this.activeTurns.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function canvasAgentMcpCommand() {
|
||||
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
||||
const entry = path.resolve(current || fileURLToPath(new URL("./index.js", import.meta.url)));
|
||||
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
||||
}
|
||||
|
||||
function codexConfig() {
|
||||
return { mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
||||
}
|
||||
|
||||
function codexInput(prompt: string, images: string[]) {
|
||||
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
|
||||
}
|
||||
|
||||
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
|
||||
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
|
||||
if (method === "turn/started") return { type: "turn.started" };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "error") return { type: "error", message: field(params, "message") };
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeItem(item: unknown) {
|
||||
const value = item && typeof item === "object" ? { ...(item as Json) } : {};
|
||||
if (value.type === "agentMessage") value.type = "agent_message";
|
||||
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
|
||||
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
|
||||
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUsage(params: Json) {
|
||||
const total = field(field(params, "tokenUsage"), "total") as Json | undefined;
|
||||
return {
|
||||
input_tokens: field(total, "inputTokens"),
|
||||
cached_input_tokens: field(total, "cachedInputTokens"),
|
||||
output_tokens: field(total, "outputTokens"),
|
||||
reasoning_output_tokens: field(total, "reasoningOutputTokens"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMaybeJson(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Json)[key] : undefined;
|
||||
}
|
||||
|
||||
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
|
||||
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
||||
}
|
||||
|
||||
async function writeAttachmentFile(item: AgentAttachment) {
|
||||
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
||||
if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
||||
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
||||
await fs.writeFile(file, Buffer.from(data, "base64"));
|
||||
return file;
|
||||
}
|
||||
|
||||
function imageExt(type = "") {
|
||||
if (type.includes("png")) return "png";
|
||||
if (type.includes("webp")) return "webp";
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
function codexBin() {
|
||||
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
||||
}
|
||||
|
||||
function pipeJsonLines(child: ReturnType<typeof spawn>, emit: AgentEmit, agent: string) {
|
||||
let out = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
out += chunk.toString();
|
||||
const lines = out.split(/\r?\n/);
|
||||
out = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
emit("agent_event", { agent, ...JSON.parse(line) });
|
||||
} catch {
|
||||
emit("agent_event", { agent, type: "raw", text: line });
|
||||
}
|
||||
});
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
||||
child.on("close", (code) => emit("agent_done", { agent, code }));
|
||||
}
|
||||
|
||||
function spawnAgent(name: string, args: string[], stdio: StdioOptions, emit: AgentEmit) {
|
||||
try {
|
||||
return spawn(name, args, { stdio, shell: process.platform === "win32", windowsHide: true });
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import crypto from "node:crypto";
|
||||
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";
|
||||
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasState: CanvasSnapshot | null = null;
|
||||
|
||||
health() {
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size };
|
||||
}
|
||||
|
||||
openEvents(url: URL, res: ServerResponse) {
|
||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
this.clients.set(clientId, res);
|
||||
sendEvent(res, "hello", { ok: true, clientId });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
this.clients.delete(clientId);
|
||||
if (this.canvasState?.clientId === clientId) this.canvasState = null;
|
||||
});
|
||||
}
|
||||
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot;
|
||||
}
|
||||
|
||||
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
if (!item || !body.requestId) return;
|
||||
this.pending.delete(body.requestId);
|
||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||
}
|
||||
|
||||
emitAll(type: string, payload: unknown) {
|
||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||
}
|
||||
|
||||
async callTool(name: unknown, rawInput: unknown) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
let tool: ToolName = name;
|
||||
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
|
||||
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
|
||||
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
|
||||
if (tool === "canvas_get_state" || tool === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
|
||||
if (tool === "canvas_get_selection") {
|
||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||
}
|
||||
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 || "" } }] };
|
||||
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()}`;
|
||||
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] }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool !== "canvas_apply_ops") throw new Error(`未知工具:${tool}`);
|
||||
if (!this.clients.size) throw new Error("当前没有已连接画布");
|
||||
return await this.requestCanvasTool(tool, input);
|
||||
}
|
||||
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value;
|
||||
if (!client) throw new Error("当前没有已连接画布");
|
||||
sendEvent(client, "tool_call", { requestId, name, input });
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error("画布操作超时"));
|
||||
}, 30000);
|
||||
this.pending.set(requestId, { resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
||||
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 type CanvasAgentConfig = { url: string; token: string; origins?: string[] };
|
||||
|
||||
export function loadConfig(create = false): CanvasAgentConfig {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) as CanvasAgentConfig;
|
||||
} catch {
|
||||
const config = { url: `http://127.0.0.1:${Number(process.env.PORT) || DEFAULT_PORT}`, token: crypto.randomBytes(18).toString("hex") };
|
||||
if (create) saveConfig(config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(config: CanvasAgentConfig) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { DEFAULT_PORT, loadConfig, saveConfig, type CanvasAgentConfig } from "./config.js";
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
import { runClaudeTurn, runCodexTurn, withAgentPrompt } from "./agents.js";
|
||||
import type { AgentAttachment } from "./types.js";
|
||||
|
||||
export function startHttpServer() {
|
||||
const config = loadConfig(true);
|
||||
const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
|
||||
config.url = `http://127.0.0.1:${port}`;
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
const emit = (type: string, payload: unknown) => session.emitAll(type, payload);
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "30mb" }));
|
||||
app.use((req, res, next) => {
|
||||
const url = requestUrl(req, config);
|
||||
if (!setCors(req, res, url, config)) return void res.status(403).json({ ok: false, error: "origin not allowed" });
|
||||
if (req.method === "OPTIONS") return void res.json({});
|
||||
next();
|
||||
});
|
||||
app.get("/health", (_req, res) => res.json(session.health()));
|
||||
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
|
||||
app.use((req, res, next) => {
|
||||
if (validToken(req, requestUrl(req, config), config.token)) return next();
|
||||
res.status(401).json({ ok: false, error: "invalid token" });
|
||||
});
|
||||
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
|
||||
app.post("/canvas/state", (req, res) => {
|
||||
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
session.resolveResult(req.body);
|
||||
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.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 });
|
||||
}));
|
||||
app.post("/agent/claude/turn", (req, res) => {
|
||||
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
|
||||
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => res.status(500).json({ ok: false, error: error.message }));
|
||||
|
||||
app.listen(port, "127.0.0.1", () => {
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
function route(handler: (req: Request, res: Response) => Promise<unknown>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next);
|
||||
}
|
||||
|
||||
function requestUrl(req: Request, config: CanvasAgentConfig) {
|
||||
return new URL(req.originalUrl || req.url || "/", config.url);
|
||||
}
|
||||
|
||||
function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfig) {
|
||||
const origin = req.headers.origin;
|
||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||
res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
||||
if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config") return true;
|
||||
config.origins ||= [];
|
||||
if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
|
||||
config.origins.push(origin);
|
||||
saveConfig(config);
|
||||
}
|
||||
res.setHeader("Vary", "Origin");
|
||||
return config.origins.includes(origin);
|
||||
}
|
||||
|
||||
function validToken(req: Request, url: URL, token: string) {
|
||||
const header = req.headers["x-canvas-agent-token"];
|
||||
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { startHttpServer } from "./http-server.js";
|
||||
import { startMcpServer } from "./mcp-server.js";
|
||||
|
||||
if (process.argv[2] === "mcp") await startMcpServer();
|
||||
else startHttpServer();
|
||||
@@ -0,0 +1,29 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
import { AGENT_PROMPT, loadConfig, type CanvasAgentConfig, VERSION } from "./config.js";
|
||||
import { toolDescriptions, toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||
|
||||
type CanvasAgentToolResponse = { ok?: boolean; result?: unknown; error?: string };
|
||||
|
||||
export async function startMcpServer() {
|
||||
const config = loadConfig(true);
|
||||
const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
|
||||
toolNames.forEach((name) => registerCanvasTool(server, config, name));
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
|
||||
function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name: ToolName) {
|
||||
const schema = toolInputSchemas[name];
|
||||
server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input: unknown) => {
|
||||
const result = await postCanvasAgentTool(config, name, schema.parse(input));
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
});
|
||||
}
|
||||
|
||||
async function postCanvasAgentTool(config: CanvasAgentConfig, name: ToolName, input: unknown) {
|
||||
const res = await fetch(`${config.url}/api/tools`, { method: "POST", headers: { "content-type": "application/json", "x-canvas-agent-token": config.token }, body: JSON.stringify({ name, input }) });
|
||||
const body = (await res.json()) as CanvasAgentToolResponse;
|
||||
if (!body.ok) throw new Error(body.error || "tool call failed");
|
||||
return body.result;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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() });
|
||||
|
||||
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 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("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(),
|
||||
]);
|
||||
|
||||
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() }),
|
||||
} 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: "创建提示词文本节点和图片生成配置节点,并自动连线。",
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||
import type { CanvasNode, CanvasSnapshot } from "./types.js";
|
||||
|
||||
export function isToolName(name: unknown): name is ToolName {
|
||||
return typeof name === "string" && toolNames.includes(name as ToolName);
|
||||
}
|
||||
|
||||
export function parseToolInput(name: ToolName, input: unknown) {
|
||||
return toolInputSchemas[name].parse(input ?? {});
|
||||
}
|
||||
|
||||
export function compactCanvasState(state: CanvasSnapshot | null) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
return { ...state, nodes: (state.nodes || []).map(compactNode) };
|
||||
}
|
||||
|
||||
export function compactNode(node: CanvasNode) {
|
||||
const metadata = { ...(node.metadata || {}) };
|
||||
if (typeof metadata.content === "string" && metadata.content.length > 240) metadata.content = `${metadata.content.slice(0, 120)}...`;
|
||||
return { id: node.id, type: node.type, title: node.title, position: node.position, width: node.width, height: node.height, metadata };
|
||||
}
|
||||
|
||||
export function nextCanvasX(state: CanvasSnapshot | null) {
|
||||
const nodes = state?.nodes || [];
|
||||
return nodes.length ? Math.max(...nodes.map((node) => node.position.x + node.width)) + 80 : 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type Position = { x: number; y: number };
|
||||
export type Viewport = { x: number; y: number; k: number };
|
||||
export type CanvasNodeType = "image" | "text" | "config" | "video" | "audio";
|
||||
export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; position: Position; width: number; height: number; metadata?: Record<string, unknown> };
|
||||
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
||||
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
||||
export type AgentEmit = (type: string, payload: unknown) => void;
|
||||
export type AgentAttachment = { name?: string; type?: string; dataUrl?: string };
|
||||
Reference in New Issue
Block a user