@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.
- package/README.md +21 -4
- package/agent-instructions.md +26 -0
- package/dist/agent/claude.d.ts +3 -0
- package/dist/agent/claude.js +46 -0
- package/dist/agent/codex-client.d.ts +73 -0
- package/dist/agent/codex-client.js +438 -0
- package/dist/agent/codex-history.d.ts +25 -0
- package/dist/agent/codex-history.js +405 -0
- package/dist/agent/codex-protocol.d.ts +208 -0
- package/dist/{agents.d.ts → agent/codex.d.ts} +34 -31
- package/dist/agent/codex.js +210 -0
- package/dist/agent/types.d.ts +14 -0
- package/dist/agent/types.js +1 -0
- package/dist/canvas/operations.d.ts +13 -0
- package/dist/canvas/operations.js +161 -0
- package/dist/{schemas.d.ts → canvas/schemas.d.ts} +21 -20
- package/dist/{schemas.js → canvas/schemas.js} +1 -0
- package/dist/{canvas-session.d.ts → canvas/session.d.ts} +21 -1
- package/dist/canvas/session.js +256 -0
- package/dist/{tools.d.ts → canvas/tools.d.ts} +13 -8
- package/dist/{tools.js → canvas/tools.js} +5 -0
- package/dist/{types.d.ts → canvas/types.d.ts} +1 -10
- package/dist/canvas/types.js +1 -0
- package/dist/config.d.ts +5 -1
- package/dist/config.js +22 -4
- package/dist/index.js +6 -3
- package/dist/server/ensure-http.d.ts +2 -0
- package/dist/server/ensure-http.js +28 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/{http-server.js → server/http.js} +130 -16
- package/dist/server/mcp.d.ts +2 -0
- package/dist/server/mcp.js +61 -0
- package/dist/utils/date.d.ts +2 -0
- package/dist/utils/date.js +7 -0
- package/dist/utils/logger.d.ts +17 -0
- package/dist/utils/logger.js +83 -0
- package/dist/utils/value.d.ts +5 -0
- package/dist/utils/value.js +8 -0
- package/dist/workflow/manager.d.ts +160 -0
- package/dist/workflow/manager.js +461 -0
- package/package.json +7 -4
- package/dist/agents.js +0 -557
- package/dist/canvas-session.js +0 -391
- package/dist/http-server.d.ts +0 -1
- package/dist/mcp-server.d.ts +0 -1
- package/dist/mcp-server.js +0 -24
- /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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 (
|
|
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(
|
|
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/
|
|
177
|
-
const
|
|
178
|
-
|
|
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(
|
|
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,
|
|
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,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,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,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 {};
|