@uzooai/anime-ai-cli 0.0.0-stage → 0.6.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.
Files changed (89) hide show
  1. package/.agents/skills/uzoo-anime-cli/SKILL.md +89 -0
  2. package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +41 -0
  3. package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
  4. package/.agents/skills/uzoo-anime-cli/references/commands.md +242 -0
  5. package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +142 -0
  6. package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +153 -0
  7. package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
  8. package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +113 -0
  9. package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +64 -0
  10. package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +90 -0
  11. package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
  12. package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +13 -0
  13. package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +37 -0
  14. package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +67 -0
  15. package/CHANGELOG.md +13 -0
  16. package/LICENSE +21 -0
  17. package/README.md +46 -3
  18. package/assets/favicon-uzoo.png +0 -0
  19. package/assets/logo-uzoo.svg +7 -0
  20. package/docs/Agent/346/216/245/345/205/245/344/270/216/347/211/210/346/234/254/346/233/264/346/226/260.md +43 -0
  21. package/examples/matting.json +5 -0
  22. package/extensions/uzoo-cli-login/manifest.json +9 -0
  23. package/extensions/uzoo-cli-login/popup.css +1 -0
  24. package/extensions/uzoo-cli-login/popup.html +14 -0
  25. package/extensions/uzoo-cli-login/popup.js +65 -0
  26. package/package.json +53 -6
  27. package/src/agreement-page.js +62 -0
  28. package/src/agreement.js +419 -0
  29. package/src/annotation-media.js +386 -0
  30. package/src/api.js +168 -0
  31. package/src/browser-login.js +13 -0
  32. package/src/callback-page.js +44 -0
  33. package/src/canvas.js +105 -0
  34. package/src/cli-authorization.js +122 -0
  35. package/src/cli.js +464 -0
  36. package/src/download-name.js +17 -0
  37. package/src/draft-finalize.js +229 -0
  38. package/src/generation-graph.js +124 -0
  39. package/src/generation-input.js +472 -0
  40. package/src/graph-disconnect.js +89 -0
  41. package/src/graph.js +431 -0
  42. package/src/group-edit.js +53 -0
  43. package/src/group-layout.js +113 -0
  44. package/src/group-membership.js +125 -0
  45. package/src/history-add.js +119 -0
  46. package/src/image-annotation.js +246 -0
  47. package/src/image-crop.js +283 -0
  48. package/src/image-matting.js +82 -0
  49. package/src/image-media.js +89 -0
  50. package/src/image-output.js +385 -0
  51. package/src/install.js +237 -0
  52. package/src/local-output.js +23 -0
  53. package/src/material-download.js +55 -0
  54. package/src/material-save.js +254 -0
  55. package/src/materials.js +147 -0
  56. package/src/matting-background.js +73 -0
  57. package/src/matting-jobs.js +63 -0
  58. package/src/matting-worker.js +49 -0
  59. package/src/matting.js +170 -0
  60. package/src/media-copy.js +53 -0
  61. package/src/media.js +343 -0
  62. package/src/model-options.js +76 -0
  63. package/src/multipart.js +28 -0
  64. package/src/node-copy.js +95 -0
  65. package/src/node-delete.js +49 -0
  66. package/src/node-history.js +231 -0
  67. package/src/normal-login.js +118 -0
  68. package/src/package-info.js +8 -0
  69. package/src/png-alpha.js +60 -0
  70. package/src/protocol.js +83 -0
  71. package/src/remake.js +738 -0
  72. package/src/repaint-archive.js +53 -0
  73. package/src/repaint-delivery.js +783 -0
  74. package/src/repaint-export.js +63 -0
  75. package/src/repaint-graph.js +214 -0
  76. package/src/repaint-history.js +267 -0
  77. package/src/repaint-materials.js +601 -0
  78. package/src/repaint-reverse.js +614 -0
  79. package/src/repaint-sequence.js +132 -0
  80. package/src/repaint-settings.js +576 -0
  81. package/src/repaint-video.js +798 -0
  82. package/src/repaint.js +326 -0
  83. package/src/resource.js +110 -0
  84. package/src/review.js +435 -0
  85. package/src/service.js +85 -0
  86. package/src/store.js +100 -0
  87. package/src/video-clip.js +381 -0
  88. package/src/video-frame.js +296 -0
  89. package/src/workflows.js +214 -0
@@ -0,0 +1,576 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { isDeepStrictEqual } from 'node:util'
4
+ import { atomic, json, root, safeName } from './store.js'
5
+ import { repaintGet, recordPath } from './repaint.js'
6
+ import { repaintPromptContext } from './repaint-reverse.js'
7
+ import { repaintOf, jobPath } from './repaint-graph.js'
8
+ import { requireServiceReady } from './service.js'
9
+
10
+ const text = (value) => String(value ?? '')
11
+ const plain = (value) =>
12
+ text(value)
13
+ .replace(/<[^>]*>/g, '')
14
+ .trim()
15
+ const object = (v) => v && typeof v === 'object' && !Array.isArray(v)
16
+ const fail = (code, message, cause) =>
17
+ Object.assign(new Error(message), {
18
+ code,
19
+ ...(cause
20
+ ? {
21
+ cause,
22
+ diagnostic: {
23
+ ...(cause.status ? { http_status: cause.status } : {}),
24
+ ...(cause.apiCode ? { api_code: cause.apiCode } : {}),
25
+ ...(cause.requestId ? { request_id: cause.requestId } : {})
26
+ }
27
+ }
28
+ : {})
29
+ })
30
+ const api = (client, endpoint, options = {}, controls) =>
31
+ client.raw(
32
+ endpoint,
33
+ {
34
+ ...options,
35
+ headers: { ...options.headers, 'X-Client-Source': 'canvas' }
36
+ },
37
+ controls
38
+ )
39
+ export const repaintSettingsPath = (profile, operation) =>
40
+ path.join(
41
+ root(),
42
+ 'repaint-settings',
43
+ safeName(profile),
44
+ safeName(operation) + '.json'
45
+ )
46
+ const persist = (r) => atomic(repaintSettingsPath(r.profile, r.operation), r)
47
+
48
+ export function repaintSettingsInput(value) {
49
+ if (
50
+ !object(value) ||
51
+ Object.keys(value).some((k) => k !== 'custom_prompt') ||
52
+ typeof value.custom_prompt !== 'string'
53
+ )
54
+ throw new Error(
55
+ '全局规则输入仅接受custom_prompt字符串;附件新增/删除/替换用独立待接入流程'
56
+ )
57
+ if (
58
+ value.custom_prompt.length > 20000 ||
59
+ /<[^>]*>|\u0000/.test(value.custom_prompt)
60
+ )
61
+ throw new Error(
62
+ '全局规则须为不含HTML/空字符的纯文本,最长20000字符;空字符串表示明确清空'
63
+ )
64
+ return { custom_prompt: value.custom_prompt.trim() }
65
+ }
66
+ const fields = (n) =>
67
+ Object.fromEntries(
68
+ ['input', 'prompt', 'output'].map((k) => [k, text(n.data?.[k])])
69
+ )
70
+ const nodeText = (n) => plain(n.data?.output || n.data?.prompt || n.data?.input)
71
+ export function repaintAttachmentView(list, base) {
72
+ if (list == null) return []
73
+ if (
74
+ !Array.isArray(list) ||
75
+ list.length > 10 ||
76
+ list.some(
77
+ (a) =>
78
+ !object(a) ||
79
+ typeof a.name !== 'string' ||
80
+ !a.name ||
81
+ a.name.length > 255 ||
82
+ typeof a.media_type !== 'string' ||
83
+ !/^(?:image\/[^\s]+|application\/pdf)$/i.test(a.media_type.trim()) ||
84
+ typeof a.url !== 'string' ||
85
+ !a.url.trim()
86
+ )
87
+ )
88
+ throw new Error('已有全局附件结构无效;不清空或静默裁减')
89
+ return list.map((a) => {
90
+ const url = new URL(a.url.trim(), new URL(base).origin)
91
+ if (
92
+ !['http:', 'https:', 'data:'].includes(url.protocol) ||
93
+ url.username ||
94
+ url.password
95
+ )
96
+ throw new Error('已有附件URL不合规,停止')
97
+ return { name: a.name, media_type: a.media_type.trim(), url: url.href }
98
+ })
99
+ }
100
+ const attachmentView = repaintAttachmentView
101
+ export async function repaintSettingsContext(client, profile, parentOperation) {
102
+ const parent = await json(recordPath(profile, parentOperation), null)
103
+ if (!parent) throw new Error('未找到原转绘operation')
104
+ const initial = await repaintGet(client, profile, parentOperation)
105
+ const index = initial.job.plan?.segments?.[0]?.index
106
+ const ctx = await repaintPromptContext(
107
+ client,
108
+ profile,
109
+ parentOperation,
110
+ index,
111
+ {
112
+ allowUnsyncedGlobal: true
113
+ }
114
+ )
115
+ const marker = ctx.doc.meta.cli_repaint_workflows[parentOperation]
116
+ const finals = ctx.doc.nodes.filter(
117
+ (n) =>
118
+ marker.node_ids.includes(n.id) && repaintOf(n).role === 'final-prompt'
119
+ )
120
+ if (
121
+ ctx.global.type !== 'text' ||
122
+ finals.length !== ctx.job.plan.segments.length ||
123
+ finals.some(
124
+ (n) =>
125
+ !ctx.doc.edges.some(
126
+ (e) =>
127
+ e.source === ctx.global.id &&
128
+ e.target === n.id &&
129
+ e.data?.role === 'global-prompt-link'
130
+ )
131
+ )
132
+ )
133
+ throw new Error('全局节点/各段系统线不完整,不编辑或重建')
134
+ const stored =
135
+ ctx.global.data?.repaint || ctx.global.data?.extra?.repaint || {}
136
+ const nodeAttachments = attachmentView(stored.attachments, client.baseUrl)
137
+ const serverAttachments = attachmentView(
138
+ ctx.job.delivery_prompt_attachments,
139
+ client.baseUrl
140
+ )
141
+ const binding = {
142
+ job_id: ctx.job.id,
143
+ plan_version: ctx.job.plan_version,
144
+ group_id: ctx.binding.group_id,
145
+ node_id: ctx.global.id,
146
+ segment_indices: ctx.job.plan.segments.map((s) => s.index),
147
+ node_ids: [...marker.node_ids].sort()
148
+ }
149
+ return {
150
+ ...ctx,
151
+ binding,
152
+ nodeAttachments,
153
+ serverAttachments,
154
+ nodeValue: nodeText(ctx.global),
155
+ serverValue: text(ctx.job.delivery_prompt_custom_prompt).trim()
156
+ }
157
+ }
158
+ const context = repaintSettingsContext
159
+ export async function requireRepaintSettingsIdle(client, ctx, profile) {
160
+ if (
161
+ ctx.job.outputs?.some(
162
+ (o) =>
163
+ o.reverse_prompt_status === 'running' ||
164
+ o.delivery_prompt_status === 'running'
165
+ )
166
+ )
167
+ throw new Error('原任务仍有提示词正在处理,待完成后再修改全局规则')
168
+ const tasks = await api(client, jobPath(ctx.job.id) + '/redraw-tasks')
169
+ if (
170
+ !Array.isArray(tasks.items) ||
171
+ tasks.items.some((t) =>
172
+ ['queued', 'processing', 'retrying'].includes(t.status)
173
+ )
174
+ )
175
+ throw new Error('原任务有进行中视频或任务列表不完整,停止修改全局规则')
176
+ for (const folder of [
177
+ 'repaint-reverse',
178
+ 'repaint-delivery',
179
+ 'repaint-video'
180
+ ]) {
181
+ const dir = path.join(root(), folder, safeName(profile))
182
+ const files = await fs.readdir(dir).catch((e) => {
183
+ if (e.code === 'ENOENT') return []
184
+ throw e
185
+ })
186
+ for (const name of files.filter((n) => n.endsWith('.json'))) {
187
+ const r = await json(path.join(dir, name))
188
+ if (
189
+ r.baseUrl === client.baseUrl &&
190
+ r.owner === ctx.parent.owner &&
191
+ r.binding?.job_id === ctx.job.id &&
192
+ (r.attempted || r.settingsAttempted) &&
193
+ !['completed', 'completed_fallback', 'failed'].includes(r.state)
194
+ )
195
+ throw new Error(
196
+ '该job存在未结束或未知提示词/视频operation,先查询原操作,不改变其全局输入'
197
+ )
198
+ }
199
+ }
200
+ }
201
+ const idle = requireRepaintSettingsIdle
202
+ async function preventDuplicate(client, profile, operation, ctx) {
203
+ const dir = path.dirname(repaintSettingsPath(profile, operation))
204
+ const files = await fs.readdir(dir).catch((e) => {
205
+ if (e.code === 'ENOENT') return []
206
+ throw e
207
+ })
208
+ for (const name of files.filter((n) => n.endsWith('.json'))) {
209
+ const r = await json(path.join(dir, name))
210
+ if (
211
+ r.operation !== operation &&
212
+ r.baseUrl === client.baseUrl &&
213
+ r.owner === ctx.parent.owner &&
214
+ r.binding?.job_id === ctx.job.id &&
215
+ (r.serverAttempted || r.canvasAttempted) &&
216
+ r.state !== 'completed'
217
+ )
218
+ throw new Error(
219
+ '同job已有未确认的全局规则保存;先恢复原operation,不能换名覆盖'
220
+ )
221
+ }
222
+ }
223
+ function check(record, ctx) {
224
+ if (
225
+ !isDeepStrictEqual(record.binding, ctx.binding) ||
226
+ record.owner !== ctx.parent.owner
227
+ )
228
+ throw new Error('原全局规则job/方案/节点关联已变化,停止')
229
+ if (
230
+ ![record.attachments, record.targetAttachments].some(
231
+ (a) => a && isDeepStrictEqual(a, ctx.nodeAttachments)
232
+ ) ||
233
+ ![record.attachments, record.targetAttachments].some(
234
+ (a) => a && isDeepStrictEqual(a, ctx.serverAttachments)
235
+ )
236
+ )
237
+ throw new Error('全局附件已变化,停止原文本保存;不清空或覆盖附件')
238
+ const current = fields(ctx.global)
239
+ const sameBefore = Object.entries(record.nodeBefore).every(
240
+ ([k, v]) => current[k] === v
241
+ )
242
+ const sameTarget = Object.values(current).every(
243
+ (v) => v === record.input.custom_prompt
244
+ )
245
+ // Web codec may drop the redundant input field; output/prompt still bind the edit.
246
+ const webTarget =
247
+ current.output === record.input.custom_prompt &&
248
+ current.prompt === record.input.custom_prompt &&
249
+ !current.input
250
+ if (!(sameBefore || sameTarget || webTarget))
251
+ throw fail(
252
+ 'REPAINT_SETTINGS_SYNC_PENDING',
253
+ '全局节点已被用户编辑,不覆盖;原保存记录保留'
254
+ )
255
+ if (
256
+ ![record.serverBefore, record.input.custom_prompt].includes(ctx.serverValue)
257
+ )
258
+ throw fail(
259
+ 'REPAINT_SETTINGS_SYNC_PENDING',
260
+ '服务端全局规则已被其他编辑修改,不覆盖'
261
+ )
262
+ return {
263
+ nodeTarget:
264
+ ctx.nodeValue === record.input.custom_prompt &&
265
+ isDeepStrictEqual(
266
+ ctx.nodeAttachments,
267
+ record.targetAttachments || record.attachments
268
+ ),
269
+ serverTarget:
270
+ ctx.serverValue === record.input.custom_prompt &&
271
+ isDeepStrictEqual(
272
+ ctx.serverAttachments,
273
+ record.targetAttachments || record.attachments
274
+ )
275
+ }
276
+ }
277
+ async function bound(client, profile, operation) {
278
+ const record = await json(repaintSettingsPath(profile, operation), null)
279
+ if (
280
+ !record ||
281
+ record.schema !== 1 ||
282
+ record.profile !== profile ||
283
+ record.baseUrl !== client.baseUrl
284
+ )
285
+ throw new Error('全局规则operation不存在或服务不匹配')
286
+ const ctx = await context(client, profile, record.parentOperation)
287
+ check(record, ctx)
288
+ return { record, ctx }
289
+ }
290
+ function summary(record, ctx) {
291
+ return {
292
+ operation: record.operation,
293
+ repaint_operation: record.parentOperation,
294
+ state: record.state,
295
+ job_id: record.binding.job_id,
296
+ plan_version: record.binding.plan_version,
297
+ node_id: record.binding.node_id,
298
+ custom_prompt: record.input.custom_prompt,
299
+ server_value: ctx.serverValue,
300
+ canvas_value: ctx.nodeValue,
301
+ server_synced: ctx.serverValue === record.input.custom_prompt,
302
+ canvas_synced: ctx.nodeValue === record.input.custom_prompt,
303
+ attachment_count: (record.targetAttachments || record.attachments).length,
304
+ attachments_preserved: true,
305
+ generation_submitted: false,
306
+ processing_submitted: false,
307
+ existing_results_regenerated: false,
308
+ settings_save_billable: false
309
+ }
310
+ }
311
+ export async function getRepaintSettings(client, profile, parentOperation) {
312
+ const ctx = await context(client, profile, parentOperation)
313
+ return {
314
+ repaint_operation: parentOperation,
315
+ job_id: ctx.job.id,
316
+ plan_version: ctx.job.plan_version,
317
+ node_id: ctx.global.id,
318
+ server_value: ctx.serverValue,
319
+ canvas_value: ctx.nodeValue,
320
+ values_match: ctx.serverValue === ctx.nodeValue,
321
+ attachments_match: isDeepStrictEqual(
322
+ ctx.nodeAttachments,
323
+ ctx.serverAttachments
324
+ ),
325
+ attachment_count: ctx.serverAttachments.length,
326
+ settings_save_billable: false,
327
+ generation_submitted: false,
328
+ processing_submitted: false
329
+ }
330
+ }
331
+ export async function startRepaintSettings(
332
+ client,
333
+ profile,
334
+ parentOperation,
335
+ operation,
336
+ value,
337
+ confirmed = false
338
+ ) {
339
+ return startSettings(
340
+ client,
341
+ profile,
342
+ parentOperation,
343
+ operation,
344
+ value,
345
+ confirmed
346
+ )
347
+ }
348
+ // Only a registered upload receipt reaches this adapter; CLI does not accept
349
+ // arbitrary attachment URLs or silently replace the existing ordered list.
350
+ export async function appendRepaintAttachment(
351
+ client,
352
+ profile,
353
+ parentOperation,
354
+ operation,
355
+ receipt
356
+ ) {
357
+ const existing = await json(repaintSettingsPath(profile, operation), null)
358
+ if (existing) {
359
+ if (
360
+ existing.parentOperation !== parentOperation ||
361
+ !isDeepStrictEqual(existing.appendReceipt, receipt)
362
+ )
363
+ throw new Error('附件设置operation已绑定其他回执')
364
+ return recoverRepaintSettings(client, profile, operation)
365
+ }
366
+ const ctx = await context(client, profile, parentOperation)
367
+ if (
368
+ !receipt?.file_ref ||
369
+ !receipt?.url ||
370
+ !receipt?.name ||
371
+ !receipt?.mime_type
372
+ )
373
+ throw new Error('缺少真实附件注册回执')
374
+ const target = attachmentView(
375
+ [
376
+ ...(ctx.job.delivery_prompt_attachments || []),
377
+ { name: receipt.name, media_type: receipt.mime_type, url: receipt.url }
378
+ ],
379
+ client.baseUrl
380
+ )
381
+ return startSettings(
382
+ client,
383
+ profile,
384
+ parentOperation,
385
+ operation,
386
+ { custom_prompt: ctx.serverValue },
387
+ true,
388
+ { target, receipt }
389
+ )
390
+ }
391
+ async function startSettings(
392
+ client,
393
+ profile,
394
+ parentOperation,
395
+ operation,
396
+ value,
397
+ confirmed = false,
398
+ append
399
+ ) {
400
+ safeName(operation)
401
+ const input = repaintSettingsInput(value)
402
+ await requireServiceReady(client)
403
+ let ctx = await context(client, profile, parentOperation),
404
+ record = await json(repaintSettingsPath(profile, operation), null)
405
+ await preventDuplicate(client, profile, operation, ctx)
406
+ if (record) {
407
+ if (
408
+ record.parentOperation !== parentOperation ||
409
+ !isDeepStrictEqual(record.input, input)
410
+ )
411
+ throw new Error('operation已绑定其他全局文本,不修改原记录')
412
+ ;({ record, ctx } = await bound(client, profile, operation))
413
+ if (record.serverAttempted || record.canvasAttempted)
414
+ return confirmed
415
+ ? recoverRepaintSettings(client, profile, operation)
416
+ : summary(record, ctx)
417
+ } else {
418
+ if (
419
+ ctx.nodeValue !== ctx.serverValue ||
420
+ !isDeepStrictEqual(ctx.nodeAttachments, ctx.serverAttachments)
421
+ )
422
+ throw new Error(
423
+ '画布与后端已有不同全局规则/附件,先明确原编辑来源;不自动选边覆盖'
424
+ )
425
+ record = {
426
+ schema: 1,
427
+ profile,
428
+ operation,
429
+ parentOperation,
430
+ input,
431
+ owner: ctx.parent.owner,
432
+ baseUrl: client.baseUrl,
433
+ binding: ctx.binding,
434
+ nodeBefore: fields(ctx.global),
435
+ serverBefore: ctx.serverValue,
436
+ attachments: ctx.serverAttachments,
437
+ bodyAttachments: structuredClone(
438
+ ctx.job.delivery_prompt_attachments || []
439
+ ),
440
+ ...(append
441
+ ? { targetAttachments: append.target, appendReceipt: append.receipt }
442
+ : {}),
443
+ state: 'prepared',
444
+ createdAt: new Date().toISOString()
445
+ }
446
+ }
447
+ if (!confirmed) {
448
+ await persist(record)
449
+ return {
450
+ ...summary(record, ctx),
451
+ next: '展示原值与新全局要求;已授权的同次免费编辑无需重复费用确认,加--yes保存。保存不重处理任何已有结果。'
452
+ }
453
+ }
454
+ await idle(client, ctx, profile)
455
+ ctx = await context(client, profile, parentOperation)
456
+ check(record, ctx)
457
+ if (!check(record, ctx).serverTarget) {
458
+ record.serverAttempted = true
459
+ record.state = 'saving_server'
460
+ await persist(record)
461
+ try {
462
+ await api(
463
+ client,
464
+ jobPath(ctx.job.id) + '/delivery-prompt-settings',
465
+ {
466
+ method: 'POST',
467
+ body: {
468
+ custom_prompt: input.custom_prompt,
469
+ attachments: record.targetAttachments || record.bodyAttachments
470
+ }
471
+ },
472
+ { retry: false }
473
+ )
474
+ } catch (error) {
475
+ record.state = 'server_unknown'
476
+ await persist(record)
477
+ throw fail(
478
+ 'REPAINT_SETTINGS_SAVE_UNKNOWN',
479
+ '全局规则服务端保存响应未确认;仅查询原operation,不重发设置或生成',
480
+ error
481
+ )
482
+ }
483
+ record.serverConfirmed = true
484
+ await persist(record)
485
+ }
486
+ return recoverRepaintSettings(client, profile, operation)
487
+ }
488
+ export async function repaintSettingsOperation(client, profile, operation) {
489
+ const { record, ctx } = await bound(client, profile, operation)
490
+ return summary(record, ctx)
491
+ }
492
+ export async function recoverRepaintSettings(client, profile, operation) {
493
+ await requireServiceReady(client)
494
+ const { record, ctx } = await bound(client, profile, operation)
495
+ const status = check(record, ctx)
496
+ if (
497
+ !record.serverAttempted &&
498
+ !record.canvasAttempted &&
499
+ !record.serverConfirmed &&
500
+ !status.serverTarget
501
+ )
502
+ return summary(record, ctx)
503
+ if (!status.serverTarget)
504
+ throw fail(
505
+ 'REPAINT_SETTINGS_SAVE_UNKNOWN',
506
+ '原设置POST尚未查到目标文本;保留未知状态,不自动重发或覆盖后端'
507
+ )
508
+ record.serverConfirmed = true
509
+ await persist(record)
510
+ await idle(client, ctx, profile)
511
+ if (!status.nodeTarget) {
512
+ record.canvasAttempted = true
513
+ record.state = 'saving_canvas'
514
+ await persist(record)
515
+ ctx.global.data = {
516
+ ...ctx.global.data,
517
+ ...Object.fromEntries(
518
+ ['input', 'prompt', 'output'].map((k) => [
519
+ k,
520
+ record.input.custom_prompt
521
+ ])
522
+ )
523
+ }
524
+ if (record.targetAttachments) {
525
+ const d = ctx.global.data
526
+ if (d.repaint)
527
+ d.repaint = { ...d.repaint, attachments: record.targetAttachments }
528
+ d.extra = {
529
+ ...d.extra,
530
+ repaint: { ...d.extra?.repaint, attachments: record.targetAttachments }
531
+ }
532
+ }
533
+ try {
534
+ const saved = await api(
535
+ client,
536
+ '/canvas/docs/' + encodeURIComponent(ctx.doc.id),
537
+ {
538
+ method: 'PUT',
539
+ body: {
540
+ version: ctx.doc.version,
541
+ name: ctx.doc.name,
542
+ viewport: ctx.doc.viewport,
543
+ nodes: ctx.doc.nodes,
544
+ edges: ctx.doc.edges,
545
+ meta: ctx.doc.meta
546
+ }
547
+ },
548
+ { retry: false }
549
+ )
550
+ if (
551
+ saved.version !== ctx.doc.version + 1 ||
552
+ nodeText(saved.nodes?.find((n) => n.id === ctx.global.id) || {}) !==
553
+ record.input.custom_prompt
554
+ )
555
+ throw new Error('保存响应缺少对应全局节点或版本')
556
+ } catch (error) {
557
+ record.state = 'sync_pending'
558
+ await persist(record)
559
+ throw fail(
560
+ 'REPAINT_SETTINGS_SYNC_PENDING',
561
+ '后端全局规则已保存,画布同步未确认;恢复只同步原文本,不重发设置或处理任务',
562
+ error
563
+ )
564
+ }
565
+ }
566
+ const fresh = await context(client, profile, record.parentOperation)
567
+ const final = check(record, fresh)
568
+ if (!final.nodeTarget || !final.serverTarget)
569
+ throw fail(
570
+ 'REPAINT_SETTINGS_SYNC_PENDING',
571
+ '全局文本最终读回不一致,停止;不反复覆盖'
572
+ )
573
+ record.state = 'completed'
574
+ await persist(record)
575
+ return summary(record, fresh)
576
+ }