cortico-world-image 0.5.4 → 0.5.5

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/README.md CHANGED
@@ -381,7 +381,8 @@ pnpm add "D:\path\to\cortico-world-image" --ignore-workspace
381
381
 
382
382
  控制台「图片工坊 → 作品」有个 **插入自检样图** 按钮:它会用手写的极简 PNG 编码器造一张图,
383
383
  走完「落盘 → 索引 → 面板取图 → 缩略图显示」整条路。看不到这张图,说明是扩展或面板的问题,
384
- 不是图片接口的问题。
384
+ 不是图片接口的问题。「状态」页也有同一个按钮,两边都能用(`status.selftest` 与 `gallery.selftest`
385
+ 都认;0.5.5 前只有后者,从状态页点会报 `未知面板方法:status.selftest`,issue #3 一起修了)。
385
386
 
386
387
  开发侧一共有五份检查脚本,外加一份发布前自检。
387
388
 
@@ -489,6 +490,10 @@ cortico-world-image/
489
490
  - 尺寸:`size` 能写 `1024x1536`、比例 `16:9` / `4:3`、或 `square` / `portrait` / `landscape`(中文
490
491
  `竖图` / `横图` / `方图` 也认),比例会换成一套对齐好的尺寸(16:9 → 1344×768,4:3 → 1152×864);
491
492
  写死的数字只按 latent 要求对齐到 8,不动你写的整数。
493
+ - 云端那档(OpenAI 兼容端点)**只认 `宽x高`**:别名和比例会先换成它认的三档之一
494
+ (`1024x1024` / `1536x1024` / `1024x1536`),认不出来的值就不发这个字段、让服务端用自己的默认,
495
+ 并在回执里说明一句。以前是原样透传,`square` 这种会让上游直接失败(报成 503「无返回结果」,
496
+ 很难联想到 size),issue #3 修的就是这个。
492
497
  - 改图的尺寸:ComfyUI 那档**不传 `size`、或写 `auto`,就照参考图的比例**出图(最长边压到 1536、对齐
493
498
  16),传了 `size` 就按给的;云端那档走接口自己的默认(一般不传尺寸)。所以改一张 1600×1200 的图
494
499
  会出 1536×1152,不会变成方图。图生图的回执/作品页记的是**实际出的尺寸**,不再是 `auto`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-image",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
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",
package/src/api.js CHANGED
@@ -18,6 +18,39 @@ export class ImageApiError extends Error {
18
18
 
19
19
  const DEFAULT_TIMEOUT = 180000;
20
20
 
21
+ /**
22
+ * 云端端点只认 `宽x高` 这种字面尺寸:把 `square` 这类别名原样发过去,
23
+ * 上游会以「无返回」包装成 503(issue #3)。这里先归一化,认不出来就干脆不发。
24
+ * 键都是小写、去掉空格之后的写法。
25
+ */
26
+ const CLOUD_SIZE_ALIASES = {
27
+ square: '1024x1024', '1:1': '1024x1024', '方图': '1024x1024', '方形': '1024x1024', '正方形': '1024x1024',
28
+ portrait: '1024x1536', '3:4': '1024x1536', '9:16': '1024x1536', '竖图': '1024x1536', '竖屏': '1024x1536', '竖版': '1024x1536',
29
+ landscape: '1536x1024', '4:3': '1536x1024', '3:2': '1536x1024', '16:9': '1536x1024', '横图': '1536x1024', '横屏': '1536x1024', '横版': '1536x1024',
30
+ };
31
+
32
+ /**
33
+ * 云端那档的 `size` 归一化:返回合法的 `宽x高`,`auto`/空/认不出来返回 ''(调用方就别发这个字段)。
34
+ * 字面尺寸原样保留(`1792x1024` 这类也认),只把 `×`/`*` 统一成 `x`。
35
+ */
36
+ export function normalizeCloudSize(size) {
37
+ const raw = String(size ?? '').trim().toLowerCase().replace(/\s+/g, '');
38
+ if (!raw || raw === 'auto') return '';
39
+ const wh = /^(\d{2,5})[x×*](\d{2,5})$/.exec(raw);
40
+ if (wh) return `${Number(wh[1])}x${Number(wh[2])}`;
41
+ const alias = CLOUD_SIZE_ALIASES[raw];
42
+ if (alias) return alias;
43
+ // 任意 a:b:按横竖落到云端那三档上(方/横/竖)。
44
+ const ratio = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(raw);
45
+ if (ratio) {
46
+ const a = Number(ratio[1]);
47
+ const b = Number(ratio[2]);
48
+ if (a > 0 && b > 0) return a === b ? '1024x1024' : (a > b ? '1536x1024' : '1024x1536');
49
+ }
50
+ return '';
51
+ }
52
+
53
+
21
54
  function joinUrl(baseUrl, path) {
22
55
  const base = String(baseUrl ?? '').trim().replace(/[/\s]+$/, '');
23
56
  if (!base) throw new ImageApiError('没有配置接口地址');
@@ -175,11 +208,11 @@ async function materialize(json, { timeoutMs, format }) {
175
208
  throw new ImageApiError('接口返回里既没有 base64 也没有图片地址');
176
209
  }
177
210
 
178
- /** 画图请求体:`n` 固定 1,size / quality 是 auto 就不发。 */
211
+ /** 画图请求体:`n` 固定 1;size 归一化成 `宽x高`(别名云端不认),认不出来就不发;quality 是 auto 也不发。 */
179
212
  function generationBody(opts) {
180
213
  const body = { model: opts.model, prompt: opts.prompt, n: 1 };
181
- const size = String(opts.size ?? '').trim();
182
- if (size && size.toLowerCase() !== 'auto') body.size = size;
214
+ const size = normalizeCloudSize(opts.size);
215
+ if (size) body.size = size;
183
216
  const quality = String(opts.quality ?? '').trim();
184
217
  if (quality && quality.toLowerCase() !== 'auto') body.quality = quality;
185
218
  return body;
@@ -233,8 +266,8 @@ export async function editImage(opts = {}) {
233
266
  form.set('model', String(opts.model ?? ''));
234
267
  form.set('prompt', String(opts.prompt ?? ''));
235
268
  form.set('n', '1');
236
- const size = String(opts.size ?? '').trim();
237
- if (size && size.toLowerCase() !== 'auto') form.set('size', size);
269
+ const size = normalizeCloudSize(opts.size);
270
+ if (size) form.set('size', size);
238
271
  const quality = String(opts.quality ?? '').trim();
239
272
  if (quality && quality.toLowerCase() !== 'auto') form.set('quality', quality);
240
273
  form.set('image', new Blob([opts.image], { type: opts.mime ?? 'image/png' }), opts.name ?? 'image.png');
package/src/world.js CHANGED
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node
13
13
  import { extname, isAbsolute, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
16
- import { ImageApiError, editImage, generateImage } from './api.js';
16
+ import { ImageApiError, editImage, generateImage, normalizeCloudSize } from './api.js';
17
17
  import { ChainError, chainSnapshot, entryLabel, entryState, parseChainEntries, runChain } from './chain.js';
18
18
  import { applyChatImagePatches, consolePort, patchBackupStatus, requestConsoleRestart, resolveAppRoot, restoreChatImagePatches } from './chat-image-patch.js';
19
19
  import { comfyDraw, comfyEdit, listModels, probe as probeComfy } from './comfyui.js';
@@ -360,7 +360,8 @@ export class ImageWorld {
360
360
  const label = entryLabel(used);
361
361
  const shown = res.revisedPrompt ? String(res.revisedPrompt) : prompt;
362
362
  const note = asText(args.note);
363
- const dims = res.size || size || used.size || 'auto';
363
+ const sentSize = used.preset === 'openai' ? normalizeCloudSize(size || used.size) : '';
364
+ const dims = res.size || sentSize || size || used.size || 'auto';
364
365
  const { item, warning } = this.#saveSafe({
365
366
  bytes: res.bytes,
366
367
  mime: res.mime,
@@ -381,6 +382,7 @@ export class ImageWorld {
381
382
  item,
382
383
  warning,
383
384
  attempts: res.attempts,
385
+ hint: this.#sizeHint(used, size),
384
386
  });
385
387
  } catch (err) {
386
388
  return this.#failure('image_draw', err);
@@ -413,7 +415,8 @@ export class ImageWorld {
413
415
  const label = entryLabel(used);
414
416
  const shown = res.revisedPrompt ? String(res.revisedPrompt) : prompt;
415
417
  const note = asText(args.note);
416
- const dims = res.size || size || used.size || 'auto';
418
+ const sentSize = used.preset === 'openai' ? normalizeCloudSize(size || used.size) : '';
419
+ const dims = res.size || sentSize || size || used.size || 'auto';
417
420
  const { item, warning } = this.#saveSafe({
418
421
  bytes: res.bytes,
419
422
  mime: res.mime,
@@ -434,6 +437,7 @@ export class ImageWorld {
434
437
  item,
435
438
  warning,
436
439
  attempts: res.attempts,
440
+ hint: this.#sizeHint(used, size),
437
441
  });
438
442
  } catch (err) {
439
443
  return this.#failure('image_edit', err);
@@ -593,6 +597,17 @@ export class ImageWorld {
593
597
  return item;
594
598
  }
595
599
 
600
+ /**
601
+ * 云端那档只认「宽x高」:别名会被换成三档之一,认不出来的干脆不发。
602
+ * 换掉的不吭声(结果就是你要的方向),**没发**的必须在回执里说一句,否则用户会以为尺寸生效了。
603
+ */
604
+ #sizeHint(entry, sizeArg) {
605
+ if (entry?.preset !== 'openai') return '';
606
+ const asked = asText(sizeArg) || asText(entry.size);
607
+ if (!asked || normalizeCloudSize(asked)) return '';
608
+ return `提示:云端这档不认 size「${asked}」,这次没带 size、按它的默认尺寸出图;要指定就写「1024x1536」或「16:9」。`;
609
+ }
610
+
596
611
  /** 工具回执:文本给模型读,blob 交给 Core 进上下文。 */
597
612
  #outcome(spec) {
598
613
  const bytes = spec.bytes;
@@ -601,6 +616,7 @@ export class ImageWorld {
601
616
  const lines = [spec.verb];
602
617
  if (spec.item) lines.push(`文件:${spec.item.path}`);
603
618
  lines.push(`参数:${spec.size || '默认尺寸'} · ${spec.model || '默认模型'} · ${kb} KB`);
619
+ if (spec.hint) lines.push(spec.hint);
604
620
  if (Array.isArray(spec.attempts) && spec.attempts.length) {
605
621
  lines.push(`前几档没成:${spec.attempts.map((it) => `${it.name}(${it.message})`).join(';')}`);
606
622
  }
@@ -674,7 +690,7 @@ export class ImageWorld {
674
690
  ],
675
691
  panels: [
676
692
  { id: 'gallery', title: t.panelGalleryTitle, description: t.panelGalleryDesc, getMethods: ['list'] },
677
- { id: 'status', title: t.panelStatusTitle, description: t.panelStatusDesc, getMethods: ['state'] },
693
+ { id: 'status', title: t.panelStatusTitle, description: t.panelStatusDesc, getMethods: ['state', 'selftest'] },
678
694
  ],
679
695
  config: [configGroup(this.language)],
680
696
  promptDocs: [{
@@ -715,6 +731,8 @@ export class ImageWorld {
715
731
  case 'restart': return this.#askRestart('控制台手动请求');
716
732
  case 'setKey': return this.#setKey(list[0]);
717
733
  case 'clearKey': return this.#setKey('');
734
+ // 面板「自检」按钮发的是 status.selftest(旧版挂在 gallery 上),两处都认。
735
+ case 'selftest': return this.#selfTest();
718
736
  default: throw new Error(`未知面板方法:status.${method}`);
719
737
  }
720
738
  }
package/tools/check.mjs CHANGED
@@ -541,6 +541,65 @@ try {
541
541
  calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
542
542
  await api.generateImage({ ...editOpts, image: undefined });
543
543
  check(!('image' in JSON.parse(String(calls[0].init.body))), 'image_draw 的请求体里没有 image 字段');
544
+
545
+ // 6. size 别名(issue #3):云端只吃「宽x高」,别名必须换掉;认不出来的干脆不发。
546
+ const cloudSizeCases = [
547
+ ['square', '1024x1024'],
548
+ ['1:1', '1024x1024'],
549
+ ['方图', '1024x1024'],
550
+ ['portrait', '1024x1536'],
551
+ ['9:16', '1024x1536'],
552
+ ['landscape', '1536x1024'],
553
+ ['16:9', '1536x1024'],
554
+ ['4:3', '1536x1024'],
555
+ ['21:9', '1536x1024'],
556
+ ['1024x1536', '1024x1536'],
557
+ ['1792x1024', '1792x1024'],
558
+ ['1024×1024', '1024x1024'],
559
+ ['auto', ''],
560
+ ['', ''],
561
+ ['超大图', ''],
562
+ ];
563
+ let badSize = null;
564
+ for (const [input, want] of cloudSizeCases) {
565
+ const got = api.normalizeCloudSize(input);
566
+ if (got !== want) badSize = `${input} → ${got || '(空)'},期望 ${want || '(空)'}`;
567
+ }
568
+ check(!badSize, '云端尺寸归一化:别名换成宽x高、字面尺寸原样、认不出来不发', badSize ?? '');
569
+
570
+ calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
571
+ await api.generateImage({ ...editOpts, image: undefined, size: 'square' });
572
+ check(JSON.parse(String(calls[0].init.body)).size === '1024x1024', 'size=square 发给云端前换成 1024x1024(以前原样发、必 503)', String(JSON.parse(String(calls[0].init.body)).size));
573
+
574
+ calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
575
+ await api.generateImage({ ...editOpts, image: undefined, size: '16:9' });
576
+ check(JSON.parse(String(calls[0].init.body)).size === '1536x1024', 'size=16:9 换成 1536x1024');
577
+
578
+ calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
579
+ await api.generateImage({ ...editOpts, image: undefined, size: '怪尺寸' });
580
+ check(!('size' in JSON.parse(String(calls[0].init.body))), '认不出的 size 不发,交给服务端默认(不再 503)');
581
+
582
+ calls = stubFetch([['/images/edits', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
583
+ await api.editImage({ ...editOpts, size: 'portrait' });
584
+ check(calls[0].init.body.get('size') === '1024x1536', 'multipart 那条路的 size 也换成长宽', String(calls[0].init.body.get('size')));
585
+
586
+ // 7. 世界层:云端那档 + 认不出的 size,回执照实说一句(以前是静默按默认尺寸出图)。
587
+ const cloudWorld = definition.create({
588
+ cfg: {
589
+ ...definition.defaults(),
590
+ enabled: true,
591
+ models: JSON.stringify([{ name: 'cloud', preset: 'openai', baseUrl: 'https://api.test/v1', model: 'gpt-image-1', apiKeySecret: 'IMAGE_API_KEY' }]),
592
+ },
593
+ dataDir,
594
+ botDir,
595
+ packageDir: pkgDir,
596
+ secret: (name) => (name === 'IMAGE_API_KEY' ? 'sk-x' : ''),
597
+ storeSecret: () => {},
598
+ });
599
+ calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
600
+ const drewOdd = await cloudWorld.tools().find((t) => t.name === 'image_draw').handler({ prompt: '猫', size: '怪尺寸' });
601
+ check(drewOdd && !drewOdd.failed, '云端那档能出图', JSON.stringify(drewOdd).slice(0, 100));
602
+ check(String(drewOdd.text).includes('不认 size'), '认不出的 size 在回执里说了一句,不是静默', String(drewOdd.text).split('\n')[2]);
544
603
  } finally {
545
604
  globalThis.fetch = realFetch;
546
605
  }
@@ -548,6 +607,11 @@ try {
548
607
  section('离线自检样图(落盘 → 索引 → 取图)');
549
608
  const self = await world.invoke('gallery', 'selftest', []);
550
609
  check(self?.ok === true && self.item?.id, 'selftest 返回条目');
610
+ // 面板「自检」按钮发的是 status.selftest(issue #3:以前只挂在 gallery 上,点了必报未知方法)。
611
+ const selfStatus = await world.invoke('status', 'selftest', []);
612
+ check(selfStatus?.ok === true && selfStatus.item?.id, 'status 面板也能自检(面板按钮走的就是这条)');
613
+ check(existsSync(selfStatus.item.path), 'status 自检的图也真落盘了', String(selfStatus.item?.path));
614
+ await world.invoke('gallery', 'remove', [selfStatus.item.id]); // 别影响后面「只有一张」的断言
551
615
  check(existsSync(self.item.path), '图片真的落盘了', String(self.item?.path));
552
616
  const bytes = readFileSync(self.item.path);
553
617
  check(bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'PNG 魔术字节正确');