yueqiao-canvas-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/codex.mjs +91 -0
- package/index.mjs +310 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# 粤乔 Canvas Agent
|
|
2
|
+
|
|
3
|
+
网页对话通过本地服务运行 Codex,MCP 通过同一个本地服务操作画布。工具定义、节点操作和生成流程继续使用网页现有实现。
|
|
4
|
+
|
|
5
|
+
发布在 npm 上的包名为 `yueqiao-canvas-agent`。
|
|
6
|
+
|
|
7
|
+
## 启动
|
|
8
|
+
|
|
9
|
+
安装 Node.js 22+ 后,在终端执行,npx 会从 npm 获取服务及依赖:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx -y yueqiao-canvas-agent@latest
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
首次启动自动生成 Token,保存在当前用户的 `~/.yueqiao/codex-agent.json`;下次启动复用。终端显示 `Local URL` 和 `Connect token`,默认地址为 `http://127.0.0.1:3210`。网页与服务在同一台电脑运行,不需要克隆仓库或手工创建 `.env`;正确 Token 连接后自动记录网站来源。
|
|
16
|
+
|
|
17
|
+
Codex 尚未登录时执行 `npx -y @openai/codex@0.153.4 login` 并完成登录,已有登录态会直接复用。
|
|
18
|
+
|
|
19
|
+
## 自动打开画布
|
|
20
|
+
|
|
21
|
+
Agent 提供 `open` 子命令,把连接信息带进原画布链接并交给指定浏览器打开:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx -y yueqiao-canvas-agent@latest open "<画布URL>" <浏览器> [浏览器参数...]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
浏览器支持 `chrome`、`edge`、`firefox`、`brave` 和 `default`,保持原浏览器及配置;画布地址沿用实际的 `/canvas/[id]` 路由。浏览器的本地网络权限提示由用户允许。已有画布优先复用原浏览器标签。
|
|
28
|
+
|
|
29
|
+
## 注册 MCP
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
codex mcp add yueqiao -- npx -y yueqiao-canvas-agent@latest mcp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
需要先启动服务并在网页连接画布。移除时执行 `codex mcp remove yueqiao`。
|
|
36
|
+
|
|
37
|
+
## 可选:作为 Codex 插件分发
|
|
38
|
+
|
|
39
|
+
仓库根目录的 `.agents/plugins/marketplace.json` 已配置为插件市场(市场名 `yueqiao`),指向仓库内的 `canvas-agent` 目录。把仓库推到公开 GitHub 后即可让 Codex 自动安装:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
codex plugin marketplace add https://github.com/<你的账号>/yueqiao.git
|
|
43
|
+
codex plugin add canvas-agent@yueqiao
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
插件方式会自动启动服务并带入连接信息,省去手动粘贴 Token。尚未推到 GitHub 时,用上面的 `open` 子命令或在网页手动连接即可。
|
|
47
|
+
|
|
48
|
+
## 本机配置与范围
|
|
49
|
+
|
|
50
|
+
`npx -y yueqiao-canvas-agent@latest config` 输出 `{url, token, origins}`,首次尚未启动时会提示先启动服务。HTTP `GET /config` 只返回 `{url, hasToken}`;Token 和包含它的连接链接不要分享。
|
|
51
|
+
|
|
52
|
+
`CANVAS_AGENT_TOKEN`、`CANVAS_AGENT_PORT`、`CANVAS_AGENT_ORIGINS` 可选环境变量覆盖 Token、端口和预置来源;更改后重启服务。通常无需设置。
|
|
53
|
+
|
|
54
|
+
外部 MCP 默认只操作唯一已连接的画布;多个画布连接时,通过 MCP 环境变量 `CANVAS_AGENT_CLIENT_ID` 明确目标。断开连接会结束对应 Codex 进程,已提交的生成任务仍由现有画布流程处理。
|
|
55
|
+
|
|
56
|
+
默认权限为 `workspace-write` 与 `on-request`,工作目录为实际 Agent 包目录。服务不写 Codex 全局配置文件;其他全局 MCP 是否合并到当前会话尚未联调确认。
|
package/codex.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const binary = join(dirname(require.resolve("@openai/codex/package.json")), "bin/codex.js");
|
|
8
|
+
|
|
9
|
+
export class CodexClient {
|
|
10
|
+
pending = new Map();
|
|
11
|
+
approvals = new Set();
|
|
12
|
+
nextId = 0;
|
|
13
|
+
stopped = false;
|
|
14
|
+
|
|
15
|
+
constructor(emit) {
|
|
16
|
+
this.emit = emit;
|
|
17
|
+
this.child = spawn(process.execPath, [binary, "app-server"], {
|
|
18
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
});
|
|
21
|
+
createInterface({ input: this.child.stdout }).on("line", (line) => {
|
|
22
|
+
try {
|
|
23
|
+
const message = JSON.parse(line);
|
|
24
|
+
if (message.method) {
|
|
25
|
+
if (message.id !== undefined) this.approvals.add(message.id);
|
|
26
|
+
if (message.method === "serverRequest/resolved") this.approvals.delete(message.params.requestId);
|
|
27
|
+
this.emit("rpc", message);
|
|
28
|
+
} else {
|
|
29
|
+
const pending = this.pending.get(message.id);
|
|
30
|
+
if (!pending) return;
|
|
31
|
+
clearTimeout(pending.timer);
|
|
32
|
+
this.pending.delete(message.id);
|
|
33
|
+
if (message.error) pending.reject(new Error(message.error.message));
|
|
34
|
+
else pending.resolve(message.result);
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
this.close(new Error("Codex 输出解析失败:" + error.message));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
this.child.stderr.resume();
|
|
41
|
+
this.child.stdin.on("error", (error) => this.close(error));
|
|
42
|
+
this.child.on("error", (error) => this.close(error));
|
|
43
|
+
this.child.on("exit", (code) => this.close(new Error("Codex 已退出:" + code)));
|
|
44
|
+
this.ready = this.request("initialize", {
|
|
45
|
+
clientInfo: { name: "yueqiao", title: "粤乔", version: "0.1.0" },
|
|
46
|
+
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
47
|
+
}).then(() => this.write({ method: "initialized" }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
write(message) {
|
|
51
|
+
if (this.stopped) throw new Error("Codex 已断开");
|
|
52
|
+
this.child.stdin.write(JSON.stringify(message) + "\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
request(method, params) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const id = ++this.nextId;
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
this.close(new Error("Codex 请求超时:" + method));
|
|
60
|
+
}, 90000);
|
|
61
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
62
|
+
try { this.write({ id, method, params }); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
this.pending.delete(id);
|
|
66
|
+
reject(error);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
reply(id, result, error) {
|
|
72
|
+
if (!this.approvals.delete(id)) return;
|
|
73
|
+
this.write(error ? { id, error } : { id, result });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
close(error = new Error("画布连接已关闭")) {
|
|
77
|
+
if (this.stopped) return;
|
|
78
|
+
this.stopped = true;
|
|
79
|
+
for (const pending of this.pending.values()) {
|
|
80
|
+
clearTimeout(pending.timer);
|
|
81
|
+
pending.reject(error);
|
|
82
|
+
}
|
|
83
|
+
this.pending.clear();
|
|
84
|
+
this.approvals.clear();
|
|
85
|
+
if (process.platform === "win32" && this.child.pid && this.child.exitCode === null && this.child.signalCode === null) {
|
|
86
|
+
spawn("taskkill", ["/PID", String(this.child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" })
|
|
87
|
+
.on("error", () => this.child.kill());
|
|
88
|
+
} else this.child.kill();
|
|
89
|
+
this.emit("rpc", { method: "bridge/disconnected", params: { message: error.message } });
|
|
90
|
+
}
|
|
91
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { CodexClient } from "./codex.mjs";
|
|
12
|
+
|
|
13
|
+
const entry = fileURLToPath(import.meta.url);
|
|
14
|
+
const configPath = resolve(homedir(), ".yueqiao", "codex-agent.json");
|
|
15
|
+
const readConfig = () => existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : null;
|
|
16
|
+
const savedConfig = readConfig();
|
|
17
|
+
const serviceId = savedConfig?.serviceId || randomUUID();
|
|
18
|
+
const mode = process.argv[2];
|
|
19
|
+
if (!savedConfig && ["config", "open"].includes(mode)) throw new Error("请先启动本地 Canvas Agent 服务");
|
|
20
|
+
const token = process.env.CANVAS_AGENT_TOKEN || savedConfig?.token || (mode === "mcp" ? "" : randomBytes(32).toString("hex"));
|
|
21
|
+
if (mode !== "mcp" && (typeof token !== "string" || token.length < 32)) throw new Error("本地连接 Token 至少需要 32 位字符");
|
|
22
|
+
const port = Number(process.env.CANVAS_AGENT_PORT || savedConfig?.port || 3210);
|
|
23
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("本地服务端口无效");
|
|
24
|
+
const endpoint = "http://127.0.0.1:" + port;
|
|
25
|
+
const workspace = dirname(entry);
|
|
26
|
+
const origins = new Set(process.env.CANVAS_AGENT_ORIGINS?.split(",").map((value) => value.trim()).filter(Boolean) || savedConfig?.origins || []);
|
|
27
|
+
const sessions = new Map();
|
|
28
|
+
const methods = new Set(["model/list", "thread/start", "thread/resume", "thread/archive", "turn/start", "turn/interrupt", "bridge/stop"]);
|
|
29
|
+
|
|
30
|
+
async function openCanvas() {
|
|
31
|
+
const target = URL.canParse(process.argv[3] || "") ? new URL(process.argv[3]) : null;
|
|
32
|
+
if (!target || !["http:", "https:"].includes(target.protocol) || target.username || target.password || !/^\/canvas\/[^/]+\/?$/.test(target.pathname)) throw new Error("请提供实际画布的 HTTP(S) 地址");
|
|
33
|
+
const browser = process.argv[4];
|
|
34
|
+
if (!["chrome", "edge", "firefox", "brave", "default"].includes(browser)) throw new Error("请指定原画布浏览器:chrome、edge、firefox、brave 或 default");
|
|
35
|
+
const params = new URLSearchParams(target.hash.slice(1));
|
|
36
|
+
params.set("agentUrl", endpoint);
|
|
37
|
+
params.set("agentToken", token);
|
|
38
|
+
target.hash = params.toString();
|
|
39
|
+
const { default: open, apps } = await import("open");
|
|
40
|
+
try {
|
|
41
|
+
await open(target.href, { app: { name: browser === "default" ? apps.browser : apps[browser], arguments: process.argv.slice(5) } });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error("打开浏览器失败,请检查原浏览器、配置和启动权限" + (error.code ? ":" + error.code : ""));
|
|
44
|
+
}
|
|
45
|
+
console.log("已请求浏览器打开原画布,等待 MCP 确认连接");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveConfig() {
|
|
49
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
50
|
+
writeFileSync(configPath, JSON.stringify({ url: endpoint, port, token, serviceId, origins: [...origins] }, null, 2) + "\n", { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findSession(clientId) {
|
|
54
|
+
if (!clientId) {
|
|
55
|
+
const connected = [...sessions.values()].filter((session) => session.events && session.canvasId);
|
|
56
|
+
if (connected.length !== 1) throw new Error("请只连接一个画布,或为 MCP 设置 CANVAS_AGENT_CLIENT_ID");
|
|
57
|
+
return connected[0];
|
|
58
|
+
}
|
|
59
|
+
const session = sessions.get(clientId);
|
|
60
|
+
if (!session) throw new Error("画布尚未连接");
|
|
61
|
+
return session;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function emit(session, event, data) {
|
|
65
|
+
session.events?.write("event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function closeSession(session) {
|
|
69
|
+
if (sessions.get(session.clientId) === session) sessions.delete(session.clientId);
|
|
70
|
+
for (const pending of session.toolsPending.values()) {
|
|
71
|
+
clearTimeout(pending.timer);
|
|
72
|
+
pending.reject(new Error("画布已断开,工具未完成"));
|
|
73
|
+
}
|
|
74
|
+
session.toolsPending.clear();
|
|
75
|
+
const events = session.events;
|
|
76
|
+
session.events = undefined;
|
|
77
|
+
session.codex?.close();
|
|
78
|
+
events?.end();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function callTool(session, name, args, source, generation, res) {
|
|
82
|
+
if (!session.events || !session.tools.some((tool) => tool.name === name)) throw new Error("画布未连接或工具不存在");
|
|
83
|
+
if (source === "codex" && (generation !== session.codexGeneration || !session.codex || session.codex.stopped)) throw new Error("Codex 请求已停止");
|
|
84
|
+
if (res.destroyed) throw new Error("画布工具请求已取消");
|
|
85
|
+
const requestId = randomUUID();
|
|
86
|
+
const cancel = (message) => {
|
|
87
|
+
const pending = session.toolsPending.get(requestId);
|
|
88
|
+
if (!pending) return;
|
|
89
|
+
clearTimeout(pending.timer);
|
|
90
|
+
session.toolsPending.delete(requestId);
|
|
91
|
+
emit(session, "tool-cancel", { requestId });
|
|
92
|
+
pending.reject(new Error(message));
|
|
93
|
+
};
|
|
94
|
+
const onClose = () => { if (!res.writableEnded) cancel("画布工具请求已取消"); };
|
|
95
|
+
res.once("close", onClose);
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const timer = setTimeout(() => cancel("画布工具执行超时"), 90000);
|
|
98
|
+
session.toolsPending.set(requestId, { resolve, reject, timer, source, cancel });
|
|
99
|
+
emit(session, "tool", { requestId, name, arguments: args, source, generation });
|
|
100
|
+
}).finally(() => res.off("close", onClose));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function runRpc(session, method, params = {}, generation) {
|
|
104
|
+
if (!methods.has(method)) throw new Error("不支持的 Codex 方法");
|
|
105
|
+
if (!session.events || !session.canvasId) throw new Error("请先连接画布并注册工具");
|
|
106
|
+
if (!Number.isSafeInteger(generation) || generation < session.codexGeneration) throw new Error("Codex 请求已停止");
|
|
107
|
+
if (generation > session.codexGeneration) {
|
|
108
|
+
session.codexGeneration = generation;
|
|
109
|
+
session.codex?.close();
|
|
110
|
+
}
|
|
111
|
+
if (method === "bridge/stop") return { ok: true };
|
|
112
|
+
if (!session.codex || session.codex.stopped) session.codex = new CodexClient((event, data) => {
|
|
113
|
+
if (data.method === "bridge/disconnected") {
|
|
114
|
+
if (generation === session.codexGeneration) session.codexGeneration += 1;
|
|
115
|
+
for (const pending of session.toolsPending.values()) if (pending.source === "codex") pending.cancel("Codex 请求已停止");
|
|
116
|
+
}
|
|
117
|
+
emit(session, event, { ...data, generation });
|
|
118
|
+
});
|
|
119
|
+
const codex = session.codex;
|
|
120
|
+
await codex.ready;
|
|
121
|
+
if (method === "thread/start" || method === "thread/resume") {
|
|
122
|
+
params = {
|
|
123
|
+
...params,
|
|
124
|
+
cwd: workspace,
|
|
125
|
+
approvalPolicy: "on-request",
|
|
126
|
+
sandbox: "workspace-write",
|
|
127
|
+
config: { mcp_servers: { "yueqiao": {
|
|
128
|
+
command: process.execPath,
|
|
129
|
+
args: [entry, "mcp"],
|
|
130
|
+
default_tools_approval_mode: "approve",
|
|
131
|
+
env: { CANVAS_AGENT_TOKEN: token, CANVAS_AGENT_PORT: String(port), CANVAS_AGENT_CLIENT_ID: session.clientId, CANVAS_AGENT_SOURCE: "codex", CANVAS_AGENT_CODEX_GENERATION: String(generation) },
|
|
132
|
+
startup_timeout_sec: 20,
|
|
133
|
+
tool_timeout_sec: 90,
|
|
134
|
+
} } },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (method === "turn/start") {
|
|
138
|
+
params = { ...params, approvalPolicy: "on-request", sandboxPolicy: { type: "workspaceWrite", networkAccess: false } };
|
|
139
|
+
}
|
|
140
|
+
return codex.request(method, params);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function startMcp() {
|
|
144
|
+
const clientId = process.env.CANVAS_AGENT_CLIENT_ID || "";
|
|
145
|
+
const source = process.env.CANVAS_AGENT_SOURCE === "codex" ? "codex" : "external";
|
|
146
|
+
const generation = Number(process.env.CANVAS_AGENT_CODEX_GENERATION || 0);
|
|
147
|
+
async function request(path, body, signal) {
|
|
148
|
+
const config = readConfig();
|
|
149
|
+
const requestToken = process.env.CANVAS_AGENT_TOKEN || config?.token;
|
|
150
|
+
if (!requestToken) throw new Error("请先启动本地 Canvas Agent 服务");
|
|
151
|
+
const url = "http://127.0.0.1:" + (process.env.CANVAS_AGENT_PORT || config?.port || 3210);
|
|
152
|
+
const response = await fetch(url + path, {
|
|
153
|
+
method: body ? "POST" : "GET",
|
|
154
|
+
headers: { "Content-Type": "application/json", "x-canvas-agent-token": requestToken },
|
|
155
|
+
body: body ? JSON.stringify({ ...body, clientId, source, generation }) : undefined,
|
|
156
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(95000)]) : AbortSignal.timeout(95000),
|
|
157
|
+
});
|
|
158
|
+
const value = await response.json();
|
|
159
|
+
if (!response.ok) throw new Error(value.error || "画布连接失败");
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
const server = new Server({ name: "yueqiao", version: "0.1.0" }, { capabilities: { tools: { listChanged: true } } });
|
|
163
|
+
const toolsPath = "/tools?clientId=" + encodeURIComponent(clientId);
|
|
164
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
165
|
+
try {
|
|
166
|
+
const tools = await request(toolsPath);
|
|
167
|
+
return { tools: source === "codex" ? tools : tools.filter(({ name }) => name !== "set_agent_state" && name !== "read_skill_file") };
|
|
168
|
+
} catch { return { tools: [] }; }
|
|
169
|
+
});
|
|
170
|
+
server.setRequestHandler(CallToolRequestSchema, async ({ params }, { signal }) => {
|
|
171
|
+
try {
|
|
172
|
+
const result = await request("/tools/call", { name: params.name, arguments: params.arguments || {} }, signal);
|
|
173
|
+
return { isError: result?.ok === false, content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { isError: true, content: [{ type: "text", text: error.message }] };
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
let retry;
|
|
179
|
+
let closed = false;
|
|
180
|
+
async function announceTools() {
|
|
181
|
+
try {
|
|
182
|
+
const tools = await request(toolsPath);
|
|
183
|
+
if (!tools.length) throw new Error("等待画布连接");
|
|
184
|
+
if (!closed) await server.notification({ method: "notifications/tools/list_changed" });
|
|
185
|
+
} catch {
|
|
186
|
+
if (!closed) retry = setTimeout(announceTools, 2000);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
server.oninitialized = () => { void announceTools(); };
|
|
190
|
+
server.onclose = () => { closed = true; clearTimeout(retry); };
|
|
191
|
+
await server.connect(new StdioServerTransport());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function startHttp() {
|
|
195
|
+
const app = express();
|
|
196
|
+
app.use((req, res, next) => {
|
|
197
|
+
if (!["127.0.0.1:" + port, "localhost:" + port].includes(req.headers.host)) return res.status(403).json({ error: "无效的本地地址" });
|
|
198
|
+
const origin = req.headers.origin;
|
|
199
|
+
if (origin) {
|
|
200
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
201
|
+
res.setHeader("Vary", "Origin");
|
|
202
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, x-canvas-agent-token");
|
|
203
|
+
res.setHeader("Access-Control-Expose-Headers", "x-canvas-agent-generation");
|
|
204
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
205
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
206
|
+
}
|
|
207
|
+
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
208
|
+
if (req.method === "GET" && req.path === "/config") return res.json({ url: endpoint, hasToken: Boolean(token) });
|
|
209
|
+
const credential = req.path === "/events" ? req.query.token : req.headers["x-canvas-agent-token"];
|
|
210
|
+
if (credential !== token) return res.status(401).json({ error: "Token 不正确" });
|
|
211
|
+
if (origin && !origins.has(origin)) {
|
|
212
|
+
origins.add(origin);
|
|
213
|
+
saveConfig();
|
|
214
|
+
}
|
|
215
|
+
next();
|
|
216
|
+
});
|
|
217
|
+
app.use(express.json({ limit: "20mb" }));
|
|
218
|
+
app.post("/connect", (req, res) => {
|
|
219
|
+
const { clientId, canvasId, tools } = req.body;
|
|
220
|
+
if (typeof clientId !== "string" || !clientId || typeof canvasId !== "string" || !canvasId || !Array.isArray(tools)) {
|
|
221
|
+
return res.status(400).json({ error: "连接参数不完整" });
|
|
222
|
+
}
|
|
223
|
+
const session = findSession(clientId);
|
|
224
|
+
if ((session.canvasId && session.canvasId !== canvasId) || session.origin !== req.headers.origin) {
|
|
225
|
+
return res.status(409).json({ error: "连接标识已被其他画布使用" });
|
|
226
|
+
}
|
|
227
|
+
session.canvasId = canvasId;
|
|
228
|
+
session.tools = tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
|
|
229
|
+
res.json({ serviceId });
|
|
230
|
+
});
|
|
231
|
+
app.get("/events", (req, res) => {
|
|
232
|
+
const clientId = req.query.clientId;
|
|
233
|
+
if (typeof clientId !== "string" || !clientId) return res.status(400).json({ error: "缺少连接标识" });
|
|
234
|
+
let session = sessions.get(clientId);
|
|
235
|
+
if (!session) {
|
|
236
|
+
session = { clientId, origin: req.headers.origin, tools: [], toolsPending: new Map(), codexGeneration: 0 };
|
|
237
|
+
sessions.set(clientId, session);
|
|
238
|
+
}
|
|
239
|
+
if (session.origin !== req.headers.origin) return res.status(403).json({ error: "连接来源不匹配" });
|
|
240
|
+
const previous = session.events;
|
|
241
|
+
session.events = res;
|
|
242
|
+
previous?.end();
|
|
243
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
244
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
245
|
+
res.setHeader("Connection", "keep-alive");
|
|
246
|
+
res.flushHeaders();
|
|
247
|
+
res.write(": connected\n\n");
|
|
248
|
+
const heartbeat = setInterval(() => res.write(": ping\n\n"), 15000);
|
|
249
|
+
res.on("close", () => {
|
|
250
|
+
clearInterval(heartbeat);
|
|
251
|
+
if (session.events === res) closeSession(session);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
app.post("/rpc", async (req, res) => {
|
|
255
|
+
const { clientId, method, params, generation } = req.body;
|
|
256
|
+
const session = findSession(clientId);
|
|
257
|
+
res.json(await runRpc(session, method, params, generation).finally(() => res.setHeader("x-canvas-agent-generation", String(session.codexGeneration))));
|
|
258
|
+
});
|
|
259
|
+
app.post("/reply", (req, res) => {
|
|
260
|
+
const { clientId, id, result, error, generation } = req.body;
|
|
261
|
+
const session = findSession(clientId);
|
|
262
|
+
if (generation === session.codexGeneration) session.codex?.reply(id, result, error);
|
|
263
|
+
res.json({ ok: true });
|
|
264
|
+
});
|
|
265
|
+
app.post("/result", (req, res) => {
|
|
266
|
+
const { clientId, requestId, result, error } = req.body;
|
|
267
|
+
const session = findSession(clientId);
|
|
268
|
+
const pending = session.toolsPending.get(requestId);
|
|
269
|
+
if (!pending) return res.status(409).json({ error: "工具请求已结束" });
|
|
270
|
+
clearTimeout(pending.timer);
|
|
271
|
+
session.toolsPending.delete(requestId);
|
|
272
|
+
if (error) pending.reject(new Error(error));
|
|
273
|
+
else pending.resolve(result);
|
|
274
|
+
res.json({ ok: true });
|
|
275
|
+
});
|
|
276
|
+
app.get("/tools", (req, res) => {
|
|
277
|
+
const session = findSession(req.query.clientId);
|
|
278
|
+
if (!session.canvasId) throw new Error("请先在网页连接画布");
|
|
279
|
+
res.json(session.tools);
|
|
280
|
+
});
|
|
281
|
+
app.post("/tools/call", async (req, res) => {
|
|
282
|
+
const { clientId, name, arguments: args, source, generation } = req.body;
|
|
283
|
+
res.json(await callTool(findSession(clientId), name, args, source === "codex" ? "codex" : "external", generation, res));
|
|
284
|
+
});
|
|
285
|
+
app.use((error, req, res, next) => {
|
|
286
|
+
if (res.destroyed) return;
|
|
287
|
+
if (res.headersSent) return next(error);
|
|
288
|
+
res.status(400).json({ error: error.message });
|
|
289
|
+
});
|
|
290
|
+
const server = app.listen(port, "127.0.0.1", () => {
|
|
291
|
+
saveConfig();
|
|
292
|
+
console.log("Local URL: " + endpoint);
|
|
293
|
+
console.log("Connect token: " + token);
|
|
294
|
+
});
|
|
295
|
+
server.on("error", (error) => {
|
|
296
|
+
console.error("Canvas Agent 启动失败:" + error.message);
|
|
297
|
+
process.exitCode = 1;
|
|
298
|
+
});
|
|
299
|
+
function shutdown() {
|
|
300
|
+
for (const session of sessions.values()) closeSession(session);
|
|
301
|
+
server.close();
|
|
302
|
+
}
|
|
303
|
+
process.on("SIGINT", shutdown);
|
|
304
|
+
process.on("SIGTERM", shutdown);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (mode === "config") console.log(JSON.stringify({ url: endpoint, token, origins: [...origins] }));
|
|
308
|
+
else if (mode === "open") await openCanvas();
|
|
309
|
+
else if (mode === "mcp") await startMcp();
|
|
310
|
+
else startHttp();
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yueqiao-canvas-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"canvas-agent": "index.mjs"
|
|
7
|
+
},
|
|
8
|
+
"files": ["index.mjs", "codex.mjs"],
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node index.mjs",
|
|
14
|
+
"codex": "codex"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
21
|
+
"@openai/codex": "0.153.4",
|
|
22
|
+
"express": "^5.1.0",
|
|
23
|
+
"open": "^11.0.1"
|
|
24
|
+
}
|
|
25
|
+
}
|