@uzooai/anime-ai-cli 0.6.3 → 0.6.4

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.
@@ -69,7 +69,7 @@ Saving a generation/processing result to the canvas does not save it to the mate
69
69
  3. Before business discovery or writes, ask whether to use personal or team space unless the user already chose it for this task. Do not infer personal space from missing flags or an empty list. For team space, resolve the user's choice against actual account `teams`; if there is only one available team, announce and use it; if several remain possible, show names/UIDs and ask which. No available team means stop, not fallback to personal. Confirm a project only when relevant, never guess its ID. Keep the confirmed choice for this task; ask again when the user switches context.
70
70
  4. Follow the [canvas workflow](references/canvas-workflow.md) to list the chosen scope and ask via a card whether to create or use an existing canvas when the target is unspecified. For creation, obtain a name or explicit acceptance of the suggested name; then create once and read back ownership. A canvas-only request ends there: no model discovery, nodes, uploads or generation. For node creation, follow the [node workflow](references/node-workflow.md); directly create ordinary nodes only, and interpret “all nodes” as all ordinary node types. Derived nodes must come from a real workflow, not empty placeholders or injected roles. A node-only request ends after read-back verification, without requiring models or materials for empty ordinary nodes. For a generation request, use model options in that same space, collect the model and applicable settings through cards unless already specified or explicitly delegated, save the selected node configuration and run generation prepare. Do not hand-assemble an ordinary generation request to bypass preparation. Dedicated workflows use their own verified request builders. Use `node update` for input/position changes; read [commands](references/commands.md) for the node patch schema. Team model visibility and promotions can differ.
71
71
  5. Before node writes, upload, estimation, submission or recovery, read the target canvas and verify its team_uid matches the confirmed space/team (personal means no team_uid). Stop on mismatch; do not transfer ownership or silently switch wallets. A generation uses the canvas’s actual ownership, not a client billing override. After the user selects node parameters, use a confirmation card to show the space, team/project, personal or team wallet source and estimated OO cost and wait for the user’s confirmation for each submission. Changed parameters or price require confirmation again. Never retry a timed-out submission blindly.
72
- 6. Poll an existing generation with `generation wait <id>` and download only after the task is completed.
72
+ 6. For an authorized ordinary canvas generation, keep the task open until the original node is updated and verified. Prefer `generation wait <id>`; if polling with `generation get`, run `generation sync <id>` on the same completed task before delivery. Verify the saved node with `canvas get`. After interruption, find the original task and last sync state with `generation operations` on the same profile. Task completion or download alone does not verify canvas persistence. Follow [canvas result delivery](references/generation-workflow.md#生成结果回填与交付) for recovery and read-only requests.
73
73
 
74
74
  The following segmentation procedure is retained for historical maintenance and explicit future resumption only; it is not an active development or acceptance task while repaint is paused. Follow the repaint reference instead of improvising API calls or generic node writes. `repaint start` first persists a preview of the actual wallet and analysis fee; require explicit approval before `--yes`. Review the real segment plan before `repaint confirm --yes`, then wait for CLI to save the derived chain. A segment chain contains video generation nodes, not generated videos. Further paid actions need their own parameters, estimate and approval.
75
75
 
@@ -60,10 +60,10 @@
60
60
  | `generation limits` | 可选 `--format json\|table`;离线查看普通生成提交限额和等待建议,不需要登录,不查询实时剩余额度或提交任务。遇到 429 见 [生成提交限额与处理](generation-workflow.md#生成提交限额与429处理) |
61
61
  | `generation submit` | `--input request.json --operation NAME` 默认只预估;Seedance 预览额外返回 protocol;--yes 在需要协议时打开阅读页并等待本人确认,核验后继续原操作;可选 --browser 和 --agreement-timeout;首次 --yes 必须带 `--expected-cost OO` 与当次确认费用严格比对,不一致停止;返回 `{operation, estimate, task}`。保存 operation,任务 ID 是 `task.task_id` |
62
62
  | `generation recover NAME` | 有已知任务 ID 时只查询该任务;未知提交结果时默认只预估,加 `--yes` 以原请求和原幂等键恢复;原记录无确认金额或价格变化时必须重新卡片确认并带 `--expected-cost OO`,已有有效金额且报价未变可沿用。新授权金额先落原记录,不改请求或键。不得改用新 operation |
63
- | `generation operations` | 列出本地 operation、taskId、状态与创建时间;失败时包含脱敏 lastFailure(HTTP/业务错误码),不输出原始请求 |
64
- | `generation get ID` / `logs ID` | 原样返回任务或日志,用于调查失败,不触发重新生成 |
65
- | `generation wait ID` | `--interval MS` 默认 3000,`--timeout MS` 默认 600000;轮询原任务,completed 后将真实结果同步普通生成节点;不再次提交任务 |
66
- | `generation sync ID` | 读取原任务及最新画布,保存真实任务 ID/状态/媒体结果或文本输出;无生成提交;CAS 冲突或节点关联变化停止 |
63
+ | `generation operations` | 只读列出本地 operation、taskId、状态与创建时间;已知任务含 doc_id、node_id 和上次 canvas_sync(unverified/pending/verified);失败时包含脱敏 lastFailure,不输出原始请求,不主动查询或写画布 |
64
+ | `generation get ID` / `logs ID` | 只读返回任务或日志;`get` 轮询至 completed 后,如需交付到画布且尚未回填,使用同一任务 ID 执行 sync |
65
+ | `generation wait ID` | `--interval MS` 默认 3000,`--timeout MS` 默认 600000;轮询原任务,completed 后同步普通生成节点并返回 canvas_sync;成功后用 canvas get 读回确认,不再次提交任务 |
66
+ | `generation sync ID` | 读取原任务及最新画布,保存真实任务 ID/状态/媒体结果或文本输出;无生成提交;CAS 冲突或节点关联变化停止。交付与续接见[生成结果回填与交付](generation-workflow.md#生成结果回填与交付) |
67
67
  | `generation download ID` | `--output FILE`;可选 `--sha256 HEX` 比对预期摘要,返回本地路径、bytes、sha256、filename。--output省略时优先采用实际文件响应Content-Disposition的安全文件名,再回退业务响应filename;显式路径保留。已有文件不覆盖 |
68
68
  | `review start` | `--input review.json --operation NAME` 默认只预检;明确审核范围后加 `--yes`,仅审核素材,不提交生成。输入 doc_id/node_id/space、团队 team_uid,mode 为 current/upstream;同 operation 不重发 POST |
69
69
  | `review get NAME` | 查询原审核并更新本地记录,不保存画布;返回 operation/state/canvas_synced/items(task_id/source_node_ids/status/review/可选 last_failure) |
@@ -5,7 +5,7 @@
5
5
  以下命令均以安装后的 `uzoo-anime --profile PROFILE` 为前缀,本任务保持同一 profile。
6
6
 
7
7
  1. 运行 `auth status` 确认当前账号并复用有效登录;按 [画布流程](canvas-workflow.md) 确定空间、必要团队/项目及目标画布,本任务内沿用明确选择。核对画布 team_uid 与选择一致。用 `model list --space personal` 或 `model list --space team --team-uid UID` 读取实际模型能力,在前端已使用且 CLI 已接入的节点参数范围内,根据用户目标确认缺少的模型、提示词、比例/分辨率/时长、数量与素材;已明确的不要重复询问。目录字段不自动等于可配置开关,不擅自承诺“无声”等未接入能力。缺少目标画布时按画布流程用卡片选择新建/已有及名称,再创建或读取;缺少对应类型节点时执行 `node add --doc ID --type image`(文本/视频换相应类型),再保存选定参数,不需要网页准备。没有生成请求时不要进入本流程。
8
- 2. 有素材时按 [素材与分组流程](material-graph-workflow.md) 上传到普通素材节点或从同空间素材库导入,核验可读取媒体及持久化引用。需要 Seedance 素材审核时先按 [审核流程](seedance-review-workflow.md) 完成并回填原素材。用 `generation prepare --doc DOC_ID --node NODE_ID --space personal --output request.json` 准备请求(团队须改用已确认团队参数);它读取当前合法上游输出及持久参考,核对模型、参数、参考及审核关联后再预估。没有连线但有持久参考也可准备;不要把本地路径当服务端 URL,不擅自设置 `protocol_agreed` 等用户同意字段。
8
+ 2. 有素材时按 [素材与分组流程](material-graph-workflow.md) 复用、上传或导入到原画布,按[参考素材可见性](#参考素材可见性)核验目标节点的关联。需要 Seedance 素材审核时先按 [审核流程](seedance-review-workflow.md) 完成并回填原素材。用 `generation prepare --doc DOC_ID --node NODE_ID --space personal --output request.json` 准备请求(团队须改用已确认团队参数);它读取当前合法上游输出及持久参考,核对模型、参数、参考及审核关联后再预估。不要把本地路径当服务端 URL,不擅自设置 `protocol_agreed` 等用户同意字段。
9
9
  3. 将 generation prepare 输出的 request 原样保存为本地 UTF-8 JSON 文件;专用路线使用专用预检/构造器。下方仅解释结构,不作为 Agent 手工拼装请求的操作模板,文档、节点、模型须来自实际查询:
10
10
 
11
11
  ```json
@@ -14,10 +14,21 @@
14
14
 
15
15
  4. `generation estimate --input request.json` 或 `generation submit --input request.json --operation cat-window-001` 查看费用。`will_intercept=true` 时停止并调整参数。费用为预估,最终计费由后端决定;卡片区分预估、实际预扣与结算,不将 --expected-cost 说成最终扣费上限。
16
16
  5. 每次先通过交互确认卡片向用户展示个人/团队归属、具体付款钱包、节点、参数和本次预估 OO 币,等待用户当次确认后,执行 `generation submit --input request.json --operation cat-window-001 --yes --expected-cost 已确认的数值`。缺少授权时先向用户展示具体请求和预估。保留 operation 和返回的 task ID。
17
- 6. `generation wait TASK_ID`;完成后 CLI 自动将真实任务结果回填普通生成节点;已完成旧任务或同步中断用 `generation sync TASK_ID`,仅恢复原任务结果,不再次生成。读回节点的 last_task_id/result/status(文本还需 output),再下载与交付。任务与节点、模型不符、已有其他任务、版本冲突时停止,保留用户编辑;不能将任务 completed 等同画布已落结果。超时继续等待同一 ID,失败查看 get/logs,不自动重新生成。
17
+ 6. 提交后继续执行[生成结果回填与交付](#生成结果回填与交付)。只有原画布节点读回核验通过,才能报告画布结果已交付。
18
18
 
19
19
  生成结果回填画布不等于存入素材库。当前前端没有自动入库,生成/恢复成功后不追加 material save;只有用户要求保存到库时,才按 [素材入库流程](material-graph-workflow.md#已有节点结果直接存素材库) 明确目标空间及目录选择,再独立执行。
20
20
 
21
+ ## 生成结果回填与交付
22
+
23
+ 适用于已授权的普通画布文本、图片和视频生成。保留同一 profile、operation、原 task ID、画布和节点;沿用本次有效的[画布标签关闭确认](interaction-workflow.md#画布标签关闭确认),保持到回填读回结束。用户明确只查状态、只读排查或只下载原文件时,不执行会写画布的命令。
24
+
25
+ 1. **默认等待并回填。** 提交成功后运行 `generation wait TASK_ID`,让命令运行至完成。收到 `canvas_sync.synced=true` 后直接读回,不再重复同步。等待超时只继续跟踪同一任务,不把超时判为生成失败。
26
+ 2. **分开轮询时补齐回填。** `generation get TASK_ID` 只读。若用它查到 `completed`,先确认返回的是原任务、原画布和原节点,核对最新节点及任务记录;已有更新任务或目标不再匹配时停止。原结果尚未落图且可以安全继续时,执行一次 `generation sync TASK_ID`。下载成功也不替代回填,不重新提交、不重新上传素材。
27
+ 3. **独立读回再交付。** 用 `canvas get DOC_ID` 找到原节点:`data.last_task_id` 必须是本次任务,`data.status` 为 `completed`,`data.result` 对应原任务。图片/视频核对可用媒体引用(以 `file_ref` 核对来源,不以签名 URL 是否逐字相同判断),并核对任务返回的必要媒体信息;文本核对 `data.output`。按需要下载,并分别报告生成、回填和下载结果。
28
+ 4. **中断后续接原任务。** Agent 或 CLI 退出后不会在后台自动回填。用同一 profile 的 `generation operations` 找回原任务及画布/节点。先用 `generation get` 和 `canvas get` 核对当前状态,再按本节继续等待或同步。若上次保存响应未知,先读回;确认原结果已在时,可用同一任务的 `generation sync` 核验并补记,不重复写入已匹配的结果。版本冲突、节点删除、模型或任务绑定改变时停止并报告。若任务已完成但回填失败或未核验,明确报「已生成,画布回填待处理」,不要改用 `node update` 手填结果或另起生成。
29
+
30
+ `generation operations` 的 `canvas_sync.state` 是本机最后一次记录,不是实时画布状态:`unverified` 表示未记录回填核验(包括旧记录),`pending` 表示 CLI 已看到任务完成但尚未核验落图,`verified` 表示 CLI 已核验原节点结果,并给出当时的画布版本。仅用 `generation get` 查询不会改变该记录;对待续接任务仍以最新任务和节点读回为准。不在启动时自动扫描并写画布。
31
+
21
32
  ## 团队共享钱包与费用核验
22
33
 
23
34
  - 团队 OO 币余额由团队成员共用。普通成员只能看到自己的消费记录,不能查看其他成员的消费;本人没有执行消费任务或没有新增本人消费记录时,余额仍可能因其他成员同时使用而减少。这是用户明确的产品规则,不把成员可见账单当作团队全部账单。
@@ -43,6 +54,12 @@ CLI 按当前前端普通节点及请求构造器已使用的字段维护生成
43
54
  - Seedance 视频的比例/分辨率按前端放在顶层;图片和其他视频模型按前端放入 extra。prepare 不补用户协议同意;active 审核可生成 reference_canvas_task_ids,素材引用与保存的审核不一致时停止。
44
55
  - 当前前端普通图片运行路径以入边判断模式,但参考列表可包含断线保留素材,因此可能出现有 reference_image_urls 而模式为 text2image。CLI 保留该差异并展示实际请求;这不是对服务端能成功执行该组合的保证。需要图生图时先核对实际模型和合法连线,不静默更换模式或丢弃参考。
45
56
 
57
+ ### 参考素材可见性
58
+
59
+ 生成请求里的媒体 URL、素材 ID 或审核任务 ID 不会自动在画布创建素材节点,也不会自动挂到生成节点。对授权交付到画布的任务,先将用户选定的素材复用、上传或导入同一画布,再通过合法连线连接到原生成节点;已保存且能显示的节点引用可直接复用。用 `canvas get` 确认关联已保存,再用 `generation prepare` 对照 `source_node_ids`、`references` 和 `reference_mapping` 核对用户指定的素材及其顺序。不把 URL 手工加进生成请求来代替关联。
60
+
61
+ 回填时只更新生成结果,保留现有连线和节点引用。读回结果时一并确认预期素材关联仍在;不把「文件存在」或「结果已回填」当作「引用已显示」。历史任务若没有可核验的原素材来源,报告引用待核对,不凭 URL 猜测源节点,不自动把历史素材添加到当前节点或再次生成。
62
+
46
63
  ## 视频协议前置检查
47
64
 
48
65
  Seedance 首次使用或协议更新时,按 [协议签署与继续原任务](agreement-workflow.md) 查询当前 required/agreed/version。HTTP 200 + code 4401 表示《统一协议》中《Seedance2.0模型使用承诺书》待签署,是生成前置条件,不是生成任务失败。
@@ -55,6 +55,7 @@
55
55
  - 预估、实际预扣和最终结算分别报告,不能把估价或 expected-cost 描述为保证最终费用上限;团队余额差额不代表本次消费。
56
56
  - 卡片授权与系统命令执行审批独立。执行权限继续走宿主审批工具;用户协议必须通过既有阅读签署流程由本人确认,聊天卡片不能代签。
57
57
  - 确认后执行同一 operation,保留幂等键和 task ID,自动查询、同步、下载并交付。等待超时继续查询原任务;响应未知先查原副作用再按专用恢复规则处理,不换键重提。不要在提交成功或排队时就结束整个创作任务。
58
+ - 对授权交付到画布的普通生成,按[生成结果回填与交付](generation-workflow.md#生成结果回填与交付)收尾:优先用 wait 等待并回填;如果用 get 轮询,完成后还需同步原任务并读回。只读查询和只下载不触发回填。
58
59
 
59
60
  ## 验收情景
60
61
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.6.4
4
+
5
+ - 普通生成支持从本地 operation 查找原任务和上次回填状态;等待或同步后读回画布,确认结果已落到原节点。
6
+ - 更新随包 Skill:任务中断后续接原任务;提交前核对参考素材已关联到画布节点。
7
+
3
8
  ## 0.6.3
4
9
 
5
10
  - 新增 `generation limits`,可离线查看普通 AI 生成提交限额与等待建议。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzooai/anime-ai-cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
package/src/cli.js CHANGED
@@ -17,7 +17,7 @@ import { materialFolders, materialList, materialGet, materialUpload, materialUse
17
17
  import { downloadMaterial } from './material-download.js'
18
18
  import { graphWrite, prepareGeneration, renameGraphGroup, ungroupGraph } from './graph.js'
19
19
  import { uploadAndBind, bindUpload } from './resource.js'
20
- import {syncGeneration} from './generation-graph.js'
20
+ import {generationOperationSummary,syncGeneration} from './generation-graph.js'
21
21
  import { prepareDraftFinalize, submitDraftFinalize, getDraftFinalize, waitDraftFinalize } from './draft-finalize.js'
22
22
  import { listNodeHistory, selectNodeHistory, recoverNodeHistory } from './node-history.js'
23
23
  import { addNodeHistory, recoverHistoryAdd } from './history-add.js'
@@ -106,14 +106,15 @@ const help = `Uzoo Anime CLI — 浏览器授权与独立 CLI 会话
106
106
  generation estimate --input request.json
107
107
  generation limits [--format json|table](离线查看普通 AI 生成提交限额及 429 处理)
108
108
  generation submit --input request.json --operation NAME [--yes --expected-cost OO]
109
- generation recover NAME [--yes] | operations
110
- generation get ID | logs ID | wait ID [--interval MS] [--timeout MS]
109
+ generation recover NAME [--yes] | operations(本地任务及回填记录)
110
+ generation get ID | logs ID(只读;get 查到完成不会回填)
111
+ generation wait ID [--interval MS] [--timeout MS](等待原任务,完成后回填画布)
111
112
  generation history --doc ID --node ID --space personal|team [--team-uid UID --status STATUS --limit N --offset N]
112
113
  generation history-select --input FILE --operation NAME [--yes]
113
114
  generation history-add --input FILE --operation NAME [--yes]
114
115
  generation history-add-get|history-add-recover OPERATION
115
116
  generation history-get NAME | history-recover NAME(查询原历史选择,不重发写入)
116
- generation sync ID(将原任务真实结果保存至普通生成节点)
117
+ generation sync ID(补回填原任务,不重新生成;用 canvas get 读回确认)
117
118
  generation protocol-status(只读查询视频《统一协议》确认状态)
118
119
  generation protocol-open [--browser default|msedge|chrome] [--timeout MS]
119
120
  generation download ID [--output FILE] [--sha256 HEX]
@@ -329,7 +330,7 @@ async function main(args) {
329
330
  const dir = path.join(root(), 'operations', store.name)
330
331
  const files = await fs.readdir(dir).catch(e => { if (e.code === 'ENOENT') return []; throw e })
331
332
  const records = await Promise.all(files.filter(f => f.endsWith('.json')).map(f => json(path.join(dir, f))))
332
- print(records.map(({ operation, taskId, state, createdAt, lastFailure }) => ({ operation, taskId, state:lastFailure?.apiCode===4401?'blocked_agreement':state, createdAt, ...(lastFailure?{lastFailure}:{}) })), format); return
333
+ print(records.map(generationOperationSummary), format); return
333
334
  }
334
335
  if (group === 'repaint' && action === 'operations') { print(await listRepaints(store.name), format); return }
335
336
  if (group === 'service') {
@@ -468,8 +469,8 @@ async function main(args) {
468
469
  else if (action === 'estimate') { const body = await input(o.input); validateGeneration(body); data = await client.raw('/canvas/generations/estimate', { method: 'POST', body }) }
469
470
  else if (action === 'submit') data = await submit(client, store.name, await input(o.input), need(o.operation, '--operation'), o.yes, expectedCost,{onAgreementRequired:({operation,body,estimate})=>openAgreement({operation,model:body.model_name,cost:estimate.estimated_credit_cost})})
470
471
  else if (action === 'recover') data = await recover(client, store.name, id(), o.yes, expectedCost,{onAgreementRequired:({operation,body,estimate})=>openAgreement({operation,model:body.model_name,cost:estimate.estimated_credit_cost})})
471
- else if (action === 'sync') data=await syncGeneration(client,id())
472
- else if (action === 'wait') {data = await wait(client, id(), number(o.interval, 3000), number(o.timeout, 600000));data={...data,canvas_sync:await syncGeneration(client,id())}}
472
+ else if (action === 'sync') data=await syncGeneration(client,id(),{track:true})
473
+ else if (action === 'wait') {data = await wait(client, id(), number(o.interval, 3000), number(o.timeout, 600000));data={...data,canvas_sync:await syncGeneration(client,id(),{track:true})}}
473
474
  else if (action === 'download') { if (o.sha256 && !/^[a-fA-F0-9]{64}$/.test(o.sha256)) throw new Error('--sha256 必须是 64 位十六进制'); data = await download(client, id(), o.output, o.sha256) }
474
475
  else data = await client.raw(`/canvas/generations/${segment(id())}${action === 'logs' ? '/logs' : ''}`)
475
476
  print(data, format)
@@ -2,21 +2,51 @@ import { writeNode } from './canvas.js'
2
2
  import { segment } from './workflows.js'
3
3
  import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
- import { root, safeName, json } from './store.js'
5
+ import { atomic, root, safeName, json } from './store.js'
6
6
  import { isDeepStrictEqual } from 'node:util'
7
7
 
8
- async function submissionBaseline(client,taskId) {
8
+ async function matchingOperation(client, task) {
9
9
  if(!client.profile)return null
10
10
  const directory=path.join(root(),'operations',safeName(client.profile))
11
11
  const files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error})
12
12
  const candidates=[]
13
- for(const file of files.filter(f=>f.endsWith('.json'))){
14
- const record=await json(path.join(directory,file),null)
15
- if(String(record?.taskId)===String(taskId)&&record.baseUrl===client.baseUrl&&record.syncBaseline)candidates.push(record)
13
+ for(const name of files.filter(f=>f.endsWith('.json'))){
14
+ const file=path.join(directory,name)
15
+ const record=await json(file,null)
16
+ if(String(record?.taskId)===String(task.task_id??task.id)&&record.baseUrl===client.baseUrl)candidates.push({file,record})
16
17
  }
17
18
  if(candidates.length!==1)return null
19
+ const match=candidates[0]
20
+ const binding=match.record.body || match.record.syncBaseline
21
+ if(binding?.doc_id!==task.doc_id || (binding.node_id??binding.node?.id)!==task.node_id)return null
18
22
  const me=await client.raw('/auth/me')
19
- return String(me.uid??me.id)===candidates[0].owner?candidates[0].syncBaseline:null
23
+ return String(me.uid??me.id)===match.record.owner?match:null
24
+ }
25
+
26
+ export function generationOperationSummary({ operation, taskId, state, createdAt, lastFailure, body, syncBaseline, canvas_sync }) {
27
+ const syncState=['pending','verified'].includes(canvas_sync?.state)?canvas_sync.state:'unverified'
28
+ return { operation, taskId, state:lastFailure?.apiCode===4401?'blocked_agreement':state, createdAt,
29
+ ...(lastFailure?{lastFailure}:{}),
30
+ ...(taskId?{
31
+ doc_id:body?.doc_id??syncBaseline?.doc_id,
32
+ node_id:body?.node_id??syncBaseline?.node?.id,
33
+ canvas_sync:{state:syncState,...(syncState==='verified'&&Number.isSafeInteger(canvas_sync.version)?{version:canvas_sync.version}:{})}
34
+ }:{})
35
+ }
36
+ }
37
+
38
+ async function recordCanvasSync(match, state, version) {
39
+ if(!match)return
40
+ const next={state,...(version===undefined?{}:{version})}
41
+ if(isDeepStrictEqual(match.record.canvas_sync,next))return
42
+ match.record.canvas_sync=next
43
+ await atomic(match.file,match.record)
44
+ }
45
+
46
+ function hasResultPatch(node, patch) {
47
+ return Object.entries(patch).every(([key,value])=>key==='extra'
48
+ ? Object.entries(value).every(([k,v])=>isDeepStrictEqual(node?.data?.extra?.[k],v))
49
+ : isDeepStrictEqual(node?.data?.[key],value))
20
50
  }
21
51
  async function matchingDraftModel(client,doc,node,task){
22
52
  if(task.segment_type!=='video'||task.draft?.step!=='preview'||node.data?.extra?.draft_preview!==true)return false
@@ -34,7 +64,7 @@ async function matchingDraftModel(client,doc,node,task){
34
64
 
35
65
  // Task completion and canvas persistence are separate contracts. Reconcile the
36
66
  // original real task; never create or re-submit a generation here.
37
- export async function syncGeneration(client, taskId, { baseline } = {}) {
67
+ export async function syncGeneration(client, taskId, { baseline, track = false } = {}) {
38
68
  const task = await client.raw(`/canvas/generations/${segment(taskId)}`)
39
69
  if (
40
70
  String(task.task_id ?? task.id) !== String(taskId) ||
@@ -45,7 +75,11 @@ export async function syncGeneration(client, taskId, { baseline } = {}) {
45
75
  throw new Error('生成任务绑定不完整')
46
76
  if (!['completed', 'failed', 'cancelled', 'canceled'].includes(task.status))
47
77
  return { synced: false, task_id: taskId, status: task.status, next: '继续查询原任务' }
48
- const doc = await client.raw(`/canvas/docs/${segment(task.doc_id)}`)
78
+ const trackCompleted=track && task.status==='completed'
79
+ const operation=trackCompleted?await matchingOperation(client,task):null
80
+ if(operation?.record.canvas_sync?.state!=='verified')await recordCanvasSync(operation,'pending')
81
+ const docPath=`/canvas/docs/${segment(task.doc_id)}`
82
+ const doc = await client.raw(docPath)
49
83
  const node = doc.nodes?.find((n) => n.id === task.node_id)
50
84
  if (
51
85
  !node ||
@@ -58,7 +92,7 @@ export async function syncGeneration(client, taskId, { baseline } = {}) {
58
92
  throw new Error('任务目标不是普通生成节点;派生流程不能通用回填')
59
93
  const current = String(node.data?.last_task_id || node.data?.result?.task_id || '')
60
94
  if (current && current !== String(taskId)) {
61
- const original=baseline || await submissionBaseline(client,taskId)
95
+ const original=baseline || (trackCompleted?operation:await matchingOperation(client,task))?.record.syncBaseline
62
96
  if(!original || original.doc_id!==doc.id || (original.team_uid||null)!==(doc.team_uid||null) ||
63
97
  !isDeepStrictEqual(original.node,node) || !/^\d+$/.test(current) || BigInt(current)>=BigInt(taskId))
64
98
  throw new Error('节点已关联其他生成任务;提交前快照不匹配,停止以保留后续结果')
@@ -89,12 +123,8 @@ export async function syncGeneration(client, taskId, { baseline } = {}) {
89
123
  ...(task.segment_type==='video'&&task.draft?.step==='preview'?{draft:structuredClone(task.draft)}:{}) }
90
124
  if(task.segment_type==='video'&&task.draft?.step==='preview'&&Object.hasOwn(node.data||{},'draft'))patch.draft=structuredClone(task.draft)
91
125
  }
92
- const same = Object.entries(patch).every(([key, value]) =>
93
- key === 'extra'
94
- ? Object.entries(value).every(([k, v]) => isDeepStrictEqual(node.data?.extra?.[k],v))
95
- : JSON.stringify(node.data?.[key]) === JSON.stringify(value)
96
- )
97
- if (same)
126
+ if (hasResultPatch(node,patch)) {
127
+ await recordCanvasSync(operation,'verified',doc.version)
98
128
  return {
99
129
  synced: true,
100
130
  unchanged: true,
@@ -104,6 +134,14 @@ export async function syncGeneration(client, taskId, { baseline } = {}) {
104
134
  version: doc.version,
105
135
  status: task.status
106
136
  }
137
+ }
138
+ await recordCanvasSync(operation,'pending')
139
+ if(trackCompleted && (!current || current===String(taskId))){
140
+ const query=new URLSearchParams({doc_id:task.doc_id,node_id:task.node_id,limit:'1'})
141
+ const latest=await client.raw('/canvas/generations?'+query)
142
+ if(String(latest.items?.[0]?.task_id??latest.items?.[0]?.id)!==String(taskId))
143
+ throw new Error('存在后续任务或最新任务无法核验,停止回填')
144
+ }
107
145
  const bound = await writeNode(client, {
108
146
  docId: doc.id,
109
147
  nodeId: node.id,
@@ -113,12 +151,22 @@ export async function syncGeneration(client, taskId, { baseline } = {}) {
113
151
  expectedTeamUid: doc.team_uid,
114
152
  expectedVersion: doc.version
115
153
  })
154
+ let version=bound.version
155
+ if(trackCompleted){
156
+ const saved=await client.raw(docPath)
157
+ if(saved.id!==doc.id || (saved.team_uid||null)!==(doc.team_uid||null) ||
158
+ !Number.isSafeInteger(saved.version) || saved.version<bound.version ||
159
+ !hasResultPatch(saved.nodes?.find(n=>n.id===task.node_id&&n.type===task.segment_type),patch))
160
+ throw new Error(`任务 ${taskId} 已完成,但画布回填读回未通过;先核对原节点,不要重新生成`)
161
+ version=saved.version
162
+ await recordCanvasSync(operation,'verified',version)
163
+ }
116
164
  return {
117
165
  synced: true,
118
166
  task_id: taskId,
119
167
  doc_id: doc.id,
120
168
  node_id: node.id,
121
- version: bound.version,
169
+ version,
122
170
  status: task.status
123
171
  }
124
172
  }