cortico-world-image 0.1.2 → 0.1.3
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 +1 -1
- package/README.md +12 -6
- package/package.json +1 -1
- package/src/api.js +48 -8
- package/src/world.js +1 -1
- package/tools/check.mjs +93 -0
- package/tools/patch.mjs +7 -1
package/ENV_PROMPT.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## 四个工具
|
|
4
4
|
|
|
5
5
|
- `image_draw`:按一段文字描述画一张新图。图会存到磁盘、附加进本次对话(你自己看得到)、并显示在控制台「图片工坊 → 作品」页。
|
|
6
|
-
- `image_edit`:拿一张已有的图按指令改(换风格、改细节)。`image` 给绝对路径,或 `image_gallery`
|
|
6
|
+
- `image_edit`:拿一张已有的图按指令改(换风格、改细节)。`image` 给绝对路径,或 `image_gallery` 里的编号。服务端支不支持改图由接口决定,报错就照实告诉用户。
|
|
7
7
|
- `image_gallery`:列出画过的图,最新的在前,带编号和绝对路径。编号可以交给 `image_show` 或 `image_edit`。
|
|
8
8
|
- `image_show`:把一张旧图重新放回眼前(重新附加进对话,并在作品页高亮)。
|
|
9
9
|
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
| 工具 | 标签 | 作用 |
|
|
22
22
|
| --- | --- | --- |
|
|
23
23
|
| `image_draw` | `act` | 按文字描述画一张新图,落盘、记进索引 |
|
|
24
|
-
| `image_edit` | `act` |
|
|
24
|
+
| `image_edit` | `act` | 拿一张已有的图按指令改(支持上传改图,或阿里云百炼式的 `image` 扩展字段) |
|
|
25
25
|
| `image_gallery` | `read` | 列出画过的图,带编号与绝对路径 |
|
|
26
26
|
| `image_show` | `speak` | 把一张旧图重新放回对话与作品页 |
|
|
27
27
|
|
|
@@ -192,9 +192,9 @@
|
|
|
192
192
|
修法是抬版本 + 重启,主程序会在 Core 就绪时自动重载控制台外壳(
|
|
193
193
|
`resources\app\app\main.cjs:285` `if (settings) settings.loadURL(consoleUrl());`):
|
|
194
194
|
|
|
195
|
-
1. 把 `package.json` 的 `version` 抬一格(例如 `0.1.
|
|
195
|
+
1. 把 `package.json` 的 `version` 抬一格(例如 `0.1.2` → `0.1.3`);
|
|
196
196
|
2. 重启控制台(状态页「重启控制台」或 `node tools/patch.mjs --restart`);
|
|
197
|
-
3. 程序内窗口自动重载 → 新的 URL `/assets/extensions/cortico-world-image/0.1.
|
|
197
|
+
3. 程序内窗口自动重载 → 新的 URL `/assets/extensions/cortico-world-image/0.1.3/console.js`
|
|
198
198
|
取到新脚本,旧 URL 立刻变 404。
|
|
199
199
|
|
|
200
200
|
不想抬版本也行,但要**先完全退出程序**再删缓存目录:`<安装>\data\Cache`、
|
|
@@ -230,7 +230,7 @@ registry 上有版本时直接从 npm 装;想自己改代码、或者 `npm vie
|
|
|
230
230
|
或者用命令行,在 `data/extensions` 目录里(`extensions/package.json` 所在处)执行:
|
|
231
231
|
|
|
232
232
|
```powershell
|
|
233
|
-
pnpm add cortico-world-image --ignore-workspace # 想钉住版本就写 cortico-world-image@0.1.
|
|
233
|
+
pnpm add cortico-world-image --ignore-workspace # 想钉住版本就写 cortico-world-image@0.1.3
|
|
234
234
|
```
|
|
235
235
|
|
|
236
236
|
装完重启。升级同理:重跑一遍(控制台的「扩展」页会显示已装版本与最新版本),装完同样要重启。
|
|
@@ -320,7 +320,10 @@ pnpm add "D:\path\to\cortico-world-image" --ignore-workspace
|
|
|
320
320
|
只要能对上这两条 OpenAI 形状的路:
|
|
321
321
|
|
|
322
322
|
- `POST <baseUrl>/images/generations`(JSON,`{model, prompt, size, quality, n}`)
|
|
323
|
-
- `POST <baseUrl>/images/edits`(multipart,`image` 文件 + `prompt`)——只有 `image_edit`
|
|
323
|
+
- `POST <baseUrl>/images/edits`(multipart,`image` 文件 + `prompt`)——只有 `image_edit` 要它。
|
|
324
|
+
端点没有这条路由(回 404/405)时,`image_edit` 会自动退回 `/images/generations`,把原图编成
|
|
325
|
+
`data:` URI 放进扩展字段 `image`——阿里云百炼的 OpenAI 兼容模式就是这么改图的,配上
|
|
326
|
+
`baseUrl` 和模型(如 `qwen-image-3.0-pro`)即可,不用额外设置。
|
|
324
327
|
|
|
325
328
|
响应里认这些形状:`{data:[{b64_json|url}]}`、`{images:[...]}`、`{output|artifacts|content:[...]}`,
|
|
326
329
|
以及顶层的 `{image|url|b64_json|image_url}`。base64 直接落盘,`http(s)` 链接会再下载一次。
|
|
@@ -330,6 +333,7 @@ pnpm add "D:\path\to\cortico-world-image" --ignore-workspace
|
|
|
330
333
|
- 硅基流动:`https://api.siliconflow.cn/v1`
|
|
331
334
|
- 智谱:`https://open.bigmodel.cn/api/paas/v4`
|
|
332
335
|
- 火山方舟:`https://ark.cn-beijing.volces.com/api/v3`
|
|
336
|
+
- 阿里云百炼:`https://dashscope.aliyuncs.com/compatible-mode/v1`(图生图走 `/images/generations` 的 `image` 字段)
|
|
333
337
|
|
|
334
338
|
## 自检(不联网)
|
|
335
339
|
|
|
@@ -422,7 +426,9 @@ cortico-world-image/
|
|
|
422
426
|
- `pet_show_image` 的相对路径按桌宠工作区解析(`pet-workspace-*` 那三处补丁);万一装配层那处
|
|
423
427
|
(`pet-workspace-wire`)没打上、世界里没拿到 `workspaceDir`,行为退回老样子:相对路径报错、
|
|
424
428
|
要求给绝对路径——不会静默拼出一个错的路径。
|
|
425
|
-
- `image_edit`
|
|
429
|
+
- `image_edit` 先走 `/images/edits`(multipart 上传);端点没这条路由(404/405)时自动退回
|
|
430
|
+
`/images/generations` 的 `image` 扩展字段(data URI,阿里云百炼那类)。两条路都不通时会回一条
|
|
431
|
+
带 HTTP 码的错误,不是静默失败。
|
|
426
432
|
- 只有 Windows 上的「打开文件夹」会真的弹资源管理器;其他平台返回一句说明。
|
|
427
433
|
- 画图本身要联网,只有自检样图是纯本地的。
|
|
428
434
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-image",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
@@ -175,17 +175,23 @@ async function materialize(json, { timeoutMs, format }) {
|
|
|
175
175
|
throw new ImageApiError('接口返回里既没有 base64 也没有图片地址');
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/** 画图请求体:`n` 固定 1,size / quality 是 auto 就不发。 */
|
|
179
|
+
function generationBody(opts) {
|
|
180
|
+
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;
|
|
183
|
+
const quality = String(opts.quality ?? '').trim();
|
|
184
|
+
if (quality && quality.toLowerCase() !== 'auto') body.quality = quality;
|
|
185
|
+
return body;
|
|
186
|
+
}
|
|
187
|
+
|
|
178
188
|
/**
|
|
179
189
|
* 文生图。`extraBody` 会并进请求体,留给各家的私有参数。
|
|
180
190
|
* @returns {Promise<{bytes: Buffer, mime: string, revisedPrompt: string|null}>}
|
|
181
191
|
*/
|
|
182
192
|
export async function generateImage(opts = {}) {
|
|
183
193
|
const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : DEFAULT_TIMEOUT;
|
|
184
|
-
const body =
|
|
185
|
-
const size = String(opts.size ?? '').trim();
|
|
186
|
-
if (size && size.toLowerCase() !== 'auto') body.size = size;
|
|
187
|
-
const quality = String(opts.quality ?? '').trim();
|
|
188
|
-
if (quality && quality.toLowerCase() !== 'auto') body.quality = quality;
|
|
194
|
+
const body = generationBody(opts);
|
|
189
195
|
if (opts.extraBody && typeof opts.extraBody === 'object') Object.assign(body, opts.extraBody);
|
|
190
196
|
const json = await postJson(joinUrl(opts.baseUrl, 'images/generations'), {
|
|
191
197
|
apiKey: opts.apiKey,
|
|
@@ -195,8 +201,30 @@ export async function generateImage(opts = {}) {
|
|
|
195
201
|
return await materialize(json, { timeoutMs, format: opts.format });
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
/** 把本地图片编成 data URI;阿里云百炼那类兼容端点的 `image` 字段认这个。 */
|
|
205
|
+
function dataUri(bytes, mime) {
|
|
206
|
+
const type = String(mime ?? '').trim() || 'image/png';
|
|
207
|
+
return `data:${type};base64,${Buffer.from(bytes ?? []).toString('base64')}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 端点没有 `/images/edits` 时的退路:把原图编成 data URI,跟着 prompt 一起
|
|
212
|
+
* 发给 `/images/generations` 的扩展字段 `image`(阿里云百炼的兼容模式就这么改图)。
|
|
213
|
+
*/
|
|
214
|
+
async function editViaGenerations(opts, timeoutMs) {
|
|
215
|
+
const body = generationBody(opts);
|
|
216
|
+
body.image = dataUri(opts.image, opts.mime);
|
|
217
|
+
const json = await postJson(joinUrl(opts.baseUrl, 'images/generations'), {
|
|
218
|
+
apiKey: opts.apiKey,
|
|
219
|
+
body,
|
|
220
|
+
timeoutMs,
|
|
221
|
+
});
|
|
222
|
+
return await materialize(json, { timeoutMs, format: opts.format });
|
|
223
|
+
}
|
|
224
|
+
|
|
198
225
|
/**
|
|
199
|
-
* 图生图 /
|
|
226
|
+
* 图生图 / 改图。先走 `/images/edits` 的 multipart;端点没这条路由(404/405)时
|
|
227
|
+
* 退回 `/images/generations` 的 `image` 扩展字段,见 `editViaGenerations`。
|
|
200
228
|
* @returns {Promise<{bytes: Buffer, mime: string, revisedPrompt: string|null}>}
|
|
201
229
|
*/
|
|
202
230
|
export async function editImage(opts = {}) {
|
|
@@ -210,6 +238,18 @@ export async function editImage(opts = {}) {
|
|
|
210
238
|
const quality = String(opts.quality ?? '').trim();
|
|
211
239
|
if (quality && quality.toLowerCase() !== 'auto') form.set('quality', quality);
|
|
212
240
|
form.set('image', new Blob([opts.image], { type: opts.mime ?? 'image/png' }), opts.name ?? 'image.png');
|
|
213
|
-
|
|
214
|
-
|
|
241
|
+
try {
|
|
242
|
+
const json = await postForm(joinUrl(opts.baseUrl, 'images/edits'), { apiKey: opts.apiKey, form, timeoutMs });
|
|
243
|
+
return await materialize(json, { timeoutMs, format: opts.format });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (!(err instanceof ImageApiError) || (err.status !== 404 && err.status !== 405)) throw err;
|
|
246
|
+
try {
|
|
247
|
+
return await editViaGenerations(opts, timeoutMs);
|
|
248
|
+
} catch (again) {
|
|
249
|
+
if (again instanceof ImageApiError) {
|
|
250
|
+
again.message = `${again.message}(端点也不支持 multipart /images/edits:HTTP ${err.status})`;
|
|
251
|
+
}
|
|
252
|
+
throw again;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
215
255
|
}
|
package/src/world.js
CHANGED
|
@@ -270,7 +270,7 @@ export class ImageWorld {
|
|
|
270
270
|
name: 'image_edit',
|
|
271
271
|
description: 'Redraw an existing local picture with the image API, following a text instruction. '
|
|
272
272
|
+ '`image` takes an absolute file path, or a picture id from image_gallery. '
|
|
273
|
-
+ '
|
|
273
|
+
+ 'Works with endpoints that implement /images/edits (multipart upload) and with those that take a base64 `image` field on /images/generations instead (e.g. Aliyun Bailian).',
|
|
274
274
|
tags: ['act'],
|
|
275
275
|
parameters: {
|
|
276
276
|
type: 'object',
|
package/tools/check.mjs
CHANGED
|
@@ -443,6 +443,99 @@ try {
|
|
|
443
443
|
const showMissing = await tools.find((t) => t.name === 'image_show').handler({ id: 'deadbeef' });
|
|
444
444
|
check(showMissing && showMissing.failed === true, 'image_show 找不到编号时失败');
|
|
445
445
|
|
|
446
|
+
section('改图请求的两种走法(stub fetch)');
|
|
447
|
+
const api = await import(pathToFileURL(join(pkgDir, 'src', 'api.js')).href);
|
|
448
|
+
const tinyPng = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5, 6, 7, 8]);
|
|
449
|
+
const tinyB64 = tinyPng.toString('base64');
|
|
450
|
+
const jsonResponse = (obj, status = 200) => new Response(JSON.stringify(obj), {
|
|
451
|
+
status,
|
|
452
|
+
headers: { 'content-type': 'application/json' },
|
|
453
|
+
});
|
|
454
|
+
/** 按 path 后缀分发的 fetch 替身;返回记下每次调用的 calls 数组。 */
|
|
455
|
+
const stubFetch = (routes) => {
|
|
456
|
+
const calls = [];
|
|
457
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
458
|
+
const call = { url: String(url), init };
|
|
459
|
+
calls.push(call);
|
|
460
|
+
const path = new URL(String(url)).pathname;
|
|
461
|
+
for (const [suffix, handler] of routes) {
|
|
462
|
+
if (path.endsWith(suffix)) return await handler(call);
|
|
463
|
+
}
|
|
464
|
+
return new Response('not found', { status: 404 });
|
|
465
|
+
};
|
|
466
|
+
return calls;
|
|
467
|
+
};
|
|
468
|
+
const realFetch = globalThis.fetch;
|
|
469
|
+
const editOpts = {
|
|
470
|
+
baseUrl: 'https://img.example.test/v1',
|
|
471
|
+
apiKey: 'sk-x',
|
|
472
|
+
model: 'gpt-image-1',
|
|
473
|
+
prompt: '把它改成水彩',
|
|
474
|
+
image: tinyPng,
|
|
475
|
+
mime: 'image/png',
|
|
476
|
+
name: 'src.png',
|
|
477
|
+
};
|
|
478
|
+
try {
|
|
479
|
+
// 1. 端点有 /images/edits:multipart 一次就够,不碰 generations。
|
|
480
|
+
let calls = stubFetch([['/images/edits', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
481
|
+
let out = await api.editImage(editOpts);
|
|
482
|
+
check(calls.length === 1 && calls[0].url.endsWith('/images/edits'), '/images/edits 能走通时只发一次 multipart', calls.map((c) => c.url).join(' '));
|
|
483
|
+
check(calls[0].init.body instanceof FormData && calls[0].init.body.get('prompt') === '把它改成水彩', 'multipart 里带着 prompt');
|
|
484
|
+
check(out.bytes.equals(tinyPng) && out.mime === 'image/png', 'multipart 的图正常取回');
|
|
485
|
+
|
|
486
|
+
// 2. 端点没有 /images/edits(阿里云百炼:404):退回 generations 的 image 字段。
|
|
487
|
+
calls = stubFetch([
|
|
488
|
+
['/images/edits', () => jsonResponse({ error: { message: 'not found' } }, 404)],
|
|
489
|
+
['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })],
|
|
490
|
+
]);
|
|
491
|
+
out = await api.editImage(editOpts);
|
|
492
|
+
check(calls.length === 2 && calls[1].url.endsWith('/images/generations'), '404 后退回 /images/generations', calls.map((c) => c.url).join(' '));
|
|
493
|
+
const fallbackBody = JSON.parse(String(calls[1].init.body));
|
|
494
|
+
check(fallbackBody.image === `data:image/png;base64,${tinyB64}`, '原图编成 data URI 塞进 image 字段', String(fallbackBody.image).slice(0, 40));
|
|
495
|
+
check(fallbackBody.prompt === '把它改成水彩' && fallbackBody.model === 'gpt-image-1' && fallbackBody.n === 1, '退路请求体带着 model / prompt / n');
|
|
496
|
+
check(calls[1].init.headers.Authorization === 'Bearer sk-x', '退路也带着密钥');
|
|
497
|
+
check(out.bytes.equals(tinyPng), '退路的图正常取回');
|
|
498
|
+
|
|
499
|
+
// 2b. 405(没有这条方法)同样算「没这条路由」。
|
|
500
|
+
calls = stubFetch([
|
|
501
|
+
['/images/edits', () => jsonResponse({ error: { message: 'method not allowed' } }, 405)],
|
|
502
|
+
['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })],
|
|
503
|
+
]);
|
|
504
|
+
out = await api.editImage(editOpts);
|
|
505
|
+
check(calls.length === 2 && out.bytes.equals(tinyPng), '405 也退回 /images/generations');
|
|
506
|
+
|
|
507
|
+
// 3. 端点报别的错(500):不试退路,原样抛。
|
|
508
|
+
calls = stubFetch([['/images/edits', () => jsonResponse({ error: { message: 'boom' } }, 500)]]);
|
|
509
|
+
let caught = null;
|
|
510
|
+
try {
|
|
511
|
+
await api.editImage(editOpts);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
caught = err;
|
|
514
|
+
}
|
|
515
|
+
check(caught instanceof api.ImageApiError && caught.status === 500, '500 不会被当成「没这条路由」', String(caught?.status));
|
|
516
|
+
check(calls.length === 1 && !calls.some((c) => c.url.endsWith('/images/generations')), '500 不触发退路');
|
|
517
|
+
|
|
518
|
+
// 4. 两条路都失败:错误里交代清楚先试了什么。
|
|
519
|
+
calls = stubFetch([
|
|
520
|
+
['/images/edits', () => jsonResponse({ error: { message: 'not found' } }, 404)],
|
|
521
|
+
['/images/generations', () => jsonResponse({ error: { message: 'model not exist' } }, 400)],
|
|
522
|
+
]);
|
|
523
|
+
caught = null;
|
|
524
|
+
try {
|
|
525
|
+
await api.editImage(editOpts);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
caught = err;
|
|
528
|
+
}
|
|
529
|
+
check(String(caught?.message).includes('model not exist') && String(caught?.message).includes('/images/edits'), '退路也失败时错误里带两条线索', String(caught?.message).slice(0, 120));
|
|
530
|
+
|
|
531
|
+
// 5. 画图那条路不带 image,别把文生图带歪。
|
|
532
|
+
calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
533
|
+
await api.generateImage({ ...editOpts, image: undefined });
|
|
534
|
+
check(!('image' in JSON.parse(String(calls[0].init.body))), 'image_draw 的请求体里没有 image 字段');
|
|
535
|
+
} finally {
|
|
536
|
+
globalThis.fetch = realFetch;
|
|
537
|
+
}
|
|
538
|
+
|
|
446
539
|
section('离线自检样图(落盘 → 索引 → 取图)');
|
|
447
540
|
const self = await world.invoke('gallery', 'selftest', []);
|
|
448
541
|
check(self?.ok === true && self.item?.id, 'selftest 返回条目');
|
package/tools/patch.mjs
CHANGED
|
@@ -88,6 +88,11 @@ function toolsReturnHarness(worldFile) {
|
|
|
88
88
|
const seg = text.slice(at.index, at.index + 3000);
|
|
89
89
|
const rm = /^([ \t]*)return ([^\n]*);$/m.exec(seg);
|
|
90
90
|
if (!rm) return { ok: false, reason: '没找到 tools() 里的 return' };
|
|
91
|
+
// 那句 return 会引用方法体里前面声明的局部量(桌宠 0.1.23 起是 `const own = petToolDecls(…)`)。
|
|
92
|
+
// 桩里把它们都声明成「两个假工具」的数组:真跑时若还有没桩到的名字,报错才说明补丁真的坏了。
|
|
93
|
+
const locals = [...seg.slice(0, rm.index).matchAll(/^[ \t]*const ([A-Za-z_$][\w$]*)\s*=/gm)]
|
|
94
|
+
.map((m) => m[1])
|
|
95
|
+
.filter((name, i, all) => all.indexOf(name) === i);
|
|
91
96
|
const dir = mkdtempSync(join(tmpdir(), 'coopanion-tools-return-'));
|
|
92
97
|
const file = join(dir, 'run.mjs');
|
|
93
98
|
const report = join(dir, 'out.json');
|
|
@@ -99,7 +104,8 @@ function toolsReturnHarness(worldFile) {
|
|
|
99
104
|
"const petToolDecls = () => [{ name: 'pet_say' }, { name: 'pet_ask' }];",
|
|
100
105
|
'const handlers = { pet_say: () => {}, pet_ask: () => {}, pet_walk_to: () => {} };',
|
|
101
106
|
"const CHAT_IMAGE_TOOL = { name: 'pet_show_image' };",
|
|
102
|
-
|
|
107
|
+
...locals.map((name) => `const ${name} = petToolDecls('zh');`),
|
|
108
|
+
"const world = { appLanguage: 'zh', cuaOn: false, cua: { tools: () => [] }, showImage: () => ({ text: 'ok' }) };",
|
|
103
109
|
`const out = (function () { return ${rm[2]}; }).call(world);`,
|
|
104
110
|
'writeFileSync(process.argv[2], JSON.stringify({',
|
|
105
111
|
' isArray: Array.isArray(out),',
|