@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
|
@@ -0,0 +1,461 @@
|
|
|
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
|
+
const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
|
|
12
|
+
const SCRIPT_CHUNK_SIZE = 10;
|
|
13
|
+
const DOWNLOAD_POLL_MS = 10_000;
|
|
14
|
+
/** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
|
|
15
|
+
export class WorkflowManager {
|
|
16
|
+
config;
|
|
17
|
+
emit;
|
|
18
|
+
state = loadState();
|
|
19
|
+
runningScripts = new Set();
|
|
20
|
+
scriptQueueRunning = false;
|
|
21
|
+
syncingDownloads = false;
|
|
22
|
+
directorySelection;
|
|
23
|
+
downloadTimer;
|
|
24
|
+
constructor(config, emit) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.emit = emit;
|
|
27
|
+
for (const record of Object.values(this.state.scripts)) {
|
|
28
|
+
if (record.status === "queued" || record.status === "running")
|
|
29
|
+
this.scheduleScript(record.id);
|
|
30
|
+
}
|
|
31
|
+
this.downloadTimer = setInterval(() => void this.syncDownloads(), DOWNLOAD_POLL_MS);
|
|
32
|
+
this.downloadTimer.unref?.();
|
|
33
|
+
void this.syncDownloads();
|
|
34
|
+
}
|
|
35
|
+
/** 接收网站创建的短期交接能力并立即启动本机 Codex。 */
|
|
36
|
+
enqueueScript(input) {
|
|
37
|
+
const id = workflowId(input.id, "脚本交接 ID");
|
|
38
|
+
const apiBase = commerceApiBase(input.apiBase);
|
|
39
|
+
const accessToken = secret(input.accessToken, "脚本交接令牌");
|
|
40
|
+
const previous = this.state.scripts[id];
|
|
41
|
+
this.state.scripts[id] = {
|
|
42
|
+
id,
|
|
43
|
+
apiBase,
|
|
44
|
+
accessToken,
|
|
45
|
+
status: previous?.status === "complete" ? "complete" : "queued",
|
|
46
|
+
requestedCount: previous?.requestedCount || 0,
|
|
47
|
+
receivedOrdinals: previous?.receivedOrdinals || [],
|
|
48
|
+
threadId: previous?.threadId,
|
|
49
|
+
expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
|
|
50
|
+
attempts: previous?.attempts || 0,
|
|
51
|
+
message: previous?.status === "complete" ? previous.message : "已进入本机 Codex 队列",
|
|
52
|
+
updatedAt: now(),
|
|
53
|
+
};
|
|
54
|
+
this.save();
|
|
55
|
+
this.scheduleScript(id);
|
|
56
|
+
return this.scriptStatus(id);
|
|
57
|
+
}
|
|
58
|
+
retryScript(idValue) {
|
|
59
|
+
const id = workflowId(idValue, "脚本交接 ID");
|
|
60
|
+
const record = this.scriptRecord(id);
|
|
61
|
+
record.status = "queued";
|
|
62
|
+
record.message = "正在重新连接本机 Codex";
|
|
63
|
+
record.updatedAt = now();
|
|
64
|
+
this.save();
|
|
65
|
+
this.scheduleScript(id);
|
|
66
|
+
return this.scriptStatus(id);
|
|
67
|
+
}
|
|
68
|
+
scriptStatus(idValue) {
|
|
69
|
+
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
70
|
+
return publicScript(record);
|
|
71
|
+
}
|
|
72
|
+
/** MCP 读取服务端持久化的完整任务,不向模型暴露令牌。 */
|
|
73
|
+
async scriptTask(idValue) {
|
|
74
|
+
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
75
|
+
const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token");
|
|
76
|
+
const task = data.handoff;
|
|
77
|
+
record.requestedCount = Number(task.requested_count || 0);
|
|
78
|
+
record.expiresAt = task.expires_at;
|
|
79
|
+
record.updatedAt = now();
|
|
80
|
+
this.save();
|
|
81
|
+
return task;
|
|
82
|
+
}
|
|
83
|
+
/** MCP 分段回传脚本,中心接口再次执行数量、产品索引和脚本完整性校验。 */
|
|
84
|
+
async submitScriptChunk(idValue, jobsValue) {
|
|
85
|
+
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
86
|
+
if (!Array.isArray(jobsValue) || !jobsValue.length || jobsValue.length > 25)
|
|
87
|
+
throw new Error("每段必须包含 1–25 条脚本");
|
|
88
|
+
const jobs = jobsValue;
|
|
89
|
+
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 }) });
|
|
90
|
+
const accepted = new Set(record.receivedOrdinals);
|
|
91
|
+
for (const job of jobs)
|
|
92
|
+
if (Number.isInteger(Number(job?.ordinal)))
|
|
93
|
+
accepted.add(Number(job.ordinal));
|
|
94
|
+
record.receivedOrdinals = [...accepted].sort((a, b) => a - b);
|
|
95
|
+
record.requestedCount = Number(data.requestedCount || record.requestedCount);
|
|
96
|
+
record.status = data.status === "ready" ? "complete" : "running";
|
|
97
|
+
record.message = data.status === "ready" ? `全部 ${record.requestedCount} 条高质量脚本已回传` : `已回传 ${data.received}/${data.requestedCount} 条脚本`;
|
|
98
|
+
record.updatedAt = now();
|
|
99
|
+
this.save();
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
downloadState() {
|
|
103
|
+
return {
|
|
104
|
+
configured: Boolean(this.state.downloadDirectory),
|
|
105
|
+
directoryName: this.state.downloadDirectory ? path.basename(this.state.downloadDirectory) : undefined,
|
|
106
|
+
subscriptions: Object.values(this.state.downloads).map(publicDownload),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
startDownloadDirectorySelection() {
|
|
110
|
+
if (this.directorySelection?.status === "selecting")
|
|
111
|
+
return this.directorySelection;
|
|
112
|
+
const selection = { id: randomUUID(), status: "selecting" };
|
|
113
|
+
this.directorySelection = selection;
|
|
114
|
+
void this.finishDownloadDirectorySelection(selection.id);
|
|
115
|
+
return selection;
|
|
116
|
+
}
|
|
117
|
+
downloadDirectorySelection(idValue) {
|
|
118
|
+
const id = workflowId(idValue, "文件夹选择 ID");
|
|
119
|
+
if (!this.directorySelection || this.directorySelection.id !== id)
|
|
120
|
+
throw new Error("文件夹选择任务不存在");
|
|
121
|
+
return this.directorySelection;
|
|
122
|
+
}
|
|
123
|
+
clearDownloadDirectory() {
|
|
124
|
+
delete this.state.downloadDirectory;
|
|
125
|
+
this.save();
|
|
126
|
+
return this.downloadState();
|
|
127
|
+
}
|
|
128
|
+
subscribeDownload(input) {
|
|
129
|
+
const batchId = workflowId(input.batchId, "视频批次 ID");
|
|
130
|
+
const previous = this.state.downloads[batchId];
|
|
131
|
+
this.state.downloads[batchId] = {
|
|
132
|
+
batchId,
|
|
133
|
+
apiBase: commerceApiBase(input.apiBase),
|
|
134
|
+
accessToken: secret(input.accessToken, "下载能力令牌"),
|
|
135
|
+
status: previous?.status === "complete" ? "complete" : "waiting",
|
|
136
|
+
downloadedOrdinals: previous?.downloadedOrdinals || [],
|
|
137
|
+
market: previous?.market,
|
|
138
|
+
expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
|
|
139
|
+
message: this.state.downloadDirectory ? "等待视频完成" : "请先选择本机保存文件夹",
|
|
140
|
+
updatedAt: now(),
|
|
141
|
+
};
|
|
142
|
+
this.save();
|
|
143
|
+
void this.syncDownloads();
|
|
144
|
+
return publicDownload(this.state.downloads[batchId]);
|
|
145
|
+
}
|
|
146
|
+
async syncDownload(batchIdValue) {
|
|
147
|
+
const batchId = batchIdValue ? workflowId(batchIdValue, "视频批次 ID") : undefined;
|
|
148
|
+
await this.syncDownloads(batchId);
|
|
149
|
+
return batchId ? publicDownload(this.downloadRecord(batchId)) : this.downloadState();
|
|
150
|
+
}
|
|
151
|
+
scheduleScript(_id) {
|
|
152
|
+
queueMicrotask(() => void this.pumpScriptQueue());
|
|
153
|
+
}
|
|
154
|
+
/** 同一台电脑串行处理脚本交接,避免多个大批次争用一个 Codex app-server。 */
|
|
155
|
+
async pumpScriptQueue() {
|
|
156
|
+
if (this.scriptQueueRunning)
|
|
157
|
+
return;
|
|
158
|
+
this.scriptQueueRunning = true;
|
|
159
|
+
try {
|
|
160
|
+
while (true) {
|
|
161
|
+
const next = Object.values(this.state.scripts)
|
|
162
|
+
.filter((record) => (record.status === "queued" || record.status === "running") && !this.runningScripts.has(record.id))
|
|
163
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))[0];
|
|
164
|
+
if (!next)
|
|
165
|
+
break;
|
|
166
|
+
await this.runScript(next.id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
this.scriptQueueRunning = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async finishDownloadDirectorySelection(id) {
|
|
174
|
+
try {
|
|
175
|
+
const selected = await selectNativeDirectory();
|
|
176
|
+
if (!selected) {
|
|
177
|
+
this.directorySelection = { id, status: "cancelled" };
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const resolved = path.resolve(selected);
|
|
181
|
+
await mkdir(resolved, { recursive: true });
|
|
182
|
+
const info = await stat(resolved);
|
|
183
|
+
if (!info.isDirectory())
|
|
184
|
+
throw new Error("选择的路径不是文件夹");
|
|
185
|
+
this.state.downloadDirectory = resolved;
|
|
186
|
+
this.save();
|
|
187
|
+
this.directorySelection = { id, status: "selected", directoryName: path.basename(resolved) };
|
|
188
|
+
void this.syncDownloads();
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
this.directorySelection = { id, status: "error", error: error instanceof Error ? error.message : "无法选择本机文件夹" };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async runScript(id) {
|
|
195
|
+
if (this.runningScripts.has(id))
|
|
196
|
+
return;
|
|
197
|
+
this.runningScripts.add(id);
|
|
198
|
+
const record = this.scriptRecord(id);
|
|
199
|
+
try {
|
|
200
|
+
record.status = "running";
|
|
201
|
+
record.message = "正在读取完整产品清单";
|
|
202
|
+
record.updatedAt = now();
|
|
203
|
+
this.save();
|
|
204
|
+
const task = await this.scriptTask(id);
|
|
205
|
+
if (Date.parse(task.expires_at) <= Date.now())
|
|
206
|
+
throw new ExpiredCapabilityError("脚本交接已过期,请在网页重新点击交给本机 Codex");
|
|
207
|
+
const workspace = ensureSiteWorkspace(this.config);
|
|
208
|
+
if (!record.threadId) {
|
|
209
|
+
const thread = await startCodexThread(this.emit, workspace.workspacePath, "full");
|
|
210
|
+
record.threadId = String(thread.id || "");
|
|
211
|
+
this.save();
|
|
212
|
+
}
|
|
213
|
+
while (record.receivedOrdinals.length < task.requested_count) {
|
|
214
|
+
const missing = missingOrdinals(task.requested_count, record.receivedOrdinals).slice(0, SCRIPT_CHUNK_SIZE);
|
|
215
|
+
if (!missing.length)
|
|
216
|
+
break;
|
|
217
|
+
let progressed = false;
|
|
218
|
+
for (let attempt = 1; attempt <= 3 && !progressed; attempt += 1) {
|
|
219
|
+
const before = record.receivedOrdinals.length;
|
|
220
|
+
record.attempts += 1;
|
|
221
|
+
record.message = `本机 Codex 正在写第 ${missing[0]}–${missing.at(-1)} 条(总计 ${task.requested_count} 条)`;
|
|
222
|
+
record.updatedAt = now();
|
|
223
|
+
this.save();
|
|
224
|
+
await runCodexTurn(scriptChunkPrompt(id, task, missing), this.emit, [], { threadId: record.threadId, cwd: workspace.workspacePath, permissionMode: "full", onThread: (threadId) => { record.threadId = threadId; this.save(); } });
|
|
225
|
+
progressed = record.receivedOrdinals.length > before;
|
|
226
|
+
if (!progressed && attempt < 3)
|
|
227
|
+
record.message = `第 ${missing[0]}–${missing.at(-1)} 条未成功回传,正在自动重试 ${attempt + 1}/3`;
|
|
228
|
+
}
|
|
229
|
+
if (!progressed)
|
|
230
|
+
throw new Error(`本机 Codex 连续 3 次未回传第 ${missing[0]}–${missing.at(-1)} 条,请点击重试`);
|
|
231
|
+
}
|
|
232
|
+
if (record.receivedOrdinals.length >= task.requested_count) {
|
|
233
|
+
record.status = "complete";
|
|
234
|
+
record.message = `全部 ${task.requested_count} 条高质量脚本已回传`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
record.status = error instanceof ExpiredCapabilityError ? "expired" : "error";
|
|
239
|
+
record.message = error instanceof Error ? error.message : "本机 Codex 处理失败";
|
|
240
|
+
logger.warn("Local Flow C script handoff paused", { handoffId: id, error: record.message });
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
record.updatedAt = now();
|
|
244
|
+
this.save();
|
|
245
|
+
this.runningScripts.delete(id);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async syncDownloads(onlyBatchId) {
|
|
249
|
+
if (this.syncingDownloads || !this.state.downloadDirectory)
|
|
250
|
+
return;
|
|
251
|
+
this.syncingDownloads = true;
|
|
252
|
+
try {
|
|
253
|
+
const records = Object.values(this.state.downloads).filter((record) => !onlyBatchId || record.batchId === onlyBatchId);
|
|
254
|
+
for (const record of records)
|
|
255
|
+
await this.syncDownloadRecord(record);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
this.syncingDownloads = false;
|
|
259
|
+
this.save();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async syncDownloadRecord(record) {
|
|
263
|
+
if (record.expiresAt && Date.parse(record.expiresAt) <= Date.now()) {
|
|
264
|
+
record.status = "expired";
|
|
265
|
+
record.message = "下载授权已过期;打开网站后会自动续期";
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
record.status = "running";
|
|
270
|
+
const data = await commerceJson(`${record.apiBase}/workflow-downloads/${encodeURIComponent(record.batchId)}`, record.accessToken, "x-workflow-download-token");
|
|
271
|
+
const batch = data.batch;
|
|
272
|
+
record.market = batch.market;
|
|
273
|
+
for (const delivery of batch.deliveries || []) {
|
|
274
|
+
await this.saveDelivery(batch, delivery);
|
|
275
|
+
if (!record.downloadedOrdinals.includes(delivery.ordinal))
|
|
276
|
+
record.downloadedOrdinals.push(delivery.ordinal);
|
|
277
|
+
}
|
|
278
|
+
record.downloadedOrdinals.sort((a, b) => a - b);
|
|
279
|
+
const finished = ["completed", "cancelled", "failed"].includes(batch.status);
|
|
280
|
+
record.status = finished && record.downloadedOrdinals.length >= (batch.deliveries?.length || 0) ? "complete" : "waiting";
|
|
281
|
+
record.message = batch.deliveries?.length
|
|
282
|
+
? `已自动保存 ${record.downloadedOrdinals.length} 条视频;${finished ? "本批次已结束" : "继续等待新视频"}`
|
|
283
|
+
: finished ? "批次已结束,暂无可下载视频" : "等待视频完成";
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
record.status = /expired|not found/i.test(error instanceof Error ? error.message : "") ? "expired" : "error";
|
|
287
|
+
record.message = error instanceof Error ? error.message : "自动下载失败,稍后重试";
|
|
288
|
+
logger.warn("Local Flow C download sync failed", { batchId: record.batchId, error: record.message });
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
record.updatedAt = now();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async saveDelivery(batch, delivery) {
|
|
295
|
+
const key = `${batch.id}:${delivery.ordinal}`;
|
|
296
|
+
const manifest = this.state.manifest[key];
|
|
297
|
+
if (manifest && await isExistingFile(manifest.filePath, manifest.bytes, manifest.sha256))
|
|
298
|
+
return;
|
|
299
|
+
const source = safeDownloadUrl(delivery.archive);
|
|
300
|
+
const base = this.state.downloadDirectory;
|
|
301
|
+
if (!base)
|
|
302
|
+
return;
|
|
303
|
+
const folder = path.join(base, "抖音小辉跨境工具", `${safeName(batch.market)}-${batch.id.slice(0, 8)}`);
|
|
304
|
+
await mkdir(folder, { recursive: true });
|
|
305
|
+
const finalPath = path.join(folder, `flow-c-${batch.id.slice(0, 8)}-${String(delivery.ordinal).padStart(4, "0")}.mp4`);
|
|
306
|
+
if (await isExistingFile(finalPath, Number(delivery.bytes || 0), String(delivery.sha256 || ""))) {
|
|
307
|
+
const info = await stat(finalPath);
|
|
308
|
+
this.state.manifest[key] = { batchId: batch.id, ordinal: delivery.ordinal, filePath: finalPath, bytes: info.size, sha256: String(delivery.sha256 || ""), source, savedAt: now() };
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const partialPath = `${finalPath}.part`;
|
|
312
|
+
await unlink(partialPath).catch(() => undefined);
|
|
313
|
+
const response = await fetch(source, { signal: AbortSignal.timeout(10 * 60 * 1000) });
|
|
314
|
+
if (!response.ok || !response.body)
|
|
315
|
+
throw new Error(`第 ${delivery.ordinal} 条视频下载失败(HTTP ${response.status})`);
|
|
316
|
+
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
|
317
|
+
if (contentType && !contentType.startsWith("video/") && !contentType.includes("octet-stream"))
|
|
318
|
+
throw new Error(`第 ${delivery.ordinal} 条下载内容不是视频文件`);
|
|
319
|
+
const hash = createHash("sha256");
|
|
320
|
+
let bytes = 0;
|
|
321
|
+
const meter = new Transform({ transform(chunk, _encoding, callback) { const buffer = Buffer.from(chunk); bytes += buffer.length; hash.update(buffer); callback(null, buffer); } });
|
|
322
|
+
const handle = await open(partialPath, "wx", 0o600);
|
|
323
|
+
try {
|
|
324
|
+
await pipeline(Readable.fromWeb(response.body), meter, handle.createWriteStream());
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
await handle.close().catch(() => undefined);
|
|
328
|
+
}
|
|
329
|
+
const digest = hash.digest("hex");
|
|
330
|
+
if (bytes < 1024) {
|
|
331
|
+
await unlink(partialPath).catch(() => undefined);
|
|
332
|
+
throw new Error(`第 ${delivery.ordinal} 条视频文件不完整`);
|
|
333
|
+
}
|
|
334
|
+
if (delivery.bytes && bytes !== Number(delivery.bytes)) {
|
|
335
|
+
await unlink(partialPath).catch(() => undefined);
|
|
336
|
+
throw new Error(`第 ${delivery.ordinal} 条视频字节数校验失败`);
|
|
337
|
+
}
|
|
338
|
+
if (delivery.sha256 && digest.toLowerCase() !== delivery.sha256.toLowerCase()) {
|
|
339
|
+
await unlink(partialPath).catch(() => undefined);
|
|
340
|
+
throw new Error(`第 ${delivery.ordinal} 条视频校验值不一致`);
|
|
341
|
+
}
|
|
342
|
+
await unlink(finalPath).catch(() => undefined);
|
|
343
|
+
await rename(partialPath, finalPath);
|
|
344
|
+
this.state.manifest[key] = { batchId: batch.id, ordinal: delivery.ordinal, filePath: finalPath, bytes, sha256: digest, source, savedAt: now() };
|
|
345
|
+
this.save();
|
|
346
|
+
}
|
|
347
|
+
scriptRecord(id) {
|
|
348
|
+
const record = this.state.scripts[id];
|
|
349
|
+
if (!record)
|
|
350
|
+
throw new Error("本机没有这条脚本交接记录,请从网站重新发送");
|
|
351
|
+
return record;
|
|
352
|
+
}
|
|
353
|
+
downloadRecord(id) {
|
|
354
|
+
const record = this.state.downloads[id];
|
|
355
|
+
if (!record)
|
|
356
|
+
throw new Error("本机没有这个下载批次");
|
|
357
|
+
return record;
|
|
358
|
+
}
|
|
359
|
+
save() { saveState(this.state); }
|
|
360
|
+
}
|
|
361
|
+
class ExpiredCapabilityError extends Error {
|
|
362
|
+
}
|
|
363
|
+
function scriptChunkPrompt(id, task, ordinals) {
|
|
364
|
+
return `你正在后台处理“抖音小辉跨境工具”Flow C 脚本交接 ${id}。
|
|
365
|
+
必须使用 MCP 工具 flow_c_get_script_task 读取完整任务,再只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
|
|
366
|
+
脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的 10 秒节奏。
|
|
367
|
+
写完后必须调用 flow_c_submit_script_chunk 一次回传这 ${ordinals.length} 条,handoffId=${id}。不要创建付费批次,不要调用供应商模型,不要在聊天输出大段 JSON。工具返回成功后仅简短结束。`;
|
|
368
|
+
}
|
|
369
|
+
function publicScript(record) {
|
|
370
|
+
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 };
|
|
371
|
+
}
|
|
372
|
+
function publicDownload(record) {
|
|
373
|
+
return { batchId: record.batchId, status: record.status, downloaded: record.downloadedOrdinals.length, market: record.market, message: record.message, expiresAt: record.expiresAt, updatedAt: record.updatedAt };
|
|
374
|
+
}
|
|
375
|
+
function missingOrdinals(total, received) { const set = new Set(received); return Array.from({ length: total }, (_, index) => index + 1).filter((ordinal) => !set.has(ordinal)); }
|
|
376
|
+
function workflowId(value, label) { const id = String(value || "").trim(); if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(id))
|
|
377
|
+
throw new Error(`${label}无效`); return id; }
|
|
378
|
+
function secret(value, label) { const token = String(value || "").trim(); if (token.length < 32 || token.length > 512)
|
|
379
|
+
throw new Error(`${label}无效`); return token; }
|
|
380
|
+
function commerceApiBase(value) {
|
|
381
|
+
const url = new URL(String(value || ""));
|
|
382
|
+
const local = ["127.0.0.1", "localhost"].includes(url.hostname);
|
|
383
|
+
if (url.protocol !== "https:" && !local)
|
|
384
|
+
throw new Error("中心接口必须使用 HTTPS");
|
|
385
|
+
if (!/\/api\/commerce\/?$/.test(url.pathname))
|
|
386
|
+
throw new Error("中心接口路径无效");
|
|
387
|
+
url.search = "";
|
|
388
|
+
url.hash = "";
|
|
389
|
+
return url.toString().replace(/\/+$/, "");
|
|
390
|
+
}
|
|
391
|
+
function safeDownloadUrl(value) { const url = new URL(String(value || "")); if (url.protocol !== "https:")
|
|
392
|
+
throw new Error("下载地址必须使用 HTTPS"); return url.toString(); }
|
|
393
|
+
function safeName(value) { return String(value || "市场").replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0, 60) || "市场"; }
|
|
394
|
+
function now() { return new Date().toISOString(); }
|
|
395
|
+
async function commerceJson(url, token, tokenHeader, init = {}) {
|
|
396
|
+
const headers = new Headers(init.headers);
|
|
397
|
+
headers.set("accept", "application/json");
|
|
398
|
+
headers.set(tokenHeader, token);
|
|
399
|
+
if (init.body)
|
|
400
|
+
headers.set("content-type", "application/json");
|
|
401
|
+
const response = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(120_000) });
|
|
402
|
+
const body = await response.json().catch(() => ({}));
|
|
403
|
+
if (!response.ok)
|
|
404
|
+
throw new Error(body.error || `中心接口返回 ${response.status}`);
|
|
405
|
+
return body;
|
|
406
|
+
}
|
|
407
|
+
function loadState() {
|
|
408
|
+
try {
|
|
409
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
410
|
+
return { downloadDirectory: parsed.downloadDirectory, scripts: parsed.scripts || {}, downloads: parsed.downloads || {}, manifest: parsed.manifest || {} };
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return { scripts: {}, downloads: {}, manifest: {} };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function saveState(state) {
|
|
417
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
418
|
+
const temporary = `${STATE_FILE}.${process.pid}.tmp`;
|
|
419
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
420
|
+
fs.renameSync(temporary, STATE_FILE);
|
|
421
|
+
try {
|
|
422
|
+
fs.chmodSync(STATE_FILE, 0o600);
|
|
423
|
+
}
|
|
424
|
+
catch { }
|
|
425
|
+
}
|
|
426
|
+
async function isExistingFile(filePath, expectedBytes, expectedSha256 = "") {
|
|
427
|
+
try {
|
|
428
|
+
const info = await stat(filePath);
|
|
429
|
+
if (!info.isFile() || info.size < 1024 || (expectedBytes && info.size !== expectedBytes))
|
|
430
|
+
return false;
|
|
431
|
+
if (!expectedSha256)
|
|
432
|
+
return true;
|
|
433
|
+
const hash = createHash("sha256");
|
|
434
|
+
for await (const chunk of fs.createReadStream(filePath))
|
|
435
|
+
hash.update(chunk);
|
|
436
|
+
return hash.digest("hex").toLowerCase() === expectedSha256.toLowerCase();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function selectNativeDirectory() {
|
|
443
|
+
if (process.platform === "win32") {
|
|
444
|
+
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}";
|
|
445
|
+
return commandOutput("powershell.exe", ["-NoProfile", "-STA", "-Command", script]);
|
|
446
|
+
}
|
|
447
|
+
if (process.platform === "darwin")
|
|
448
|
+
return commandOutput("osascript", ["-e", "POSIX path of (choose folder with prompt \"选择视频保存位置\")"]);
|
|
449
|
+
return commandOutput("zenity", ["--file-selection", "--directory", "--title=选择视频保存位置"]);
|
|
450
|
+
}
|
|
451
|
+
function commandOutput(command, args) {
|
|
452
|
+
return new Promise((resolve, reject) => {
|
|
453
|
+
const child = spawn(command, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
454
|
+
let stdout = "";
|
|
455
|
+
let stderr = "";
|
|
456
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
457
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
458
|
+
child.once("error", reject);
|
|
459
|
+
child.once("exit", (code) => code === 0 ? resolve(stdout.trim()) : code === 1 ? resolve("") : reject(new Error(stderr.trim() || "无法打开本机文件夹选择器")));
|
|
460
|
+
});
|
|
461
|
+
}
|
package/package.json
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaohhhh1/canvas-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"canvas-agent": "
|
|
8
|
+
"canvas-agent": "dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"agent-instructions.md",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"scripts": {
|
|
15
16
|
"dev": "tsx src/index.ts",
|
|
16
|
-
"
|
|
17
|
+
"debug": "tsx src/index.ts --debug",
|
|
18
|
+
"test": "tsx --test src/canvas/session.test.ts",
|
|
17
19
|
"build": "tsc -p tsconfig.json",
|
|
18
20
|
"start": "node dist/index.js",
|
|
19
21
|
"prepack": "npm run build"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
23
|
-
"@openai/codex": "
|
|
25
|
+
"@openai/codex": "0.145.0",
|
|
24
26
|
"express": "^5.1.0",
|
|
25
27
|
"ws": "^8.18.3",
|
|
28
|
+
"winston": "^3.19.0",
|
|
26
29
|
"zod": "^3.25.0"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|