cortico-world-image 0.3.1 → 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: '默认参数',
@@ -161,6 +234,65 @@ const EN = {
161
234
  clearKey: 'Clear key',
162
235
  clearKeyBody: 'Empties that line in .env. Drawing will fail until a key is entered again.',
163
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.',
164
296
  endpoint: 'Endpoint',
165
297
  model: 'Model',
166
298
  params: 'Defaults',
@@ -224,6 +356,128 @@ function message(err) {
224
356
  return String(err);
225
357
  }
226
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
+
227
481
  function injectStyle(ctx) {
228
482
  const el = ctx.ui.h('style');
229
483
  el.textContent = STYLE;
@@ -531,6 +785,365 @@ function statusPanel(ctx) {
531
785
  keyCard.appendChild(keyRow);
532
786
  body.appendChild(keyCard);
533
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
+
534
1147
  const patchCard = ctx.ui.section(tx.patchTitle, tx.patchDesc);
535
1148
  const patchHead = ctx.ui.h('div', 'iw-conn');
536
1149
  patchCard.appendChild(patchHead);
@@ -635,6 +1248,12 @@ function statusPanel(ctx) {
635
1248
  { k: tx.runtime, v: `Node ${s?.node ?? '?'} · ${s?.platform ?? '?'}` },
636
1249
  ]));
637
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
+ }
638
1257
  renderPatch(s?.patch);
639
1258
  };
640
1259
 
@@ -652,6 +1271,49 @@ function statusPanel(ctx) {
652
1271
  }
653
1272
  };
654
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
+
655
1317
  const saveKey = async () => {
656
1318
  const value = keyInput.value.trim();
657
1319
  if (!value) {