cortico-world-image 0.2.0 → 0.5.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.
package/dist/webui.js CHANGED
@@ -20,6 +20,20 @@ const STYLE = `
20
20
  .iw-big img { max-width: 100%; max-height: 68vh; display: block; margin: 0 auto; }
21
21
  .iw-bigmeta { font-size: 12px; opacity: .7; margin: 8px 0; word-break: break-all; }
22
22
  .iw-prompt { font-size: 12px; white-space: pre-wrap; word-break: break-word; }
23
+ .iw-chain { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
24
+ .iw-chainhead { display: grid; grid-template-columns: 24px 120px 150px 1fr 1fr 120px; gap: 6px; padding: 0 6px; font-size: 11px; opacity: .55; }
25
+ .iw-chainrow { display: grid; grid-template-columns: 24px 120px 150px 1fr 1fr 120px; gap: 6px; align-items: center; padding: 6px; border: 1px solid rgba(128,128,128,.3); border-radius: 8px; }
26
+ .iw-chainrow input, .iw-chainrow select { min-width: 0; width: 100%; box-sizing: border-box; }
27
+ .iw-chainbad { grid-column: 1 / -1; color: #d9534f; font-size: 11px; }
28
+ .iw-chaintools { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
29
+ .iw-chaintools button { padding: 2px 6px; }
30
+ .iw-chainadv { border: 1px dashed rgba(128,128,128,.45); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
31
+ .iw-chainadvhead { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
32
+ .iw-lorarows { display: flex; flex-direction: column; gap: 4px; }
33
+ .iw-lorarow { display: grid; grid-template-columns: 1fr 90px 90px 64px; gap: 6px; align-items: center; }
34
+ .iw-lorarow input { min-width: 0; width: 100%; box-sizing: border-box; }
35
+ .iw-chainadvgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; align-items: end; }
36
+ .iw-chainadvgrid .iw-chainadvwide { grid-column: 1 / -1; }
23
37
  `;
24
38
 
25
39
  const ZH = {
@@ -64,6 +78,65 @@ const ZH = {
64
78
  clearKey: '清除密钥',
65
79
  clearKeyBody: '会把 .env 里那一行置空。之后画图会直接失败,直到重新填入。',
66
80
  saveKey: '保存密钥',
81
+ chainTitle: '模型链',
82
+ chainDesc: '画图时按这里的顺序挨个试:本地 ComfyUI 与云端 OpenAI 兼容接口可以混在一起,上面的先用。前一档连不上、超时、额度用完、模型没装,就自动换下一档。空着则用下面那套单档配置。',
83
+ chainPlaceholder: '[{"name":"local","preset":"comfyui","baseUrl":"http://127.0.0.1:8188","ckpt":"sd_xl_base_1.0.safetensors"}]',
84
+ chainName: '名称',
85
+ chainKind: '类型',
86
+ chainBase: '地址',
87
+ chainWhere: '模型 / ckpt',
88
+ chainKindOpenai: '云端接口(OpenAI 兼容)',
89
+ chainKindComfyui: '本地 ComfyUI',
90
+ chainNamePlaceholder: 'local / cloud',
91
+ chainAddComfyui: '+ 本地 ComfyUI',
92
+ chainAddCloud: '+ 云端接口',
93
+ chainAdv: '高级参数',
94
+ chainAdvTitle: '高级参数',
95
+ chainAdvClose: '收起',
96
+ chainAdvHint: 'LoRA 文件要先放进 ComfyUI 的 models/loras,名字带扩展名(可带子目录);强度留空按 1,CLIP 强度留空就跟强度一样。',
97
+ chainAdvLora: 'LoRA',
98
+ chainAdvLoraAdd: '+ LoRA',
99
+ chainAdvLoraEmpty: '还没加 LoRA;只用 checkpoint 出图就留空。',
100
+ chainAdvLoraName: '文件名,如 add_detail.safetensors',
101
+ chainAdvLoraStrength: '强度',
102
+ chainAdvLoraClip: 'CLIP 强度',
103
+ chainAdvNegative: '负面提示词',
104
+ chainAdvNegativePh: 'lowres, blurry, watermark',
105
+ chainAdvSteps: 'steps',
106
+ chainAdvCfg: 'cfg',
107
+ chainAdvDenoise: 'denoise',
108
+ chainAdvSampler: '采样器',
109
+ chainAdvScheduler: '调度器',
110
+ chainAdvModel: '模型文件',
111
+ chainAdvModelHint: 'Anima、Flux 这类不是 all-in-one 的模型要分三个文件:UNet + 文本编码器 + VAE。填了 UNet 就忽略上面的 checkpoint;LoRA 只作用于 UNet(没有 CLIP 那一半)。',
112
+ chainAdvUnet: 'UNet,如 anima-base-v1.0.safetensors',
113
+ chainAdvTextEncoder: '文本编码器,如 qwen_3_06b_base.safetensors',
114
+ chainAdvTextEncoderType: '编码器类型',
115
+ chainAdvVae: 'VAE,如 qwen_image_vae.safetensors',
116
+ chainUnetInAdv: '已用高级参数里的裸 UNet',
117
+ chainNeedUnetParts: '裸 UNet 还差文本编码器 / VAE',
118
+ chainJson: '高级:JSON',
119
+ chainJsonHint: '这里是原始的 worlds.image.models:workflow、loras、steps、denoise 这些高级字段直接在这改。链空着打开时会填一份现成范例(本地 ComfyUI 带 LoRA + 云端各一档),把 ckpt / loras 换成你自己 models 里的文件名,先「应用 JSON」再「保存模型链」。',
120
+ chainJsonApply: '应用 JSON',
121
+ chainJsonBack: '收起 JSON',
122
+ chainJsonApplied: 'JSON 已读进来,点「保存模型链」才写进配置',
123
+ chainBroken: '这段 JSON 读不了',
124
+ chainBrokenHint: '先修好 JSON,或把这一段清空重来。在保存之前配置不会动。',
125
+ chainEmptyHint: '还没有配模型链,现在用的是下面那套单档配置。想让本地 ComfyUI 和云端轮着试,点「+ 本地 ComfyUI」或「+ 云端接口」加一档;想照着现成范例改(本地 ComfyUI 带 LoRA + 云端各一档),点「高级:JSON」。',
126
+ chainOrderHint: '从上到下就是优先级。',
127
+ chainNeedBase: '缺地址',
128
+ chainNeedCkpt: '缺 ckpt 文件名',
129
+ chainNeedModel: '缺模型名',
130
+ chainMoveUp: '上移',
131
+ chainMoveDown: '下移',
132
+ chainDel: '删除',
133
+ chainBad: '模型链没保存',
134
+ chainSave: '保存模型链',
135
+ chainSaved: '模型链已保存',
136
+ chainTest: '测试模型链',
137
+ chainTesting: '正在探活…',
138
+ chainTestDirty: '链上还有没保存的改动,先点「保存模型链」再测',
139
+ chainTestNone: '还没测试。点「测试模型链」挨个试一下。',
67
140
  endpoint: '接口地址',
68
141
  model: '模型',
69
142
  params: '默认参数',
@@ -84,13 +157,14 @@ const ZH = {
84
157
  runtime: '运行时',
85
158
  unknown: '未知',
86
159
  loadBad: '读不到作品列表',
87
- patchTitle: '对话页发图',
88
- patchDesc: '桌宠要在控制台「对话」页发图,得改安装目录里的两处代码(页面渲染、桌宠的事件出口)。程序自动更新会把它们覆写回去,所以每次启动都重打一遍;打不上会显示在这里。',
160
+ patchTitle: 'QQ 发图路径',
161
+ patchDesc: '旧版 QQ 扩展(qqbot)的 qq_send_image / qq_view_image 只按扩展目录解析本地路径;这里把它们改成先按桌宠工作区解析、扩展目录兜底。第三方包升级会把改动覆写掉,所以每次启动都重打一遍;打不上会显示在这里。切到官方 QQ 扩展(qq)后它按 blob 句柄发图,这两处自动跳过、不碰文件。',
89
162
  patchWhere: '安装目录',
90
163
  patchCount: '补丁',
91
164
  patchAt: '上次检查',
92
165
  patchLastRestart: '上次重启请求',
93
- patchAllDone: '7 处都在',
166
+ patchAllDone: '处都在',
167
+ patchNone: '没有要打补丁的 QQ 扩展',
94
168
  patchPending: '刚补上,重启后生效',
95
169
  patchMissing: '有没打上的',
96
170
  patchNoApp: '没找到安装目录',
@@ -100,7 +174,7 @@ const ZH = {
100
174
  patchRepatch: '重打补丁',
101
175
  patchRestore: '还原备份',
102
176
  patchRestoreAsk: '按备份还原补丁?',
103
- patchRestoreBody: '用更新下发的原文(.coopanion-orig)覆盖回这三份文件,桌宠发图会随之失效(重启控制台后生效)。备份不会删,随时能再打回来。',
177
+ patchRestoreBody: '用更新下发的原文(.coopanion-orig)覆盖回 QQ 扩展的 world.ts,QQ 发图改回只认扩展目录里的相对路径(重启控制台后生效)。备份不会删,随时能再打回来。',
104
178
  patchRestoreDone: '已按备份还原',
105
179
  patchRestoreNone: '没动:文件本来就是原文',
106
180
  patchRestoreBad: '还原出错',
@@ -111,7 +185,7 @@ const ZH = {
111
185
  patchBackupSame: '与当前一致',
112
186
  patchRestart: '重启控制台',
113
187
  patchRestartAsk: '现在重启控制台?',
114
- patchRestartBody: '控制台会自己重启(几秒钟),正在跑的对话会断。桌宠那一侧的补丁要重启才装载。',
188
+ patchRestartBody: '控制台会自己重启(几秒钟),正在跑的对话会断。QQ 扩展的补丁要重启才装载。',
115
189
  patchRestartSent: '重启请求已发出,稍等几秒',
116
190
  patchRestartBad: '重启请求没送到',
117
191
  patchRepatched: '又对了一遍',
@@ -160,6 +234,65 @@ const EN = {
160
234
  clearKey: 'Clear key',
161
235
  clearKeyBody: 'Empties that line in .env. Drawing will fail until a key is entered again.',
162
236
  saveKey: 'Save key',
237
+ chainTitle: 'Model chain',
238
+ chainDesc: 'Tried from top to bottom: a local ComfyUI and cloud OpenAI-compatible endpoints can be mixed in one list. If an entry is unreachable, times out, runs out of quota or lacks the model, the next one is tried. Empty means the single-backend settings below.',
239
+ chainPlaceholder: '[{"name":"local","preset":"comfyui","baseUrl":"http://127.0.0.1:8188","ckpt":"sd_xl_base_1.0.safetensors"}]',
240
+ chainName: 'Name',
241
+ chainKind: 'Type',
242
+ chainBase: 'Endpoint',
243
+ chainWhere: 'Model / ckpt',
244
+ chainKindOpenai: 'Cloud (OpenAI-compatible)',
245
+ chainKindComfyui: 'Local ComfyUI',
246
+ chainNamePlaceholder: 'local / cloud',
247
+ chainAddComfyui: '+ Local ComfyUI',
248
+ chainAddCloud: '+ Cloud endpoint',
249
+ chainAdv: 'Advanced',
250
+ chainAdvTitle: 'Advanced settings',
251
+ chainAdvClose: 'Hide',
252
+ chainAdvHint: 'LoRA files must live in ComfyUI\'s models/loras; include the extension (subfolders OK). Blank strength means 1; blank CLIP strength follows strength.',
253
+ chainAdvLora: 'LoRA',
254
+ chainAdvLoraAdd: '+ LoRA',
255
+ chainAdvLoraEmpty: 'No LoRA yet — leave empty to use the checkpoint alone.',
256
+ chainAdvLoraName: 'file name, e.g. add_detail.safetensors',
257
+ chainAdvLoraStrength: 'strength',
258
+ chainAdvLoraClip: 'CLIP strength',
259
+ chainAdvNegative: 'Negative prompt',
260
+ chainAdvNegativePh: 'lowres, blurry, watermark',
261
+ chainAdvSteps: 'steps',
262
+ chainAdvCfg: 'cfg',
263
+ chainAdvDenoise: 'denoise',
264
+ chainAdvSampler: 'sampler',
265
+ chainAdvScheduler: 'scheduler',
266
+ chainAdvModel: 'Model files',
267
+ chainAdvModelHint: 'Anima / Flux are not all-in-one: they need a UNet, a text encoder and a VAE as three separate files. A UNet here overrides the checkpoint above; LoRAs then only touch the UNet (no CLIP half).',
268
+ chainAdvUnet: 'UNet, e.g. anima-base-v1.0.safetensors',
269
+ chainAdvTextEncoder: 'Text encoder, e.g. qwen_3_06b_base.safetensors',
270
+ chainAdvTextEncoderType: 'encoder type',
271
+ chainAdvVae: 'VAE, e.g. qwen_image_vae.safetensors',
272
+ chainUnetInAdv: 'using the bare UNet from Advanced',
273
+ chainNeedUnetParts: 'bare UNet still needs a text encoder / VAE',
274
+ chainJson: 'Advanced: JSON',
275
+ chainJsonHint: 'Raw worlds.image.models — edit workflow, loras, steps, denoise and other advanced fields here. With no chain yet it opens pre-filled with a working example (a local ComfyUI tier with LoRA plus a cloud tier); swap ckpt / loras for your own file names, then Apply JSON and Save chain.',
276
+ chainJsonApply: 'Apply JSON',
277
+ chainJsonBack: 'Hide JSON',
278
+ chainJsonApplied: 'JSON loaded — press "Save chain" to write it',
279
+ chainBroken: 'This JSON will not parse',
280
+ chainBrokenHint: 'Fix it, or clear the box and start over. Nothing changes until you save.',
281
+ chainEmptyHint: 'No model chain yet: the single-backend settings below are in use. Add a local ComfyUI or a cloud entry to mix them — or open Advanced: JSON to start from a ready example (local ComfyUI with LoRA plus a cloud tier).',
282
+ chainOrderHint: 'Top to bottom is the priority order.',
283
+ chainNeedBase: 'missing endpoint',
284
+ chainNeedCkpt: 'missing ckpt file name',
285
+ chainNeedModel: 'missing model name',
286
+ chainMoveUp: 'Up',
287
+ chainMoveDown: 'Down',
288
+ chainDel: 'Remove',
289
+ chainBad: 'Model chain not saved',
290
+ chainSave: 'Save chain',
291
+ chainSaved: 'Model chain saved',
292
+ chainTest: 'Test chain',
293
+ chainTesting: 'Probing…',
294
+ chainTestDirty: 'The chain has unsaved edits — press "Save chain" before testing',
295
+ chainTestNone: 'Not tested yet. Press "Test chain" to try each entry.',
163
296
  endpoint: 'Endpoint',
164
297
  model: 'Model',
165
298
  params: 'Defaults',
@@ -180,13 +313,14 @@ const EN = {
180
313
  runtime: 'Runtime',
181
314
  unknown: 'unknown',
182
315
  loadBad: 'Could not read the picture list',
183
- patchTitle: 'Images in Chat',
184
- patchDesc: 'Sending a picture to the console Chat page needs two code patches in the install directory (the page renderer and the pet\'s event outlet). The updater overwrites them, so they are re-applied on every start; anything that fails shows up here.',
316
+ patchTitle: 'QQ Image Paths',
317
+ patchDesc: 'The legacy QQ extension (qqbot) resolves local picture paths against its own folder; these two patches make qq_send_image / qq_view_image try your pet workspace first. The updater overwrites them, so they are re-applied on every start; anything that fails shows up here. With the official QQ extension (qq) pictures travel as blob handles, so both patches are skipped and no file is touched.',
185
318
  patchWhere: 'Install dir',
186
319
  patchCount: 'Patches',
187
320
  patchAt: 'Checked',
188
321
  patchLastRestart: 'Last restart request',
189
- patchAllDone: 'all 7 in place',
322
+ patchAllDone: 'in place',
323
+ patchNone: 'no QQ extension needs patching',
190
324
  patchPending: 'applied, takes effect after restart',
191
325
  patchMissing: 'some are missing',
192
326
  patchNoApp: 'install directory not found',
@@ -196,7 +330,7 @@ const EN = {
196
330
  patchRepatch: 'Re-apply',
197
331
  patchRestore: 'Restore from backup',
198
332
  patchRestoreAsk: 'Restore the patches from backup?',
199
- patchRestoreBody: 'Overwrites the three files with the updater-shipped originals (.coopanion-orig); the pet loses image sending after a console restart. Backups are kept, so you can re-apply any time.',
333
+ patchRestoreBody: 'Overwrites the QQ extension world.ts with the updater-shipped original (.coopanion-orig); QQ images fall back to extension-relative paths after a console restart. Backups are kept, so you can re-apply any time.',
200
334
  patchRestoreDone: 'Restored from backup',
201
335
  patchRestoreNone: 'Nothing changed: files were already pristine',
202
336
  patchRestoreBad: 'Restore failed',
@@ -207,7 +341,7 @@ const EN = {
207
341
  patchBackupSame: 'same as current file',
208
342
  patchRestart: 'Restart console',
209
343
  patchRestartAsk: 'Restart the console now?',
210
- patchRestartBody: 'The console restarts by itself (a few seconds); a running conversation is cut off. The pet-side patch only loads on restart.',
344
+ patchRestartBody: 'The console restarts by itself (a few seconds); a running conversation is cut off. The QQ patch only loads on restart.',
211
345
  patchRestartSent: 'Restart requested, give it a few seconds',
212
346
  patchRestartBad: 'Restart request did not go through',
213
347
  patchRepatched: 'Checked again',
@@ -222,6 +356,128 @@ function message(err) {
222
356
  return String(err);
223
357
  }
224
358
 
359
+ /** 模型链的 JSON:读坏了要能接着说清是哪坏,所以不抛异常,返回 `{entries, error}`。 */
360
+ function parseChainJson(raw) {
361
+ const value = String(raw ?? '').trim();
362
+ if (!value) return { entries: [], error: '' };
363
+ try {
364
+ const parsed = JSON.parse(value);
365
+ if (!Array.isArray(parsed)) return { entries: [], error: 'not an array / 不是数组' };
366
+ const entries = parsed.filter((it) => it && typeof it === 'object' && !Array.isArray(it)).map((it) => ({ ...it }));
367
+ return { entries, error: '' };
368
+ } catch (err) {
369
+ return { entries: [], error: message(err) };
370
+ }
371
+ }
372
+
373
+ /** 面板里编辑的是对象数组,落盘的仍是 `worlds.image.models` 的 JSON 文本。 */
374
+ function chainToJson(entries) {
375
+ const list = entries.map((entry) => {
376
+ const out = { ...entry };
377
+ if (!String(out.preset ?? '').trim()) out.preset = 'openai';
378
+ return out;
379
+ });
380
+ return list.length ? JSON.stringify(list, null, 2) : '[]';
381
+ }
382
+
383
+ /**
384
+ * 「高级:JSON」空着的时候给的范例:一档本地 ComfyUI(带 LoRA、采样参数)
385
+ * + 一档云端。照着改就能用,不用去翻文档。
386
+ */
387
+ function chainExampleJson() {
388
+ return JSON.stringify([
389
+ {
390
+ name: 'local',
391
+ preset: 'comfyui',
392
+ baseUrl: 'http://127.0.0.1:8188',
393
+ ckpt: 'sd_xl_base_1.0.safetensors',
394
+ loras: [
395
+ 'add_detail.safetensors:0.6',
396
+ { name: 'style_anime.safetensors', strength: 0.8, clipStrength: 0.5 },
397
+ ],
398
+ negative: 'lowres, blurry, watermark, text',
399
+ steps: 25,
400
+ cfg: 7,
401
+ sampler: 'euler',
402
+ scheduler: 'normal',
403
+ denoise: 0.6,
404
+ },
405
+ {
406
+ name: 'cloud',
407
+ preset: 'openai',
408
+ baseUrl: 'https://api.openai.com/v1',
409
+ model: 'gpt-image-1',
410
+ },
411
+ ], null, 2);
412
+ }
413
+
414
+ /** 一档的 LoRA 摊平成面板里能编辑的行:`name` / `strength` / `clipStrength`,字符串简写也收。 */
415
+ function chainLoraList(entry) {
416
+ const raw = Array.isArray(entry?.loras) ? entry.loras : (entry?.loras ? [entry.loras] : []);
417
+ return raw.map((it) => {
418
+ if (typeof it === 'string') {
419
+ const cut = it.lastIndexOf(':');
420
+ const name = cut > 0 ? it.slice(0, cut) : it;
421
+ const strength = cut > 0 ? it.slice(cut + 1) : '';
422
+ return { name: name.trim(), strength: String(strength).trim(), clipStrength: '' };
423
+ }
424
+ const obj = it && typeof it === 'object' ? it : {};
425
+ return {
426
+ name: String(obj.name ?? '').trim(),
427
+ strength: obj.strength === undefined || obj.strength === null ? '' : String(obj.strength),
428
+ clipStrength: obj.clipStrength === undefined || obj.clipStrength === null ? '' : String(obj.clipStrength),
429
+ };
430
+ });
431
+ }
432
+
433
+ /** 面板里的 LoRA 行写回档位:没名字的丢掉,强度按数字存,CLIP 强度空着就不写。 */
434
+ function chainLorasSet(entry, list) {
435
+ const out = [];
436
+ for (const it of list) {
437
+ const name = String(it?.name ?? '').trim();
438
+ if (!name) continue;
439
+ const num = (v) => {
440
+ const n = Number(String(v ?? '').trim());
441
+ return Number.isFinite(n) ? n : null;
442
+ };
443
+ const strength = num(it.strength);
444
+ const clip = num(it.clipStrength);
445
+ const one = { name, strength: strength === null ? 1 : strength };
446
+ if (clip !== null) one.clipStrength = clip;
447
+ out.push(one);
448
+ }
449
+ if (out.length) entry.loras = out;
450
+ else delete entry.loras;
451
+ }
452
+
453
+ /** 一档是哪种:只认 comfyui,其余都当 OpenAI 兼容(和 src/chain.js 对齐)。 */
454
+ function chainKindOf(entry) {
455
+ return String(entry?.preset ?? '').trim().toLowerCase() === 'comfyui' ? 'comfyui' : 'openai';
456
+ }
457
+
458
+ /** 这一档还差什么;齐了返回空串。 */
459
+ function chainRowProblem(entry, tx) {
460
+ if (!String(entry?.baseUrl ?? '').trim()) return tx.chainNeedBase;
461
+ if (chainKindOf(entry) === 'comfyui') {
462
+ // 自定义工作流自己写文件名,这里不挑毛病。
463
+ if (entry?.workflow) return '';
464
+ if (String(entry?.unet ?? '').trim()) {
465
+ const ok = String(entry?.textEncoder ?? '').trim() && String(entry?.vaeName ?? '').trim();
466
+ return ok ? '' : tx.chainNeedUnetParts;
467
+ }
468
+ return String(entry?.ckpt ?? '').trim() ? '' : tx.chainNeedCkpt;
469
+ }
470
+ return String(entry?.model ?? '').trim() ? '' : tx.chainNeedModel;
471
+ }
472
+
473
+ /** 新加一档时别撞名:`local` → `local2` → `local3`。 */
474
+ function chainUniqueName(base, list) {
475
+ const taken = new Set(list.map((it) => String(it?.name ?? '').trim()));
476
+ if (!taken.has(base)) return base;
477
+ for (let i = 2; i < 100; i += 1) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
478
+ return `${base}-${Date.now().toString(36)}`;
479
+ }
480
+
225
481
  function injectStyle(ctx) {
226
482
  const el = ctx.ui.h('style');
227
483
  el.textContent = STYLE;
@@ -529,6 +785,365 @@ function statusPanel(ctx) {
529
785
  keyCard.appendChild(keyRow);
530
786
  body.appendChild(keyCard);
531
787
 
788
+ // 模型链:面板里是一串可排序的小卡片,落盘仍是 worlds.image.models 的 JSON。
789
+ let chainEdit = [];
790
+ let chainBroken = '';
791
+ let chainDirty = false;
792
+ let chainProbe = null;
793
+ let chainAdvEntry = null;
794
+ let chainJsonShown = false;
795
+
796
+ const chainCard = ctx.ui.section(tx.chainTitle, tx.chainDesc);
797
+ const chainOrderLine = ctx.ui.h('div', 'iw-sub', tx.chainOrderHint);
798
+ const chainHead = ctx.ui.h('div', 'iw-chainhead');
799
+ chainHead.append(
800
+ ctx.ui.h('span', null, '#'),
801
+ ctx.ui.h('span', null, tx.chainName),
802
+ ctx.ui.h('span', null, tx.chainKind),
803
+ ctx.ui.h('span', null, tx.chainBase),
804
+ ctx.ui.h('span', null, tx.chainWhere),
805
+ ctx.ui.h('span', null, ''),
806
+ );
807
+ const chainRows = ctx.ui.h('div', 'iw-chain');
808
+ const chainEmptyLine = ctx.ui.msgline(tx.chainEmptyHint);
809
+ const chainBar = ctx.ui.actions();
810
+ const chainTestBox = ctx.ui.h('div', 'iw-sub', tx.chainTestNone);
811
+ const chainJsonWrap = ctx.ui.h('div');
812
+ const chainJson = ctx.ui.textarea({ rows: 8, cls: 'mono', placeholder: tx.chainPlaceholder });
813
+ const chainJsonBar = ctx.ui.actions();
814
+ chainJsonWrap.append(
815
+ ctx.ui.h('div', 'iw-sub', tx.chainJsonHint),
816
+ chainJson,
817
+ chainJsonBar,
818
+ );
819
+ chainJsonBar.append(
820
+ ctx.ui.button(tx.chainJsonApply, { size: 'sm', onClick: () => { applyChainJson(); } }),
821
+ ctx.ui.button(tx.chainJsonBack, { size: 'sm', onClick: () => { toggleChainJson(false); } }),
822
+ );
823
+ chainJsonWrap.style.display = 'none';
824
+ chainBar.append(
825
+ ctx.ui.button(tx.chainSave, { size: 'sm', variant: 'primary', onClick: () => { void saveChain(); } }),
826
+ ctx.ui.button(tx.chainTest, { size: 'sm', onClick: () => { void testChain(); } }),
827
+ ctx.ui.button(tx.chainAddComfyui, { size: 'sm', onClick: () => { addChainEntry('comfyui'); } }),
828
+ ctx.ui.button(tx.chainAddCloud, { size: 'sm', onClick: () => { addChainEntry('openai'); } }),
829
+ ctx.ui.button(tx.chainJson, { size: 'sm', onClick: () => { toggleChainJson(); } }),
830
+ );
831
+ chainCard.append(chainOrderLine, chainHead, chainRows, chainEmptyLine, chainBar, chainTestBox, chainJsonWrap);
832
+ body.appendChild(chainCard);
833
+
834
+ /** 一行一档:名称、类型、地址、模型/ckpt,加排序与删除。 */
835
+ const chainRowOf = (entry, index) => {
836
+ const kind = chainKindOf(entry);
837
+ const row = ctx.ui.h('div', 'iw-chainrow');
838
+ row.appendChild(ctx.ui.h('span', 'iw-sub', String(index + 1)));
839
+
840
+ const bad = ctx.ui.h('div', 'iw-chainbad');
841
+ const syncBad = () => {
842
+ const problem = chainRowProblem(entry, tx);
843
+ bad.textContent = problem ? `第 ${index + 1} 档:${problem}` : '';
844
+ bad.style.display = problem ? '' : 'none';
845
+ };
846
+
847
+ const name = ctx.ui.input({ value: String(entry.name ?? ''), placeholder: tx.chainNamePlaceholder, cls: 'mono' });
848
+ name.addEventListener('input', () => {
849
+ entry.name = name.value.trim();
850
+ markChainDirty();
851
+ });
852
+
853
+ const kindSel = ctx.ui.select({ cls: 'mono' });
854
+ for (const pair of [['openai', tx.chainKindOpenai], ['comfyui', tx.chainKindComfyui]]) {
855
+ const opt = ctx.ui.h('option', null, pair[1]);
856
+ opt.value = pair[0];
857
+ kindSel.appendChild(opt);
858
+ }
859
+ kindSel.value = kind;
860
+ kindSel.addEventListener('change', () => {
861
+ entry.preset = kindSel.value === 'comfyui' ? 'comfyui' : 'openai';
862
+ if (entry.preset !== 'comfyui' && chainAdvEntry === entry) chainAdvEntry = null;
863
+ markChainDirty();
864
+ renderChain();
865
+ });
866
+
867
+ const base = ctx.ui.input({
868
+ value: String(entry.baseUrl ?? ''),
869
+ placeholder: kind === 'comfyui' ? 'http://127.0.0.1:8188' : 'https://api.openai.com/v1',
870
+ cls: 'mono',
871
+ });
872
+ base.addEventListener('input', () => {
873
+ entry.baseUrl = base.value.trim();
874
+ markChainDirty();
875
+ syncBad();
876
+ });
877
+
878
+ const whereKey = kind === 'comfyui' ? 'ckpt' : 'model';
879
+ const where = ctx.ui.input({
880
+ value: String(entry[whereKey] ?? ''),
881
+ placeholder:
882
+ kind === 'comfyui'
883
+ ? (String(entry.unet ?? '').trim() ? tx.chainUnetInAdv : 'sd_xl_base_1.0.safetensors')
884
+ : 'gpt-image-1',
885
+ cls: 'mono',
886
+ });
887
+ where.addEventListener('input', () => {
888
+ entry[whereKey] = where.value.trim();
889
+ markChainDirty();
890
+ syncBad();
891
+ });
892
+
893
+ const tools = ctx.ui.h('div', 'iw-chaintools');
894
+ if (kind === 'comfyui') {
895
+ tools.append(ctx.ui.button(tx.chainAdv, {
896
+ size: 'sm',
897
+ onClick: () => {
898
+ chainAdvEntry = chainAdvEntry === entry ? null : entry;
899
+ renderChain();
900
+ },
901
+ }));
902
+ }
903
+ tools.append(
904
+ ctx.ui.button(tx.chainMoveUp, { size: 'sm', onClick: () => { moveChainEntry(index, -1); } }),
905
+ ctx.ui.button(tx.chainMoveDown, { size: 'sm', onClick: () => { moveChainEntry(index, 1); } }),
906
+ ctx.ui.button(tx.chainDel, { size: 'sm', onClick: () => { removeChainEntry(index); } }),
907
+ );
908
+
909
+ row.append(name, kindSel, base, where, tools, bad);
910
+ syncBad();
911
+ return row;
912
+ };
913
+
914
+ /** 一档的「高级参数」:多行 LoRA + 采样参数,只对本地 ComfyUI 档出现。 */
915
+ const chainAdvBlock = (entry) => {
916
+ const box = ctx.ui.h('div', 'iw-chainadv');
917
+ const head = ctx.ui.h('div', 'iw-chainadvhead');
918
+ head.append(
919
+ ctx.ui.h('span', null, `${tx.chainAdvTitle} · ${String(entry.name || entry.ckpt || '')}`),
920
+ ctx.ui.button(tx.chainAdvClose, {
921
+ size: 'sm',
922
+ onClick: () => {
923
+ chainAdvEntry = null;
924
+ renderChain();
925
+ },
926
+ }),
927
+ );
928
+ box.appendChild(head);
929
+
930
+ // LoRA:摊平成可编辑的行,改完立刻写回这一档。
931
+ const list = chainLoraList(entry);
932
+ const loraBox = ctx.ui.h('div', 'iw-lorarows');
933
+ const drawLoras = () => {
934
+ loraBox.replaceChildren();
935
+ if (!list.length) {
936
+ loraBox.appendChild(ctx.ui.msgline(tx.chainAdvLoraEmpty));
937
+ return;
938
+ }
939
+ list.forEach((it, i) => {
940
+ const line = ctx.ui.h('div', 'iw-lorarow');
941
+ const nm = ctx.ui.input({ value: it.name, placeholder: tx.chainAdvLoraName, cls: 'mono' });
942
+ nm.addEventListener('input', () => {
943
+ it.name = nm.value;
944
+ chainLorasSet(entry, list);
945
+ markChainDirty();
946
+ });
947
+ const st = ctx.ui.input({ value: it.strength, placeholder: '1', cls: 'mono' });
948
+ st.addEventListener('input', () => {
949
+ it.strength = st.value;
950
+ chainLorasSet(entry, list);
951
+ markChainDirty();
952
+ });
953
+ const cp = ctx.ui.input({ value: it.clipStrength, placeholder: '=', cls: 'mono' });
954
+ cp.addEventListener('input', () => {
955
+ it.clipStrength = cp.value;
956
+ chainLorasSet(entry, list);
957
+ markChainDirty();
958
+ });
959
+ line.append(
960
+ nm,
961
+ st,
962
+ cp,
963
+ ctx.ui.button(tx.chainDel, {
964
+ size: 'sm',
965
+ onClick: () => {
966
+ list.splice(i, 1);
967
+ chainLorasSet(entry, list);
968
+ markChainDirty();
969
+ drawLoras();
970
+ },
971
+ }),
972
+ );
973
+ loraBox.appendChild(line);
974
+ });
975
+ };
976
+ drawLoras();
977
+ const loraBar = ctx.ui.actions();
978
+ loraBar.append(ctx.ui.button(tx.chainAdvLoraAdd, {
979
+ size: 'sm',
980
+ onClick: () => {
981
+ list.push({ name: '', strength: '', clipStrength: '' });
982
+ drawLoras();
983
+ },
984
+ }));
985
+ box.append(ctx.ui.h('div', 'iw-sub', tx.chainAdvLora), loraBox, loraBar, ctx.ui.h('div', 'iw-sub', tx.chainAdvHint));
986
+
987
+ /** 数字/文本字段改完就写回;清空就当没写(让 src/chain.js 用它的默认值)。 */
988
+ const fieldOf = (key, placeholder) => {
989
+ const raw = entry[key];
990
+ const el = ctx.ui.input({ value: raw === undefined || raw === null ? '' : String(raw), placeholder, cls: 'mono' });
991
+ el.addEventListener('input', () => {
992
+ const v = el.value.trim();
993
+ if (!v) delete entry[key];
994
+ else {
995
+ const num = Number(v);
996
+ entry[key] = Number.isFinite(num) ? num : v;
997
+ }
998
+ markChainDirty();
999
+ });
1000
+ return el;
1001
+ };
1002
+
1003
+ const grid = ctx.ui.h('div', 'iw-chainadvgrid');
1004
+ const negative = ctx.ui.textarea({ rows: 2, cls: 'mono', placeholder: tx.chainAdvNegativePh });
1005
+ negative.value = String(entry.negative ?? '');
1006
+ negative.addEventListener('input', () => {
1007
+ entry.negative = negative.value;
1008
+ markChainDirty();
1009
+ });
1010
+ const negWrap = ctx.ui.h('div', 'iw-chainadvwide');
1011
+ negWrap.appendChild(ctx.ui.field(tx.chainAdvNegative, negative));
1012
+
1013
+ const pick = (key, options, fallback) => {
1014
+ const sel = ctx.ui.select({ cls: 'mono' });
1015
+ const current = String(entry[key] ?? fallback);
1016
+ const all = options.includes(current) ? options : [current, ...options];
1017
+ for (const opt of all) {
1018
+ const el = ctx.ui.h('option', null, opt);
1019
+ el.value = opt;
1020
+ sel.appendChild(el);
1021
+ }
1022
+ sel.value = current;
1023
+ sel.addEventListener('change', () => {
1024
+ entry[key] = sel.value;
1025
+ markChainDirty();
1026
+ });
1027
+ return sel;
1028
+ };
1029
+ grid.append(
1030
+ negWrap,
1031
+ ctx.ui.field(tx.chainAdvSteps, fieldOf('steps', '25')),
1032
+ ctx.ui.field(tx.chainAdvCfg, fieldOf('cfg', '7')),
1033
+ ctx.ui.field(tx.chainAdvDenoise, fieldOf('denoise', '0.6')),
1034
+ ctx.ui.field(tx.chainAdvSampler, pick('sampler', ['euler', 'euler_ancestral', 'dpmpp_2m', 'dpmpp_2m_sde', 'dpmpp_sde', 'ddim', 'heun', 'lms'], 'euler')),
1035
+ ctx.ui.field(tx.chainAdvScheduler, pick('scheduler', ['normal', 'karras', 'exponential', 'sgm_uniform', 'simple', 'ddim_uniform'], 'normal')),
1036
+ );
1037
+ box.appendChild(grid);
1038
+
1039
+ // 裸 UNet 三件套(Anima / Flux):三个文件 + 编码器类型。填了 UNet 就顶掉上面的 ckpt。
1040
+ const textField = (key, placeholder) => {
1041
+ const el = ctx.ui.input({ value: String(entry[key] ?? ''), placeholder, cls: 'mono' });
1042
+ el.addEventListener('input', () => {
1043
+ const v = el.value.trim();
1044
+ if (v) entry[key] = v;
1045
+ else delete entry[key];
1046
+ markChainDirty();
1047
+ });
1048
+ return el;
1049
+ };
1050
+ const modelGrid = ctx.ui.h('div', 'iw-chainadvgrid');
1051
+ modelGrid.append(
1052
+ ctx.ui.field(tx.chainAdvUnet, textField('unet', 'anima-base-v1.0.safetensors')),
1053
+ ctx.ui.field(tx.chainAdvTextEncoder, textField('textEncoder', 'qwen_3_06b_base.safetensors')),
1054
+ ctx.ui.field(
1055
+ tx.chainAdvTextEncoderType,
1056
+ pick('textEncoderType', ['stable_diffusion', 'sd3', 'flux', 'qwen_image', 'cosmos', 'lumina2'], 'stable_diffusion'),
1057
+ ),
1058
+ ctx.ui.field(tx.chainAdvVae, textField('vaeName', 'qwen_image_vae.safetensors')),
1059
+ );
1060
+ box.append(ctx.ui.h('div', 'iw-sub', tx.chainAdvModel), modelGrid, ctx.ui.h('div', 'iw-sub', tx.chainAdvModelHint));
1061
+ return box;
1062
+ };
1063
+
1064
+ const renderChain = () => {
1065
+ chainOrderLine.style.display = chainEdit.length ? '' : 'none';
1066
+ chainHead.style.display = chainEdit.length ? '' : 'none';
1067
+ chainEmptyLine.style.display = chainEdit.length || chainBroken ? 'none' : '';
1068
+ chainRows.replaceChildren();
1069
+ if (chainBroken) {
1070
+ chainRows.appendChild(ctx.ui.h('div', 'iw-sub', `${tx.chainBroken}:${chainBroken}`));
1071
+ chainRows.appendChild(ctx.ui.h('div', 'iw-sub', tx.chainBrokenHint));
1072
+ }
1073
+ chainEdit.forEach((entry, index) => {
1074
+ chainRows.appendChild(chainRowOf(entry, index));
1075
+ if (chainAdvEntry === entry) chainRows.appendChild(chainAdvBlock(entry));
1076
+ });
1077
+ // 链空着、且 JSON 框开着时,别用 `[]` 把里面的范例/草稿冲掉。
1078
+ if (!chainJsonShown || chainEdit.length || chainBroken) chainJson.value = chainToJson(chainEdit);
1079
+ chainTestBox.replaceChildren();
1080
+ if (chainProbe?.error) chainTestBox.appendChild(ctx.ui.h('div', 'iw-sub', chainProbe.error));
1081
+ for (const it of Array.isArray(chainProbe?.items) ? chainProbe.items : []) {
1082
+ const line = ctx.ui.h('div', 'iw-sub');
1083
+ line.appendChild(ctx.ui.pill(it.ok ? tx.setLabel : tx.unsetLabel, it.ok ? 'on' : 'off'));
1084
+ line.appendChild(ctx.ui.h('span', null, ` ${it.name} · ${it.preset} — ${it.detail}`));
1085
+ chainTestBox.appendChild(line);
1086
+ }
1087
+ if (!chainProbe) chainTestBox.textContent = tx.chainTestNone;
1088
+ };
1089
+
1090
+ const markChainDirty = () => {
1091
+ chainDirty = true;
1092
+ chainProbe = null;
1093
+ chainTestBox.replaceChildren();
1094
+ chainTestBox.textContent = tx.chainTestNone;
1095
+ };
1096
+
1097
+ const toggleChainJson = (force) => {
1098
+ chainJsonShown = typeof force === 'boolean' ? force : !chainJsonShown;
1099
+ chainJsonWrap.style.display = chainJsonShown ? '' : 'none';
1100
+ if (!chainJsonShown) return;
1101
+ const raw = chainJson.value.trim();
1102
+ // 空链打开时给一份现成范例(本地 ComfyUI + LoRA、云端各一档);已有草稿就留着。
1103
+ if (!chainBroken && !chainEdit.length && (!raw || raw === '[]')) chainJson.value = chainExampleJson();
1104
+ else if (!raw || (chainEdit.length && raw === '[]')) chainJson.value = chainToJson(chainEdit);
1105
+ };
1106
+
1107
+ const applyChainJson = () => {
1108
+ const parsed = parseChainJson(chainJson.value);
1109
+ chainEdit = parsed.entries;
1110
+ chainBroken = parsed.error;
1111
+ chainDirty = true;
1112
+ chainProbe = null;
1113
+ renderChain();
1114
+ if (parsed.error) ctx.ui.toast(`${tx.chainBroken}:${parsed.error}`, 'bad');
1115
+ else ctx.ui.toast(tx.chainJsonApplied, 'ok');
1116
+ };
1117
+
1118
+ const addChainEntry = (kind) => {
1119
+ const entry = kind === 'comfyui'
1120
+ ? { name: chainUniqueName('local', chainEdit), preset: 'comfyui', baseUrl: 'http://127.0.0.1:8188', ckpt: '' }
1121
+ : { name: chainUniqueName('cloud', chainEdit), preset: 'openai', baseUrl: '', model: '' };
1122
+ chainEdit.push(entry);
1123
+ chainBroken = '';
1124
+ chainDirty = true;
1125
+ chainProbe = null;
1126
+ renderChain();
1127
+ };
1128
+
1129
+ const moveChainEntry = (index, delta) => {
1130
+ const to = index + delta;
1131
+ if (to < 0 || to >= chainEdit.length) return;
1132
+ const [item] = chainEdit.splice(index, 1);
1133
+ chainEdit.splice(to, 0, item);
1134
+ chainDirty = true;
1135
+ chainProbe = null;
1136
+ renderChain();
1137
+ };
1138
+
1139
+ const removeChainEntry = (index) => {
1140
+ const [gone] = chainEdit.splice(index, 1);
1141
+ if (gone === chainAdvEntry) chainAdvEntry = null;
1142
+ chainDirty = true;
1143
+ chainProbe = null;
1144
+ renderChain();
1145
+ };
1146
+
532
1147
  const patchCard = ctx.ui.section(tx.patchTitle, tx.patchDesc);
533
1148
  const patchHead = ctx.ui.h('div', 'iw-conn');
534
1149
  patchCard.appendChild(patchHead);
@@ -556,7 +1171,7 @@ function statusPanel(ctx) {
556
1171
 
557
1172
  let currentDir = '';
558
1173
 
559
- /** 「对话页发图」补丁:7 处都在?这次补了几处?备份还在吗?要不要重启? */
1174
+ /** QQ 发图路径补丁:2 处都在?这次补了几处?备份还在吗?要不要重启? */
560
1175
  const renderPatch = (p) => {
561
1176
  const total = Number(p?.total ?? 0);
562
1177
  const done = Number(p?.done ?? 0);
@@ -573,7 +1188,7 @@ function statusPanel(ctx) {
573
1188
  ? tx.patchUnchecked
574
1189
  : restored.length
575
1190
  ? tx.patchRestored
576
- : (pending ? tx.patchPending : `${tx.patchAllDone}`);
1191
+ : (pending ? tx.patchPending : (total === 0 ? tx.patchNone : `${done}/${total} ${tx.patchAllDone}`));
577
1192
  const tone = p?.error || failed.length || restored.length ? 'off' : 'on';
578
1193
  patchHead.replaceChildren(ctx.ui.pill(label, tone));
579
1194
  if (patched.length) patchHead.appendChild(ctx.ui.pill(`${tx.patchApplied} ${patched.length}`, 'on'));
@@ -583,7 +1198,7 @@ function statusPanel(ctx) {
583
1198
  : tx.none;
584
1199
  patchKv.replaceChildren(ctx.ui.kv([
585
1200
  { k: tx.patchWhere, v: ctx.ui.h('span', 'mono', String(p?.appRoot || '—')) },
586
- { k: tx.patchCount, v: `${done}/${total}` },
1201
+ { k: tx.patchCount, v: total === 0 ? (tx.patchNone) : `${done}/${total}` },
587
1202
  { k: tx.patchAt, v: p?.at ? ctx.ui.fmt.clock(p.at) : tx.none },
588
1203
  { k: tx.patchBackup, v: ctx.ui.h('span', 'mono', backupText) },
589
1204
  ...(restored.length
@@ -633,6 +1248,12 @@ function statusPanel(ctx) {
633
1248
  { k: tx.runtime, v: `Node ${s?.node ?? '?'} · ${s?.platform ?? '?'}` },
634
1249
  ]));
635
1250
  keyInput.placeholder = s?.keySet ? tx.keyPlaceholderSet : tx.keyPlaceholder;
1251
+ if (!chainDirty) {
1252
+ const parsed = parseChainJson(s?.models);
1253
+ chainEdit = parsed.entries;
1254
+ chainBroken = parsed.error;
1255
+ renderChain();
1256
+ }
636
1257
  renderPatch(s?.patch);
637
1258
  };
638
1259
 
@@ -650,6 +1271,49 @@ function statusPanel(ctx) {
650
1271
  }
651
1272
  };
652
1273
 
1274
+ const saveChain = async () => {
1275
+ if (chainBroken) {
1276
+ ctx.ui.toast(`${tx.chainBroken}:${chainBroken}`, 'bad');
1277
+ return;
1278
+ }
1279
+ const missing = chainEdit.map((entry, index) => ({ index, problem: chainRowProblem(entry, tx) })).find((it) => it.problem);
1280
+ if (missing) {
1281
+ ctx.ui.toast(`${tx.chainBad}:第 ${missing.index + 1} 档${missing.problem}`, 'bad');
1282
+ return;
1283
+ }
1284
+ const release = ctx.ui.busy(tx.busier, tx.chainSave);
1285
+ try {
1286
+ await ctx.setConfig('world:image', { 'worlds.image.models': chainToJson(chainEdit) });
1287
+ chainDirty = false;
1288
+ chainProbe = null;
1289
+ note.textContent = tx.chainSaved;
1290
+ ctx.ui.toast(tx.chainSaved, 'ok');
1291
+ await load(false);
1292
+ } catch (err) {
1293
+ note.textContent = `${tx.chainBad}:${message(err)}`;
1294
+ ctx.ui.toast(`${tx.chainBad}:${message(err)}`, 'bad');
1295
+ } finally {
1296
+ release.dispose();
1297
+ }
1298
+ };
1299
+
1300
+ const testChain = async () => {
1301
+ if (chainDirty) {
1302
+ ctx.ui.toast(tx.chainTestDirty, 'bad');
1303
+ return;
1304
+ }
1305
+ const release = ctx.ui.busy(tx.chainTesting, tx.chainTest);
1306
+ try {
1307
+ const res = await ctx.invoke('probe', []);
1308
+ chainProbe = res && typeof res === 'object' ? res : { at: '', error: '', items: [] };
1309
+ renderChain();
1310
+ } catch (err) {
1311
+ ctx.ui.toast(message(err), 'bad');
1312
+ } finally {
1313
+ release.dispose();
1314
+ }
1315
+ };
1316
+
653
1317
  const saveKey = async () => {
654
1318
  const value = keyInput.value.trim();
655
1319
  if (!value) {