@xiaohhhh1/canvas-agent 0.2.2 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +21 -4
  2. package/agent-instructions.md +26 -0
  3. package/dist/agent/claude.d.ts +3 -0
  4. package/dist/agent/claude.js +46 -0
  5. package/dist/agent/codex-client.d.ts +73 -0
  6. package/dist/agent/codex-client.js +438 -0
  7. package/dist/agent/codex-history.d.ts +25 -0
  8. package/dist/agent/codex-history.js +405 -0
  9. package/dist/agent/codex-protocol.d.ts +208 -0
  10. package/dist/{agents.d.ts → agent/codex.d.ts} +34 -31
  11. package/dist/agent/codex.js +210 -0
  12. package/dist/agent/types.d.ts +14 -0
  13. package/dist/agent/types.js +1 -0
  14. package/dist/canvas/operations.d.ts +13 -0
  15. package/dist/canvas/operations.js +161 -0
  16. package/dist/{schemas.d.ts → canvas/schemas.d.ts} +21 -20
  17. package/dist/{schemas.js → canvas/schemas.js} +1 -0
  18. package/dist/{canvas-session.d.ts → canvas/session.d.ts} +21 -1
  19. package/dist/canvas/session.js +256 -0
  20. package/dist/{tools.d.ts → canvas/tools.d.ts} +13 -8
  21. package/dist/{tools.js → canvas/tools.js} +5 -0
  22. package/dist/{types.d.ts → canvas/types.d.ts} +1 -10
  23. package/dist/canvas/types.js +1 -0
  24. package/dist/config.d.ts +5 -1
  25. package/dist/config.js +22 -4
  26. package/dist/index.js +6 -3
  27. package/dist/server/ensure-http.d.ts +2 -0
  28. package/dist/server/ensure-http.js +28 -0
  29. package/dist/server/http.d.ts +2 -0
  30. package/dist/{http-server.js → server/http.js} +130 -16
  31. package/dist/server/mcp.d.ts +2 -0
  32. package/dist/server/mcp.js +61 -0
  33. package/dist/utils/date.d.ts +2 -0
  34. package/dist/utils/date.js +7 -0
  35. package/dist/utils/logger.d.ts +17 -0
  36. package/dist/utils/logger.js +83 -0
  37. package/dist/utils/value.d.ts +5 -0
  38. package/dist/utils/value.js +8 -0
  39. package/dist/workflow/manager.d.ts +160 -0
  40. package/dist/workflow/manager.js +461 -0
  41. package/package.json +7 -4
  42. package/dist/agents.js +0 -557
  43. package/dist/canvas-session.js +0 -391
  44. package/dist/http-server.d.ts +0 -1
  45. package/dist/mcp-server.d.ts +0 -1
  46. package/dist/mcp-server.js +0 -24
  47. /package/dist/{types.js → agent/codex-protocol.js} +0 -0
@@ -1,27 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
1
4
  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";
5
+ import { runClaudeTurn } from "../agent/claude.js";
6
+ import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
7
+ import { CanvasSession } from "../canvas/session.js";
8
+ import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace } from "../config.js";
9
+ import { startRelayBridge } from "../relay-bridge.js";
10
+ import { logger } from "../utils/logger.js";
11
+ import { WorkflowManager } from "../workflow/manager.js";
12
+ /** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
6
13
  export function startHttpServer() {
7
14
  const config = loadConfig(true);
8
15
  const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
9
16
  config.url = `http://127.0.0.1:${port}`;
10
17
  saveConfig(config);
11
18
  const session = new CanvasSession();
19
+ /** 将 Agent 事件广播到所属线程或全部网页。 */
12
20
  const emit = (type, payload) => {
13
21
  const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload };
14
22
  const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
15
23
  threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
16
24
  };
25
+ /** 保存并广播当前站点工作空间的活跃线程。 */
17
26
  const setActiveThread = (activeThreadId, payload = {}) => {
18
27
  const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
19
28
  session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
20
29
  return workspace;
21
30
  };
31
+ const workflows = new WorkflowManager(config, emit);
22
32
  const app = express();
23
33
  app.disable("x-powered-by");
24
34
  app.use(express.json({ limit: "30mb" }));
35
+ app.use((req, res, next) => {
36
+ if (!logger.enabled)
37
+ return next();
38
+ const startedAt = Date.now();
39
+ const url = requestUrl(req, config);
40
+ res.on("finish", () => {
41
+ if (req.method === "OPTIONS" || (res.statusCode < 400 && ["/health", "/canvas/state", "/canvas/activate"].includes(url.pathname)))
42
+ return;
43
+ logger.debug(`HTTP ${req.method} ${url.pathname}`, { status: res.statusCode, durationMs: Date.now() - startedAt });
44
+ });
45
+ next();
46
+ });
25
47
  app.use((req, res, next) => {
26
48
  const url = requestUrl(req, config);
27
49
  if (!setCors(req, res, url, config))
@@ -58,7 +80,43 @@ export function startHttpServer() {
58
80
  res.setHeader("Cache-Control", "no-store");
59
81
  res.type(attachment.type).send(Buffer.from(data, "base64"));
60
82
  }));
83
+ app.post("/agent/local-file/reveal", route(async (req, res) => {
84
+ const filePath = String(req.body?.path || "");
85
+ if (!path.isAbsolute(filePath))
86
+ return res.status(400).json({ ok: false, error: "文件路径必须是绝对路径" });
87
+ const file = await stat(filePath);
88
+ await revealLocalFile(filePath, file.isDirectory());
89
+ res.json({ ok: true });
90
+ }));
91
+ app.post("/agent/local-image", route(async (req, res) => {
92
+ const filePath = String(req.body?.path || "");
93
+ if (!path.isAbsolute(filePath) || !/\.(?:avif|gif|jpe?g|png|webp)$/i.test(filePath))
94
+ return res.status(400).json({ ok: false, error: "图片路径无效" });
95
+ const file = await stat(filePath);
96
+ if (!file.isFile())
97
+ return res.status(400).json({ ok: false, error: "图片文件无效" });
98
+ res.setHeader("Cache-Control", "no-store");
99
+ res.type(path.extname(filePath)).send(await readFile(filePath));
100
+ }));
61
101
  app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
102
+ app.post("/agent/workflow/script-handoffs", route(async (req, res) => res.json({ ok: true, handoff: workflows.enqueueScript(req.body || {}) })));
103
+ app.get("/agent/workflow/script-handoffs/:handoffId", route(async (req, res) => res.json({ ok: true, handoff: workflows.scriptStatus(routeParam(req.params.handoffId)) })));
104
+ app.post("/agent/workflow/script-handoffs/:handoffId/retry", route(async (req, res) => res.json({ ok: true, handoff: workflows.retryScript(routeParam(req.params.handoffId)) })));
105
+ app.get("/agent/workflow/script-handoffs/:handoffId/task", route(async (req, res) => res.json({ ok: true, handoff: await workflows.scriptTask(routeParam(req.params.handoffId)) })));
106
+ app.post("/agent/workflow/script-handoffs/:handoffId/draft-chunks", route(async (req, res) => res.json({ ok: true, result: await workflows.submitScriptChunk(routeParam(req.params.handoffId), req.body?.jobs) })));
107
+ app.get("/agent/workflow/downloads", (_req, res) => res.json({ ok: true, ...workflows.downloadState() }));
108
+ app.post("/agent/workflow/downloads/select-directory", (_req, res) => res.json({ ok: true, selection: workflows.startDownloadDirectorySelection() }));
109
+ app.get("/agent/workflow/downloads/select-directory/:selectionId", route(async (req, res) => res.json({ ok: true, selection: workflows.downloadDirectorySelection(routeParam(req.params.selectionId)), ...workflows.downloadState() })));
110
+ app.post("/agent/workflow/downloads/clear-directory", (_req, res) => res.json({ ok: true, ...workflows.clearDownloadDirectory() }));
111
+ app.post("/agent/workflow/downloads/subscriptions", route(async (req, res) => res.json({ ok: true, subscription: workflows.subscribeDownload(req.body || {}) })));
112
+ app.post("/agent/workflow/downloads/:batchId/sync", route(async (req, res) => res.json({ ok: true, subscription: await workflows.syncDownload(routeParam(req.params.batchId)) })));
113
+ app.post("/agent/workflow/open", route(async (_req, res) => {
114
+ const url = new URL("https://canvas.xiaohhhh1.com/workflow-batches");
115
+ url.searchParams.set("agentUrl", config.url);
116
+ url.searchParams.set("agentToken", config.token);
117
+ await openExternalUrl(url.toString());
118
+ res.json({ ok: true });
119
+ }));
62
120
  app.get("/agent/codex/workspace", (_req, res) => {
63
121
  const workspace = ensureSiteWorkspace(config);
64
122
  res.json({ ok: true, workspace });
@@ -68,15 +126,20 @@ export function startHttpServer() {
68
126
  const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
69
127
  res.json({ ok: true, workspace, ...result });
70
128
  }));
71
- app.post("/agent/codex/threads/new", route(async (_req, res) => {
129
+ app.post("/agent/codex/threads/new", route(async (req, res) => {
72
130
  if (session.codexBusy)
73
131
  return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
74
132
  const workspace = ensureSiteWorkspace(config);
75
- const thread = await startCodexThread(emit, workspace.workspacePath);
133
+ const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
76
134
  const activeThreadId = String(thread.id || "");
77
135
  const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
78
136
  res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
79
137
  }));
138
+ app.post("/agent/codex/threads/reset", (req, res) => {
139
+ if (session.codexBusy)
140
+ return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
141
+ res.json({ ok: true, workspace: setActiveThread("", { emptyThread: true, draftThread: true }) });
142
+ });
80
143
  app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
81
144
  const workspace = ensureSiteWorkspace(config);
82
145
  const threadId = routeParam(req.params.threadId);
@@ -94,7 +157,7 @@ export function startHttpServer() {
94
157
  return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
95
158
  const workspace = ensureSiteWorkspace(config);
96
159
  const threadId = routeParam(req.params.threadId);
97
- const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
160
+ const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
98
161
  const nextWorkspace = setActiveThread(threadId);
99
162
  res.json({ ok: true, workspace: nextWorkspace, ...result });
100
163
  }));
@@ -116,12 +179,13 @@ export function startHttpServer() {
116
179
  if (!prompt.trim())
117
180
  return res.status(400).json({ ok: false, error: "请输入任务内容" });
118
181
  const clientId = String(req.body?.clientId || "");
182
+ logger.info("Codex turn accepted", { threadId: req.body?.threadId, promptLength: prompt.length, attachmentCount: attachments.length });
119
183
  session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
120
184
  try {
121
185
  let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
122
186
  let turnId = "";
123
187
  if (!threadId) {
124
- const thread = await startCodexThread(emit, workspace.workspacePath);
188
+ const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
125
189
  threadId = String(thread.id || "");
126
190
  setActiveThread(threadId, { emptyThread: true });
127
191
  }
@@ -135,13 +199,15 @@ export function startHttpServer() {
135
199
  message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
136
200
  };
137
201
  let chatThreadId = "";
202
+ /** 将当前 turn 事件固定广播到实际线程。 */
138
203
  const turnEmit = (type, payload) => {
139
204
  const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload };
140
- session.emitThread(type, threadId, data);
205
+ session.emitThread(type, threadId, { ...data, ...(turnId ? { turn_id: turnId } : {}) });
141
206
  };
142
- void runCodexTurn(withAgentPrompt(withAttachmentContext(prompt, attachmentRefs)), turnEmit, attachments, {
207
+ void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
143
208
  threadId,
144
209
  cwd: workspace.workspacePath,
210
+ permissionMode: permissionMode(req.body?.permissionMode),
145
211
  appEmit: emit,
146
212
  onStart: clientId ? () => session.bindClient(clientId) : undefined,
147
213
  onThread: (actualThreadId) => {
@@ -157,9 +223,11 @@ export function startHttpServer() {
157
223
  },
158
224
  onTurn: (actualTurnId) => {
159
225
  turnId = actualTurnId;
226
+ logger.info("Codex turn started", { threadId, turnId });
160
227
  session.setCodexState({ busy: true, threadId, turnId });
161
228
  },
162
229
  onFinish: () => {
230
+ logger.info("Codex turn finished", { threadId, turnId });
163
231
  session.clearTurnAttachments(clientId);
164
232
  if (clientId)
165
233
  session.releaseClient(clientId);
@@ -173,16 +241,23 @@ export function startHttpServer() {
173
241
  throw error;
174
242
  }
175
243
  }));
176
- app.post("/agent/codex/interrupt", (req, res) => {
177
- const ok = interruptCodexTurn(String(req.body?.threadId || ""));
178
- res.json({ ok });
179
- });
244
+ app.post("/agent/codex/approval", route(async (req, res) => {
245
+ const decision = String(req.body?.decision || "");
246
+ if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision))
247
+ return res.status(400).json({ ok: false, error: "无效的审批决定" });
248
+ const ok = await resolveCodexApproval(String(req.body?.requestId || ""), decision);
249
+ res.status(ok ? 200 : 409).json({ ok, ...(ok ? {} : { error: "审批请求已失效" }) });
250
+ }));
251
+ app.post("/agent/codex/interrupt", route(async (req, res) => res.json({ ok: await interruptCodexTurn(String(req.body?.threadId || "")) })));
180
252
  app.post("/agent/claude/turn", (req, res) => {
181
- runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
253
+ runClaudeTurn(String(req.body?.prompt || ""), emit);
182
254
  res.json({ ok: true });
183
255
  });
184
256
  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 }));
257
+ app.use((error, req, res, _next) => {
258
+ logger.error("HTTP request failed", { method: req.method, path: req.path, error });
259
+ res.status(500).json({ ok: false, error: error.message });
260
+ });
186
261
  app.listen(port, "127.0.0.1", () => {
187
262
  console.log("Infinite Canvas Agent");
188
263
  console.log(`Local URL: ${config.url}`);
@@ -191,17 +266,54 @@ export function startHttpServer() {
191
266
  console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @xiaohhhh1/canvas-agent mcp");
192
267
  console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
193
268
  startRelayBridge(config);
269
+ if (logger.enabled)
270
+ console.log(`Debug log: ${logger.filePath}`);
271
+ logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
194
272
  });
195
273
  }
274
+ /** 将异步 Express 路由异常交给统一错误处理中间件。 */
196
275
  function route(handler) {
197
276
  return (req, res, next) => void handler(req, res).catch(next);
198
277
  }
278
+ /** 从 Express 路由参数中读取单个字符串。 */
199
279
  function routeParam(value) {
200
280
  return Array.isArray(value) ? value[0] || "" : value;
201
281
  }
282
+ function permissionMode(value) {
283
+ return value === "automatic" || value === "full" ? value : "request";
284
+ }
285
+ /** 使用当前操作系统的文件管理器定位本地文件。 */
286
+ function revealLocalFile(filePath, isDirectory) {
287
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
288
+ const args = process.platform === "darwin"
289
+ ? ["-R", filePath]
290
+ : process.platform === "win32"
291
+ ? [isDirectory ? filePath : `/select,${filePath}`]
292
+ : [isDirectory ? filePath : path.dirname(filePath)];
293
+ return new Promise((resolve, reject) => {
294
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
295
+ child.once("spawn", () => {
296
+ child.unref();
297
+ resolve();
298
+ });
299
+ child.once("error", reject);
300
+ });
301
+ }
302
+ /** 在默认浏览器打开本机已经配对的网站;URL 内的连接参数会被网页立即移除。 */
303
+ function openExternalUrl(url) {
304
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32.exe" : "xdg-open";
305
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
306
+ return new Promise((resolve, reject) => {
307
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
308
+ child.once("spawn", () => { child.unref(); resolve(); });
309
+ child.once("error", reject);
310
+ });
311
+ }
312
+ /** 结合服务配置解析当前请求 URL。 */
202
313
  function requestUrl(req, config) {
203
314
  return new URL(req.originalUrl || req.url || "/", config.url);
204
315
  }
316
+ /** 设置跨域响应头并记录通过 token 授权的来源。 */
205
317
  function setCors(req, res, url, config) {
206
318
  const origin = req.headers.origin;
207
319
  res.setHeader("Access-Control-Allow-Origin", origin || "*");
@@ -218,10 +330,12 @@ function setCors(req, res, url, config) {
218
330
  res.setHeader("Vary", "Origin");
219
331
  return config.origins.includes(origin);
220
332
  }
333
+ /** 校验请求查询参数或请求头中的连接 token。 */
221
334
  function validToken(req, url, token) {
222
335
  const header = req.headers["x-canvas-agent-token"];
223
336
  return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
224
337
  }
338
+ /** 向 Agent 提示词追加本轮图片附件引用说明。 */
225
339
  function withAttachmentContext(prompt, attachments) {
226
340
  if (!attachments.length)
227
341
  return prompt;
@@ -0,0 +1,2 @@
1
+ /** 启动通过标准输入输出通信的 MCP 服务。 */
2
+ export declare function startMcpServer(): Promise<void>;
@@ -0,0 +1,61 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { toolDescriptions, toolInputSchemas, toolNames } from "../canvas/schemas.js";
5
+ import { AGENT_PROMPT, loadConfig, VERSION } from "../config.js";
6
+ /** 启动通过标准输入输出通信的 MCP 服务。 */
7
+ export async function startMcpServer() {
8
+ const config = loadConfig(true);
9
+ const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
10
+ toolNames.forEach((name) => registerCanvasTool(server, config, name));
11
+ registerWorkflowTools(server, config);
12
+ await server.connect(new StdioServerTransport());
13
+ }
14
+ /** 注册仅供本机 Codex 使用的 Flow C 交接工具,能力令牌始终留在本机 Agent。 */
15
+ function registerWorkflowTools(server, config) {
16
+ server.registerTool("open_flow_c_website", {
17
+ description: "在客户默认浏览器打开并安全连接抖音小辉跨境工具的带货任务页。连接密钥留在本机,不会返回给模型或要求客户复制。",
18
+ inputSchema: {},
19
+ }, async () => workflowTool(config, "/agent/workflow/open", { method: "POST" }));
20
+ server.registerTool("flow_c_get_script_task", {
21
+ description: "读取一个已经由网站安全交给本机 Agent 的 Flow C 脚本任务。返回完整内容规范、产品清单、图片顺序和数量;不会返回任何令牌。",
22
+ inputSchema: { handoffId: z.string().uuid().describe("网站创建的脚本交接 ID") },
23
+ }, async ({ handoffId }) => workflowTool(config, `/agent/workflow/script-handoffs/${encodeURIComponent(handoffId)}/task`, { method: "GET" }));
24
+ server.registerTool("flow_c_submit_script_chunk", {
25
+ description: "把本段独立完成的 Flow C 高质量脚本持久化回传给网站。每次 1–25 条;成功后再创作下一段。",
26
+ inputSchema: {
27
+ handoffId: z.string().uuid().describe("脚本交接 ID"),
28
+ jobs: z.array(z.object({
29
+ ordinal: z.number().int().positive(),
30
+ productIndex: z.number().int().nonnegative(),
31
+ sellingFormId: z.string().min(1).max(100),
32
+ script: z.string().min(40).max(20_000),
33
+ })).min(1).max(25),
34
+ },
35
+ }, async ({ handoffId, jobs }) => workflowTool(config, `/agent/workflow/script-handoffs/${encodeURIComponent(handoffId)}/draft-chunks`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jobs }) }));
36
+ }
37
+ async function workflowTool(config, route, init) {
38
+ const result = await fetchCanvasAgent(config, route, init);
39
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
40
+ }
41
+ /** 向 MCP Server 注册单个 Canvas Agent 工具。 */
42
+ function registerCanvasTool(server, config, name) {
43
+ const schema = toolInputSchemas[name];
44
+ server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input) => {
45
+ const result = await postCanvasAgentTool(config, name, schema.parse(input));
46
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
47
+ });
48
+ }
49
+ /** 将 MCP 工具调用转发到本地 Canvas Agent HTTP 服务。 */
50
+ async function postCanvasAgentTool(config, name, input) {
51
+ return await fetchCanvasAgent(config, "/api/tools", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, input }) });
52
+ }
53
+ async function fetchCanvasAgent(config, route, init) {
54
+ const headers = new Headers(init.headers);
55
+ headers.set("x-canvas-agent-token", config.token);
56
+ const res = await fetch(`${config.url}${route}`, { ...init, headers });
57
+ const body = (await res.json());
58
+ if (!body.ok)
59
+ throw new Error(body.error || "tool call failed");
60
+ return body.result ?? body;
61
+ }
@@ -0,0 +1,2 @@
1
+ /** 将日期格式化为适合文件名使用的本地日期字符串。 */
2
+ export declare function formatDateForFilename(date?: Date): string;
@@ -0,0 +1,7 @@
1
+ /** 将日期格式化为适合文件名使用的本地日期字符串。 */
2
+ export function formatDateForFilename(date = new Date()) {
3
+ const year = date.getFullYear();
4
+ const month = String(date.getMonth() + 1).padStart(2, "0");
5
+ const day = String(date.getDate()).padStart(2, "0");
6
+ return `${year}-${month}-${day}`;
7
+ }
@@ -0,0 +1,17 @@
1
+ /** 管理 Canvas Agent 的终端与文件 Debug 日志。 */
2
+ export declare class Logger {
3
+ readonly enabled: boolean;
4
+ readonly filePath: string;
5
+ private readonly logger;
6
+ /** 根据命令行 Debug 参数初始化日志输出。 */
7
+ constructor();
8
+ /** 输出 Debug 级别日志。 */
9
+ debug(message: string, details?: unknown): void;
10
+ /** 输出 Info 级别日志。 */
11
+ info(message: string, details?: unknown): void;
12
+ /** 输出 Warn 级别日志。 */
13
+ warn(message: string, details?: unknown): void;
14
+ /** 输出 Error 级别日志。 */
15
+ error(message: string, details?: unknown): void;
16
+ }
17
+ export declare const logger: Logger;
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { inspect } from "node:util";
5
+ import winston, { format, transports } from "winston";
6
+ import { formatDateForFilename } from "./date.js";
7
+ /** 管理 Canvas Agent 的终端与文件 Debug 日志。 */
8
+ export class Logger {
9
+ enabled = process.argv.includes("--debug");
10
+ filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
11
+ logger;
12
+ /** 根据命令行 Debug 参数初始化日志输出。 */
13
+ constructor() {
14
+ if (!this.enabled) {
15
+ this.logger = null;
16
+ return;
17
+ }
18
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
19
+ const line = format.printf(({ level, message, timestamp, details }) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
20
+ this.logger = winston.createLogger({
21
+ level: "debug",
22
+ transports: [
23
+ new transports.Console({ format: format.combine(format.timestamp({ format: "HH:mm:ss" }), line) }),
24
+ new transports.File({ filename: this.filePath, format: format.combine(format.timestamp({ format: "HH:mm:ss" }), line) }),
25
+ ],
26
+ });
27
+ }
28
+ /** 输出 Debug 级别日志。 */
29
+ debug(message, details) {
30
+ if (details === undefined)
31
+ this.logger?.debug(message);
32
+ else
33
+ this.logger?.debug(message, { details: sanitize(details) });
34
+ }
35
+ /** 输出 Info 级别日志。 */
36
+ info(message, details) {
37
+ if (details === undefined)
38
+ this.logger?.info(message);
39
+ else
40
+ this.logger?.info(message, { details: sanitize(details) });
41
+ }
42
+ /** 输出 Warn 级别日志。 */
43
+ warn(message, details) {
44
+ if (details === undefined)
45
+ this.logger?.warn(message);
46
+ else
47
+ this.logger?.warn(message, { details: sanitize(details) });
48
+ }
49
+ /** 输出 Error 级别日志。 */
50
+ error(message, details) {
51
+ if (details === undefined)
52
+ this.logger?.error(message);
53
+ else
54
+ this.logger?.error(message, { details: sanitize(details) });
55
+ }
56
+ }
57
+ /** 将日志详情格式化为紧凑的单行文本。 */
58
+ function formatDetails(details) {
59
+ if (details === undefined)
60
+ return "";
61
+ if (!details || typeof details !== "object" || Array.isArray(details))
62
+ return ` ${inspect(details, { depth: null, breakLength: Infinity })}`;
63
+ const text = Object.entries(details).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${inspect(value, { depth: null, breakLength: Infinity })}`).join(" ");
64
+ return text ? ` ${text}` : "";
65
+ }
66
+ /** 清理日志内容中的敏感数据和不可序列化引用。 */
67
+ function sanitize(value, key = "", seen = new WeakSet()) {
68
+ if (/token|authorization|api.?key|dataurl/i.test(key))
69
+ return "[REDACTED]";
70
+ if (typeof value === "string" && value.startsWith("data:"))
71
+ return `[DATA URL ${value.length} chars]`;
72
+ if (value instanceof Error)
73
+ return { name: value.name, message: value.message, stack: value.stack };
74
+ if (!value || typeof value !== "object")
75
+ return value;
76
+ if (seen.has(value))
77
+ return "[CIRCULAR]";
78
+ seen.add(value);
79
+ if (Array.isArray(value))
80
+ return value.map((item) => sanitize(item, key, seen));
81
+ return Object.fromEntries(Object.entries(value).map(([field, item]) => [field, sanitize(item, field, seen)]));
82
+ }
83
+ export const logger = new Logger();
@@ -0,0 +1,5 @@
1
+ export type JsonRecord = Record<string, unknown>;
2
+ /** 安全读取未知对象中的指定字段。 */
3
+ export declare function field(value: unknown, key: string): unknown;
4
+ /** 将未知异常转换为可展示的错误信息。 */
5
+ export declare function errorMessage(error: unknown): string;
@@ -0,0 +1,8 @@
1
+ /** 安全读取未知对象中的指定字段。 */
2
+ export function field(value, key) {
3
+ return value && typeof value === "object" ? value[key] : undefined;
4
+ }
5
+ /** 将未知异常转换为可展示的错误信息。 */
6
+ export function errorMessage(error) {
7
+ return error instanceof Error ? error.message : String(error);
8
+ }
@@ -0,0 +1,160 @@
1
+ import type { AgentEmit } from "../agent/types.js";
2
+ import { type CanvasAgentConfig } from "../config.js";
3
+ type ScriptStatus = "queued" | "running" | "complete" | "error" | "expired";
4
+ type DownloadStatus = "waiting" | "running" | "complete" | "error" | "expired";
5
+ type ScriptTask = {
6
+ id: string;
7
+ workflow: "flow-c";
8
+ market: string;
9
+ requested_count: number;
10
+ product_quantities: number[];
11
+ instructions: string;
12
+ expires_at: string;
13
+ };
14
+ /** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
15
+ export declare class WorkflowManager {
16
+ private config;
17
+ private emit;
18
+ private state;
19
+ private runningScripts;
20
+ private scriptQueueRunning;
21
+ private syncingDownloads;
22
+ private directorySelection?;
23
+ private downloadTimer?;
24
+ constructor(config: CanvasAgentConfig, emit: AgentEmit);
25
+ /** 接收网站创建的短期交接能力并立即启动本机 Codex。 */
26
+ enqueueScript(input: {
27
+ id?: unknown;
28
+ apiBase?: unknown;
29
+ accessToken?: unknown;
30
+ expiresAt?: unknown;
31
+ }): {
32
+ id: string;
33
+ status: ScriptStatus;
34
+ requestedCount: number;
35
+ received: number;
36
+ threadId: string | undefined;
37
+ message: string | undefined;
38
+ expiresAt: string | undefined;
39
+ updatedAt: string;
40
+ };
41
+ retryScript(idValue: unknown): {
42
+ id: string;
43
+ status: ScriptStatus;
44
+ requestedCount: number;
45
+ received: number;
46
+ threadId: string | undefined;
47
+ message: string | undefined;
48
+ expiresAt: string | undefined;
49
+ updatedAt: string;
50
+ };
51
+ scriptStatus(idValue: unknown): {
52
+ id: string;
53
+ status: ScriptStatus;
54
+ requestedCount: number;
55
+ received: number;
56
+ threadId: string | undefined;
57
+ message: string | undefined;
58
+ expiresAt: string | undefined;
59
+ updatedAt: string;
60
+ };
61
+ /** MCP 读取服务端持久化的完整任务,不向模型暴露令牌。 */
62
+ scriptTask(idValue: unknown): Promise<ScriptTask>;
63
+ /** MCP 分段回传脚本,中心接口再次执行数量、产品索引和脚本完整性校验。 */
64
+ submitScriptChunk(idValue: unknown, jobsValue: unknown): Promise<{
65
+ accepted: number;
66
+ received: number;
67
+ requestedCount: number;
68
+ status: string;
69
+ } & {
70
+ error?: string;
71
+ }>;
72
+ downloadState(): {
73
+ configured: boolean;
74
+ directoryName: string | undefined;
75
+ subscriptions: {
76
+ batchId: string;
77
+ status: DownloadStatus;
78
+ downloaded: number;
79
+ market: string | undefined;
80
+ message: string | undefined;
81
+ expiresAt: string | undefined;
82
+ updatedAt: string;
83
+ }[];
84
+ };
85
+ startDownloadDirectorySelection(): {
86
+ id: string;
87
+ status: "selecting" | "selected" | "cancelled" | "error";
88
+ directoryName?: string;
89
+ error?: string;
90
+ } | {
91
+ id: `${string}-${string}-${string}-${string}-${string}`;
92
+ status: "selecting";
93
+ };
94
+ downloadDirectorySelection(idValue: unknown): {
95
+ id: string;
96
+ status: "selecting" | "selected" | "cancelled" | "error";
97
+ directoryName?: string;
98
+ error?: string;
99
+ };
100
+ clearDownloadDirectory(): {
101
+ configured: boolean;
102
+ directoryName: string | undefined;
103
+ subscriptions: {
104
+ batchId: string;
105
+ status: DownloadStatus;
106
+ downloaded: number;
107
+ market: string | undefined;
108
+ message: string | undefined;
109
+ expiresAt: string | undefined;
110
+ updatedAt: string;
111
+ }[];
112
+ };
113
+ subscribeDownload(input: {
114
+ batchId?: unknown;
115
+ apiBase?: unknown;
116
+ accessToken?: unknown;
117
+ expiresAt?: unknown;
118
+ }): {
119
+ batchId: string;
120
+ status: DownloadStatus;
121
+ downloaded: number;
122
+ market: string | undefined;
123
+ message: string | undefined;
124
+ expiresAt: string | undefined;
125
+ updatedAt: string;
126
+ };
127
+ syncDownload(batchIdValue?: unknown): Promise<{
128
+ batchId: string;
129
+ status: DownloadStatus;
130
+ downloaded: number;
131
+ market: string | undefined;
132
+ message: string | undefined;
133
+ expiresAt: string | undefined;
134
+ updatedAt: string;
135
+ } | {
136
+ configured: boolean;
137
+ directoryName: string | undefined;
138
+ subscriptions: {
139
+ batchId: string;
140
+ status: DownloadStatus;
141
+ downloaded: number;
142
+ market: string | undefined;
143
+ message: string | undefined;
144
+ expiresAt: string | undefined;
145
+ updatedAt: string;
146
+ }[];
147
+ }>;
148
+ private scheduleScript;
149
+ /** 同一台电脑串行处理脚本交接,避免多个大批次争用一个 Codex app-server。 */
150
+ private pumpScriptQueue;
151
+ private finishDownloadDirectorySelection;
152
+ private runScript;
153
+ private syncDownloads;
154
+ private syncDownloadRecord;
155
+ private saveDelivery;
156
+ private scriptRecord;
157
+ private downloadRecord;
158
+ private save;
159
+ }
160
+ export {};