@xiaohhhh1/canvas-agent 0.4.13 → 0.4.15
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 +2 -2
- package/dist/server/http.js +1 -0
- package/dist/workflow/constants.d.ts +0 -1
- package/dist/workflow/constants.js +0 -1
- package/dist/workflow/manager.d.ts +14 -1
- package/dist/workflow/manager.js +57 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Infinite Canvas Agent
|
|
2
2
|
|
|
3
|
-
本地 Canvas Agent 用来连接网站和用户自己电脑上的 Codex。它还负责持久化 Flow C 脚本队列、分段调用本机 Codex
|
|
3
|
+
本地 Canvas Agent 用来连接网站和用户自己电脑上的 Codex。它还负责持久化 Flow C 脚本队列、分段调用本机 Codex、自动回传草案,以及在用户明确点击“一键下载本批全部”后把完成视频校验并保存到所选磁盘目录。
|
|
4
4
|
|
|
5
5
|
## 启动
|
|
6
6
|
|
|
@@ -87,7 +87,7 @@ npx -y @xiaohhhh1/canvas-agent mcp
|
|
|
87
87
|
1. 客户在网站添加一个或多个产品,按顺序上传每个产品 1–5 张图片并填写数量。
|
|
88
88
|
2. 点击“交给本机 Codex 写全部脚本”。本机助手每次只处理 10 条并持久化进度,断网或重启后可继续;脚本完成会自动回传网站,不创建付费任务。
|
|
89
89
|
3. 客户审阅脚本并确认费用后,中心 `workflow-runner` 才执行故事板和视频生成。
|
|
90
|
-
4.
|
|
90
|
+
4. 客户先选择一个本机目录,再对需要的批次点击“一键下载本批全部”。登录另一台电脑、选择文件夹或查看历史批次都不会触发下载。Agent 以最多四路并行写 `.part`,校验大小与 SHA-256 后原子改名;清单已记录且校验有效的批次序号不会重复下载。
|
|
91
91
|
|
|
92
92
|
本机持久状态默认保存在 `~/.infinite-canvas/workflow-state.json`,权限设为仅当前用户可读写。这里包含短期能力令牌和本机路径,不应上传、提交到 Git 或发到聊天中。
|
|
93
93
|
|
package/dist/server/http.js
CHANGED
|
@@ -117,6 +117,7 @@ export function startHttpServer() {
|
|
|
117
117
|
app.get("/agent/workflow/downloads", (_req, res) => res.json({ ok: true, ...workflows.downloadState() }));
|
|
118
118
|
app.post("/agent/workflow/downloads/select-directory", (_req, res) => res.json({ ok: true, selection: workflows.startDownloadDirectorySelection() }));
|
|
119
119
|
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() })));
|
|
120
|
+
app.post("/agent/workflow/downloads/set-directory", route(async (req, res) => res.json({ ok: true, ...await workflows.setDownloadDirectory(req.body?.directory) })));
|
|
120
121
|
app.post("/agent/workflow/downloads/clear-directory", (_req, res) => res.json({ ok: true, ...workflows.clearDownloadDirectory() }));
|
|
121
122
|
app.post("/agent/workflow/downloads/subscriptions", route(async (req, res) => res.json({ ok: true, subscription: workflows.subscribeDownload(req.body || {}) })));
|
|
122
123
|
app.post("/agent/workflow/downloads/:batchId/sync", route(async (req, res) => res.json({ ok: true, subscription: await workflows.syncDownload(routeParam(req.params.batchId)) })));
|
|
@@ -22,8 +22,8 @@ export declare class WorkflowManager {
|
|
|
22
22
|
private runningScripts;
|
|
23
23
|
private scriptQueueRunning;
|
|
24
24
|
private syncingDownloads;
|
|
25
|
+
private pendingDownloadBatchIds;
|
|
25
26
|
private directorySelection?;
|
|
26
|
-
private downloadTimer?;
|
|
27
27
|
constructor(config: CanvasAgentConfig, emit: AgentEmit);
|
|
28
28
|
/** 接收网站创建的短期交接能力并立即启动本机 Codex。 */
|
|
29
29
|
enqueueScript(input: {
|
|
@@ -149,6 +149,19 @@ export declare class WorkflowManager {
|
|
|
149
149
|
updatedAt: string;
|
|
150
150
|
}[];
|
|
151
151
|
}>;
|
|
152
|
+
setDownloadDirectory(directoryValue: unknown): Promise<{
|
|
153
|
+
configured: boolean;
|
|
154
|
+
directoryName: string | undefined;
|
|
155
|
+
subscriptions: {
|
|
156
|
+
batchId: string;
|
|
157
|
+
status: DownloadStatus;
|
|
158
|
+
downloaded: number;
|
|
159
|
+
market: string | undefined;
|
|
160
|
+
message: string | undefined;
|
|
161
|
+
expiresAt: string | undefined;
|
|
162
|
+
updatedAt: string;
|
|
163
|
+
}[];
|
|
164
|
+
}>;
|
|
152
165
|
private scheduleScript;
|
|
153
166
|
/** 同一台电脑串行处理脚本交接,避免多个大批次争用一个 Codex app-server。 */
|
|
154
167
|
private pumpScriptQueue;
|
package/dist/workflow/manager.js
CHANGED
|
@@ -9,7 +9,7 @@ import { runCodexTurn, startCodexThread } from "../agent/codex.js";
|
|
|
9
9
|
import { CONFIG_DIR, ensureSiteWorkspace } from "../config.js";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { windowsPowerShellExecutable } from "../utils/windows.js";
|
|
12
|
-
import {
|
|
12
|
+
import { FLOW_C_SCRIPT_CHUNK_MAX, FLOW_C_SCRIPT_CHUNK_SIZES } from "./constants.js";
|
|
13
13
|
const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
|
|
14
14
|
/** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
|
|
15
15
|
export class WorkflowManager {
|
|
@@ -19,8 +19,8 @@ export class WorkflowManager {
|
|
|
19
19
|
runningScripts = new Set();
|
|
20
20
|
scriptQueueRunning = false;
|
|
21
21
|
syncingDownloads = false;
|
|
22
|
+
pendingDownloadBatchIds = new Set();
|
|
22
23
|
directorySelection;
|
|
23
|
-
downloadTimer;
|
|
24
24
|
constructor(config, emit) {
|
|
25
25
|
this.config = config;
|
|
26
26
|
this.emit = emit;
|
|
@@ -28,9 +28,6 @@ export class WorkflowManager {
|
|
|
28
28
|
if (record.status === "queued" || record.status === "running")
|
|
29
29
|
this.scheduleScript(record.id);
|
|
30
30
|
}
|
|
31
|
-
this.downloadTimer = setInterval(() => void this.syncDownloads(), FLOW_C_DOWNLOAD_POLL_MS);
|
|
32
|
-
this.downloadTimer.unref?.();
|
|
33
|
-
void this.syncDownloads();
|
|
34
31
|
}
|
|
35
32
|
/** 接收网站创建的短期交接能力并立即启动本机 Codex。 */
|
|
36
33
|
enqueueScript(input) {
|
|
@@ -127,6 +124,7 @@ export class WorkflowManager {
|
|
|
127
124
|
}
|
|
128
125
|
clearDownloadDirectory() {
|
|
129
126
|
delete this.state.downloadDirectory;
|
|
127
|
+
this.pendingDownloadBatchIds.clear();
|
|
130
128
|
this.save();
|
|
131
129
|
return this.downloadState();
|
|
132
130
|
}
|
|
@@ -137,15 +135,15 @@ export class WorkflowManager {
|
|
|
137
135
|
batchId,
|
|
138
136
|
apiBase: commerceApiBase(input.apiBase),
|
|
139
137
|
accessToken: secret(input.accessToken, "下载能力令牌"),
|
|
140
|
-
status:
|
|
138
|
+
status: "waiting",
|
|
141
139
|
downloadedOrdinals: previous?.downloadedOrdinals || [],
|
|
142
140
|
market: previous?.market,
|
|
143
141
|
expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
|
|
144
|
-
message: this.state.downloadDirectory ? "
|
|
142
|
+
message: this.state.downloadDirectory ? "已开始手动下载本批全部成片" : "请先选择本机保存文件夹",
|
|
145
143
|
updatedAt: now(),
|
|
146
144
|
};
|
|
147
145
|
this.save();
|
|
148
|
-
void this.syncDownloads();
|
|
146
|
+
void this.syncDownloads(batchId);
|
|
149
147
|
return publicDownload(this.state.downloads[batchId]);
|
|
150
148
|
}
|
|
151
149
|
async syncDownload(batchIdValue) {
|
|
@@ -153,6 +151,19 @@ export class WorkflowManager {
|
|
|
153
151
|
await this.syncDownloads(batchId);
|
|
154
152
|
return batchId ? publicDownload(this.downloadRecord(batchId)) : this.downloadState();
|
|
155
153
|
}
|
|
154
|
+
async setDownloadDirectory(directoryValue) {
|
|
155
|
+
const selected = String(directoryValue || "").trim();
|
|
156
|
+
if (!selected || !path.isAbsolute(selected))
|
|
157
|
+
throw new Error("请输入本机文件夹的完整路径");
|
|
158
|
+
const resolved = path.resolve(selected);
|
|
159
|
+
await mkdir(resolved, { recursive: true });
|
|
160
|
+
const info = await stat(resolved);
|
|
161
|
+
if (!info.isDirectory())
|
|
162
|
+
throw new Error("选择的路径不是文件夹");
|
|
163
|
+
this.state.downloadDirectory = resolved;
|
|
164
|
+
this.save();
|
|
165
|
+
return this.downloadState();
|
|
166
|
+
}
|
|
156
167
|
scheduleScript(_id) {
|
|
157
168
|
queueMicrotask(() => void this.pumpScriptQueue());
|
|
158
169
|
}
|
|
@@ -182,15 +193,8 @@ export class WorkflowManager {
|
|
|
182
193
|
this.directorySelection = { id, status: "cancelled" };
|
|
183
194
|
return;
|
|
184
195
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
const info = await stat(resolved);
|
|
188
|
-
if (!info.isDirectory())
|
|
189
|
-
throw new Error("选择的路径不是文件夹");
|
|
190
|
-
this.state.downloadDirectory = resolved;
|
|
191
|
-
this.save();
|
|
192
|
-
this.directorySelection = { id, status: "selected", directoryName: path.basename(resolved) };
|
|
193
|
-
void this.syncDownloads();
|
|
196
|
+
const state = await this.setDownloadDirectory(selected);
|
|
197
|
+
this.directorySelection = { id, status: "selected", directoryName: state.directoryName };
|
|
194
198
|
}
|
|
195
199
|
catch (error) {
|
|
196
200
|
this.directorySelection = { id, status: "error", error: error instanceof Error ? error.message : "无法选择本机文件夹" };
|
|
@@ -270,13 +274,24 @@ export class WorkflowManager {
|
|
|
270
274
|
}
|
|
271
275
|
}
|
|
272
276
|
async syncDownloads(onlyBatchId) {
|
|
273
|
-
if (
|
|
277
|
+
if (!this.state.downloadDirectory)
|
|
278
|
+
return;
|
|
279
|
+
if (onlyBatchId)
|
|
280
|
+
this.pendingDownloadBatchIds.add(onlyBatchId);
|
|
281
|
+
else
|
|
282
|
+
for (const record of Object.values(this.state.downloads))
|
|
283
|
+
this.pendingDownloadBatchIds.add(record.batchId);
|
|
284
|
+
if (this.syncingDownloads)
|
|
274
285
|
return;
|
|
275
286
|
this.syncingDownloads = true;
|
|
276
287
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
288
|
+
while (this.pendingDownloadBatchIds.size) {
|
|
289
|
+
const batchId = this.pendingDownloadBatchIds.values().next().value;
|
|
290
|
+
this.pendingDownloadBatchIds.delete(batchId);
|
|
291
|
+
const record = this.state.downloads[batchId];
|
|
292
|
+
if (record)
|
|
293
|
+
await this.syncDownloadRecord(record);
|
|
294
|
+
}
|
|
280
295
|
}
|
|
281
296
|
finally {
|
|
282
297
|
this.syncingDownloads = false;
|
|
@@ -286,7 +301,7 @@ export class WorkflowManager {
|
|
|
286
301
|
async syncDownloadRecord(record) {
|
|
287
302
|
if (record.expiresAt && Date.parse(record.expiresAt) <= Date.now()) {
|
|
288
303
|
record.status = "expired";
|
|
289
|
-
record.message = "
|
|
304
|
+
record.message = "下载授权已过期;请在网站重新点击一键下载全部";
|
|
290
305
|
return;
|
|
291
306
|
}
|
|
292
307
|
try {
|
|
@@ -294,21 +309,30 @@ export class WorkflowManager {
|
|
|
294
309
|
const data = await commerceJson(`${record.apiBase}/workflow-downloads/${encodeURIComponent(record.batchId)}`, record.accessToken, "x-workflow-download-token");
|
|
295
310
|
const batch = data.batch;
|
|
296
311
|
record.market = batch.market;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
312
|
+
const deliveries = batch.deliveries || [];
|
|
313
|
+
let cursor = 0;
|
|
314
|
+
const workers = Array.from({ length: Math.min(4, deliveries.length) }, async () => {
|
|
315
|
+
while (cursor < deliveries.length) {
|
|
316
|
+
const delivery = deliveries[cursor++];
|
|
317
|
+
await this.saveDelivery(batch, delivery);
|
|
318
|
+
if (!record.downloadedOrdinals.includes(delivery.ordinal))
|
|
319
|
+
record.downloadedOrdinals.push(delivery.ordinal);
|
|
320
|
+
record.message = `正在保存本批全部成片:${record.downloadedOrdinals.length}/${deliveries.length}`;
|
|
321
|
+
record.updatedAt = now();
|
|
322
|
+
this.save();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
await Promise.all(workers);
|
|
302
326
|
record.downloadedOrdinals.sort((a, b) => a - b);
|
|
303
327
|
const finished = ["completed", "cancelled", "failed"].includes(batch.status);
|
|
304
328
|
record.status = finished && record.downloadedOrdinals.length >= (batch.deliveries?.length || 0) ? "complete" : "waiting";
|
|
305
329
|
record.message = batch.deliveries?.length
|
|
306
|
-
?
|
|
307
|
-
: finished ? "批次已结束,暂无可下载视频" : "
|
|
330
|
+
? `已保存本批 ${record.downloadedOrdinals.length} 条成片;${finished ? "本批次已结束" : "如有新成片请再次点击下载"}`
|
|
331
|
+
: finished ? "批次已结束,暂无可下载视频" : "当前暂无成片可下载";
|
|
308
332
|
}
|
|
309
333
|
catch (error) {
|
|
310
334
|
record.status = /expired|not found/i.test(error instanceof Error ? error.message : "") ? "expired" : "error";
|
|
311
|
-
record.message = error instanceof Error ? error.message : "
|
|
335
|
+
record.message = error instanceof Error ? error.message : "下载失败,请再次点击下载";
|
|
312
336
|
logger.warn("Local Flow C download sync failed", { batchId: record.batchId, error: record.message });
|
|
313
337
|
}
|
|
314
338
|
finally {
|
|
@@ -500,15 +524,15 @@ async function isExistingFile(filePath, expectedBytes, expectedSha256 = "") {
|
|
|
500
524
|
function selectNativeDirectory() {
|
|
501
525
|
if (process.platform === "win32") {
|
|
502
526
|
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}";
|
|
503
|
-
return commandOutput(windowsPowerShellExecutable(), ["-NoProfile", "-STA", "-Command", script]);
|
|
527
|
+
return commandOutput(windowsPowerShellExecutable(), ["-NoProfile", "-STA", "-Command", script], { windowsHide: false });
|
|
504
528
|
}
|
|
505
529
|
if (process.platform === "darwin")
|
|
506
530
|
return commandOutput("osascript", ["-e", "POSIX path of (choose folder with prompt \"选择视频保存位置\")"]);
|
|
507
531
|
return commandOutput("zenity", ["--file-selection", "--directory", "--title=选择视频保存位置"]);
|
|
508
532
|
}
|
|
509
|
-
function commandOutput(command, args) {
|
|
533
|
+
function commandOutput(command, args, options = {}) {
|
|
510
534
|
return new Promise((resolve, reject) => {
|
|
511
|
-
const child = spawn(command, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
535
|
+
const child = spawn(command, args, { windowsHide: options.windowsHide ?? true, stdio: ["ignore", "pipe", "pipe"] });
|
|
512
536
|
let stdout = "";
|
|
513
537
|
let stderr = "";
|
|
514
538
|
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
|