feat(canvas): restructure source directory for Canvas Agent, enhancing code maintainability

This commit is contained in:
HouYunFei
2026-07-29 10:47:06 +08:00
parent 92fd0ce129
commit 40c47dd7ff
20 changed files with 3095 additions and 777 deletions
+1
View File
@@ -9,6 +9,7 @@
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
+ [优化] 重构 Canvas Agent 源码目录,按 Agent、画布、服务与通用工具拆分模块职责,提升代码可维护性。
## v0.11.0 - 2026-07-28
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@
"scripts": {
"dev": "tsx src/index.ts",
"debug": "tsx src/index.ts --debug",
"test": "tsx --test src/canvas-session.test.ts",
"test": "tsx --test src/canvas/session.test.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"prepack": "npm run build"
+48
View File
@@ -0,0 +1,48 @@
import { spawn } from "node:child_process";
import { AGENT_PROMPT } from "../config.js";
import { errorMessage } from "../utils/value.js";
import type { AgentEmit } from "./types.js";
/** 使用 Claude CLI 执行一次带 Canvas Agent 工具的任务。 */
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
const fullPrompt = withAgentPrompt(prompt);
if (!fullPrompt) return;
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", fullPrompt], emit);
if (child) pipeJsonLines(child, emit, "claude");
}
/** 为 Claude CLI 请求拼接 Canvas Agent 指令。 */
function withAgentPrompt(prompt: string) {
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
}
/** 将 Claude CLI 的 JSON Lines 输出转换为 Agent 事件。 */
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 }));
}
/** 启动外部 Agent CLI,并将同步启动异常转换为事件。 */
function spawnAgent(name: string, args: string[], emit: AgentEmit) {
try {
return spawn(name, args, { stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", windowsHide: true });
} catch (error) {
emit("agent_error", { message: errorMessage(error) });
return null;
}
}
+308
View File
@@ -0,0 +1,308 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { VERSION } from "../config.js";
import { logger } from "../utils/logger.js";
import { field, type JsonRecord } from "../utils/value.js";
import type { AgentEmit } from "./types.js";
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
const canvasAgentMcp = canvasAgentMcpCommand();
const require = createRequire(import.meta.url);
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
export class CodexAppClient {
private nextId = 1;
private buffer = "";
private currentThreadId = "";
private textByItem = new Map<string, string>();
private lastUsage: unknown = null;
private pending = new Map<number, PendingRequest>();
private activeTurns = new Map<string, PendingRequest>();
private completedTurns = new Map<string, Error | null>();
/** 保存 app-server 子进程和事件出口。 */
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
/** 启动并初始化 Codex app-server。 */
static async start(emit: AgentEmit, onExit: () => void) {
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
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) => {
const text = chunk.toString();
logger.warn("Codex app-server stderr", { text });
emit("agent_log", { text });
});
child.on("error", (error) => {
logger.error("Codex app-server process error", error);
emit("agent_error", { message: error.message });
});
child.on("exit", (code) => {
logger.warn("Codex app-server exited", { code });
client.failAll(`Codex app-server exited: ${code ?? 0}`);
onExit();
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;
}
/** 创建新的 Codex 线程。 */
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 JsonRecord | undefined;
const id = String(field(thread, "id") || "");
if (!id) throw new Error("Codex app-server 没有返回 thread id");
return thread || {};
}
/** 恢复已有 Codex 线程。 */
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 JsonRecord | undefined;
const id = String(field(thread, "id") || "");
if (!id) throw new Error("Codex app-server 没有返回 thread id");
return thread || {};
}
/** 查询 Codex 线程列表。 */
listThreads(params: JsonRecord) {
return this.request("thread/list", params);
}
/** 读取指定 Codex 线程。 */
readThread(threadId: string, includeTurns = true) {
return this.request("thread/read", { threadId, includeTurns });
}
/** 归档指定 Codex 线程。 */
archiveThread(threadId: string) {
return this.request("thread/archive", { threadId });
}
/** 启动一个 Codex turn 并等待完成通知。 */
async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
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");
this.currentThreadId = threadId;
onTurn?.(turnId);
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 }));
}
/** 中断当前正在运行的 Codex turn。 */
interruptCurrentTurn() {
if (this.activeTurns.size === 0) return false;
try {
logger.warn("Interrupting active Codex turn", { threadId: this.currentThreadId, activeTurns: this.activeTurns.size });
this.child.kill("SIGINT");
return true;
} catch {
return false;
}
}
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
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 }));
}
/** 发送无需响应的 JSON-RPC 通知。 */
private notify(method: string, params?: unknown) {
this.write(params === undefined ? { method } : { method, params });
}
/** 将 JSON-RPC 消息写入 app-server 标准输入。 */
private write(value: unknown) {
const method = String(field(value, "method") || "");
const params = field(value, "params");
if (method) logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
}
/** 按行解析 app-server 标准输出。 */
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 JsonRecord);
} catch (error) {
logger.warn("Invalid Codex app-server output", { error, line });
this.emit("agent_log", { text: line });
}
});
}
/** 分派单条 JSON-RPC 响应、请求或通知。 */
private handle(message: JsonRecord) {
const id = Number(message.id);
if (message.error && this.pending.has(id)) {
const error = String(field(message.error, "message") || "Codex request failed");
logger.warn("Codex request failed", { id, error });
return this.reject(id, error);
}
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 JsonRecord);
}
/** 转换并广播 app-server 通知。 */
private handleNotification(method: string, params: JsonRecord) {
if (method === "item/agentMessage/delta") return this.emitDelta(params);
if (method === "thread/tokenUsage/updated") {
this.lastUsage = normalizeUsage(params);
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
return;
}
const event = normalizeCodexNotification(method, params);
if (!event) return;
if (event.type === "item.completed") {
const item = field(event, "item") as JsonRecord | undefined;
const id = String(field(item, "id") || "");
const streamedText = this.textByItem.get(id);
if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText;
if (id) this.textByItem.delete(id);
}
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);
}
if (this.activeTurns.size === 0) this.currentThreadId = "";
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
}
}
/** 合并并广播 Agent 文本增量。 */
private emitDelta(params: JsonRecord) {
const id = String(field(params, "itemId") || "");
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
this.textByItem.set(id, text);
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
}
/** 自动回复 app-server 发起的授权或交互请求。 */
private answerServerRequest(message: JsonRecord) {
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 });
}
/** 完成指定 JSON-RPC 请求。 */
private resolve(id: number, result: unknown) {
const pending = this.pending.get(id);
if (pending) (this.pending.delete(id), pending.resolve(result));
}
/** 拒绝指定 JSON-RPC 请求。 */
private reject(id: number, message: string) {
const pending = this.pending.get(id);
if (pending) (this.pending.delete(id), pending.reject(new Error(message)));
}
/** 拒绝进程退出时仍未完成的请求与 turn。 */
private failAll(message: string) {
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
this.pending.clear();
this.activeTurns.clear();
this.currentThreadId = "";
}
}
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
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"] };
}
/** 生成 Codex app-server 使用的 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 } } };
}
/** 将文本和本地图片转换为 Codex turn 输入。 */
function codexInput(prompt: string, images: string[]) {
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
}
/** 将 app-server 通知转换为前端使用的 Agent 事件。 */
function normalizeCodexNotification(method: string, params: JsonRecord): AgentEvent | null {
const scope = codexEventScope(params);
if (method === "thread/started") return { type: "thread.started", ...scope };
if (method === "turn/started") return { type: "turn.started", ...scope };
if (method === "turn/completed") return { type: "turn.completed", usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope };
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
if (method === "error") return { type: "error", message: field(params, "message"), ...scope };
return null;
}
/** 提取 Codex 事件所属的线程和 turn。 */
function codexEventScope(params: JsonRecord) {
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
}
/** 统一 app-server item 的类型和参数格式。 */
function normalizeItem(item: unknown) {
const value = item && typeof item === "object" ? { ...(item as JsonRecord) } : {};
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;
}
/** 将 Codex token usage 转换为前端字段。 */
function normalizeUsage(params: JsonRecord) {
const last = field(field(params, "tokenUsage"), "last") as JsonRecord | undefined;
return {
input_tokens: field(last, "inputTokens"),
cached_input_tokens: field(last, "cachedInputTokens"),
output_tokens: field(last, "outputTokens"),
reasoning_output_tokens: field(last, "reasoningOutputTokens"),
};
}
/** 尝试将字符串解析为 JSON,失败时保留原值。 */
function parseMaybeJson(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** 定位当前依赖中 Codex CLI 的执行文件。 */
function codexBin() {
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
}
+100
View File
@@ -0,0 +1,100 @@
import { field } from "../utils/value.js";
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
/** 将 Codex 线程转换为列表展示所需的摘要。 */
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),
};
}
/** 将 Codex turn items 转换为网页聊天历史。 */
export 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 });
}
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");
}
/** 移除用户消息中由旧流程拼接的 Agent 前置提示词。 */
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 : [];
}
/** 将非空字符串保留为字符串,否则返回 null。 */
function stringOrNull(value: unknown) {
return typeof value === "string" && value.trim() ? value : null;
}
/** 将 MCP 工具名称转换为聊天记录中的中文标题。 */
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_attachment_nodes") 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;
}
+195
View File
@@ -0,0 +1,195 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { logger } from "../utils/logger.js";
import { errorMessage, field } from "../utils/value.js";
import { CodexAppClient } from "./codex-client.js";
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
import type { AgentAttachment, AgentEmit } from "./types.js";
type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null;
let codexAppStart: Promise<CodexAppClient> | null = null;
let codexThreadId = "";
export { summarizeCodexThread } from "./codex-history.js";
/** 将 Codex turn 加入串行队列并等待执行完成。 */
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, options));
await codexQueue;
}
/** 中断当前线程正在执行的 Codex turn。 */
export function interruptCodexTurn(threadId?: string) {
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
return codexApp.interruptCurrentTurn();
}
/** 创建新的 Codex 线程并记录当前线程 ID。 */
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
const app = await getCodexApp(emit);
const thread = await app.startThread(cwd);
codexThreadId = String(field(thread, "id") || "");
return thread;
}
/** 恢复指定 Codex 线程并返回聊天历史。 */
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
const thread = await app.resumeThread(threadId, cwd);
assertThreadWorkspace(thread, cwd);
codexThreadId = String(field(thread, "id") || threadId);
return { thread, messages: threadMessages(thread) };
}
/** 查询当前工作空间中的 Codex 线程。 */
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
const app = await getCodexApp(emit);
const result = await app.listThreads({
limit: options.limit || 40,
sortKey: "updated_at",
sortDirection: "desc",
sourceKinds: ["cli", "vscode", "appServer", "exec"],
cwd: options.cwd,
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
});
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
}
/** 读取指定 Codex 线程及其聊天历史。 */
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const thread = await loadCodexThread(emit, threadId, cwd, true);
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
}
/** 确认指定 Codex 线程属于当前工作空间。 */
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
await loadCodexThread(emit, threadId, cwd, false);
}
/** 归档指定 Codex 线程。 */
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
await app.archiveThread(threadId);
}
/** 判断线程异常是否允许自动新建线程后重试。 */
export function isRecoverableThreadError(error: unknown) {
return /thread not loaded|no rollout found/i.test(errorMessage(error));
}
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
options.onStart?.();
files = await writeAttachmentFiles(attachments);
const app = await getCodexApp(options.appEmit || emit);
let threadId = await ensureCodexThread(app, options, emit);
options.onThread?.(threadId);
try {
await app.startTurn(threadId, prompt, files, options.onTurn);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
codexThreadId = "";
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
options.onThread?.(threadId);
await app.startTurn(threadId, prompt, files, options.onTurn);
}
} catch (error) {
logger.error("Codex turn failed", error);
emit("agent_error", { message: errorMessage(error) });
} finally {
options.onFinish?.();
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
}
}
/** 恢复请求线程或创建新的 Codex 线程。 */
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
if (options.threadId) {
if (options.threadId === codexThreadId) return codexThreadId;
try {
const result = await app.readThread(options.threadId, false);
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
const thread = await app.resumeThread(options.threadId, options.cwd);
assertThreadWorkspace(thread, options.cwd);
codexThreadId = String(field(thread, "id") || options.threadId);
return codexThreadId;
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
}
}
if (!codexThreadId) {
const thread = await app.startThread(options.cwd);
codexThreadId = String(field(thread, "id") || "");
}
return codexThreadId;
}
/** 从 app-server 读取线程并校验工作空间。 */
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
const app = await getCodexApp(emit);
const result = await app.readThread(threadId, includeTurns);
const thread = field(result, "thread") || {};
assertThreadWorkspace(thread, cwd);
return thread;
}
/** 获取已启动的 Codex app-server 客户端。 */
async function getCodexApp(emit: AgentEmit) {
if (codexApp) return codexApp;
codexAppStart ||= CodexAppClient.start(emit, () => {
codexApp = null;
codexThreadId = "";
});
try {
codexApp = await codexAppStart;
return codexApp;
} finally {
codexAppStart = null;
}
}
/** 校验线程是否属于指定工作空间。 */
function assertThreadWorkspace(thread: unknown, cwd?: string) {
if (!cwd || threadInWorkspace(thread, cwd)) return;
throw new Error("该 Codex 会话不属于当前画布工作空间");
}
/** 判断线程工作目录是否与当前工作空间一致。 */
function threadInWorkspace(thread: unknown, cwd: string) {
const threadCwd = String(field(thread, "cwd") || "");
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
}
/** 将图片附件写入临时文件供 Codex 读取。 */
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
}
/** 将单个 Data URL 图片附件写入临时文件。 */
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;
}
/** 根据图片 MIME 类型返回临时文件扩展名。 */
function imageExt(type = "") {
if (type.includes("png")) return "png";
if (type.includes("webp")) return "webp";
return "jpg";
}
+5
View File
@@ -0,0 +1,5 @@
/** Agent 向网页广播事件的函数类型。 */
export type AgentEmit = (type: string, payload: unknown) => void;
/** 用户随当前 Agent 消息上传的附件。 */
export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
-576
View File
@@ -1,576 +0,0 @@
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 { logger } from "./utils/logger.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 };
type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
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;
let codexAppStart: Promise<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[] = [], options: CodexRunOptions = {}) {
if (!prompt.trim()) return;
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
await codexQueue;
}
export function interruptCodexTurn(threadId?: string) {
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
return codexApp.interruptCurrentTurn();
}
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
options.onStart?.();
files = await writeAttachmentFiles(attachments);
const app = await getCodexApp(options.appEmit || emit);
let threadId = await ensureCodexThread(app, options, emit);
options.onThread?.(threadId);
try {
await app.startTurn(threadId, prompt, files, options.onTurn);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
codexThreadId = "";
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
options.onThread?.(threadId);
await app.startTurn(threadId, prompt, files, options.onTurn);
}
} catch (error) {
logger.error("Codex turn failed", error);
emit("agent_error", { message: errorMessage(error) });
} finally {
options.onFinish?.();
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
}
}
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
const app = await getCodexApp(emit);
const thread = await app.startThread(cwd);
codexThreadId = String(field(thread, "id") || "");
return thread;
}
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
const thread = await app.resumeThread(threadId, cwd);
assertThreadWorkspace(thread, cwd);
codexThreadId = String(field(thread, "id") || threadId);
return { thread, messages: threadMessages(thread) };
}
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
const app = await getCodexApp(emit);
const result = await app.listThreads({
limit: options.limit || 40,
sortKey: "updated_at",
sortDirection: "desc",
sourceKinds: ["cli", "vscode", "appServer", "exec"],
cwd: options.cwd,
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
});
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
}
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const thread = await loadCodexThread(emit, threadId, cwd, true);
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
}
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
await loadCodexThread(emit, threadId, cwd, false);
}
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
await app.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);
if (!child) return;
pipeJsonLines(child, emit, "claude");
}
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
if (options.threadId) {
if (options.threadId === codexThreadId) return codexThreadId;
try {
const result = await app.readThread(options.threadId, false);
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
const thread = await app.resumeThread(options.threadId, options.cwd);
assertThreadWorkspace(thread, options.cwd);
codexThreadId = String(field(thread, "id") || options.threadId);
return codexThreadId;
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
}
}
if (!codexThreadId) {
const thread = await app.startThread(options.cwd);
codexThreadId = String(field(thread, "id") || "");
}
return codexThreadId;
}
export function isRecoverableThreadError(error: unknown) {
return /thread not loaded|no rollout found/i.test(errorMessage(error));
}
class CodexAppClient {
private nextId = 1;
private buffer = "";
private textByItem = new Map<string, string>();
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) {
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
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) => {
const text = chunk.toString();
logger.warn("Codex app-server stderr", { text });
emit("agent_log", { text });
});
child.on("error", (error) => {
logger.error("Codex app-server process error", error);
emit("agent_error", { message: error.message });
});
child.on("exit", (code) => {
logger.warn("Codex app-server exited", { 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(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 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, includeTurns = true) {
return this.request("thread/read", { threadId, includeTurns });
}
archiveThread(threadId: string) {
return this.request("thread/archive", { threadId });
}
async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
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");
onTurn?.(turnId);
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 }));
}
interruptCurrentTurn() {
if (this.activeTurns.size === 0) return false;
try {
logger.warn("Interrupting active Codex turn", { threadId: codexThreadId, activeTurns: this.activeTurns.size });
this.child.kill("SIGINT");
return true;
} catch {
return false;
}
}
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) {
const method = String(field(value, "method") || "");
const params = field(value, "params");
if (method) logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
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 (error) {
logger.warn("Invalid Codex app-server output", { error, line });
this.emit("agent_log", { text: line });
}
});
}
private handle(message: Json) {
const id = Number(message.id);
if (message.error && this.pending.has(id)) {
const error = String(field(message.error, "message") || "Codex request failed");
logger.warn("Codex request failed", { id, error });
return this.reject(id, error);
}
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);
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
return;
}
const event = normalizeCodexNotification(method, params);
if (!event) return;
if (event.type === "item.completed") {
const item = field(event, "item") as Json | undefined;
const id = String(field(item, "id") || "");
const streamedText = this.textByItem.get(id);
if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText;
if (id) this.textByItem.delete(id);
}
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_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
}
}
private emitDelta(params: Json) {
const id = String(field(params, "itemId") || "");
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
this.textByItem.set(id, text);
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
}
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 {
const scope = codexEventScope(params);
if (method === "thread/started") return { type: "thread.started", ...scope };
if (method === "turn/started") return { type: "turn.started", ...scope };
if (method === "turn/completed") return { type: "turn.completed", usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope };
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
if (method === "error") return { type: "error", message: field(params, "message"), ...scope };
return null;
}
function codexEventScope(params: Json) {
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
}
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
const app = await getCodexApp(emit);
const result = await app.readThread(threadId, includeTurns);
const thread = field(result, "thread") || {};
assertThreadWorkspace(thread, cwd);
return thread;
}
async function getCodexApp(emit: AgentEmit) {
if (codexApp) return codexApp;
codexAppStart ||= CodexAppClient.start(emit);
try {
codexApp = await codexAppStart;
return codexApp;
} finally {
codexAppStart = null;
}
}
function assertThreadWorkspace(thread: unknown, cwd?: string) {
if (!cwd || threadInWorkspace(thread, cwd)) return;
throw new Error("该 Codex 会话不属于当前画布工作空间");
}
function threadInWorkspace(thread: unknown, cwd: string) {
const threadCwd = String(field(thread, "cwd") || "");
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
}
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 last = field(field(params, "tokenUsage"), "last") as Json | undefined;
return {
input_tokens: field(last, "inputTokens"),
cached_input_tokens: field(last, "cachedInputTokens"),
output_tokens: field(last, "outputTokens"),
reasoning_output_tokens: field(last, "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;
}
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 });
}
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_attachment_nodes") 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));
}
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);
}
+168
View File
@@ -0,0 +1,168 @@
import crypto from "node:crypto";
import type { ToolName } from "./schemas.js";
import { nextCanvasX } from "./tools.js";
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
export type CanvasToolRequest = { name: "canvas_apply_ops"; input: Record<string, unknown> };
/** 将上层画布工具调用转换为前端可执行的批量操作。 */
export function buildCanvasToolRequest(name: ToolName, input: Record<string, unknown>, state: CanvasSnapshot | null): CanvasToolRequest {
if (name === "canvas_apply_ops") return { name, input };
if (name === "canvas_create_node") {
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
return applyOps([{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(state), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }]);
}
if (name === "canvas_create_text_node") {
const data = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number };
return applyOps([textNodeOp(data, data.x ?? nextCanvasX(state), data.y ?? 0)]);
}
if (name === "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(state));
const y = Number(data.y ?? 0);
const gap = Number(data.gap ?? 40);
return applyOps(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)))));
}
if (name === "canvas_create_image_prompt_flow") return applyOps(generationFlowOps({ ...input, mode: "image" }, state));
if (name === "canvas_create_config_node") {
const x = Number(input.x ?? nextCanvasX(state));
const y = Number(input.y ?? 0);
const configId = `config-${crypto.randomUUID()}`;
const mode = generationMode(input.mode);
const prompt = String(input.prompt || "");
return applyOps([configNodeOp(configId, input, x, y), ...(input.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])]);
}
if (name === "canvas_create_generation_flow") return applyOps(generationFlowOps(input, state));
if (name === "canvas_generate_text" || name === "canvas_generate_image" || name === "canvas_generate_video" || name === "canvas_generate_audio") {
return applyOps(generationFlowOps({ ...input, mode: name.replace("canvas_generate_", ""), autoRun: true }, state));
}
if (name === "canvas_update_node") {
const data = input as { id: string; patch?: Record<string, unknown>; metadata?: Record<string, unknown> };
return applyOps([{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }]);
}
if (name === "canvas_update_node_text") {
const data = input as { id: string; text: string; title?: string };
return applyOps([{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }]);
}
if (name === "canvas_move_nodes") {
const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> };
return applyOps(data.items.map((item) => {
const current = findNode(state, 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)) } } };
}));
}
if (name === "canvas_resize_node") {
const data = input as { id: string; width: number; height: number; freeResize?: boolean };
return applyOps([{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }]);
}
if (name === "canvas_delete_nodes") return applyOps([{ type: "delete_node", ids: (input as { ids: string[] }).ids }]);
if (name === "canvas_connect_nodes") {
const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> };
return applyOps(data.connections.map((connection) => ({ type: "connect_nodes", ...connection })));
}
if (name === "canvas_select_nodes") return applyOps([{ type: "select_nodes", ids: (input as { ids: string[] }).ids }]);
if (name === "canvas_set_viewport") return applyOps([{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }]);
if (name === "canvas_run_generation") {
const data = input as { nodeId: string; mode?: string; prompt?: string };
return applyOps([runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)]);
}
throw new Error(`未知工具:${name}`);
}
/** 按最大边限制计算附件图片节点尺寸,并保持原始比例。 */
export function fitAttachmentNodeSize(width: number, height: number) {
const scale = Math.min(1, 640 / width, 640 / height);
return { width: width * scale, height: height * scale };
}
/** 创建统一的批量画布操作请求。 */
function applyOps(ops: unknown[]): CanvasToolRequest {
return { name: "canvas_apply_ops", input: { ops } };
}
/** 创建文本节点操作。 */
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}]`)];
return [
textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y),
configNodeOp(configId, { ...input, prompt: tokens.join("\n") }, 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 "图片生成";
}
/** 按节点 ID 查找当前画布节点。 */
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,6 +6,7 @@ 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"]);
/** Canvas Agent 对外提供的工具名称。 */
export const toolNames = [
"site_navigate",
"canvas_list_projects",
@@ -3,7 +3,7 @@ import type { ServerResponse } from "node:http";
import assert from "node:assert/strict";
import test from "node:test";
import { CanvasSession } from "./canvas-session.js";
import { CanvasSession } from "./session.js";
test("MCP 读取当前激活网页的画布", async (t) => {
const session = new CanvasSession();
@@ -251,38 +251,46 @@ test("closing the bound client falls back to the active client", async (t) => {
assert.deepEqual(await result, { ok: true });
});
/** 创建用于测试的画布 SSE 连接。 */
function connect(session: CanvasSession, clientId: string) {
const response = new FakeSseResponse();
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
return response;
}
/** 创建最小画布快照。 */
function snapshot(projectId: string) {
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
}
/** 安全读取测试对象字段。 */
function field(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
}
/** 模拟 Node SSE 响应并提供事件读取能力。 */
class FakeSseResponse extends EventEmitter {
private chunks: string[] = [];
/** 模拟写入响应头。 */
writeHead() {
return this;
}
/** 保存写入的 SSE 文本块。 */
write(chunk: string) {
this.chunks.push(chunk);
return true;
}
/** 读取指定类型的首个 SSE 事件数据。 */
event(type: string) {
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
return data ? (JSON.parse(data) as unknown) : undefined;
}
/** 触发连接关闭事件。 */
close() {
this.emit("close");
}
@@ -1,10 +1,12 @@
import crypto from "node:crypto";
import type { ServerResponse } from "node:http";
import { logger } from "./utils/logger.js";
import { type ToolName } from "./schemas.js";
import type { AgentAttachment } from "../agent/types.js";
import { logger } from "../utils/logger.js";
import { buildCanvasToolRequest, fitAttachmentNodeSize } from "./operations.js";
import type { ToolName } from "./schemas.js";
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
import type { AgentAttachment, CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
import type { CanvasSnapshot } from "./types.js";
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
@@ -23,6 +25,7 @@ const SITE_TOOLS = new Set<ToolName>([
"generation_get_status",
]);
/** 管理网页画布连接、状态、附件和工具请求。 */
export class CanvasSession {
private clients = new Map<string, ServerResponse>();
private clientFocusOrder = new Map<string, number>();
@@ -34,28 +37,34 @@ export class CanvasSession {
private focusSequence = 0;
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
/** 获取当前目标网页的画布状态。 */
private get canvasState() {
return this.canvasStates.get(this.targetClientId) || null;
}
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
private get targetClientId() {
return this.boundClientId || this.activeClientId;
}
/** 返回 Canvas Agent 当前连接状态。 */
health() {
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
}
/** 返回 Codex 是否正在执行任务。 */
get codexBusy() {
return this.codexState.busy;
}
/** 更新并广播 Codex 运行状态。 */
setCodexState(patch: Partial<CodexState>) {
this.codexState = { ...this.codexState, ...patch };
logger.debug("Codex state changed", this.codexState);
this.emitAll("codex_state", this.codexState);
}
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
openEvents(url: URL, res: ServerResponse) {
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
const statusOnly = url.searchParams.get("role") === "status";
@@ -88,6 +97,7 @@ export class CanvasSession {
});
}
/** 保存指定网页上报的最新画布快照。 */
updateState(body: unknown, clientId?: string) {
const targetClientId = clientId || this.activeClientId;
if (!targetClientId) return;
@@ -95,6 +105,7 @@ export class CanvasSession {
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: Array.isArray((body as CanvasSnapshot | null)?.nodes) ? (body as CanvasSnapshot).nodes.length : 0, connections: Array.isArray((body as CanvasSnapshot | null)?.connections) ? (body as CanvasSnapshot).connections.length : 0 });
}
/** 将指定网页设为最近激活的工具目标。 */
activateClient(clientId: string) {
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
this.activeClientId = clientId;
@@ -102,17 +113,20 @@ export class CanvasSession {
logger.debug("Canvas client activated", { clientId });
}
/** 将当前 Agent turn 固定绑定到指定网页。 */
bindClient(clientId: string) {
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
this.boundClientId = clientId;
logger.debug("Canvas client bound to turn", { clientId });
}
/** 解除当前 Agent turn 的网页绑定。 */
releaseClient(clientId: string) {
if (this.boundClientId === clientId) this.boundClientId = "";
logger.debug("Canvas client released from turn", { clientId });
}
/** 保存当前 turn 可用的图片附件并返回安全引用。 */
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
this.turnAttachments.clear();
return attachments.flatMap((item, index) => {
@@ -133,12 +147,14 @@ export class CanvasSession {
});
}
/** 清理指定网页或全部 turn 附件。 */
clearTurnAttachments(clientId?: string) {
this.turnAttachments.forEach((item, id) => {
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
});
}
/** 获取属于指定网页 turn 的图片附件。 */
getTurnAttachment(clientId: string, attachmentId: string) {
const attachment = this.turnAttachments.get(attachmentId);
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
@@ -146,6 +162,7 @@ export class CanvasSession {
return attachment;
}
/** 接收网页返回的工具调用结果。 */
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
const item = body.requestId ? this.pending.get(body.requestId) : null;
if (!item || !body.requestId || item.clientId !== clientId) return false;
@@ -155,125 +172,39 @@ export class CanvasSession {
return true;
}
/** 向全部已连接网页广播事件。 */
emitAll(type: string, payload: unknown) {
this.clients.forEach((client) => sendEvent(client, type, payload));
}
/** 向全部网页广播带线程归属的事件。 */
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
this.emitAll(type, { ...payload, threadId });
}
/** 校验工具参数并将调用分派到当前目标网页。 */
async callTool(name: unknown, rawInput: unknown) {
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
logger.info("MCP tool called", { name, input: rawInput, targetClientId: this.targetClientId });
let tool: ToolName = name;
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
if (SITE_TOOLS.has(tool)) {
const input = parseToolInput(name, rawInput) as Record<string, unknown>;
if (SITE_TOOLS.has(name)) {
if (!this.clients.size) throw new Error("当前没有已连接网页");
return await this.requestCanvasTool(tool, input);
return await this.requestCanvasTool(name, input);
}
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(name);
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") {
if (name === "canvas_get_state" || name === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
if (name === "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_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
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; 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") {
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()}`;
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}`);
if (name === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
if (!this.clients.size) throw new Error("当前没有已连接画布");
return await this.requestCanvasTool(tool, input);
const request = buildCanvasToolRequest(name, input, this.canvasState);
return await this.requestCanvasTool(request.name, request.input);
}
/** 将当前 turn 的附件转换为画布图片节点。 */
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
const clientId = this.targetClientId;
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
@@ -300,6 +231,7 @@ export class CanvasSession {
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
}
/** 向目标网页发送工具请求并等待调用结果。 */
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
const requestId = crypto.randomUUID();
const clientId = this.targetClientId;
@@ -318,95 +250,12 @@ export class CanvasSession {
}
}
/** 向 SSE 连接写入一个事件。 */
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 !== ""));
}
/** 将未知数值转换为正数,否则使用默认值。 */
function positiveNumber(value: unknown, fallback: number) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function fitAttachmentNodeSize(width: number, height: number) {
const scale = Math.min(1, 640 / width, 640 / height);
return { width: width * scale, height: height * scale };
}
@@ -1,25 +1,30 @@
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 ?? {});
}
/** 压缩画布快照,避免向 Agent 返回过长的节点内容。 */
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;
@@ -1,8 +1,7 @@
/** 画布坐标。 */
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 = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
+7
View File
@@ -13,6 +13,7 @@ const initializedWorkspaces = new Set<string>();
export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
/** 读取本地 Canvas Agent 配置,不存在时生成默认配置。 */
export function loadConfig(create = false): CanvasAgentConfig {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) as CanvasAgentConfig;
@@ -23,11 +24,13 @@ export function loadConfig(create = false): CanvasAgentConfig {
}
}
/** 将 Canvas Agent 配置写入用户配置目录。 */
export function saveConfig(config: CanvasAgentConfig) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/** 确保站点级 Codex 工作空间存在并已初始化。 */
export function ensureSiteWorkspace(config: CanvasAgentConfig) {
const current = config.workspace;
if (current?.workspacePath) {
@@ -42,6 +45,7 @@ export function ensureSiteWorkspace(config: CanvasAgentConfig) {
return { workspacePath };
}
/** 更新站点级 Codex 工作空间配置。 */
export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<SiteWorkspaceConfig>) {
const current = ensureSiteWorkspace(config);
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
@@ -52,6 +56,7 @@ export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<Si
return config.workspace;
}
/** 创建工作空间目录并写入默认 AGENTS.md。 */
function initializeWorkspace(workspacePath: string) {
if (initializedWorkspaces.has(workspacePath)) return;
fs.mkdirSync(workspacePath, { recursive: true });
@@ -60,12 +65,14 @@ function initializeWorkspace(workspacePath: string) {
initializedWorkspaces.add(workspacePath);
}
/** 将用户输入的工作空间路径解析为绝对路径。 */
function resolveWorkspacePath(value: string) {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
return path.resolve(value);
}
/** 从当前包信息中读取 Canvas Agent 版本号。 */
function readPackageVersion() {
try {
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { startHttpServer } from "./http-server.js";
import { startMcpServer } from "./mcp-server.js";
import { startHttpServer } from "./server/http.js";
import { startMcpServer } from "./server/mcp.js";
if (process.argv[2] === "mcp") await startMcpServer();
else startHttpServer();
@@ -1,11 +1,13 @@
import express, { type NextFunction, type Request, type Response } from "express";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
import { CanvasSession } from "./canvas-session.js";
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
import { logger } from "./utils/logger.js";
import type { AgentAttachment } from "./types.js";
import { runClaudeTurn } from "../agent/claude.js";
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
import type { AgentAttachment } from "../agent/types.js";
import { CanvasSession } from "../canvas/session.js";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
import { logger } from "../utils/logger.js";
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
export function startHttpServer() {
const config = loadConfig(true);
const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
@@ -13,11 +15,13 @@ export function startHttpServer() {
saveConfig(config);
const session = new CanvasSession();
/** 将 Agent 事件广播到所属线程或全部网页。 */
const emit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
};
/** 保存并广播当前站点工作空间的活跃线程。 */
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
@@ -138,6 +142,7 @@ export function startHttpServer() {
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
};
let chatThreadId = "";
/** 将当前 turn 事件固定广播到实际线程。 */
const turnEmit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
session.emitThread(type, threadId, data);
@@ -181,7 +186,7 @@ export function startHttpServer() {
res.json({ ok });
});
app.post("/agent/claude/turn", (req, res) => {
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
runClaudeTurn(String(req.body?.prompt || ""), emit);
res.json({ ok: true });
});
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
@@ -202,18 +207,22 @@ export function startHttpServer() {
});
}
/** 将异步 Express 路由异常交给统一错误处理中间件。 */
function route(handler: (req: Request, res: Response) => Promise<unknown>) {
return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next);
}
/** 从 Express 路由参数中读取单个字符串。 */
function routeParam(value: string | string[]) {
return Array.isArray(value) ? value[0] || "" : value;
}
/** 结合服务配置解析当前请求 URL。 */
function requestUrl(req: Request, config: CanvasAgentConfig) {
return new URL(req.originalUrl || req.url || "/", config.url);
}
/** 设置跨域响应头并记录通过 token 授权的来源。 */
function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfig) {
const origin = req.headers.origin;
res.setHeader("Access-Control-Allow-Origin", origin || "*");
@@ -230,11 +239,13 @@ function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfi
return config.origins.includes(origin);
}
/** 校验请求查询参数或请求头中的连接 token。 */
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));
}
/** 向 Agent 提示词追加本轮图片附件引用说明。 */
function withAttachmentContext(prompt: string, attachments: Array<{ id: string; name: string }>) {
if (!attachments.length) return prompt;
const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
@@ -1,11 +1,12 @@
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";
import { toolDescriptions, toolInputSchemas, toolNames, type ToolName } from "../canvas/schemas.js";
import { AGENT_PROMPT, loadConfig, type CanvasAgentConfig, VERSION } from "../config.js";
type CanvasAgentToolResponse = { ok?: boolean; result?: unknown; error?: string };
/** 启动通过标准输入输出通信的 MCP 服务。 */
export async function startMcpServer() {
const config = loadConfig(true);
const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
@@ -13,6 +14,7 @@ export async function startMcpServer() {
await server.connect(new StdioServerTransport());
}
/** 向 MCP Server 注册单个 Canvas Agent 工具。 */
function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name: ToolName) {
const schema = toolInputSchemas[name];
server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input: unknown) => {
@@ -21,6 +23,7 @@ function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name:
});
}
/** 将 MCP 工具调用转发到本地 Canvas Agent HTTP 服务。 */
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;
+11
View File
@@ -0,0 +1,11 @@
export type JsonRecord = Record<string, unknown>;
/** 安全读取未知对象中的指定字段。 */
export function field(value: unknown, key: string) {
return value && typeof value === "object" ? (value as JsonRecord)[key] : undefined;
}
/** 将未知异常转换为可展示的错误信息。 */
export function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}