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/ENV_PROMPT.md +8 -3
- package/README.md +164 -106
- package/describe.json +17 -9
- package/dist/webui.js +677 -13
- package/package.json +2 -2
- package/src/chain.js +261 -0
- package/src/chat-image-patch.js +93 -262
- package/src/comfyui.js +801 -0
- package/src/config.js +16 -4
- package/src/tools.js +20 -9
- package/src/world.js +314 -70
- package/tools/check.mjs +718 -65
- package/tools/patch.mjs +178 -398
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
46
|
+
// 这里只给**旧版**第三方 QQ 扩展(`cortico-world-qq-better-alont1`,world `qqbot`)打补丁:
|
|
47
|
+
// 它的 `qq_send_image` / `qq_view_image` 只按扩展目录解析相对路径,这里改成先按桌宠工作区
|
|
48
|
+
// (记忆目录)解析、扩展目录兜底。第三方包升级会把这些文件覆写回去,所以靠扩展模块的顶层
|
|
49
|
+
// 代码每次启动重打一遍 —— `loadExtensions()` 无论世界开没开都会 import 本模块。
|
|
47
50
|
//
|
|
48
|
-
//
|
|
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
|
-
//
|
|
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
|
|
211
|
-
const model = this.#str('model');
|
|
261
|
+
const { entries, error } = this.#chain();
|
|
212
262
|
const key = this.#keyLookup();
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
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:
|
|
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:
|
|
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
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
const
|
|
330
|
-
const size = asText(args.size)
|
|
331
|
-
const quality = asText(args.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
|
|
335
|
-
|
|
336
|
-
|
|
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:
|
|
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:
|
|
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('
|
|
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
|
}
|