@xiaohhhh1/canvas-agent 0.2.2

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.
@@ -0,0 +1,230 @@
1
+ import express from "express";
2
+ import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace } from "./config.js";
3
+ import { CanvasSession } from "./canvas-session.js";
4
+ import { startRelayBridge } from "./relay-bridge.js";
5
+ import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
6
+ export function startHttpServer() {
7
+ const config = loadConfig(true);
8
+ const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
9
+ config.url = `http://127.0.0.1:${port}`;
10
+ saveConfig(config);
11
+ const session = new CanvasSession();
12
+ const emit = (type, payload) => {
13
+ const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload };
14
+ const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
15
+ threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
16
+ };
17
+ const setActiveThread = (activeThreadId, payload = {}) => {
18
+ const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
19
+ session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
20
+ return workspace;
21
+ };
22
+ const app = express();
23
+ app.disable("x-powered-by");
24
+ app.use(express.json({ limit: "30mb" }));
25
+ app.use((req, res, next) => {
26
+ const url = requestUrl(req, config);
27
+ if (!setCors(req, res, url, config))
28
+ return void res.status(403).json({ ok: false, error: "origin not allowed" });
29
+ if (req.method === "OPTIONS")
30
+ return void res.json({});
31
+ next();
32
+ });
33
+ app.get("/health", (_req, res) => res.json(session.health()));
34
+ app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
35
+ app.use((req, res, next) => {
36
+ if (validToken(req, requestUrl(req, config), config.token))
37
+ return next();
38
+ res.status(401).json({ ok: false, error: "invalid token" });
39
+ });
40
+ app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
41
+ app.post("/canvas/state", (req, res) => {
42
+ session.updateState(req.body, String(req.query.clientId || "") || undefined);
43
+ res.json({ ok: true });
44
+ });
45
+ app.post("/canvas/activate", (req, res) => {
46
+ session.activateClient(String(req.query.clientId || ""));
47
+ res.json({ ok: true });
48
+ });
49
+ app.post("/canvas/result", (req, res) => {
50
+ const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
51
+ res.status(ok ? 200 : 409).json({ ok });
52
+ });
53
+ app.get("/agent/attachments/:attachmentId", route(async (req, res) => {
54
+ const attachment = session.getTurnAttachment(String(req.query.clientId || ""), routeParam(req.params.attachmentId));
55
+ const data = attachment.dataUrl.split(",", 2)[1];
56
+ if (!data)
57
+ throw new Error("图片附件内容无效");
58
+ res.setHeader("Cache-Control", "no-store");
59
+ res.type(attachment.type).send(Buffer.from(data, "base64"));
60
+ }));
61
+ app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
62
+ app.get("/agent/codex/workspace", (_req, res) => {
63
+ const workspace = ensureSiteWorkspace(config);
64
+ res.json({ ok: true, workspace });
65
+ });
66
+ app.get("/agent/codex/threads", route(async (req, res) => {
67
+ const workspace = ensureSiteWorkspace(config);
68
+ const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
69
+ res.json({ ok: true, workspace, ...result });
70
+ }));
71
+ app.post("/agent/codex/threads/new", route(async (_req, res) => {
72
+ if (session.codexBusy)
73
+ return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
74
+ const workspace = ensureSiteWorkspace(config);
75
+ const thread = await startCodexThread(emit, workspace.workspacePath);
76
+ const activeThreadId = String(thread.id || "");
77
+ const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
78
+ res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
79
+ }));
80
+ app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
81
+ const workspace = ensureSiteWorkspace(config);
82
+ const threadId = routeParam(req.params.threadId);
83
+ try {
84
+ res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
85
+ }
86
+ catch (error) {
87
+ if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error))
88
+ throw error;
89
+ res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
90
+ }
91
+ }));
92
+ app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
93
+ if (session.codexBusy)
94
+ return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
95
+ const workspace = ensureSiteWorkspace(config);
96
+ const threadId = routeParam(req.params.threadId);
97
+ const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
98
+ const nextWorkspace = setActiveThread(threadId);
99
+ res.json({ ok: true, workspace: nextWorkspace, ...result });
100
+ }));
101
+ app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
102
+ if (session.codexBusy)
103
+ return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
104
+ const workspace = ensureSiteWorkspace(config);
105
+ const threadId = routeParam(req.params.threadId);
106
+ await archiveCodexThread(emit, threadId, workspace.workspacePath);
107
+ setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
108
+ res.json({ ok: true });
109
+ }));
110
+ app.post("/agent/codex/turn", route(async (req, res) => {
111
+ if (session.codexBusy)
112
+ return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
113
+ const attachments = Array.isArray(req.body?.attachments) ? req.body.attachments : [];
114
+ const workspace = ensureSiteWorkspace(config);
115
+ const prompt = String(req.body?.prompt || "");
116
+ if (!prompt.trim())
117
+ return res.status(400).json({ ok: false, error: "请输入任务内容" });
118
+ const clientId = String(req.body?.clientId || "");
119
+ session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
120
+ try {
121
+ let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
122
+ let turnId = "";
123
+ if (!threadId) {
124
+ const thread = await startCodexThread(emit, workspace.workspacePath);
125
+ threadId = String(thread.id || "");
126
+ setActiveThread(threadId, { emptyThread: true });
127
+ }
128
+ else if (threadId !== workspace.activeThreadId) {
129
+ await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
130
+ setActiveThread(threadId);
131
+ }
132
+ const attachmentRefs = session.setTurnAttachments(clientId, attachments);
133
+ const chatMessage = {
134
+ sourceClientId: clientId,
135
+ message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
136
+ };
137
+ let chatThreadId = "";
138
+ const turnEmit = (type, payload) => {
139
+ const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload };
140
+ session.emitThread(type, threadId, data);
141
+ };
142
+ void runCodexTurn(withAgentPrompt(withAttachmentContext(prompt, attachmentRefs)), turnEmit, attachments, {
143
+ threadId,
144
+ cwd: workspace.workspacePath,
145
+ appEmit: emit,
146
+ onStart: clientId ? () => session.bindClient(clientId) : undefined,
147
+ onThread: (actualThreadId) => {
148
+ if (actualThreadId !== threadId) {
149
+ threadId = actualThreadId;
150
+ setActiveThread(threadId, { emptyThread: true });
151
+ }
152
+ session.setCodexState({ busy: true, threadId, turnId: "" });
153
+ if (chatThreadId !== threadId) {
154
+ chatThreadId = threadId;
155
+ session.emitThread("chat_message", threadId, chatMessage);
156
+ }
157
+ },
158
+ onTurn: (actualTurnId) => {
159
+ turnId = actualTurnId;
160
+ session.setCodexState({ busy: true, threadId, turnId });
161
+ },
162
+ onFinish: () => {
163
+ session.clearTurnAttachments(clientId);
164
+ if (clientId)
165
+ session.releaseClient(clientId);
166
+ session.setCodexState({ busy: false, threadId, turnId });
167
+ },
168
+ });
169
+ res.json({ ok: true, threadId });
170
+ }
171
+ catch (error) {
172
+ session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
173
+ throw error;
174
+ }
175
+ }));
176
+ app.post("/agent/codex/interrupt", (req, res) => {
177
+ const ok = interruptCodexTurn(String(req.body?.threadId || ""));
178
+ res.json({ ok });
179
+ });
180
+ app.post("/agent/claude/turn", (req, res) => {
181
+ runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
182
+ res.json({ ok: true });
183
+ });
184
+ app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
185
+ app.use((error, _req, res, _next) => res.status(500).json({ ok: false, error: error.message }));
186
+ app.listen(port, "127.0.0.1", () => {
187
+ console.log("Infinite Canvas Agent");
188
+ console.log(`Local URL: ${config.url}`);
189
+ console.log(`Connect token: ${config.token}`);
190
+ console.log("Codex MCP is not installed by this command.");
191
+ console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @xiaohhhh1/canvas-agent mcp");
192
+ console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
193
+ startRelayBridge(config);
194
+ });
195
+ }
196
+ function route(handler) {
197
+ return (req, res, next) => void handler(req, res).catch(next);
198
+ }
199
+ function routeParam(value) {
200
+ return Array.isArray(value) ? value[0] || "" : value;
201
+ }
202
+ function requestUrl(req, config) {
203
+ return new URL(req.originalUrl || req.url || "/", config.url);
204
+ }
205
+ function setCors(req, res, url, config) {
206
+ const origin = req.headers.origin;
207
+ res.setHeader("Access-Control-Allow-Origin", origin || "*");
208
+ res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
209
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
210
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
211
+ if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config")
212
+ return true;
213
+ config.origins ||= [];
214
+ if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
215
+ config.origins.push(origin);
216
+ saveConfig(config);
217
+ }
218
+ res.setHeader("Vary", "Origin");
219
+ return config.origins.includes(origin);
220
+ }
221
+ function validToken(req, url, token) {
222
+ const header = req.headers["x-canvas-agent-token"];
223
+ return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
224
+ }
225
+ function withAttachmentContext(prompt, attachments) {
226
+ if (!attachments.length)
227
+ return prompt;
228
+ const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
229
+ return `${prompt}\n\n本轮可用图片附件(顺序与图片输入一致):\n${list}\n需要把附件放入画布或作为生成参考图时,先调用 canvas_create_attachment_nodes,再使用返回的画布节点 ID 创建生成流程。`;
230
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { startHttpServer } from "./http-server.js";
3
+ import { startMcpServer } from "./mcp-server.js";
4
+ if (process.argv[2] === "mcp")
5
+ await startMcpServer();
6
+ else
7
+ startHttpServer();
@@ -0,0 +1 @@
1
+ export declare function startMcpServer(): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { AGENT_PROMPT, loadConfig, VERSION } from "./config.js";
4
+ import { toolDescriptions, toolInputSchemas, toolNames } from "./schemas.js";
5
+ export async function startMcpServer() {
6
+ const config = loadConfig(true);
7
+ const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
8
+ toolNames.forEach((name) => registerCanvasTool(server, config, name));
9
+ await server.connect(new StdioServerTransport());
10
+ }
11
+ function registerCanvasTool(server, config, name) {
12
+ const schema = toolInputSchemas[name];
13
+ server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input) => {
14
+ const result = await postCanvasAgentTool(config, name, schema.parse(input));
15
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
16
+ });
17
+ }
18
+ async function postCanvasAgentTool(config, name, input) {
19
+ 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 }) });
20
+ const body = (await res.json());
21
+ if (!body.ok)
22
+ throw new Error(body.error || "tool call failed");
23
+ return body.result;
24
+ }
@@ -0,0 +1,7 @@
1
+ import type { CanvasAgentConfig } from "./config.js";
2
+ /**
3
+ * Keeps an outbound, encrypted connection to the production relay. The canvas
4
+ * browser can then use same-origin requests instead of directly reaching a
5
+ * loopback HTTP address, which Chromium clients can block before CORS runs.
6
+ */
7
+ export declare function startRelayBridge(config: CanvasAgentConfig): () => void;
@@ -0,0 +1,126 @@
1
+ import WebSocket from "ws";
2
+ const DEFAULT_RELAY_URL = "wss://canvas.xiaohhhh1.com/api/agent-relay";
3
+ const RECONNECT_DELAY_MS = 3_000;
4
+ /**
5
+ * Keeps an outbound, encrypted connection to the production relay. The canvas
6
+ * browser can then use same-origin requests instead of directly reaching a
7
+ * loopback HTTP address, which Chromium clients can block before CORS runs.
8
+ */
9
+ export function startRelayBridge(config) {
10
+ const relayUrl = process.env.CANVAS_AGENT_RELAY_URL || DEFAULT_RELAY_URL;
11
+ const subscriptions = new Map();
12
+ let socket = null;
13
+ let stopped = false;
14
+ let reconnectTimer = null;
15
+ const send = (message) => {
16
+ if (socket?.readyState === WebSocket.OPEN)
17
+ socket.send(JSON.stringify(message));
18
+ };
19
+ const stopSubscription = (clientId) => {
20
+ subscriptions.get(clientId)?.abort();
21
+ subscriptions.delete(clientId);
22
+ };
23
+ const startSubscription = (clientId) => {
24
+ stopSubscription(clientId);
25
+ const controller = new AbortController();
26
+ subscriptions.set(clientId, controller);
27
+ void pipeEvents(clientId, config, controller.signal, send).finally(() => {
28
+ if (subscriptions.get(clientId) === controller)
29
+ subscriptions.delete(clientId);
30
+ });
31
+ };
32
+ const handleRequest = async (message) => {
33
+ try {
34
+ const target = new URL(message.path, config.url);
35
+ if (target.origin !== new URL(config.url).origin)
36
+ throw new Error("relay request target is invalid");
37
+ const headers = new Headers(message.headers || {});
38
+ headers.set("x-canvas-agent-token", config.token);
39
+ const body = message.bodyBase64 ? Buffer.from(message.bodyBase64, "base64") : undefined;
40
+ const response = await fetch(target, { method: message.method || "GET", headers, body });
41
+ const bytes = Buffer.from(await response.arrayBuffer());
42
+ send({
43
+ type: "response",
44
+ id: message.id,
45
+ status: response.status,
46
+ contentType: response.headers.get("content-type") || "application/octet-stream",
47
+ bodyBase64: bytes.toString("base64"),
48
+ });
49
+ }
50
+ catch (error) {
51
+ send({ type: "response", id: message.id, status: 502, contentType: "application/json", bodyBase64: Buffer.from(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : "Agent relay request failed" })).toString("base64") });
52
+ }
53
+ };
54
+ const onMessage = (raw) => {
55
+ try {
56
+ const message = JSON.parse(raw.toString());
57
+ if (message.type === "request")
58
+ void handleRequest(message);
59
+ if (message.type === "subscribe")
60
+ startSubscription(message.clientId);
61
+ if (message.type === "unsubscribe")
62
+ stopSubscription(message.clientId);
63
+ }
64
+ catch {
65
+ // Ignore malformed relay messages. The relay never receives a secret in an error response.
66
+ }
67
+ };
68
+ const connect = () => {
69
+ if (stopped)
70
+ return;
71
+ try {
72
+ socket = new WebSocket(relayUrl);
73
+ socket.on("open", () => send({ type: "hello", role: "agent", token: config.token }));
74
+ socket.on("message", onMessage);
75
+ socket.on("close", () => {
76
+ subscriptions.forEach((controller) => controller.abort());
77
+ subscriptions.clear();
78
+ if (!stopped)
79
+ reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
80
+ });
81
+ socket.on("error", () => undefined);
82
+ }
83
+ catch {
84
+ reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
85
+ }
86
+ };
87
+ connect();
88
+ return () => {
89
+ stopped = true;
90
+ if (reconnectTimer)
91
+ clearTimeout(reconnectTimer);
92
+ subscriptions.forEach((controller) => controller.abort());
93
+ subscriptions.clear();
94
+ socket?.close();
95
+ };
96
+ }
97
+ async function pipeEvents(clientId, config, signal, send) {
98
+ const eventsUrl = new URL("/events", config.url);
99
+ eventsUrl.searchParams.set("clientId", clientId);
100
+ const response = await fetch(eventsUrl, { headers: { "x-canvas-agent-token": config.token }, signal });
101
+ if (!response.ok || !response.body)
102
+ throw new Error("Unable to open local Agent event stream");
103
+ const reader = response.body.getReader();
104
+ const decoder = new TextDecoder();
105
+ let buffer = "";
106
+ while (!signal.aborted) {
107
+ const { done, value } = await reader.read();
108
+ if (done)
109
+ break;
110
+ buffer += decoder.decode(value, { stream: true });
111
+ let end = buffer.indexOf("\n\n");
112
+ while (end >= 0) {
113
+ const block = buffer.slice(0, end);
114
+ buffer = buffer.slice(end + 2);
115
+ const event = block.match(/^event:\s*(.+)$/m)?.[1] || "message";
116
+ const data = block
117
+ .split("\n")
118
+ .filter((line) => line.startsWith("data:"))
119
+ .map((line) => line.slice(5).trimStart())
120
+ .join("\n");
121
+ if (data)
122
+ send({ type: "event", clientId, event, data });
123
+ end = buffer.indexOf("\n\n");
124
+ }
125
+ }
126
+ }