@xiaohhhh1/canvas-agent 0.3.0 → 0.4.1
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 +13 -4
- package/agent-instructions.md +5 -0
- package/dist/index.js +4 -1
- package/dist/server/ensure-http.d.ts +2 -0
- package/dist/server/ensure-http.js +28 -0
- package/dist/server/http.js +32 -1
- package/dist/server/mcp.js +36 -2
- package/dist/utils/windows.d.ts +5 -0
- package/dist/utils/windows.js +14 -0
- package/dist/workflow/manager.d.ts +160 -0
- package/dist/workflow/manager.js +462 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Infinite Canvas Agent
|
|
2
2
|
|
|
3
|
-
本地 Canvas Agent
|
|
3
|
+
本地 Canvas Agent 用来连接网站和用户自己电脑上的 Codex。它还负责持久化 Flow C 脚本队列、分段调用本机 Codex、自动回传草案,以及在网页关闭后继续把完成视频校验并保存到用户选择的磁盘目录。
|
|
4
4
|
|
|
5
5
|
## 启动
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ npx -y @xiaohhhh1/canvas-agent
|
|
|
11
11
|
需要排查连接、线程、Codex app-server 或工具调用问题时,可开启 Debug 模式:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npx -y @
|
|
14
|
+
npx -y @xiaohhhh1/canvas-agent --debug
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Debug 日志会以 `[DEBUG][HH:mm:ss]` 等传统格式输出到终端,并按启动日期保存到 `~/.infinite-canvas/logs/canvas-agent-YYYY-MM-DD.log`。终端日志带级别颜色,文件日志为纯文本;日志包含 HTTP、SSE、线程、turn、Codex app-server 和工具调用事件,token 与图片 Data URL 会自动隐藏。
|
|
@@ -74,13 +74,22 @@ codex plugin marketplace add "$(pwd)"
|
|
|
74
74
|
codex plugin add infinite-canvas@infinite-canvas-local
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
插件默认通过 npm 启动 MCP
|
|
77
|
+
插件默认通过 npm 启动 MCP。MCP 启动时会自动检查并拉起常驻本机 HTTP/中继服务,因此客户安装插件后不需要另开终端:
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
80
|
npx -y @xiaohhhh1/canvas-agent mcp
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
使用时可以直接打开 `https://canvas.xiaohhhh1.com/workflow-batches`。网站通过已认证的 Agent Relay 与本机助手通信,不把本机目录、Codex 登录信息或短期任务令牌显示给客户。
|
|
84
|
+
|
|
85
|
+
## Flow C 本机后台工作流
|
|
86
|
+
|
|
87
|
+
1. 客户在网站添加一个或多个产品,按顺序上传每个产品 1–5 张图片并填写数量。
|
|
88
|
+
2. 点击“交给本机 Codex 写全部脚本”。本机助手每次只处理 10 条并持久化进度,断网或重启后可继续;脚本完成会自动回传网站,不创建付费任务。
|
|
89
|
+
3. 客户审阅脚本并确认费用后,中心 `workflow-runner` 才执行故事板和视频生成。
|
|
90
|
+
4. 客户只需首次选择一个本机目录。Agent 每 10 秒检查交付,先写 `.part`,校验大小与 SHA-256 后原子改名;清单已记录的批次序号不会重复下载,网页关闭后仍会继续。
|
|
91
|
+
|
|
92
|
+
本机持久状态默认保存在 `~/.infinite-canvas/workflow-state.json`,权限设为仅当前用户可读写。这里包含短期能力令牌和本机路径,不应上传、提交到 Git 或发到聊天中。
|
|
84
93
|
|
|
85
94
|
Canvas Agent 启动后,给 Codex 添加 MCP:
|
|
86
95
|
|
package/agent-instructions.md
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
## 流程 C:脚本分镜到视频
|
|
16
16
|
|
|
17
|
+
- 用户要求打开带货任务时,调用 `open_flow_c_website`,不要输出或询问 Local URL、连接令牌。
|
|
18
|
+
- 网站把脚本任务交给本机 Codex 后,使用 `flow_c_get_script_task` 读取完整、服务器持久化的任务,再用 `flow_c_submit_script_chunk` 每次回传 1–25 条。不要要求用户复制 JSON、选择交接文件或把短期令牌发到聊天中。
|
|
19
|
+
- 大批量脚本按本机助手指定的序号分段创作;批量大小不能成为降低质量、缩短脚本或套用模板的理由。每条都必须独立构思、符合目标市场自然语言习惯、保持紧凑但清晰可说完的 10 秒节奏。
|
|
20
|
+
- 写脚本和回传草案不创建付费任务。只有客户在网站审阅并确认费用后,中心服务才可开始故事板和视频生成。
|
|
21
|
+
|
|
17
22
|
- 用户明确选择流程 C 时,必须新建独立画布、清单和归档目录,不得混用流程 A 或 B 的资产。
|
|
18
23
|
- 先使用原始同 SKU 商品图和完整脚本生成 9:16、10 秒导演分镜板;每镜必须包含时长、画面/镜头、对应配音、音效/BGM 节奏和情绪,并附专业技术规格栏。
|
|
19
24
|
- 原始商品图是唯一商品标准:每一格都必须保持颜色、外形、文字或 Logo、比例、部件和数量一致。分镜通过逐格检查前,严禁提交视频任务。
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { startHttpServer } from "./server/http.js";
|
|
3
|
+
import { ensureHttpServer } from "./server/ensure-http.js";
|
|
3
4
|
import { startMcpServer } from "./server/mcp.js";
|
|
4
|
-
if (process.argv[2] === "mcp")
|
|
5
|
+
if (process.argv[2] === "mcp") {
|
|
6
|
+
await ensureHttpServer();
|
|
5
7
|
await startMcpServer();
|
|
8
|
+
}
|
|
6
9
|
else
|
|
7
10
|
startHttpServer();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
/** 插件 MCP 启动时确保本机 HTTP/后台队列服务常驻,无需客户另开终端。 */
|
|
4
|
+
export async function ensureHttpServer() {
|
|
5
|
+
const config = loadConfig(true);
|
|
6
|
+
if (await healthy(config.url))
|
|
7
|
+
return;
|
|
8
|
+
const entry = process.argv[1];
|
|
9
|
+
if (!entry)
|
|
10
|
+
throw new Error("无法定位 Canvas Agent 启动文件");
|
|
11
|
+
const child = spawn(process.execPath, [entry, "serve"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
12
|
+
child.unref();
|
|
13
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
14
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
15
|
+
if (await healthy(config.url))
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
throw new Error("本机 Canvas Agent 后台服务启动失败");
|
|
19
|
+
}
|
|
20
|
+
async function healthy(url) {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(new URL("/health", url), { signal: AbortSignal.timeout(800) });
|
|
23
|
+
return response.ok;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/server/http.js
CHANGED
|
@@ -8,6 +8,8 @@ import { CanvasSession } from "../canvas/session.js";
|
|
|
8
8
|
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace } from "../config.js";
|
|
9
9
|
import { startRelayBridge } from "../relay-bridge.js";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
|
+
import { windowsRootExecutable, windowsSystemExecutable } from "../utils/windows.js";
|
|
12
|
+
import { WorkflowManager } from "../workflow/manager.js";
|
|
11
13
|
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
|
12
14
|
export function startHttpServer() {
|
|
13
15
|
const config = loadConfig(true);
|
|
@@ -27,6 +29,7 @@ export function startHttpServer() {
|
|
|
27
29
|
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
|
28
30
|
return workspace;
|
|
29
31
|
};
|
|
32
|
+
const workflows = new WorkflowManager(config, emit);
|
|
30
33
|
const app = express();
|
|
31
34
|
app.disable("x-powered-by");
|
|
32
35
|
app.use(express.json({ limit: "30mb" }));
|
|
@@ -97,6 +100,24 @@ export function startHttpServer() {
|
|
|
97
100
|
res.type(path.extname(filePath)).send(await readFile(filePath));
|
|
98
101
|
}));
|
|
99
102
|
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
|
103
|
+
app.post("/agent/workflow/script-handoffs", route(async (req, res) => res.json({ ok: true, handoff: workflows.enqueueScript(req.body || {}) })));
|
|
104
|
+
app.get("/agent/workflow/script-handoffs/:handoffId", route(async (req, res) => res.json({ ok: true, handoff: workflows.scriptStatus(routeParam(req.params.handoffId)) })));
|
|
105
|
+
app.post("/agent/workflow/script-handoffs/:handoffId/retry", route(async (req, res) => res.json({ ok: true, handoff: workflows.retryScript(routeParam(req.params.handoffId)) })));
|
|
106
|
+
app.get("/agent/workflow/script-handoffs/:handoffId/task", route(async (req, res) => res.json({ ok: true, handoff: await workflows.scriptTask(routeParam(req.params.handoffId)) })));
|
|
107
|
+
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) })));
|
|
108
|
+
app.get("/agent/workflow/downloads", (_req, res) => res.json({ ok: true, ...workflows.downloadState() }));
|
|
109
|
+
app.post("/agent/workflow/downloads/select-directory", (_req, res) => res.json({ ok: true, selection: workflows.startDownloadDirectorySelection() }));
|
|
110
|
+
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() })));
|
|
111
|
+
app.post("/agent/workflow/downloads/clear-directory", (_req, res) => res.json({ ok: true, ...workflows.clearDownloadDirectory() }));
|
|
112
|
+
app.post("/agent/workflow/downloads/subscriptions", route(async (req, res) => res.json({ ok: true, subscription: workflows.subscribeDownload(req.body || {}) })));
|
|
113
|
+
app.post("/agent/workflow/downloads/:batchId/sync", route(async (req, res) => res.json({ ok: true, subscription: await workflows.syncDownload(routeParam(req.params.batchId)) })));
|
|
114
|
+
app.post("/agent/workflow/open", route(async (_req, res) => {
|
|
115
|
+
const url = new URL("https://canvas.xiaohhhh1.com/workflow-batches");
|
|
116
|
+
url.searchParams.set("agentUrl", config.url);
|
|
117
|
+
url.searchParams.set("agentToken", config.token);
|
|
118
|
+
await openExternalUrl(url.toString());
|
|
119
|
+
res.json({ ok: true });
|
|
120
|
+
}));
|
|
100
121
|
app.get("/agent/codex/workspace", (_req, res) => {
|
|
101
122
|
const workspace = ensureSiteWorkspace(config);
|
|
102
123
|
res.json({ ok: true, workspace });
|
|
@@ -264,7 +285,7 @@ function permissionMode(value) {
|
|
|
264
285
|
}
|
|
265
286
|
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
|
266
287
|
function revealLocalFile(filePath, isDirectory) {
|
|
267
|
-
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
288
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? windowsRootExecutable("explorer.exe") : "xdg-open";
|
|
268
289
|
const args = process.platform === "darwin"
|
|
269
290
|
? ["-R", filePath]
|
|
270
291
|
: process.platform === "win32"
|
|
@@ -279,6 +300,16 @@ function revealLocalFile(filePath, isDirectory) {
|
|
|
279
300
|
child.once("error", reject);
|
|
280
301
|
});
|
|
281
302
|
}
|
|
303
|
+
/** 在默认浏览器打开本机已经配对的网站;URL 内的连接参数会被网页立即移除。 */
|
|
304
|
+
function openExternalUrl(url) {
|
|
305
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? windowsSystemExecutable("rundll32.exe") : "xdg-open";
|
|
306
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
309
|
+
child.once("spawn", () => { child.unref(); resolve(); });
|
|
310
|
+
child.once("error", reject);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
282
313
|
/** 结合服务配置解析当前请求 URL。 */
|
|
283
314
|
function requestUrl(req, config) {
|
|
284
315
|
return new URL(req.originalUrl || req.url || "/", config.url);
|
package/dist/server/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { toolDescriptions, toolInputSchemas, toolNames } from "../canvas/schemas.js";
|
|
4
5
|
import { AGENT_PROMPT, loadConfig, VERSION } from "../config.js";
|
|
5
6
|
/** 启动通过标准输入输出通信的 MCP 服务。 */
|
|
@@ -7,8 +8,36 @@ export async function startMcpServer() {
|
|
|
7
8
|
const config = loadConfig(true);
|
|
8
9
|
const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
|
|
9
10
|
toolNames.forEach((name) => registerCanvasTool(server, config, name));
|
|
11
|
+
registerWorkflowTools(server, config);
|
|
10
12
|
await server.connect(new StdioServerTransport());
|
|
11
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
|
+
}
|
|
12
41
|
/** 向 MCP Server 注册单个 Canvas Agent 工具。 */
|
|
13
42
|
function registerCanvasTool(server, config, name) {
|
|
14
43
|
const schema = toolInputSchemas[name];
|
|
@@ -19,9 +48,14 @@ function registerCanvasTool(server, config, name) {
|
|
|
19
48
|
}
|
|
20
49
|
/** 将 MCP 工具调用转发到本地 Canvas Agent HTTP 服务。 */
|
|
21
50
|
async function postCanvasAgentTool(config, name, input) {
|
|
22
|
-
|
|
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 });
|
|
23
57
|
const body = (await res.json());
|
|
24
58
|
if (!body.ok)
|
|
25
59
|
throw new Error(body.error || "tool call failed");
|
|
26
|
-
return body.result;
|
|
60
|
+
return body.result ?? body;
|
|
27
61
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type WindowsEnvironment = NodeJS.ProcessEnv;
|
|
2
|
+
export declare function windowsRootExecutable(name: string, env?: WindowsEnvironment): string;
|
|
3
|
+
export declare function windowsSystemExecutable(name: string, env?: WindowsEnvironment): string;
|
|
4
|
+
export declare function windowsPowerShellExecutable(env?: WindowsEnvironment): string;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** Resolve Windows inbox executables without relying on a possibly restricted PATH. */
|
|
3
|
+
function windowsRoot(env = process.env) {
|
|
4
|
+
return env.SystemRoot || env.SYSTEMROOT || env.WINDIR || "C:\\Windows";
|
|
5
|
+
}
|
|
6
|
+
export function windowsRootExecutable(name, env = process.env) {
|
|
7
|
+
return path.win32.join(windowsRoot(env), name);
|
|
8
|
+
}
|
|
9
|
+
export function windowsSystemExecutable(name, env = process.env) {
|
|
10
|
+
return path.win32.join(windowsRoot(env), "System32", name);
|
|
11
|
+
}
|
|
12
|
+
export function windowsPowerShellExecutable(env = process.env) {
|
|
13
|
+
return path.win32.join(windowsRoot(env), "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
14
|
+
}
|
|
@@ -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 {};
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { mkdir, open, rename, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { Readable, Transform } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { runCodexTurn, startCodexThread } from "../agent/codex.js";
|
|
9
|
+
import { CONFIG_DIR, ensureSiteWorkspace } from "../config.js";
|
|
10
|
+
import { logger } from "../utils/logger.js";
|
|
11
|
+
import { windowsPowerShellExecutable } from "../utils/windows.js";
|
|
12
|
+
const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
|
|
13
|
+
const SCRIPT_CHUNK_SIZE = 10;
|
|
14
|
+
const DOWNLOAD_POLL_MS = 10_000;
|
|
15
|
+
/** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
|
|
16
|
+
export class WorkflowManager {
|
|
17
|
+
config;
|
|
18
|
+
emit;
|
|
19
|
+
state = loadState();
|
|
20
|
+
runningScripts = new Set();
|
|
21
|
+
scriptQueueRunning = false;
|
|
22
|
+
syncingDownloads = false;
|
|
23
|
+
directorySelection;
|
|
24
|
+
downloadTimer;
|
|
25
|
+
constructor(config, emit) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.emit = emit;
|
|
28
|
+
for (const record of Object.values(this.state.scripts)) {
|
|
29
|
+
if (record.status === "queued" || record.status === "running")
|
|
30
|
+
this.scheduleScript(record.id);
|
|
31
|
+
}
|
|
32
|
+
this.downloadTimer = setInterval(() => void this.syncDownloads(), DOWNLOAD_POLL_MS);
|
|
33
|
+
this.downloadTimer.unref?.();
|
|
34
|
+
void this.syncDownloads();
|
|
35
|
+
}
|
|
36
|
+
/** 接收网站创建的短期交接能力并立即启动本机 Codex。 */
|
|
37
|
+
enqueueScript(input) {
|
|
38
|
+
const id = workflowId(input.id, "脚本交接 ID");
|
|
39
|
+
const apiBase = commerceApiBase(input.apiBase);
|
|
40
|
+
const accessToken = secret(input.accessToken, "脚本交接令牌");
|
|
41
|
+
const previous = this.state.scripts[id];
|
|
42
|
+
this.state.scripts[id] = {
|
|
43
|
+
id,
|
|
44
|
+
apiBase,
|
|
45
|
+
accessToken,
|
|
46
|
+
status: previous?.status === "complete" ? "complete" : "queued",
|
|
47
|
+
requestedCount: previous?.requestedCount || 0,
|
|
48
|
+
receivedOrdinals: previous?.receivedOrdinals || [],
|
|
49
|
+
threadId: previous?.threadId,
|
|
50
|
+
expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
|
|
51
|
+
attempts: previous?.attempts || 0,
|
|
52
|
+
message: previous?.status === "complete" ? previous.message : "已进入本机 Codex 队列",
|
|
53
|
+
updatedAt: now(),
|
|
54
|
+
};
|
|
55
|
+
this.save();
|
|
56
|
+
this.scheduleScript(id);
|
|
57
|
+
return this.scriptStatus(id);
|
|
58
|
+
}
|
|
59
|
+
retryScript(idValue) {
|
|
60
|
+
const id = workflowId(idValue, "脚本交接 ID");
|
|
61
|
+
const record = this.scriptRecord(id);
|
|
62
|
+
record.status = "queued";
|
|
63
|
+
record.message = "正在重新连接本机 Codex";
|
|
64
|
+
record.updatedAt = now();
|
|
65
|
+
this.save();
|
|
66
|
+
this.scheduleScript(id);
|
|
67
|
+
return this.scriptStatus(id);
|
|
68
|
+
}
|
|
69
|
+
scriptStatus(idValue) {
|
|
70
|
+
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
71
|
+
return publicScript(record);
|
|
72
|
+
}
|
|
73
|
+
/** MCP 读取服务端持久化的完整任务,不向模型暴露令牌。 */
|
|
74
|
+
async scriptTask(idValue) {
|
|
75
|
+
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
76
|
+
const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token");
|
|
77
|
+
const task = data.handoff;
|
|
78
|
+
record.requestedCount = Number(task.requested_count || 0);
|
|
79
|
+
record.expiresAt = task.expires_at;
|
|
80
|
+
record.updatedAt = now();
|
|
81
|
+
this.save();
|
|
82
|
+
return task;
|
|
83
|
+
}
|
|
84
|
+
/** MCP 分段回传脚本,中心接口再次执行数量、产品索引和脚本完整性校验。 */
|
|
85
|
+
async submitScriptChunk(idValue, jobsValue) {
|
|
86
|
+
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
87
|
+
if (!Array.isArray(jobsValue) || !jobsValue.length || jobsValue.length > 25)
|
|
88
|
+
throw new Error("每段必须包含 1–25 条脚本");
|
|
89
|
+
const jobs = jobsValue;
|
|
90
|
+
const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/draft-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ jobs }) });
|
|
91
|
+
const accepted = new Set(record.receivedOrdinals);
|
|
92
|
+
for (const job of jobs)
|
|
93
|
+
if (Number.isInteger(Number(job?.ordinal)))
|
|
94
|
+
accepted.add(Number(job.ordinal));
|
|
95
|
+
record.receivedOrdinals = [...accepted].sort((a, b) => a - b);
|
|
96
|
+
record.requestedCount = Number(data.requestedCount || record.requestedCount);
|
|
97
|
+
record.status = data.status === "ready" ? "complete" : "running";
|
|
98
|
+
record.message = data.status === "ready" ? `全部 ${record.requestedCount} 条高质量脚本已回传` : `已回传 ${data.received}/${data.requestedCount} 条脚本`;
|
|
99
|
+
record.updatedAt = now();
|
|
100
|
+
this.save();
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
downloadState() {
|
|
104
|
+
return {
|
|
105
|
+
configured: Boolean(this.state.downloadDirectory),
|
|
106
|
+
directoryName: this.state.downloadDirectory ? path.basename(this.state.downloadDirectory) : undefined,
|
|
107
|
+
subscriptions: Object.values(this.state.downloads).map(publicDownload),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
startDownloadDirectorySelection() {
|
|
111
|
+
if (this.directorySelection?.status === "selecting")
|
|
112
|
+
return this.directorySelection;
|
|
113
|
+
const selection = { id: randomUUID(), status: "selecting" };
|
|
114
|
+
this.directorySelection = selection;
|
|
115
|
+
void this.finishDownloadDirectorySelection(selection.id);
|
|
116
|
+
return selection;
|
|
117
|
+
}
|
|
118
|
+
downloadDirectorySelection(idValue) {
|
|
119
|
+
const id = workflowId(idValue, "文件夹选择 ID");
|
|
120
|
+
if (!this.directorySelection || this.directorySelection.id !== id)
|
|
121
|
+
throw new Error("文件夹选择任务不存在");
|
|
122
|
+
return this.directorySelection;
|
|
123
|
+
}
|
|
124
|
+
clearDownloadDirectory() {
|
|
125
|
+
delete this.state.downloadDirectory;
|
|
126
|
+
this.save();
|
|
127
|
+
return this.downloadState();
|
|
128
|
+
}
|
|
129
|
+
subscribeDownload(input) {
|
|
130
|
+
const batchId = workflowId(input.batchId, "视频批次 ID");
|
|
131
|
+
const previous = this.state.downloads[batchId];
|
|
132
|
+
this.state.downloads[batchId] = {
|
|
133
|
+
batchId,
|
|
134
|
+
apiBase: commerceApiBase(input.apiBase),
|
|
135
|
+
accessToken: secret(input.accessToken, "下载能力令牌"),
|
|
136
|
+
status: previous?.status === "complete" ? "complete" : "waiting",
|
|
137
|
+
downloadedOrdinals: previous?.downloadedOrdinals || [],
|
|
138
|
+
market: previous?.market,
|
|
139
|
+
expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
|
|
140
|
+
message: this.state.downloadDirectory ? "等待视频完成" : "请先选择本机保存文件夹",
|
|
141
|
+
updatedAt: now(),
|
|
142
|
+
};
|
|
143
|
+
this.save();
|
|
144
|
+
void this.syncDownloads();
|
|
145
|
+
return publicDownload(this.state.downloads[batchId]);
|
|
146
|
+
}
|
|
147
|
+
async syncDownload(batchIdValue) {
|
|
148
|
+
const batchId = batchIdValue ? workflowId(batchIdValue, "视频批次 ID") : undefined;
|
|
149
|
+
await this.syncDownloads(batchId);
|
|
150
|
+
return batchId ? publicDownload(this.downloadRecord(batchId)) : this.downloadState();
|
|
151
|
+
}
|
|
152
|
+
scheduleScript(_id) {
|
|
153
|
+
queueMicrotask(() => void this.pumpScriptQueue());
|
|
154
|
+
}
|
|
155
|
+
/** 同一台电脑串行处理脚本交接,避免多个大批次争用一个 Codex app-server。 */
|
|
156
|
+
async pumpScriptQueue() {
|
|
157
|
+
if (this.scriptQueueRunning)
|
|
158
|
+
return;
|
|
159
|
+
this.scriptQueueRunning = true;
|
|
160
|
+
try {
|
|
161
|
+
while (true) {
|
|
162
|
+
const next = Object.values(this.state.scripts)
|
|
163
|
+
.filter((record) => (record.status === "queued" || record.status === "running") && !this.runningScripts.has(record.id))
|
|
164
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))[0];
|
|
165
|
+
if (!next)
|
|
166
|
+
break;
|
|
167
|
+
await this.runScript(next.id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this.scriptQueueRunning = false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async finishDownloadDirectorySelection(id) {
|
|
175
|
+
try {
|
|
176
|
+
const selected = await selectNativeDirectory();
|
|
177
|
+
if (!selected) {
|
|
178
|
+
this.directorySelection = { id, status: "cancelled" };
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const resolved = path.resolve(selected);
|
|
182
|
+
await mkdir(resolved, { recursive: true });
|
|
183
|
+
const info = await stat(resolved);
|
|
184
|
+
if (!info.isDirectory())
|
|
185
|
+
throw new Error("选择的路径不是文件夹");
|
|
186
|
+
this.state.downloadDirectory = resolved;
|
|
187
|
+
this.save();
|
|
188
|
+
this.directorySelection = { id, status: "selected", directoryName: path.basename(resolved) };
|
|
189
|
+
void this.syncDownloads();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
this.directorySelection = { id, status: "error", error: error instanceof Error ? error.message : "无法选择本机文件夹" };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async runScript(id) {
|
|
196
|
+
if (this.runningScripts.has(id))
|
|
197
|
+
return;
|
|
198
|
+
this.runningScripts.add(id);
|
|
199
|
+
const record = this.scriptRecord(id);
|
|
200
|
+
try {
|
|
201
|
+
record.status = "running";
|
|
202
|
+
record.message = "正在读取完整产品清单";
|
|
203
|
+
record.updatedAt = now();
|
|
204
|
+
this.save();
|
|
205
|
+
const task = await this.scriptTask(id);
|
|
206
|
+
if (Date.parse(task.expires_at) <= Date.now())
|
|
207
|
+
throw new ExpiredCapabilityError("脚本交接已过期,请在网页重新点击交给本机 Codex");
|
|
208
|
+
const workspace = ensureSiteWorkspace(this.config);
|
|
209
|
+
if (!record.threadId) {
|
|
210
|
+
const thread = await startCodexThread(this.emit, workspace.workspacePath, "full");
|
|
211
|
+
record.threadId = String(thread.id || "");
|
|
212
|
+
this.save();
|
|
213
|
+
}
|
|
214
|
+
while (record.receivedOrdinals.length < task.requested_count) {
|
|
215
|
+
const missing = missingOrdinals(task.requested_count, record.receivedOrdinals).slice(0, SCRIPT_CHUNK_SIZE);
|
|
216
|
+
if (!missing.length)
|
|
217
|
+
break;
|
|
218
|
+
let progressed = false;
|
|
219
|
+
for (let attempt = 1; attempt <= 3 && !progressed; attempt += 1) {
|
|
220
|
+
const before = record.receivedOrdinals.length;
|
|
221
|
+
record.attempts += 1;
|
|
222
|
+
record.message = `本机 Codex 正在写第 ${missing[0]}–${missing.at(-1)} 条(总计 ${task.requested_count} 条)`;
|
|
223
|
+
record.updatedAt = now();
|
|
224
|
+
this.save();
|
|
225
|
+
await runCodexTurn(scriptChunkPrompt(id, task, missing), this.emit, [], { threadId: record.threadId, cwd: workspace.workspacePath, permissionMode: "full", onThread: (threadId) => { record.threadId = threadId; this.save(); } });
|
|
226
|
+
progressed = record.receivedOrdinals.length > before;
|
|
227
|
+
if (!progressed && attempt < 3)
|
|
228
|
+
record.message = `第 ${missing[0]}–${missing.at(-1)} 条未成功回传,正在自动重试 ${attempt + 1}/3`;
|
|
229
|
+
}
|
|
230
|
+
if (!progressed)
|
|
231
|
+
throw new Error(`本机 Codex 连续 3 次未回传第 ${missing[0]}–${missing.at(-1)} 条,请点击重试`);
|
|
232
|
+
}
|
|
233
|
+
if (record.receivedOrdinals.length >= task.requested_count) {
|
|
234
|
+
record.status = "complete";
|
|
235
|
+
record.message = `全部 ${task.requested_count} 条高质量脚本已回传`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
record.status = error instanceof ExpiredCapabilityError ? "expired" : "error";
|
|
240
|
+
record.message = error instanceof Error ? error.message : "本机 Codex 处理失败";
|
|
241
|
+
logger.warn("Local Flow C script handoff paused", { handoffId: id, error: record.message });
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
record.updatedAt = now();
|
|
245
|
+
this.save();
|
|
246
|
+
this.runningScripts.delete(id);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async syncDownloads(onlyBatchId) {
|
|
250
|
+
if (this.syncingDownloads || !this.state.downloadDirectory)
|
|
251
|
+
return;
|
|
252
|
+
this.syncingDownloads = true;
|
|
253
|
+
try {
|
|
254
|
+
const records = Object.values(this.state.downloads).filter((record) => !onlyBatchId || record.batchId === onlyBatchId);
|
|
255
|
+
for (const record of records)
|
|
256
|
+
await this.syncDownloadRecord(record);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
this.syncingDownloads = false;
|
|
260
|
+
this.save();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async syncDownloadRecord(record) {
|
|
264
|
+
if (record.expiresAt && Date.parse(record.expiresAt) <= Date.now()) {
|
|
265
|
+
record.status = "expired";
|
|
266
|
+
record.message = "下载授权已过期;打开网站后会自动续期";
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
record.status = "running";
|
|
271
|
+
const data = await commerceJson(`${record.apiBase}/workflow-downloads/${encodeURIComponent(record.batchId)}`, record.accessToken, "x-workflow-download-token");
|
|
272
|
+
const batch = data.batch;
|
|
273
|
+
record.market = batch.market;
|
|
274
|
+
for (const delivery of batch.deliveries || []) {
|
|
275
|
+
await this.saveDelivery(batch, delivery);
|
|
276
|
+
if (!record.downloadedOrdinals.includes(delivery.ordinal))
|
|
277
|
+
record.downloadedOrdinals.push(delivery.ordinal);
|
|
278
|
+
}
|
|
279
|
+
record.downloadedOrdinals.sort((a, b) => a - b);
|
|
280
|
+
const finished = ["completed", "cancelled", "failed"].includes(batch.status);
|
|
281
|
+
record.status = finished && record.downloadedOrdinals.length >= (batch.deliveries?.length || 0) ? "complete" : "waiting";
|
|
282
|
+
record.message = batch.deliveries?.length
|
|
283
|
+
? `已自动保存 ${record.downloadedOrdinals.length} 条视频;${finished ? "本批次已结束" : "继续等待新视频"}`
|
|
284
|
+
: finished ? "批次已结束,暂无可下载视频" : "等待视频完成";
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
record.status = /expired|not found/i.test(error instanceof Error ? error.message : "") ? "expired" : "error";
|
|
288
|
+
record.message = error instanceof Error ? error.message : "自动下载失败,稍后重试";
|
|
289
|
+
logger.warn("Local Flow C download sync failed", { batchId: record.batchId, error: record.message });
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
record.updatedAt = now();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async saveDelivery(batch, delivery) {
|
|
296
|
+
const key = `${batch.id}:${delivery.ordinal}`;
|
|
297
|
+
const manifest = this.state.manifest[key];
|
|
298
|
+
if (manifest && await isExistingFile(manifest.filePath, manifest.bytes, manifest.sha256))
|
|
299
|
+
return;
|
|
300
|
+
const source = safeDownloadUrl(delivery.archive);
|
|
301
|
+
const base = this.state.downloadDirectory;
|
|
302
|
+
if (!base)
|
|
303
|
+
return;
|
|
304
|
+
const folder = path.join(base, "抖音小辉跨境工具", `${safeName(batch.market)}-${batch.id.slice(0, 8)}`);
|
|
305
|
+
await mkdir(folder, { recursive: true });
|
|
306
|
+
const finalPath = path.join(folder, `flow-c-${batch.id.slice(0, 8)}-${String(delivery.ordinal).padStart(4, "0")}.mp4`);
|
|
307
|
+
if (await isExistingFile(finalPath, Number(delivery.bytes || 0), String(delivery.sha256 || ""))) {
|
|
308
|
+
const info = await stat(finalPath);
|
|
309
|
+
this.state.manifest[key] = { batchId: batch.id, ordinal: delivery.ordinal, filePath: finalPath, bytes: info.size, sha256: String(delivery.sha256 || ""), source, savedAt: now() };
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const partialPath = `${finalPath}.part`;
|
|
313
|
+
await unlink(partialPath).catch(() => undefined);
|
|
314
|
+
const response = await fetch(source, { signal: AbortSignal.timeout(10 * 60 * 1000) });
|
|
315
|
+
if (!response.ok || !response.body)
|
|
316
|
+
throw new Error(`第 ${delivery.ordinal} 条视频下载失败(HTTP ${response.status})`);
|
|
317
|
+
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
|
318
|
+
if (contentType && !contentType.startsWith("video/") && !contentType.includes("octet-stream"))
|
|
319
|
+
throw new Error(`第 ${delivery.ordinal} 条下载内容不是视频文件`);
|
|
320
|
+
const hash = createHash("sha256");
|
|
321
|
+
let bytes = 0;
|
|
322
|
+
const meter = new Transform({ transform(chunk, _encoding, callback) { const buffer = Buffer.from(chunk); bytes += buffer.length; hash.update(buffer); callback(null, buffer); } });
|
|
323
|
+
const handle = await open(partialPath, "wx", 0o600);
|
|
324
|
+
try {
|
|
325
|
+
await pipeline(Readable.fromWeb(response.body), meter, handle.createWriteStream());
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
await handle.close().catch(() => undefined);
|
|
329
|
+
}
|
|
330
|
+
const digest = hash.digest("hex");
|
|
331
|
+
if (bytes < 1024) {
|
|
332
|
+
await unlink(partialPath).catch(() => undefined);
|
|
333
|
+
throw new Error(`第 ${delivery.ordinal} 条视频文件不完整`);
|
|
334
|
+
}
|
|
335
|
+
if (delivery.bytes && bytes !== Number(delivery.bytes)) {
|
|
336
|
+
await unlink(partialPath).catch(() => undefined);
|
|
337
|
+
throw new Error(`第 ${delivery.ordinal} 条视频字节数校验失败`);
|
|
338
|
+
}
|
|
339
|
+
if (delivery.sha256 && digest.toLowerCase() !== delivery.sha256.toLowerCase()) {
|
|
340
|
+
await unlink(partialPath).catch(() => undefined);
|
|
341
|
+
throw new Error(`第 ${delivery.ordinal} 条视频校验值不一致`);
|
|
342
|
+
}
|
|
343
|
+
await unlink(finalPath).catch(() => undefined);
|
|
344
|
+
await rename(partialPath, finalPath);
|
|
345
|
+
this.state.manifest[key] = { batchId: batch.id, ordinal: delivery.ordinal, filePath: finalPath, bytes, sha256: digest, source, savedAt: now() };
|
|
346
|
+
this.save();
|
|
347
|
+
}
|
|
348
|
+
scriptRecord(id) {
|
|
349
|
+
const record = this.state.scripts[id];
|
|
350
|
+
if (!record)
|
|
351
|
+
throw new Error("本机没有这条脚本交接记录,请从网站重新发送");
|
|
352
|
+
return record;
|
|
353
|
+
}
|
|
354
|
+
downloadRecord(id) {
|
|
355
|
+
const record = this.state.downloads[id];
|
|
356
|
+
if (!record)
|
|
357
|
+
throw new Error("本机没有这个下载批次");
|
|
358
|
+
return record;
|
|
359
|
+
}
|
|
360
|
+
save() { saveState(this.state); }
|
|
361
|
+
}
|
|
362
|
+
class ExpiredCapabilityError extends Error {
|
|
363
|
+
}
|
|
364
|
+
function scriptChunkPrompt(id, task, ordinals) {
|
|
365
|
+
return `你正在后台处理“抖音小辉跨境工具”Flow C 脚本交接 ${id}。
|
|
366
|
+
必须使用 MCP 工具 flow_c_get_script_task 读取完整任务,再只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
|
|
367
|
+
脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的 10 秒节奏。
|
|
368
|
+
写完后必须调用 flow_c_submit_script_chunk 一次回传这 ${ordinals.length} 条,handoffId=${id}。不要创建付费批次,不要调用供应商模型,不要在聊天输出大段 JSON。工具返回成功后仅简短结束。`;
|
|
369
|
+
}
|
|
370
|
+
function publicScript(record) {
|
|
371
|
+
return { id: record.id, status: record.status, requestedCount: record.requestedCount, received: record.receivedOrdinals.length, threadId: record.threadId, message: record.message, expiresAt: record.expiresAt, updatedAt: record.updatedAt };
|
|
372
|
+
}
|
|
373
|
+
function publicDownload(record) {
|
|
374
|
+
return { batchId: record.batchId, status: record.status, downloaded: record.downloadedOrdinals.length, market: record.market, message: record.message, expiresAt: record.expiresAt, updatedAt: record.updatedAt };
|
|
375
|
+
}
|
|
376
|
+
function missingOrdinals(total, received) { const set = new Set(received); return Array.from({ length: total }, (_, index) => index + 1).filter((ordinal) => !set.has(ordinal)); }
|
|
377
|
+
function workflowId(value, label) { const id = String(value || "").trim(); if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(id))
|
|
378
|
+
throw new Error(`${label}无效`); return id; }
|
|
379
|
+
function secret(value, label) { const token = String(value || "").trim(); if (token.length < 32 || token.length > 512)
|
|
380
|
+
throw new Error(`${label}无效`); return token; }
|
|
381
|
+
function commerceApiBase(value) {
|
|
382
|
+
const url = new URL(String(value || ""));
|
|
383
|
+
const local = ["127.0.0.1", "localhost"].includes(url.hostname);
|
|
384
|
+
if (url.protocol !== "https:" && !local)
|
|
385
|
+
throw new Error("中心接口必须使用 HTTPS");
|
|
386
|
+
if (!/\/api\/commerce\/?$/.test(url.pathname))
|
|
387
|
+
throw new Error("中心接口路径无效");
|
|
388
|
+
url.search = "";
|
|
389
|
+
url.hash = "";
|
|
390
|
+
return url.toString().replace(/\/+$/, "");
|
|
391
|
+
}
|
|
392
|
+
function safeDownloadUrl(value) { const url = new URL(String(value || "")); if (url.protocol !== "https:")
|
|
393
|
+
throw new Error("下载地址必须使用 HTTPS"); return url.toString(); }
|
|
394
|
+
function safeName(value) { return String(value || "市场").replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0, 60) || "市场"; }
|
|
395
|
+
function now() { return new Date().toISOString(); }
|
|
396
|
+
async function commerceJson(url, token, tokenHeader, init = {}) {
|
|
397
|
+
const headers = new Headers(init.headers);
|
|
398
|
+
headers.set("accept", "application/json");
|
|
399
|
+
headers.set(tokenHeader, token);
|
|
400
|
+
if (init.body)
|
|
401
|
+
headers.set("content-type", "application/json");
|
|
402
|
+
const response = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(120_000) });
|
|
403
|
+
const body = await response.json().catch(() => ({}));
|
|
404
|
+
if (!response.ok)
|
|
405
|
+
throw new Error(body.error || `中心接口返回 ${response.status}`);
|
|
406
|
+
return body;
|
|
407
|
+
}
|
|
408
|
+
function loadState() {
|
|
409
|
+
try {
|
|
410
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
411
|
+
return { downloadDirectory: parsed.downloadDirectory, scripts: parsed.scripts || {}, downloads: parsed.downloads || {}, manifest: parsed.manifest || {} };
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return { scripts: {}, downloads: {}, manifest: {} };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function saveState(state) {
|
|
418
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
419
|
+
const temporary = `${STATE_FILE}.${process.pid}.tmp`;
|
|
420
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
421
|
+
fs.renameSync(temporary, STATE_FILE);
|
|
422
|
+
try {
|
|
423
|
+
fs.chmodSync(STATE_FILE, 0o600);
|
|
424
|
+
}
|
|
425
|
+
catch { }
|
|
426
|
+
}
|
|
427
|
+
async function isExistingFile(filePath, expectedBytes, expectedSha256 = "") {
|
|
428
|
+
try {
|
|
429
|
+
const info = await stat(filePath);
|
|
430
|
+
if (!info.isFile() || info.size < 1024 || (expectedBytes && info.size !== expectedBytes))
|
|
431
|
+
return false;
|
|
432
|
+
if (!expectedSha256)
|
|
433
|
+
return true;
|
|
434
|
+
const hash = createHash("sha256");
|
|
435
|
+
for await (const chunk of fs.createReadStream(filePath))
|
|
436
|
+
hash.update(chunk);
|
|
437
|
+
return hash.digest("hex").toLowerCase() === expectedSha256.toLowerCase();
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function selectNativeDirectory() {
|
|
444
|
+
if (process.platform === "win32") {
|
|
445
|
+
const script = "$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.Windows.Forms; $d=New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description='选择抖音小辉跨境工具的本机视频保存位置'; $d.ShowNewFolderButton=$true; if($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){[Console]::OutputEncoding=[Text.Encoding]::UTF8; Write-Output $d.SelectedPath}";
|
|
446
|
+
return commandOutput(windowsPowerShellExecutable(), ["-NoProfile", "-STA", "-Command", script]);
|
|
447
|
+
}
|
|
448
|
+
if (process.platform === "darwin")
|
|
449
|
+
return commandOutput("osascript", ["-e", "POSIX path of (choose folder with prompt \"选择视频保存位置\")"]);
|
|
450
|
+
return commandOutput("zenity", ["--file-selection", "--directory", "--title=选择视频保存位置"]);
|
|
451
|
+
}
|
|
452
|
+
function commandOutput(command, args) {
|
|
453
|
+
return new Promise((resolve, reject) => {
|
|
454
|
+
const child = spawn(command, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
455
|
+
let stdout = "";
|
|
456
|
+
let stderr = "";
|
|
457
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
458
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
459
|
+
child.once("error", reject);
|
|
460
|
+
child.once("exit", (code) => code === 0 ? resolve(stdout.trim()) : code === 1 ? resolve("") : reject(new Error(stderr.trim() || "无法打开本机文件夹选择器")));
|
|
461
|
+
});
|
|
462
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaohhhh1/canvas-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"dev": "tsx src/index.ts",
|
|
17
17
|
"debug": "tsx src/index.ts --debug",
|
|
18
|
-
"test": "tsx --test src
|
|
18
|
+
"test": "tsx --test src/**/*.test.ts",
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
20
|
"start": "node dist/index.js",
|
|
21
21
|
"prepack": "npm run build"
|