@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 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. 客户只需首次选择一个本机目录。Agent 10 秒检查交付,先写 `.part`,校验大小与 SHA-256 后原子改名;清单已记录的批次序号不会重复下载,网页关闭后仍会继续。
90
+ 4. 客户先选择一个本机目录,再对需要的批次点击“一键下载本批全部”。登录另一台电脑、选择文件夹或查看历史批次都不会触发下载。Agent 以最多四路并行写 `.part`,校验大小与 SHA-256 后原子改名;清单已记录且校验有效的批次序号不会重复下载。
91
91
 
92
92
  本机持久状态默认保存在 `~/.infinite-canvas/workflow-state.json`,权限设为仅当前用户可读写。这里包含短期能力令牌和本机路径,不应上传、提交到 Git 或发到聊天中。
93
93
 
@@ -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)) })));
@@ -1,4 +1,3 @@
1
1
  /** Flow C 脚本分段的唯一上限和自动降级顺序,MCP 与调度器必须共用。 */
2
2
  export declare const FLOW_C_SCRIPT_CHUNK_SIZES: readonly [30, 15, 10];
3
3
  export declare const FLOW_C_SCRIPT_CHUNK_MAX: 30;
4
- export declare const FLOW_C_DOWNLOAD_POLL_MS = 3000;
@@ -1,4 +1,3 @@
1
1
  /** Flow C 脚本分段的唯一上限和自动降级顺序,MCP 与调度器必须共用。 */
2
2
  export const FLOW_C_SCRIPT_CHUNK_SIZES = [30, 15, 10];
3
3
  export const FLOW_C_SCRIPT_CHUNK_MAX = FLOW_C_SCRIPT_CHUNK_SIZES[0];
4
- export const FLOW_C_DOWNLOAD_POLL_MS = 3_000;
@@ -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;
@@ -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 { FLOW_C_DOWNLOAD_POLL_MS, FLOW_C_SCRIPT_CHUNK_MAX, FLOW_C_SCRIPT_CHUNK_SIZES } from "./constants.js";
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: previous?.status === "complete" ? "complete" : "waiting",
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 resolved = path.resolve(selected);
186
- await mkdir(resolved, { recursive: true });
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 (this.syncingDownloads || !this.state.downloadDirectory)
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
- const records = Object.values(this.state.downloads).filter((record) => !onlyBatchId || record.batchId === onlyBatchId);
278
- for (const record of records)
279
- await this.syncDownloadRecord(record);
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
- for (const delivery of batch.deliveries || []) {
298
- await this.saveDelivery(batch, delivery);
299
- if (!record.downloadedOrdinals.includes(delivery.ordinal))
300
- record.downloadedOrdinals.push(delivery.ordinal);
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
- ? `已自动保存 ${record.downloadedOrdinals.length} 条视频;${finished ? "本批次已结束" : "继续等待新视频"}`
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(); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",