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/src/world.js CHANGED
@@ -14,7 +14,9 @@ import { extname, isAbsolute, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
16
16
  import { ImageApiError, editImage, generateImage } from './api.js';
17
+ import { ChainError, chainSnapshot, entryLabel, entryState, parseChainEntries, runChain } from './chain.js';
17
18
  import { applyChatImagePatches, consolePort, patchBackupStatus, requestConsoleRestart, resolveAppRoot, restoreChatImagePatches } from './chat-image-patch.js';
19
+ import { comfyDraw, comfyEdit, listModels, probe as probeComfy } from './comfyui.js';
18
20
  import { DEFAULTS, ID, configGroup, text } from './config.js';
19
21
  import { Gallery } from './gallery.js';
20
22
  import { samplePng } from './png.js';
@@ -38,14 +40,21 @@ const asText = (value) => (typeof value === 'string' ? value.trim() : '');
38
40
  const PACKAGE_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
39
41
 
40
42
  // ---------------------------------------------------------------------------
41
- // 自愈补丁:安装目录里的两处代码每次启动都对一遍
43
+ // 自愈补丁:QQ 扩展里的两处代码每次启动都对一遍
42
44
  // ---------------------------------------------------------------------------
43
45
  //
44
- // 桌宠要在控制台「对话」页发图,得改安装目录里的两份文件(客户端 bundle 的 say 渲染、
45
- // 桌宠包的 chat.ts/world.ts)。程序自动更新会把这些文件覆写回去,所以靠扩展模块的
46
- // 顶层代码每次启动重打一遍 —— `loadExtensions()` 无论世界开没开都会 import 本模块。
46
+ // 这里只给**旧版**第三方 QQ 扩展(`cortico-world-qq-better-alont1`,world `qqbot`)打补丁:
47
+ // 它的 `qq_send_image` / `qq_view_image` 只按扩展目录解析相对路径,这里改成先按桌宠工作区
48
+ // (记忆目录)解析、扩展目录兜底。第三方包升级会把这些文件覆写回去,所以靠扩展模块的顶层
49
+ // 代码每次启动重打一遍 —— `loadExtensions()` 无论世界开没开都会 import 本模块。
47
50
  //
48
- // 打好的补丁写回了磁盘,但桌宠包的 TS 是进程启动时 import 的,要重启进程才装载;
51
+ // 官方 `cortico-world-qq`(world `qq`)发图收的是 blob 句柄(`host.blob(...)`),根本不解析
52
+ // 本地路径,用不着补丁;切到它之后 `worlds.qqbot.enabled=false`,这两处会自动记 absent、不碰文件。
53
+ //
54
+ // 0.3.0 起不再往桌宠包里打补丁(改用官方 `pet_say.images`);早先版本打过的旧补丁会在启动时
55
+ // 按 `.coopanion-orig` 备份还原回去。
56
+ //
57
+ // 打好的补丁写回了磁盘,但扩展的代码是进程启动时 import 的,要重启进程才装载;
49
58
  // 重启请求放在 `start()` 里(那时才拿得到控制台端口),并用状态文件防死循环。
50
59
 
51
60
  /** 最近一次补丁检查的结果;「状态」页显示它,也能手动重打或按备份还原。 */
@@ -58,6 +67,8 @@ let PATCH_STATE = {
58
67
  total: 0,
59
68
  stuck: false,
60
69
  restartPending: false,
70
+ /** 这次启动从桌宠包里还原掉的旧补丁文件(0.3.0 起才有)。 */
71
+ reverted: [],
61
72
  error: '',
62
73
  restart: null,
63
74
  /** 手动还原的记录:`{at, restored:[id], failed:[id], byFile:[{file,backup,state}]}`,没还原过就是 null。 */
@@ -77,10 +88,13 @@ function refreshPatchState({ stateDir, log } = {}) {
77
88
  .filter((r) => r.state === 'failed' || r.state === 'missing')
78
89
  .map((r) => ({ id: r.id, what: r.what, error: r.error ?? '' })),
79
90
  done: report.results.filter((r) => r.state === 'done').length,
80
- // 可选补丁(第三方扩展没装)记 absent:不计进总数,免得状态页写「7/9 没打上」。
91
+ // 可选补丁(QQ 扩展没装、或那个世界没开)记 absent:不计进总数,免得状态页写「1/2 没打上」。
81
92
  total: report.results.filter((r) => r.state !== 'absent').length,
93
+ // 切到官方 QQ 扩展后一处都不用打:状态页据此显示「没有要打补丁的 QQ 扩展」。
94
+ skipped: report.results.filter((r) => r.state === 'absent').length,
82
95
  stuck: report.stuck,
83
96
  restartPending: report.restartPending,
97
+ reverted: report.reverted?.filter((r) => r.reverted).map((r) => r.file) ?? [],
84
98
  error: report.error ?? '',
85
99
  restored: report.patched.length ? null : PATCH_STATE.restored,
86
100
  };
@@ -145,6 +159,38 @@ export class ImageWorld {
145
159
  return text(this.language);
146
160
  }
147
161
 
162
+ /**
163
+ * 解析模型链:`worlds.image.models`(JSON 数组)优先,空则退回旧的单档配置。
164
+ * 每次现读,控制台改完即生效。
165
+ */
166
+ #chain() {
167
+ return parseChainEntries(this.#raw('models'), {
168
+ baseUrl: this.#str('baseUrl'),
169
+ model: this.#str('model'),
170
+ apiKeySecret: this.#str('apiKeySecret'),
171
+ size: this.#str('size'),
172
+ quality: this.#str('quality'),
173
+ format: this.#str('format'),
174
+ timeoutMs: this.#num('timeoutMs', 10000, 900000),
175
+ });
176
+ }
177
+
178
+ /** 这一档现在能不能用(ComfyUI 只要地址 + ckpt,OpenAI 兼容还要求有密钥)。 */
179
+ #entryState(entry) {
180
+ return entryState(entry, { apiKey: this.#entryKey(entry) });
181
+ }
182
+
183
+ /** 能出图的那些档,顺序不变。 */
184
+ #usableEntries(entries, { forEdit = false } = {}) {
185
+ return entries.filter((entry) => this.#entryState(entry).ok && (!forEdit || entry.edit));
186
+ }
187
+
188
+ /** 一档都没有时,把每档差在哪拼成一句话。 */
189
+ #chainReasons(entries) {
190
+ const lines = entries.map((entry) => `- ${entryLabel(entry)}(${entry.preset}):${this.#entryState(entry).reason || '可用'}`);
191
+ return lines.length ? `\n${lines.join('\n')}` : '模型链是空的,去「图片工坊 → 状态 → 模型链」里配一档';
192
+ }
193
+
148
194
  /** 图片根目录:配置了就用配置的(相对路径按 bot 目录解析),否则 <dataDir>/images。 */
149
195
  dir() {
150
196
  const raw = String(this.#raw('saveDir') ?? '').trim();
@@ -158,8 +204,7 @@ export class ImageWorld {
158
204
  * 先问部署 .env(`ctx.secret`),再退到同级 `providers/<端点>/.env`:
159
205
  * 机器上往往已经为画图建过一个端点,密钥名直接填那个就行,不必抄第二份。
160
206
  */
161
- #keyLookup() {
162
- const name = this.#str('apiKeySecret');
207
+ #keyLookup(name = this.#str('apiKeySecret')) {
163
208
  if (!name) return { name, value: '', from: '', provider: '' };
164
209
  let own = '';
165
210
  try {
@@ -177,6 +222,12 @@ export class ImageWorld {
177
222
  };
178
223
  }
179
224
 
225
+ /** 某一档要的密钥:先看这一档的 `apiKeySecret`,没写就用全局那个。 */
226
+ #entryKey(entry) {
227
+ const name = String(entry?.apiKeySecret ?? '').trim() || this.#str('apiKeySecret');
228
+ return name ? this.#keyLookup(name).value : '';
229
+ }
230
+
180
231
  /** 扫 `<botDir>/../providers/<端点>/.env`,找同名变量。 */
181
232
  #keyFromProviders(name) {
182
233
  const root = resolve(this.botDir, '..', 'providers');
@@ -207,15 +258,50 @@ export class ImageWorld {
207
258
 
208
259
  #envState() {
209
260
  const t = this.#text();
210
- const baseUrl = this.#str('baseUrl');
211
- const model = this.#str('model');
261
+ const { entries, error } = this.#chain();
212
262
  const key = this.#keyLookup();
213
- const name = key.name;
214
- const keySet = Boolean(key.value);
215
- const extra = { keyFrom: key.from, keyProvider: key.provider };
216
- if (!baseUrl) return { ok: false, baseUrl, model, name, keySet, detail: t.readyNoBase, ...extra };
217
- if (!keySet) return { ok: false, baseUrl, model, name, keySet, detail: t.readyNoKey, ...extra };
218
- return { ok: true, baseUrl, model, name, keySet, detail: t.readyOk(model), ...extra };
263
+ const base = {
264
+ name: key.name,
265
+ keySet: Boolean(key.value),
266
+ keyFrom: key.from,
267
+ keyProvider: key.provider,
268
+ baseUrl: '',
269
+ model: '',
270
+ chain: [],
271
+ chainError: error,
272
+ };
273
+ if (error) return { ...base, ok: false, detail: `${t.readyChainBad}:${error}` };
274
+ const states = entries.map((entry) => ({ entry, state: this.#entryState(entry) }));
275
+ const chain = states.map(({ entry, state }) => ({
276
+ name: entryLabel(entry),
277
+ preset: entry.preset,
278
+ baseUrl: entry.baseUrl,
279
+ model: entry.preset === 'comfyui' ? entry.ckpt : entry.model,
280
+ ok: state.ok,
281
+ reason: state.reason,
282
+ }));
283
+ const usable = states.filter((s) => s.state.ok).map((s) => s.entry);
284
+ if (!usable.length) {
285
+ const first = entries[0];
286
+ const bare = entries.length === 1 && !first?.baseUrl;
287
+ return {
288
+ ...base,
289
+ chain,
290
+ ok: false,
291
+ baseUrl: first?.baseUrl ?? '',
292
+ model: first ? entryLabel(first) : '',
293
+ detail: bare ? t.readyNoBase : `${t.readyNoChain}${this.#chainReasons(entries)}`,
294
+ };
295
+ }
296
+ const labels = usable.map(entryLabel);
297
+ return {
298
+ ...base,
299
+ chain,
300
+ ok: true,
301
+ baseUrl: usable[0].baseUrl,
302
+ model: labels[0],
303
+ detail: labels.length > 1 ? t.readyChain(labels.join(' → ')) : t.readyOk(labels[0]),
304
+ };
219
305
  }
220
306
 
221
307
  // -------------------------------------------------------------------------
@@ -260,38 +346,28 @@ export class ImageWorld {
260
346
  async #draw(args) {
261
347
  const prompt = asText(args.prompt);
262
348
  if (!prompt) return { text: '[image_draw 没执行] prompt 是空的。不想画就别调用。', failed: true };
263
- const keyName = this.#str('apiKeySecret');
264
- const key = this.apiKey();
265
- if (!key) {
266
- return {
267
- text: `[image_draw 没执行] 没有配置 ${keyName}:请在控制台「图片工坊 → 状态」里填入密钥(或在部署 .env 里写 ${keyName}=...)。`,
268
- failed: true,
269
- };
270
- }
271
- const model = this.#str('model');
272
- const size = asText(args.size) || this.#str('size');
273
- const quality = asText(args.quality) || this.#str('quality');
349
+ const { entries, error } = this.#chain();
350
+ if (error) return { text: `[image_draw 没执行] ${error}`, failed: true };
351
+ const usable = this.#usableEntries(entries);
352
+ if (!usable.length) return { text: `[image_draw 没执行] 没有能出图的一档:${this.#chainReasons(entries)}`, failed: true };
353
+ const named = asText(args.model);
354
+ const size = asText(args.size);
355
+ const quality = asText(args.quality);
274
356
  this.#busy += 1;
275
357
  try {
276
- const res = await generateImage({
277
- baseUrl: this.#str('baseUrl'),
278
- apiKey: key,
279
- model,
280
- prompt,
281
- size,
282
- quality,
283
- format: this.#str('format'),
284
- timeoutMs: this.#num('timeoutMs', 10000, 900000),
285
- });
358
+ const res = await runChain(usable, (entry) => this.#generateWith(entry, { prompt, size, quality }), { only: named });
359
+ const used = res.entry;
360
+ const label = entryLabel(used);
286
361
  const shown = res.revisedPrompt ? String(res.revisedPrompt) : prompt;
287
362
  const note = asText(args.note);
363
+ const dims = res.size || size || used.size || 'auto';
288
364
  const { item, warning } = this.#saveSafe({
289
365
  bytes: res.bytes,
290
366
  mime: res.mime,
291
367
  prompt: shown,
292
368
  note,
293
- model,
294
- size: size || 'auto',
369
+ model: label,
370
+ size: dims,
295
371
  source: 'api',
296
372
  });
297
373
  return this.#outcome({
@@ -300,10 +376,11 @@ export class ImageWorld {
300
376
  mime: res.mime,
301
377
  prompt: shown,
302
378
  note,
303
- model,
304
- size: size || 'auto',
379
+ model: label,
380
+ size: dims,
305
381
  item,
306
382
  warning,
383
+ attempts: res.attempts,
307
384
  });
308
385
  } catch (err) {
309
386
  return this.#failure('image_draw', err);
@@ -322,37 +399,28 @@ export class ImageWorld {
322
399
  failed: true,
323
400
  };
324
401
  }
325
- const key = this.apiKey();
326
- if (!key) {
327
- return { text: `[image_edit 没执行] 没有配置 ${this.#str('apiKeySecret')}。`, failed: true };
328
- }
329
- const model = this.#str('model');
330
- const size = asText(args.size) || this.#str('size');
331
- const quality = asText(args.quality) || this.#str('quality');
402
+ const { entries, error } = this.#chain();
403
+ if (error) return { text: `[image_edit 没执行] ${error}`, failed: true };
404
+ const usable = this.#usableEntries(entries, { forEdit: true });
405
+ if (!usable.length) return { text: `[image_edit 没执行] 没有能改图的一档:${this.#chainReasons(entries)}`, failed: true };
406
+ const named = asText(args.model);
407
+ const size = asText(args.size);
408
+ const quality = asText(args.quality);
332
409
  this.#busy += 1;
333
410
  try {
334
- const res = await editImage({
335
- baseUrl: this.#str('baseUrl'),
336
- apiKey: key,
337
- model,
338
- prompt,
339
- image: readFileSync(local.path),
340
- mime: local.mime,
341
- name: local.path.split(/[\\/]/).pop(),
342
- size,
343
- quality,
344
- format: this.#str('format'),
345
- timeoutMs: this.#num('timeoutMs', 10000, 900000),
346
- });
411
+ const res = await runChain(usable, (entry) => this.#editWith(entry, { prompt, size, quality, local }), { only: named });
412
+ const used = res.entry;
413
+ const label = entryLabel(used);
347
414
  const shown = res.revisedPrompt ? String(res.revisedPrompt) : prompt;
348
415
  const note = asText(args.note);
416
+ const dims = res.size || size || used.size || 'auto';
349
417
  const { item, warning } = this.#saveSafe({
350
418
  bytes: res.bytes,
351
419
  mime: res.mime,
352
420
  prompt: shown,
353
421
  note,
354
- model,
355
- size: size || 'auto',
422
+ model: label,
423
+ size: dims,
356
424
  source: 'edit',
357
425
  });
358
426
  return this.#outcome({
@@ -361,10 +429,11 @@ export class ImageWorld {
361
429
  mime: res.mime,
362
430
  prompt: shown,
363
431
  note,
364
- model,
365
- size: size || 'auto',
432
+ model: label,
433
+ size: dims,
366
434
  item,
367
435
  warning,
436
+ attempts: res.attempts,
368
437
  });
369
438
  } catch (err) {
370
439
  return this.#failure('image_edit', err);
@@ -373,6 +442,93 @@ export class ImageWorld {
373
442
  }
374
443
  }
375
444
 
445
+ /** 用一档后端画一张新图。 */
446
+ async #generateWith(entry, { prompt, size, quality }) {
447
+ if (entry.preset === 'comfyui') {
448
+ const res = await comfyDraw({
449
+ baseUrl: entry.baseUrl,
450
+ ckpt: entry.ckpt,
451
+ unet: entry.unet,
452
+ textEncoder: entry.textEncoder,
453
+ textEncoderType: entry.textEncoderType,
454
+ vaeName: entry.vaeName,
455
+ loras: entry.loras,
456
+ negative: entry.negative,
457
+ prompt,
458
+ size: size || entry.size,
459
+ steps: entry.steps,
460
+ cfg: entry.cfg,
461
+ sampler: entry.sampler,
462
+ scheduler: entry.scheduler,
463
+ workflow: entry.workflow,
464
+ prefix: 'coopanion',
465
+ timeoutMs: entry.timeoutMs,
466
+ });
467
+ return { bytes: res.bytes, mime: res.mime, revisedPrompt: null, promptId: res.promptId, size: res.size };
468
+ }
469
+ const key = this.#entryKey(entry);
470
+ if (!key) {
471
+ throw new ImageApiError(`没有配置 ${entry.apiKeySecret || this.#str('apiKeySecret')}:在「图片工坊 → 状态」里填入密钥,或在部署 .env 里写这个变量`, { status: 401 });
472
+ }
473
+ return await generateImage({
474
+ baseUrl: entry.baseUrl,
475
+ apiKey: key,
476
+ model: entry.model,
477
+ prompt,
478
+ size: size || entry.size,
479
+ quality: quality || entry.quality,
480
+ format: entry.format,
481
+ timeoutMs: entry.timeoutMs,
482
+ });
483
+ }
484
+
485
+ /** 用一档后端改一张已有的图。 */
486
+ async #editWith(entry, { prompt, size, quality, local }) {
487
+ if (entry.preset === 'comfyui') {
488
+ const res = await comfyEdit({
489
+ baseUrl: entry.baseUrl,
490
+ ckpt: entry.ckpt,
491
+ unet: entry.unet,
492
+ textEncoder: entry.textEncoder,
493
+ textEncoderType: entry.textEncoderType,
494
+ vaeName: entry.vaeName,
495
+ loras: entry.loras,
496
+ negative: entry.negative,
497
+ prompt,
498
+ image: readFileSync(local.path),
499
+ mime: local.mime,
500
+ name: local.path.split(/[\\/]/).pop(),
501
+ size: size || entry.size,
502
+ denoise: entry.denoise,
503
+ steps: entry.steps,
504
+ cfg: entry.cfg,
505
+ sampler: entry.sampler,
506
+ scheduler: entry.scheduler,
507
+ workflow: entry.workflow,
508
+ prefix: 'coopanion',
509
+ timeoutMs: entry.timeoutMs,
510
+ });
511
+ return { bytes: res.bytes, mime: res.mime, revisedPrompt: null, promptId: res.promptId, size: res.size };
512
+ }
513
+ const key = this.#entryKey(entry);
514
+ if (!key) {
515
+ throw new ImageApiError(`没有配置 ${entry.apiKeySecret || this.#str('apiKeySecret')}`, { status: 401 });
516
+ }
517
+ return await editImage({
518
+ baseUrl: entry.baseUrl,
519
+ apiKey: key,
520
+ model: entry.model,
521
+ prompt,
522
+ image: readFileSync(local.path),
523
+ mime: local.mime,
524
+ name: local.path.split(/[\\/]/).pop(),
525
+ size: size || entry.size,
526
+ quality: quality || entry.quality,
527
+ format: entry.format,
528
+ timeoutMs: entry.timeoutMs,
529
+ });
530
+ }
531
+
376
532
  async #list(args) {
377
533
  const raw = Number(args.limit);
378
534
  const limit = Number.isFinite(raw) ? Math.max(1, Math.min(Math.round(raw), 50)) : 12;
@@ -445,10 +601,13 @@ export class ImageWorld {
445
601
  const lines = [spec.verb];
446
602
  if (spec.item) lines.push(`文件:${spec.item.path}`);
447
603
  lines.push(`参数:${spec.size || '默认尺寸'} · ${spec.model || '默认模型'} · ${kb} KB`);
604
+ if (Array.isArray(spec.attempts) && spec.attempts.length) {
605
+ lines.push(`前几档没成:${spec.attempts.map((it) => `${it.name}(${it.message})`).join(';')}`);
606
+ }
448
607
  if (spec.note) lines.push(`名字:${spec.note}`);
449
608
  if (spec.prompt) lines.push(`画面:${spec.prompt}`);
450
609
  if (spec.warning) lines.push(spec.warning);
451
- lines.push('图已进入本次对话,也显示在「图片工坊」页。要发给某人或某个群,把上面的绝对路径交给 qq_send_image。');
610
+ lines.push('图已进入本次对话,也显示在「图片工坊」页。要发给某人或某个群,就把上面那行 `[blob …]` 的句柄交给那个世界的发图工具(官方 QQ 扩展是 `qq_draft` 的 `image`;旧版 QQ 扩展是 `qq_send_image`,填文件绝对路径)。');
452
611
  return {
453
612
  text: lines.join('\n'),
454
613
  blobs: [{
@@ -550,6 +709,7 @@ export class ImageWorld {
550
709
  if (panel === 'status') {
551
710
  switch (method) {
552
711
  case 'state': return this.#state();
712
+ case 'probe': return await this.#probeChain();
553
713
  case 'repatch': return this.#repatch();
554
714
  case 'restore': return this.#restore();
555
715
  case 'restart': return this.#askRestart('控制台手动请求');
@@ -587,6 +747,9 @@ export class ImageWorld {
587
747
  keyFrom: env.keyFrom,
588
748
  keyProvider: env.keyProvider,
589
749
  detail: env.detail,
750
+ models: this.#raw('models'),
751
+ chain: env.chain,
752
+ chainError: env.chainError,
590
753
  patch: {
591
754
  ...PATCH_STATE,
592
755
  packageDir: PACKAGE_DIR,
@@ -596,7 +759,88 @@ export class ImageWorld {
596
759
  }
597
760
 
598
761
  /**
599
- * 重新对一遍「对话页发图」补丁。
762
+ * 逐个探活:ComfyUI 看 `/system_stats`(顺带核对 ckpt 在不在),
763
+ * OpenAI 兼容看 `/models`(有的端点没这条路由,当作不判定)。
764
+ * 「状态」页的「测试模型链」用它;只返回结果,不改配置。
765
+ */
766
+ async #probeChain() {
767
+ const { entries, error } = this.#chain();
768
+ const at = new Date().toISOString();
769
+ if (error) return { at, error, items: [] };
770
+ // 探活是给人在面板上点的,宁可「说不清」也不能把 RPC 拖到超时:
771
+ // 每档最多 6 秒、几档并行,最慢一次也就 6 秒出结果。
772
+ const cap = (ms) => Math.max(1000, Math.min(Number(ms) > 0 ? Number(ms) : 6000, 6000));
773
+ const items = await Promise.all(
774
+ entries.map(async (entry) => {
775
+ const name = entryLabel(entry);
776
+ const preset = entry.preset;
777
+ try {
778
+ const state = this.#entryState(entry);
779
+ if (!state.ok) return { name, preset, ok: false, detail: state.reason };
780
+ if (preset === 'comfyui') {
781
+ const probe = await probeComfy(entry.baseUrl, { timeoutMs: cap(entry.timeoutMs) });
782
+ if (!probe.ok) return { name, preset, ok: false, detail: probe.error };
783
+ const where = probe.gpu ? `(${probe.gpu})` : '';
784
+ // 自定义工作流里的文件名都写在模板里,这里只确认服务在线。
785
+ if (entry.workflow) return { name, preset, ok: true, detail: `在线${where} · 自定义工作流` };
786
+ const files = await listModels(entry.baseUrl, { timeoutMs: cap(entry.timeoutMs) });
787
+ // 拿不到清单(老版本 / 路由没有)就只报在线,不硬说文件不在。
788
+ if (entry.unet) {
789
+ const miss = [];
790
+ const check = (list, value, hint) => {
791
+ if (!value || !list.length || list.includes(value)) return;
792
+ miss.push(`${hint}:${value}`);
793
+ };
794
+ check(files.unets, entry.unet, '没有这个 UNet');
795
+ check(files.textEncoders, entry.textEncoder, '没有这个文本编码器');
796
+ check(files.vaes, entry.vaeName, '没有这个 VAE');
797
+ const label = `${entry.unet} + ${entry.textEncoder || '?'} + ${entry.vaeName || '?'}`;
798
+ return {
799
+ name,
800
+ preset,
801
+ ok: miss.length === 0,
802
+ detail: miss.length
803
+ ? `在线,但${miss.join('、')}(那边有 ${files.unets.length} 个 UNet / ${files.textEncoders.length} 个文本编码器 / ${files.vaes.length} 个 VAE)`
804
+ : `在线${where} · ${label}${files.unets.length ? '' : '(没拿到模型清单,未核对)'}`,
805
+ };
806
+ }
807
+ const ckpts = files.checkpoints;
808
+ const has = !ckpts.length || ckpts.includes(entry.ckpt);
809
+ return {
810
+ name,
811
+ preset,
812
+ ok: has,
813
+ detail: has
814
+ ? `在线${where} · ${entry.ckpt || '没写 ckpt'}`
815
+ : `在线,但没有这个 ckpt:${entry.ckpt}(那边装了 ${ckpts.length} 个)`,
816
+ };
817
+ }
818
+ const key = this.#entryKey(entry);
819
+ const target = `${String(entry.baseUrl ?? '').replace(/[/\s]+$/, '')}/models`;
820
+ const res = await fetch(target, {
821
+ headers: key ? { Authorization: `Bearer ${key}` } : {},
822
+ signal: AbortSignal.timeout(cap(entry.timeoutMs)),
823
+ });
824
+ return {
825
+ name,
826
+ preset,
827
+ ok: res.ok || res.status === 404,
828
+ detail: res.ok
829
+ ? `在线 · ${entry.model}`
830
+ : res.status === 404
831
+ ? `端点没有 /models,跳过检查 · ${entry.model}`
832
+ : `返回 ${res.status} · ${entry.model}`,
833
+ };
834
+ } catch (err) {
835
+ return { name, preset, ok: false, detail: `连不上:${msg(err)}` };
836
+ }
837
+ }),
838
+ );
839
+ return { at, error: '', items };
840
+ }
841
+
842
+ /**
843
+ * 重新对一遍「QQ 发图路径」补丁(顺带还原桌宠包上的旧补丁)。
600
844
  *
601
845
  * 平时用不着(每次启动都会自己打),留给两处:更新覆写后发现没打上,
602
846
  * 或者想看看到底改了哪几处。
@@ -611,10 +855,10 @@ export class ImageWorld {
611
855
  }
612
856
 
613
857
  /**
614
- * 按 `.coopanion-orig` 手动还原:把安装目录里的三份文件覆盖回更新下发的原样。
858
+ * 按 `.coopanion-orig` 手动还原:把改过的文件覆盖回更新下发的原样。
615
859
  *
616
860
  * 备份不删,所以这是可逆的(再点「重打补丁」或下次启动就会补回来)。
617
- * 还原后桌宠那一侧要重启才回到「没有发图功能」,但这里**不自动重启**
861
+ * 还原后 QQ 发图回到「只认扩展目录的相对路径」,要重启才生效;但这里**不自动重启**
618
862
  * ——用户是主动撤销的,等他自己决定。
619
863
  */
620
864
  #restore() {
@@ -809,7 +1053,7 @@ export class ImageWorld {
809
1053
  });
810
1054
  if (PATCH_STATE.error) this.#host?.log?.warn?.(`图片工坊补丁:${PATCH_STATE.error}`);
811
1055
  if (PATCH_STATE.failed.length) {
812
- this.#host?.log?.warn?.(`图片工坊补丁:有 ${PATCH_STATE.failed.length} 处没打上(${PATCH_STATE.failed.map((f) => f.id).join(', ')}),桌宠发图可能不生效`);
1056
+ this.#host?.log?.warn?.(`图片工坊补丁:有 ${PATCH_STATE.failed.length} 处没打上(${PATCH_STATE.failed.map((f) => f.id).join(', ')}),QQ 发图可能不认工作区相对路径`);
813
1057
  }
814
1058
  if (PATCH_STATE.restartPending) this.#scheduleRestart('这次启动补上了新补丁');
815
1059
  }