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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "cortico-world-image",
3
- "version": "0.2.0",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
- "description": "【注意:未升级0.2.0以上版本请安装本插件的上一个历史版本】Image Studio(图片工坊)—— 给 Coopanion 桌宠的 cortico World 扩展:用任意 OpenAI 兼容的图片接口(包括阿里)画图/改图、落盘成作品库、把图贴进控制台「对话」页,并自带一套自动重打的自愈补丁。 / A cortico World extension that draws through any OpenAI-compatible image API, keeps pictures on disk and shows them in the console.",
5
+ "description": "Image Studio(图片工坊)—— 给 Coopanion 桌宠的 cortico World 扩展:用任意 OpenAI 兼容的图片接口画图/改图、落盘成作品库、把图贴进控制台「对话」页,并自带一套自动重打的自愈补丁。 / A cortico World extension that draws through any OpenAI-compatible image API, keeps pictures on disk and shows them in the console.",
6
6
  "license": "MIT",
7
7
  "author": "lostdegree",
8
8
  "homepage": "https://github.com/Lostdegree/cortico-world-image#readme",
package/src/chain.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * 模型链:把「画一张图」交给一串后端,按顺序试,失败就换下一档。
3
+ *
4
+ * 配置写在 `worlds.image.models`(JSON 数组,数组顺序就是优先级),每档可以是
5
+ * `preset: 'openai'`(任何 OpenAI 兼容端点,含云端)或 `preset: 'comfyui'`(本地跑)。
6
+ * 不写 `models` 时退回旧的单档配置(baseUrl / model / apiKeySecret / …),老用户不用改。
7
+ *
8
+ * 这里只管「按什么顺序试、什么错误该换下一档」,具体怎么调在 api.js / comfyui.js。
9
+ */
10
+ import { ImageApiError } from './api.js';
11
+
12
+ export const PRESETS = ['openai', 'comfyui'];
13
+
14
+ const PRESET_LABEL = { openai: 'OpenAI 兼容', comfyui: 'ComfyUI' };
15
+
16
+ /** 链上每一档都失败时抛这个;`attempts` 是逐档的失败原因。 */
17
+ export class ChainError extends ImageApiError {
18
+ constructor(message, attempts = []) {
19
+ super(message, { status: attempts.length ? Number(attempts[attempts.length - 1]?.status) || 0 : 0 });
20
+ this.name = 'ChainError';
21
+ this.attempts = attempts;
22
+ }
23
+ }
24
+
25
+ function asText(value, fallback = '') {
26
+ const s = String(value ?? '').trim();
27
+ return s || fallback;
28
+ }
29
+
30
+ function asNumber(value, fallback) {
31
+ const n = Number(value);
32
+ return Number.isFinite(n) ? n : fallback;
33
+ }
34
+
35
+ function asBool(value, fallback = true) {
36
+ if (value === undefined || value === null || value === '') return fallback;
37
+ if (typeof value === 'boolean') return value;
38
+ const s = String(value).trim().toLowerCase();
39
+ if (['false', '0', 'off', 'no'].includes(s)) return false;
40
+ if (['true', '1', 'on', 'yes'].includes(s)) return true;
41
+ return fallback;
42
+ }
43
+
44
+ /**
45
+ * 解析 `worlds.image.models`。
46
+ *
47
+ * @param raw JSON 字符串,或已经解析好的数组;空 = 用旧单档。
48
+ * @param legacy 旧单档配置(baseUrl/model/apiKeySecret/...)。
49
+ * @returns {{entries: object[], error: string, warnings: string[]}}
50
+ * 有 `error` 表示整份配置读不了(调用方应当放弃出图并告诉用户哪里写错)。
51
+ */
52
+ export function parseChainEntries(raw, legacy = {}) {
53
+ const warnings = [];
54
+ const defaults = {
55
+ baseUrl: asText(legacy.baseUrl),
56
+ model: asText(legacy.model),
57
+ apiKeySecret: asText(legacy.apiKeySecret, 'IMAGE_API_KEY'),
58
+ size: asText(legacy.size, ''),
59
+ quality: asText(legacy.quality, 'auto'),
60
+ format: asText(legacy.format, 'png'),
61
+ timeoutMs: Math.max(10000, Math.min(900000, asNumber(legacy.timeoutMs, 180000))),
62
+ steps: 25,
63
+ cfg: 7,
64
+ sampler: 'euler',
65
+ scheduler: 'normal',
66
+ denoise: 0.6,
67
+ negative: '',
68
+ ckpt: '',
69
+ unet: '',
70
+ textEncoder: '',
71
+ textEncoderType: 'stable_diffusion',
72
+ vaeName: '',
73
+ loras: [],
74
+ workflow: null,
75
+ edit: true,
76
+ };
77
+
78
+ let list = [];
79
+ const text = typeof raw === 'string' ? raw.trim() : '';
80
+ if (Array.isArray(raw)) {
81
+ list = raw;
82
+ } else if (text) {
83
+ try {
84
+ const parsed = JSON.parse(text);
85
+ if (!Array.isArray(parsed)) {
86
+ return { entries: [], error: '`models` 要是一个 JSON 数组,例如 [{"name":"local","preset":"comfyui","baseUrl":"http://127.0.0.1:8188","ckpt":"sd_xl_base_1.0.safetensors"}]', warnings };
87
+ }
88
+ list = parsed;
89
+ } catch (err) {
90
+ return { entries: [], error: `\`models\` 不是合法 JSON:${String(err?.message ?? err)}`, warnings };
91
+ }
92
+ }
93
+
94
+ if (!list.length) {
95
+ // 没有链 = 旧的单档配置。没有 baseUrl 时也算「没配」,交给调用方报 readyNoBase。
96
+ return {
97
+ entries: [normalize({}, 0, defaults, warnings, true)],
98
+ error: '',
99
+ warnings,
100
+ };
101
+ }
102
+
103
+ const entries = [];
104
+ const seen = new Set();
105
+ list.forEach((item, index) => {
106
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
107
+ warnings.push(`第 ${index + 1} 档不是对象,已跳过`);
108
+ return;
109
+ }
110
+ const entry = normalize(item, index, defaults, warnings, false);
111
+ if (!entry.baseUrl) {
112
+ warnings.push(`「${entry.name}」没有 baseUrl,已跳过`);
113
+ return;
114
+ }
115
+ if (seen.has(entry.name)) {
116
+ const renamed = `${entry.name}#${index + 1}`;
117
+ warnings.push(`「${entry.name}」重名了,这一档改叫「${renamed}」`);
118
+ entry.name = renamed;
119
+ }
120
+ seen.add(entry.name);
121
+ entries.push(entry);
122
+ });
123
+
124
+ if (!entries.length) {
125
+ return { entries: [], error: '`models` 里没有一档可用的(每档都要有 baseUrl,preset 只能是 openai / comfyui)', warnings };
126
+ }
127
+ return { entries, error: '', warnings };
128
+ }
129
+
130
+ function normalize(item, index, defaults, warnings, single) {
131
+ const src = item && typeof item === 'object' ? item : {};
132
+ const ckpt = asText(src.ckpt ?? src.checkpoint);
133
+ // 裸 UNet 三件套(Anima / Flux 这类不是 all-in-one 的模型):填了 unet 就不看 ckpt。
134
+ const unet = asText(src.unet ?? src.unetName ?? src.diffusionModel);
135
+ const preset = asText(src.preset, ckpt || unet ? 'comfyui' : 'openai').toLowerCase();
136
+ const known = PRESETS.includes(preset);
137
+ if (!known) warnings.push(`第 ${index + 1} 档的 preset「${preset}」不认识,只能是 ${PRESETS.join(' / ')}`);
138
+ const name = asText(
139
+ src.name,
140
+ ckpt || unet || asText(src.model) || (single ? 'default' : `${PRESET_LABEL[preset] ?? preset}#${index + 1}`),
141
+ );
142
+ return {
143
+ name,
144
+ preset: known ? preset : 'openai',
145
+ baseUrl: asText(src.baseUrl, defaults.baseUrl),
146
+ model: asText(src.model, preset === 'comfyui' ? '' : defaults.model),
147
+ apiKeySecret: asText(src.apiKeySecret, defaults.apiKeySecret),
148
+ size: asText(src.size, defaults.size),
149
+ quality: asText(src.quality, defaults.quality),
150
+ format: asText(src.format, defaults.format),
151
+ timeoutMs: Math.max(5000, Math.min(1800000, asNumber(src.timeoutMs, defaults.timeoutMs))),
152
+ ckpt: preset === 'comfyui' ? ckpt : '',
153
+ unet: preset === 'comfyui' ? unet : '',
154
+ textEncoder: preset === 'comfyui' ? asText(src.textEncoder ?? src.clip ?? src.textEncoderName) : '',
155
+ textEncoderType: asText(src.textEncoderType, defaults.textEncoderType),
156
+ vaeName: preset === 'comfyui' ? asText(src.vaeName ?? src.vae) : '',
157
+ loras: Array.isArray(src.loras) ? src.loras : (src.loras ? [src.loras] : []),
158
+ negative: asText(src.negative, defaults.negative),
159
+ steps: Math.max(1, Math.min(150, Math.round(asNumber(src.steps, defaults.steps)))),
160
+ cfg: Math.max(0, Math.min(30, asNumber(src.cfg, defaults.cfg))),
161
+ sampler: asText(src.sampler, defaults.sampler),
162
+ scheduler: asText(src.scheduler, defaults.scheduler),
163
+ denoise: Math.max(0.05, Math.min(1, asNumber(src.denoise, defaults.denoise))),
164
+ workflow: src.workflow ?? null,
165
+ edit: asBool(src.edit, defaults.edit),
166
+ enabled: asBool(src.enabled, true),
167
+ };
168
+ }
169
+
170
+ /** 给人看的名字:优先 `name`,其次 ckpt / model。 */
171
+ export function entryLabel(entry) {
172
+ return asText(entry?.name) || asText(entry?.ckpt) || asText(entry?.unet) || asText(entry?.model) || asText(entry?.preset, '模型');
173
+ }
174
+
175
+ /**
176
+ * 一档的「能不能用」:ComfyUI 要求地址 + 模型文件(ckpt,或裸 UNet 三件套,或自定义工作流),
177
+ * OpenAI 兼容还要求有密钥。
178
+ */
179
+ export function entryState(entry, { apiKey = '' } = {}) {
180
+ if (!entry) return { ok: false, reason: '没有这一档' };
181
+ if (!entry.enabled) return { ok: false, reason: '已停用' };
182
+ if (!entry.baseUrl) return { ok: false, reason: '没配地址' };
183
+ if (entry.preset === 'openai' && !apiKey) return { ok: false, reason: `没有密钥(${entry.apiKeySecret || 'IMAGE_API_KEY'})` };
184
+ if (entry.preset === 'comfyui') {
185
+ // 自定义工作流:节点和文件名都写在模板里,插件不校验。
186
+ if (entry.workflow) return { ok: true, reason: '' };
187
+ if (entry.unet) {
188
+ if (!entry.textEncoder) return { ok: false, reason: '裸 UNet 还差文本编码器(text_encoders 里的文件名)' };
189
+ if (!entry.vaeName) return { ok: false, reason: '裸 UNet 还差 VAE(vae 里的文件名)' };
190
+ return { ok: true, reason: '' };
191
+ }
192
+ if (!entry.ckpt) {
193
+ return { ok: false, reason: '没配 ckpt(checkpoint 文件名);Anima 这类裸 UNet 模型就填 unet + textEncoder + vae' };
194
+ }
195
+ }
196
+ return { ok: true, reason: '' };
197
+ }
198
+
199
+ /**
200
+ * 按顺序试。`runner(entry)` 成功就返回;抛错就记一笔换下一档。
201
+ *
202
+ * `only` 指定某一档名字时只试那一档(模型在工具参数里点名了,就不该偷偷换别人)。
203
+ */
204
+ export async function runChain(entries, runner, { only = '' } = {}) {
205
+ const wanted = asText(only);
206
+ const queue = wanted
207
+ ? entries.filter((entry) => entry.name === wanted || entry.ckpt === wanted || entry.unet === wanted || entry.model === wanted)
208
+ : entries;
209
+ if (!queue.length) {
210
+ throw new ChainError(wanted ? `没有叫「${wanted}」的模型档` : '模型链是空的:去「图片工坊 → 状态 → 模型链」里配一档', []);
211
+ }
212
+ const attempts = [];
213
+ for (const entry of queue) {
214
+ try {
215
+ const res = await runner(entry);
216
+ return { ...res, entry, attempts };
217
+ } catch (err) {
218
+ attempts.push({ name: entryLabel(entry), preset: entry.preset, status: Number(err?.status) || 0, message: String(err?.message ?? err) });
219
+ if (wanted || isFatal(err)) break;
220
+ }
221
+ }
222
+ throw new ChainError(describeAttempts(attempts), attempts);
223
+ }
224
+
225
+ /**
226
+ * 换下一档也救不了的错误:提示词被内容审核拦了(同一条 prompt 换谁都会拦)。
227
+ * 其余(连不上、超时、额度、模型没装、参数不支持…)都值得再试下一档。
228
+ */
229
+ function isFatal(err) {
230
+ if (err?.fatal === true) return true;
231
+ const message = String(err?.message ?? '');
232
+ return /moderation|content[_ ]policy|safety system|违规|敏感|不安全内容/i.test(message);
233
+ }
234
+
235
+ /** 把逐档失败拼成一句人话。 */
236
+ export function describeAttempts(attempts) {
237
+ if (!attempts.length) return '没有可用的模型档';
238
+ if (attempts.length === 1) {
239
+ const one = attempts[0];
240
+ return `${one.name} 失败了:${one.message}`;
241
+ }
242
+ const lines = attempts.map((it) => `- ${it.name}(${PRESET_LABEL[it.preset] ?? it.preset}):${it.message}`);
243
+ return `模型链上的 ${attempts.length} 档都失败了:\n${lines.join('\n')}`;
244
+ }
245
+
246
+ /** 状态页 / 徽章用:每档一行快照。 */
247
+ export function chainSnapshot(entries, { keys = new Map(), probe = null } = {}) {
248
+ return entries.map((entry) => {
249
+ const apiKey = entry.preset === 'openai' ? String(keys.get(entry.name) ?? keys.get(entry.apiKeySecret) ?? '') : '';
250
+ const state = entryState(entry, { apiKey });
251
+ return {
252
+ name: entryLabel(entry),
253
+ preset: entry.preset,
254
+ baseUrl: entry.baseUrl,
255
+ model: entry.preset === 'comfyui' ? entry.ckpt || entry.unet : entry.model,
256
+ ok: state.ok,
257
+ reason: state.reason,
258
+ ...(entry.preset === 'comfyui' && probe ? { probe } : {}),
259
+ };
260
+ });
261
+ }