cortico-world-image 0.1.3 → 0.2.0
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 +27 -14
- package/describe.json +132 -0
- package/dist/{console.js → webui.js} +1 -1
- package/package.json +6 -4
- package/src/chat-image-patch.js +30 -8
- package/src/definition.js +9 -0
- package/src/index.js +1 -1
- package/src/tools.js +106 -0
- package/src/world.js +21 -80
- package/tools/check.mjs +85 -15
- package/tools/describe.mts +113 -0
- package/tools/drymount.mts +14 -6
- package/tools/patch.mjs +1 -1
- package/tools/prepublish.mjs +84 -23
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
给 Coopanion 桌宠加两件事:**调用图片生成 API 画图,并把图交回对话**;以及**把本地任意一张图贴进控制台的「对话」页**——后者靠一套自愈补丁给桌宠加一个 `pet_show_image` 工具,程序自动更新把这些文件覆写回原样之后,扩展下次启动会自己补回来。
|
|
4
4
|
|
|
5
|
-
这是一个 `cortico` 的 **World 扩展**(`kind: world`,`api:
|
|
5
|
+
这是一个 `cortico` 的 **World 扩展**(`kind: world`,`api: 6`),只补一个 `image` 世界,
|
|
6
6
|
不依赖任何第三方 npm 包(纯 ESM JS + Node 内置模块)。
|
|
7
7
|
|
|
8
8
|
> **性质说明**:这是给 Coopanion 写的**非官方**扩展,走的是框架公开的扩展接口
|
|
9
|
-
> (`kind: world` + `
|
|
9
|
+
> (`kind: world` + `webUiClient` 面板 + 必需的 `describe.json` 自描述)。唯一伸手动别人的地方是「对话页发图」那一节的
|
|
10
10
|
> 自愈补丁——它按锚点改写安装目录下的几个文件(`resources/app` 里的浏览器 bundle、桌宠包
|
|
11
11
|
> 的 `src/*.ts`,以及可选的第三方 QQ 扩展),改之前一律留 `.coopanion-orig` 备份,
|
|
12
12
|
> 程序自动更新覆写后会自己补回来,细节与还原方式都在那一节里。不愿意让脚本动这些文件,
|
|
@@ -178,10 +178,10 @@
|
|
|
178
178
|
`ws://127.0.0.1:17788/ws/providers/world%3Adesktop-pet/panels/chat` 发 `{"t":"hello"}` 会收到
|
|
179
179
|
`init`(历史 267 项),桌宠说话也会收到 `item {kind:'say',…}`。**Ctrl+F5 硬刷新一次**就恢复了。
|
|
180
180
|
|
|
181
|
-
还有一条是我们这边造成的:扩展面板脚本由服务端按 `/assets/extensions/<包>/<版本>/
|
|
181
|
+
还有一条是我们这边造成的:扩展面板脚本由服务端按 `/assets/extensions/<包>/<版本>/webui.js`
|
|
182
182
|
分配,响应头是 `Cache-Control: public, max-age=31536000, immutable`,而 URL 里的版本号来自
|
|
183
183
|
`package.json`。所以**改了面板 UI 却没抬版本号时,浏览器会一直用旧脚本**(看不到新按钮)。
|
|
184
|
-
改了 `dist/
|
|
184
|
+
改了 `dist/webui.js` 就顺手把 `version` 抬一格,或者让用户硬刷新。
|
|
185
185
|
|
|
186
186
|
**程序内那个控制台窗口更顽固**(Coopanion.exe 自己的窗口,不是外部浏览器):它是长驻的 Chromium
|
|
187
187
|
渲染进程,磁盘缓存在 `<安装>\data\Cache`,而 `immutable` 让它在新鲜期内**连重启都不回源**。
|
|
@@ -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.3` → `0.1.4`);
|
|
196
196
|
2. 重启控制台(状态页「重启控制台」或 `node tools/patch.mjs --restart`);
|
|
197
|
-
3. 程序内窗口自动重载 → 新的 URL `/assets/extensions/cortico-world-image/0.
|
|
197
|
+
3. 程序内窗口自动重载 → 新的 URL `/assets/extensions/cortico-world-image/0.2.0/webui.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.4
|
|
234
234
|
```
|
|
235
235
|
|
|
236
236
|
装完重启。升级同理:重跑一遍(控制台的「扩展」页会显示已装版本与最新版本),装完同样要重启。
|
|
@@ -341,7 +341,7 @@ pnpm add "D:\path\to\cortico-world-image" --ignore-workspace
|
|
|
341
341
|
走完「落盘 → 索引 → 面板取图 → 缩略图显示」整条路。看不到这张图,说明是扩展或面板的问题,
|
|
342
342
|
不是图片接口的问题。
|
|
343
343
|
|
|
344
|
-
|
|
344
|
+
开发侧一共有五份检查脚本,外加一份发布前自检。
|
|
345
345
|
|
|
346
346
|
不碰 DOM 的那份覆盖模块形状、dry-mount 那套判据、工具回执、索引与两个面板的挂载(自带假 DOM):
|
|
347
347
|
|
|
@@ -361,6 +361,16 @@ node --experimental-transform-types tools/drymount.mts
|
|
|
361
361
|
> 参数属性(报 `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`),所以必须带
|
|
362
362
|
> `--experimental-transform-types`(node 22.6+)。带上这个开关就不需要任何额外的 loader。
|
|
363
363
|
|
|
364
|
+
框架 0.2 起每个扩展还要带一份**自描述**(`cortico.describe` → `describe.json`:工具表、事件表,
|
|
365
|
+
以及环境提示词模板的原文)。这份脚本按同一套判据从定义重新生成一遍,跟包里那份逐字比对:
|
|
366
|
+
|
|
367
|
+
```powershell
|
|
368
|
+
node --experimental-transform-types tools/describe.mts # 生成 / 刷新 describe.json
|
|
369
|
+
node --experimental-transform-types tools/describe.mts --check # 只核对,不写盘
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
改了工具描述、`describe.events` 或 `ENV_PROMPT.md` 之后要重新生成一次,否则发布前自检会拦下来。
|
|
373
|
+
|
|
364
374
|
第三份会**真的联网画一张图**:拿部署里已有端点的密钥(只读那份 `.env`,不打印密钥),
|
|
365
375
|
走完「工具回执带图 → 落盘 → 索引 → 面板取图 → 状态页读数」整条路,图存在临时目录里:
|
|
366
376
|
|
|
@@ -379,10 +389,11 @@ node tools/live.mjs # 可加 LIVE_BASE_URL
|
|
|
379
389
|
node tools/patch.mjs
|
|
380
390
|
```
|
|
381
391
|
|
|
382
|
-
|
|
383
|
-
`cortico.api` 是否等于当前框架的 `world` 契约、`
|
|
392
|
+
还有一份是**发布前自检**:按宿主 `manifest.ts` 的同一套规则验 `package.json`(`type: module`、
|
|
393
|
+
`cortico.api` 是否等于当前框架的 `world` 契约、`webUiClient` 与 `describe` 是否真在包里、keywords 里有没有
|
|
384
394
|
`cortico-world`)、按 `files` 展开一遍发布物、扫本机路径与私有端点之类的痕迹、逐个 `node --check`,
|
|
385
|
-
然后自动跑上面的 `check.mjs`(找得到本机 Coopanion 安装时连 `patch.mjs`
|
|
395
|
+
然后自动跑上面的 `check.mjs`(找得到本机 Coopanion 安装时连 `patch.mjs` 自测、`drymount.mts`
|
|
396
|
+
与 `describe.mts --check` 一起跑)。
|
|
386
397
|
|
|
387
398
|
```powershell
|
|
388
399
|
node tools/prepublish.mjs # 或 npm run selfcheck
|
|
@@ -396,7 +407,7 @@ node tools/prepublish.mjs --only-static # 只做静态检查
|
|
|
396
407
|
|
|
397
408
|
```
|
|
398
409
|
cortico-world-image/
|
|
399
|
-
├─ package.json # cortico: { kind: world, api:
|
|
410
|
+
├─ package.json # cortico: { kind: world, api: 6, webUiClient: dist/webui.js, describe: describe.json }
|
|
400
411
|
├─ README.md
|
|
401
412
|
├─ PUBLISH.md # 发布流程(维护者用,不打进 npm 包)
|
|
402
413
|
├─ ENV_PROMPT.md # 随环境提示词交给桌宠的说明(含 {{image.dir}} / {{image.ready}} 占位符)
|
|
@@ -409,9 +420,11 @@ cortico-world-image/
|
|
|
409
420
|
│ ├─ api.js # OpenAI 兼容客户端的取图/落盘/错误归一化
|
|
410
421
|
│ ├─ gallery.js # 作品仓库:目录 + index.json
|
|
411
422
|
│ └─ png.js # 零依赖 PNG 编码器 + 自检样图
|
|
412
|
-
├─
|
|
423
|
+
├─ describe.json # 自描述(框架 0.2 起必需):工具表、事件表、环境提示词原文
|
|
424
|
+
├─ dist/webui.js # 浏览器侧面板 bundle(export default { panels })
|
|
413
425
|
└─ tools/
|
|
414
426
|
├─ check.mjs # 离线检查(自带假 DOM)
|
|
427
|
+
├─ describe.mts # 生成 / 核对 describe.json(--check 只核对)
|
|
415
428
|
├─ drymount.mts # 用框架自带判据跑 dry-mount
|
|
416
429
|
├─ live.mjs # 联网真画一张(自测整条链路)
|
|
417
430
|
├─ patch.mjs # 对话页发图补丁:自测 / --apply / --backups / --restore / --restart
|
|
@@ -437,7 +450,7 @@ cortico-world-image/
|
|
|
437
450
|
这个仓库同时就是 npm 包 `cortico-world-image` 的源码。发一版的流程:
|
|
438
451
|
|
|
439
452
|
1. 改完代码、跑完自检(`node tools/prepublish.mjs --app <安装目录>\resources\app` 全绿);
|
|
440
|
-
2. 抬 `package.json` 的 `version`(改了 `dist/
|
|
453
|
+
2. 抬 `package.json` 的 `version`(改了 `dist/webui.js` 必须抬,否则浏览器缓存旧脚本);
|
|
441
454
|
3. 把本仓库整份同步到发布目录(一份纯产物副本,`npm publish` 的根);
|
|
442
455
|
4. 提交并推进 GitHub;
|
|
443
456
|
5. 在发布目录里 `npm publish --registry https://registry.npmjs.org/`。
|
package/describe.json
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tools": [
|
|
3
|
+
{
|
|
4
|
+
"name": "image_draw",
|
|
5
|
+
"description": "Draw a picture from a text description through the configured image API. The picture is saved to disk, attached to this conversation so you can see it, and shown in the Image Studio console page. The reply carries the absolute file path; hand that path to a channel tool such as qq_send_image to actually deliver the picture to someone.",
|
|
6
|
+
"parameters": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"prompt": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "What to draw: subject, style, composition, lighting. Any language."
|
|
12
|
+
},
|
|
13
|
+
"note": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "Short label for this picture in the studio list."
|
|
16
|
+
},
|
|
17
|
+
"size": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Override the configured size, e.g. 1024x1024 or 1024x1536."
|
|
20
|
+
},
|
|
21
|
+
"quality": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Override the configured quality, e.g. low / medium / high."
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"required": [
|
|
27
|
+
"prompt"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"tags": [
|
|
31
|
+
"act"
|
|
32
|
+
],
|
|
33
|
+
"summary": {
|
|
34
|
+
"zh": "按一段文字描述画一张新图,存到磁盘、贴进对话,并显示在作品页。",
|
|
35
|
+
"en": "Draw a new picture from a text description, save it to disk and show it in the conversation."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "image_edit",
|
|
40
|
+
"description": "Redraw an existing local picture with the image API, following a text instruction. `image` takes an absolute file path, or a picture id from image_gallery. 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).",
|
|
41
|
+
"parameters": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"prompt": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "What to change or how to restyle it."
|
|
47
|
+
},
|
|
48
|
+
"image": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Absolute path of the source picture, or a picture id from image_gallery."
|
|
51
|
+
},
|
|
52
|
+
"note": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "Short label for the result in the studio list."
|
|
55
|
+
},
|
|
56
|
+
"size": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "Override the configured size."
|
|
59
|
+
},
|
|
60
|
+
"quality": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Override the configured quality."
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"required": [
|
|
66
|
+
"prompt",
|
|
67
|
+
"image"
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
"tags": [
|
|
71
|
+
"act"
|
|
72
|
+
],
|
|
73
|
+
"summary": {
|
|
74
|
+
"zh": "按一句指令改一张已有的本地图,结果同样落盘并贴进对话。",
|
|
75
|
+
"en": "Redraw an existing local picture following a text instruction."
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "image_gallery",
|
|
80
|
+
"description": "List pictures drawn so far, newest first, with their ids and absolute file paths. Ids can be handed to image_show.",
|
|
81
|
+
"parameters": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"limit": {
|
|
85
|
+
"type": "integer",
|
|
86
|
+
"description": "How many to list, 1-50. Defaults to 12."
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"required": []
|
|
90
|
+
},
|
|
91
|
+
"tags": [
|
|
92
|
+
"read"
|
|
93
|
+
],
|
|
94
|
+
"summary": {
|
|
95
|
+
"zh": "列出已经画好的图,最新的在前,带编号与绝对路径。",
|
|
96
|
+
"en": "List pictures drawn so far, newest first, with their ids and absolute paths."
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "image_show",
|
|
101
|
+
"description": "Put an existing picture back in front of the user: it is re-attached to this conversation and highlighted in the Image Studio console page. Use it to look at or talk about an older picture again.",
|
|
102
|
+
"parameters": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
"id": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"description": "Picture id from image_gallery, or its absolute path."
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"required": [
|
|
111
|
+
"id"
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
"tags": [
|
|
115
|
+
"speak"
|
|
116
|
+
],
|
|
117
|
+
"summary": {
|
|
118
|
+
"zh": "把一张已有的图重新贴回对话,并在作品页高亮。",
|
|
119
|
+
"en": "Put an existing picture back in front of the user."
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"events": [],
|
|
124
|
+
"prompts": [
|
|
125
|
+
{
|
|
126
|
+
"key": "worlds.image.envPrompt",
|
|
127
|
+
"title": "环境说明(图片工坊)",
|
|
128
|
+
"role": "envPrompt",
|
|
129
|
+
"content": "你可以作画。这一节说明工具箱和规矩。\n\n## 四个工具\n\n- `image_draw`:按一段文字描述画一张新图。图会存到磁盘、附加进本次对话(你自己看得到)、并显示在控制台「图片工坊 → 作品」页。\n- `image_edit`:拿一张已有的图按指令改(换风格、改细节)。`image` 给绝对路径,或 `image_gallery` 里的编号。服务端支不支持改图由接口决定,报错就照实告诉用户。\n- `image_gallery`:列出画过的图,最新的在前,带编号和绝对路径。编号可以交给 `image_show` 或 `image_edit`。\n- `image_show`:把一张旧图重新放回眼前(重新附加进对话,并在作品页高亮)。\n\n图片保存目录:`{{image.dir | 尚未确定;先看控制台「图片工坊 → 状态」。}}`\n\n当前接口状态:`{{image.ready | 未知。不确定就别调用,先告诉用户去控制台「图片工坊 → 状态」看看。}}`\n\n## 规矩\n\n1. **别每轮都画。** 用户明确想看/想让你画,或者确实需要一张图来解释、来送人,才调用。闲聊、讨论、解释概念都不需要配图。\n2. **一次画一张。** 想要不同方向就画一张、看一眼、再决定要不要重画或改。不要一次调好几个 `image_draw` 碰运气。\n3. **描述要具体。** 主体、画风、构图、光线、色调、背景,一句到位;别只给两三个词,也别把整段聊天记录塞进去。\n4. **说人话,不念路径。** 回执里的绝对路径是给工具用的,不是给用户看的。画完就用一句自然的话告诉用户画了什么,像 `pet_say` 那样说话。不要把路径、编号、`blobs`、JSON 念出来,也不要贴 Markdown 图片语法。\n5. **发到别处要另走一步。** 图默认只在你自己的界面里。\n - 想让主人在控制台的「对话」页看到它,画完之后再调 `pet_show_image`:`path` 填 `image_draw` 回执里的绝对路径,`caption` 可选(一句配文)。它只贴图、不出声,也不会引发语音。\n - 要发到 QQ 会话,画完之后再调 `qq_send_image`,`image` 参数填 `image_draw` 回执里那个绝对路径。\n6. **失败就停。** 如果回执说没配密钥、接口报错、超时,不要换个说法反复重试。把情况告诉用户,让他去控制台「图片工坊 → 状态」里填密钥或改接口地址。\n7. **别凭空说画好了。** 只有工具回执真的带着图回来,才算画好了。\n"
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 图片工坊的控制台面板(浏览器侧 bundle)。
|
|
3
3
|
*
|
|
4
|
-
* 宿主按 `/assets/extensions/<包>/<版本>/dist/
|
|
4
|
+
* 宿主按 `/assets/extensions/<包>/<版本>/dist/webui.js` 取这个文件,
|
|
5
5
|
* 直接 `import()` 它的 default 导出;所以它是纯浏览器 ES 模块:
|
|
6
6
|
* 不 import 任何东西(`cortico/*` 在这里只允许 `import type`),只用 ctx.ui。
|
|
7
7
|
*/
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-image",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Image Studio(图片工坊)—— 给 Coopanion 桌宠的 cortico World 扩展:用任意 OpenAI
|
|
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.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "lostdegree",
|
|
8
8
|
"homepage": "https://github.com/Lostdegree/cortico-world-image#readme",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"src",
|
|
35
35
|
"dist",
|
|
36
36
|
"tools",
|
|
37
|
+
"describe.json",
|
|
37
38
|
"ENV_PROMPT.md",
|
|
38
39
|
"README.md"
|
|
39
40
|
],
|
|
@@ -46,8 +47,9 @@
|
|
|
46
47
|
},
|
|
47
48
|
"cortico": {
|
|
48
49
|
"kind": "world",
|
|
49
|
-
"api":
|
|
50
|
+
"api": 6,
|
|
50
51
|
"displayName": "图片工坊",
|
|
51
|
-
"
|
|
52
|
+
"webUiClient": "dist/webui.js",
|
|
53
|
+
"describe": "describe.json"
|
|
52
54
|
}
|
|
53
55
|
}
|
package/src/chat-image-patch.js
CHANGED
|
@@ -320,22 +320,32 @@ const QQ_SEND_NEW = 'const p = resolveLocalImagePath(image, this.config.memoryDi
|
|
|
320
320
|
const QQ_VIEW_OLD = "const p = isAbsolute(url) ? url : join(this.packageDir || '.', url);";
|
|
321
321
|
const QQ_VIEW_NEW = 'const p = resolveLocalImagePath(url, this.config.memoryDir, this.packageDir);';
|
|
322
322
|
|
|
323
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* 工具说明里那三处要跟着改成「相对桌宠工作区」——模型只看得到说明。
|
|
325
|
+
* 上游爱改措辞(0.1.16 就把 image 那句换成了「如 /home/user/pics/a.png」),所以每条列出
|
|
326
|
+
* 见过的旧写法:一条都对不上只跳过、不判失败;整条补丁一句都认不出来才算版本变了。
|
|
327
|
+
*/
|
|
324
328
|
const QQ_HINTS = [
|
|
325
329
|
{
|
|
326
|
-
from:
|
|
330
|
+
from: [
|
|
331
|
+
"description: '本地文件路径(绝对路径,或相对扩展目录)或 http(s) 链接。'",
|
|
332
|
+
"description: '本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。'",
|
|
333
|
+
],
|
|
327
334
|
to: "description: '本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg)或 http(s) 链接。'",
|
|
328
335
|
},
|
|
329
336
|
{
|
|
330
|
-
from: '或本地文件路径;给了就不再翻缓冲。',
|
|
337
|
+
from: ['或本地文件路径;给了就不再翻缓冲。'],
|
|
331
338
|
to: '或本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg);给了就不再翻缓冲。',
|
|
332
339
|
},
|
|
333
340
|
{
|
|
334
|
-
from: '给某个会话发一张图。`image` 给本地文件路径或 http(s) 链接(本机下载后再发),二者挑一个。',
|
|
341
|
+
from: ['给某个会话发一张图。`image` 给本地文件路径或 http(s) 链接(本机下载后再发),二者挑一个。'],
|
|
335
342
|
to: '给某个会话发一张图。`image` 给本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg)或 http(s) 链接(本机下载后再发),二者挑一个。',
|
|
336
343
|
},
|
|
337
344
|
];
|
|
338
345
|
|
|
346
|
+
/** 这条说明还没改成我们的说法、而且上游还有它认得的旧写法 → 需要动手。 */
|
|
347
|
+
const hintNeedsWork = (text, h) => !text.includes(h.to) && [].concat(h.from).some((from) => text.includes(from));
|
|
348
|
+
|
|
339
349
|
/**
|
|
340
350
|
* 每个补丁:`file` 是绝对路径,`done` 说这段原文是不是已经改过了,
|
|
341
351
|
* `apply` 返回新文本或 `{ error }`。全部按顺序跑,互不依赖。
|
|
@@ -467,14 +477,26 @@ export function rulesFor(id) {
|
|
|
467
477
|
};
|
|
468
478
|
case 'qq-image-hints':
|
|
469
479
|
return {
|
|
470
|
-
|
|
480
|
+
// 已经有一处是我们的说法,且没有哪处还留着认得的旧写法 → 不用再管(上游换掉的措辞不再拖住这条)。
|
|
481
|
+
done: (text) => QQ_HINTS.some((h) => text.includes(h.to)) && QQ_HINTS.every((h) => !hintNeedsWork(text, h)),
|
|
471
482
|
apply: (text) => {
|
|
472
483
|
let work = text;
|
|
484
|
+
let recognized = 0;
|
|
473
485
|
for (const h of QQ_HINTS) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
486
|
+
if (work.includes(h.to)) {
|
|
487
|
+
recognized += 1;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
for (const from of [].concat(h.from)) {
|
|
491
|
+
const out = swap(work, from, h.to);
|
|
492
|
+
if (out !== null) {
|
|
493
|
+
work = out;
|
|
494
|
+
recognized += 1;
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
477
498
|
}
|
|
499
|
+
if (recognized === 0) return { error: `工具说明已大改,一句都认不出来:${[].concat(QQ_HINTS[0].from)[0].slice(0, 40)}` };
|
|
478
500
|
return { text: work };
|
|
479
501
|
},
|
|
480
502
|
};
|
package/src/definition.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* 所以构造函数必须是纯的(不建目录、不发请求、不读密钥也行)。
|
|
4
4
|
*/
|
|
5
5
|
import { DEFAULTS, ID, LABEL } from './config.js';
|
|
6
|
+
import { toolDescribe } from './tools.js';
|
|
6
7
|
import { ImageWorld } from './world.js';
|
|
7
8
|
|
|
8
9
|
export const imageDefinition = {
|
|
@@ -10,6 +11,14 @@ export const imageDefinition = {
|
|
|
10
11
|
label: LABEL,
|
|
11
12
|
/** `worlds.image` 段的默认值;启用与否由部署决定,定义里恒为 false。 */
|
|
12
13
|
defaults: () => structuredClone(DEFAULTS),
|
|
14
|
+
/**
|
|
15
|
+
* 自描述(框架 0.2 起必需):全部工具与会推出的事件,给人和扩展市场看,不进提示词。
|
|
16
|
+
* 工具表来自 src/tools.js,与实例 tools() 同一份;这个 World 不推事件(面板推送走 stream,不算事件)。
|
|
17
|
+
*/
|
|
18
|
+
describe: {
|
|
19
|
+
tools: toolDescribe(),
|
|
20
|
+
events: [],
|
|
21
|
+
},
|
|
13
22
|
preflight: () => {
|
|
14
23
|
if (typeof fetch !== 'function' || typeof FormData !== 'function' || typeof Blob !== 'function') {
|
|
15
24
|
throw new Error('图片工坊需要 Node 22+ 的全局 fetch / FormData / Blob');
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 扩展入口:默认导出必须是 WorldDefinition。
|
|
3
|
-
* `cortico.<kind> = world`、`api =
|
|
3
|
+
* `cortico.<kind> = world`、`api = 6` 与 `keywords` 里的 `cortico-world` 见 package.json。
|
|
4
4
|
*/
|
|
5
5
|
import definition, { imageDefinition } from './definition.js';
|
|
6
6
|
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具声明与自描述共用同一份数据。
|
|
3
|
+
*
|
|
4
|
+
* `tools()` 拿它加 handler,`describe.tools`(框架 0.2 起必需)直接把它去掉 handler 交出去,
|
|
5
|
+
* 两边就不会漂移:框架只核对实例给出的工具**名**都在 describe.tools 里,
|
|
6
|
+
* 描述或参数写岔了它看不出来,所以只能靠这里共用一份。
|
|
7
|
+
*
|
|
8
|
+
* 每条里 `summary` 是给人和扩展市场看的一句话(`{ zh, en }`),不进提示词,也不进 `tools()`。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const TOOL_SPECS = [
|
|
12
|
+
{
|
|
13
|
+
name: 'image_draw',
|
|
14
|
+
description: 'Draw a picture from a text description through the configured image API. '
|
|
15
|
+
+ 'The picture is saved to disk, attached to this conversation so you can see it, and shown in the Image Studio console page. '
|
|
16
|
+
+ 'The reply carries the absolute file path; hand that path to a channel tool such as qq_send_image to actually deliver the picture to someone.',
|
|
17
|
+
tags: ['act'],
|
|
18
|
+
parameters: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
prompt: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'What to draw: subject, style, composition, lighting. Any language.',
|
|
24
|
+
},
|
|
25
|
+
note: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Short label for this picture in the studio list.',
|
|
28
|
+
},
|
|
29
|
+
size: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Override the configured size, e.g. 1024x1024 or 1024x1536.',
|
|
32
|
+
},
|
|
33
|
+
quality: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Override the configured quality, e.g. low / medium / high.',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ['prompt'],
|
|
39
|
+
},
|
|
40
|
+
summary: {
|
|
41
|
+
zh: '按一段文字描述画一张新图,存到磁盘、贴进对话,并显示在作品页。',
|
|
42
|
+
en: 'Draw a new picture from a text description, save it to disk and show it in the conversation.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'image_edit',
|
|
47
|
+
description: 'Redraw an existing local picture with the image API, following a text instruction. '
|
|
48
|
+
+ '`image` takes an absolute file path, or a picture id from image_gallery. '
|
|
49
|
+
+ '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).',
|
|
50
|
+
tags: ['act'],
|
|
51
|
+
parameters: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
prompt: { type: 'string', description: 'What to change or how to restyle it.' },
|
|
55
|
+
image: { type: 'string', description: 'Absolute path of the source picture, or a picture id from image_gallery.' },
|
|
56
|
+
note: { type: 'string', description: 'Short label for the result in the studio list.' },
|
|
57
|
+
size: { type: 'string', description: 'Override the configured size.' },
|
|
58
|
+
quality: { type: 'string', description: 'Override the configured quality.' },
|
|
59
|
+
},
|
|
60
|
+
required: ['prompt', 'image'],
|
|
61
|
+
},
|
|
62
|
+
summary: {
|
|
63
|
+
zh: '按一句指令改一张已有的本地图,结果同样落盘并贴进对话。',
|
|
64
|
+
en: 'Redraw an existing local picture following a text instruction.',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'image_gallery',
|
|
69
|
+
description: 'List pictures drawn so far, newest first, with their ids and absolute file paths. '
|
|
70
|
+
+ 'Ids can be handed to image_show.',
|
|
71
|
+
tags: ['read'],
|
|
72
|
+
parameters: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
limit: { type: 'integer', description: 'How many to list, 1-50. Defaults to 12.' },
|
|
76
|
+
},
|
|
77
|
+
required: [],
|
|
78
|
+
},
|
|
79
|
+
summary: {
|
|
80
|
+
zh: '列出已经画好的图,最新的在前,带编号与绝对路径。',
|
|
81
|
+
en: 'List pictures drawn so far, newest first, with their ids and absolute paths.',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'image_show',
|
|
86
|
+
description: 'Put an existing picture back in front of the user: it is re-attached to this conversation and highlighted in the Image Studio console page. '
|
|
87
|
+
+ 'Use it to look at or talk about an older picture again.',
|
|
88
|
+
tags: ['speak'],
|
|
89
|
+
parameters: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
id: { type: 'string', description: 'Picture id from image_gallery, or its absolute path.' },
|
|
93
|
+
},
|
|
94
|
+
required: ['id'],
|
|
95
|
+
},
|
|
96
|
+
summary: {
|
|
97
|
+
zh: '把一张已有的图重新贴回对话,并在作品页高亮。',
|
|
98
|
+
en: 'Put an existing picture back in front of the user.',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/** 给 describe 的工具表:去掉 handler,保留发给模型的字段与给人看的一句话。 */
|
|
104
|
+
export const toolDescribe = () => TOOL_SPECS.map(({ name, description, parameters, tags, summary }) => ({
|
|
105
|
+
name, description, parameters, tags, summary,
|
|
106
|
+
}));
|
package/src/world.js
CHANGED
|
@@ -18,6 +18,7 @@ import { applyChatImagePatches, consolePort, patchBackupStatus, requestConsoleRe
|
|
|
18
18
|
import { DEFAULTS, ID, configGroup, text } from './config.js';
|
|
19
19
|
import { Gallery } from './gallery.js';
|
|
20
20
|
import { samplePng } from './png.js';
|
|
21
|
+
import { TOOL_SPECS } from './tools.js';
|
|
21
22
|
|
|
22
23
|
/** 扩展包自带的模板;部署侧的覆盖文件由框架自己接在后面。 */
|
|
23
24
|
const ENV_PROMPT_FILE = fileURLToPath(new URL('../ENV_PROMPT.md', import.meta.url));
|
|
@@ -235,85 +236,25 @@ export class ImageWorld {
|
|
|
235
236
|
// -------------------------------------------------------------------------
|
|
236
237
|
|
|
237
238
|
tools() {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
type: 'string',
|
|
258
|
-
description: 'Override the configured size, e.g. 1024x1024 or 1024x1536.',
|
|
259
|
-
},
|
|
260
|
-
quality: {
|
|
261
|
-
type: 'string',
|
|
262
|
-
description: 'Override the configured quality, e.g. low / medium / high.',
|
|
263
|
-
},
|
|
264
|
-
},
|
|
265
|
-
required: ['prompt'],
|
|
266
|
-
},
|
|
267
|
-
handler: async (args) => this.#draw(args ?? {}),
|
|
268
|
-
},
|
|
269
|
-
{
|
|
270
|
-
name: 'image_edit',
|
|
271
|
-
description: 'Redraw an existing local picture with the image API, following a text instruction. '
|
|
272
|
-
+ '`image` takes an absolute file path, or a picture id from image_gallery. '
|
|
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
|
-
tags: ['act'],
|
|
275
|
-
parameters: {
|
|
276
|
-
type: 'object',
|
|
277
|
-
properties: {
|
|
278
|
-
prompt: { type: 'string', description: 'What to change or how to restyle it.' },
|
|
279
|
-
image: { type: 'string', description: 'Absolute path of the source picture, or a picture id from image_gallery.' },
|
|
280
|
-
note: { type: 'string', description: 'Short label for the result in the studio list.' },
|
|
281
|
-
size: { type: 'string', description: 'Override the configured size.' },
|
|
282
|
-
quality: { type: 'string', description: 'Override the configured quality.' },
|
|
283
|
-
},
|
|
284
|
-
required: ['prompt', 'image'],
|
|
285
|
-
},
|
|
286
|
-
handler: async (args) => this.#edit(args ?? {}),
|
|
287
|
-
},
|
|
288
|
-
{
|
|
289
|
-
name: 'image_gallery',
|
|
290
|
-
description: 'List pictures drawn so far, newest first, with their ids and absolute file paths. '
|
|
291
|
-
+ 'Ids can be handed to image_show.',
|
|
292
|
-
tags: ['read'],
|
|
293
|
-
parameters: {
|
|
294
|
-
type: 'object',
|
|
295
|
-
properties: {
|
|
296
|
-
limit: { type: 'integer', description: 'How many to list, 1-50. Defaults to 12.' },
|
|
297
|
-
},
|
|
298
|
-
required: [],
|
|
299
|
-
},
|
|
300
|
-
handler: async (args) => this.#list(args ?? {}),
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
name: 'image_show',
|
|
304
|
-
description: 'Put an existing picture back in front of the user: it is re-attached to this conversation and highlighted in the Image Studio console page. '
|
|
305
|
-
+ 'Use it to look at or talk about an older picture again.',
|
|
306
|
-
tags: ['speak'],
|
|
307
|
-
parameters: {
|
|
308
|
-
type: 'object',
|
|
309
|
-
properties: {
|
|
310
|
-
id: { type: 'string', description: 'Picture id from image_gallery, or its absolute path.' },
|
|
311
|
-
},
|
|
312
|
-
required: ['id'],
|
|
313
|
-
},
|
|
314
|
-
handler: async (args) => this.#show(args ?? {}),
|
|
315
|
-
},
|
|
316
|
-
];
|
|
239
|
+
// 声明在 src/tools.js,definition 的 describe.tools 用的是同一份(框架 0.2 起必需)。
|
|
240
|
+
return TOOL_SPECS.map(({ name, description, parameters, tags }) => ({
|
|
241
|
+
name,
|
|
242
|
+
description,
|
|
243
|
+
parameters,
|
|
244
|
+
tags,
|
|
245
|
+
handler: async (args) => this.#runTool(name, args ?? {}),
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 按名字把 handler 接到各工具的实现上;名字表就是 TOOL_SPECS。 */
|
|
250
|
+
async #runTool(name, args) {
|
|
251
|
+
switch (name) {
|
|
252
|
+
case 'image_draw': return await this.#draw(args);
|
|
253
|
+
case 'image_edit': return await this.#edit(args);
|
|
254
|
+
case 'image_gallery': return await this.#list(args);
|
|
255
|
+
case 'image_show': return await this.#show(args);
|
|
256
|
+
default: throw new Error(`未知工具:${name}`);
|
|
257
|
+
}
|
|
317
258
|
}
|
|
318
259
|
|
|
319
260
|
async #draw(args) {
|
|
@@ -541,7 +482,7 @@ export class ImageWorld {
|
|
|
541
482
|
// 控制台
|
|
542
483
|
// -------------------------------------------------------------------------
|
|
543
484
|
|
|
544
|
-
|
|
485
|
+
webUi(language = 'zh') {
|
|
545
486
|
this.language = language === 'en' ? 'en' : 'zh';
|
|
546
487
|
const t = this.#text();
|
|
547
488
|
const env = this.#envState();
|
package/tools/check.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 图片工坊的离线检查。
|
|
3
3
|
*
|
|
4
|
-
* 不联网、不装 DOM:用一份假 DOM 把 dist/
|
|
5
|
-
* 服务端那侧则直接调 ImageWorld
|
|
4
|
+
* 不联网、不装 DOM:用一份假 DOM 把 dist/webui.js 的两个面板真的挂起来跑,
|
|
5
|
+
* 服务端那侧则直接调 ImageWorld 的工具与 WebUI 方法。
|
|
6
6
|
*
|
|
7
7
|
* node tools/check.mjs
|
|
8
8
|
*/
|
|
@@ -368,7 +368,7 @@ try {
|
|
|
368
368
|
check(world.id === definition.id, 'world.id 与 def.id 一致');
|
|
369
369
|
check(typeof world.envPromptVars === 'function', '有 envPromptVars()');
|
|
370
370
|
check(typeof world.tools === 'function', '有 tools()');
|
|
371
|
-
check(typeof world.
|
|
371
|
+
check(typeof world.webUi === 'function', '有 webUi()');
|
|
372
372
|
|
|
373
373
|
section('工具声明');
|
|
374
374
|
const tools = world.tools();
|
|
@@ -392,11 +392,11 @@ try {
|
|
|
392
392
|
check(isAbsolute(String(vars?.['image.dir'] ?? '')), 'image.dir 是绝对路径', String(vars?.['image.dir']));
|
|
393
393
|
check(typeof vars?.['image.ready'] === 'string' && vars['image.ready'] !== '', 'image.ready 非空', String(vars?.['image.ready']));
|
|
394
394
|
|
|
395
|
-
section('
|
|
396
|
-
const declined = world.
|
|
397
|
-
check(declined && typeof declined === 'object', '
|
|
398
|
-
check(typeof declined.invoke === 'function', '
|
|
399
|
-
check(typeof declined.stream === 'function', '
|
|
395
|
+
section('WebUI 声明(对齐 dry-mount)');
|
|
396
|
+
const declined = world.webUi('zh');
|
|
397
|
+
check(declined && typeof declined === 'object', 'webUi() 返回对象');
|
|
398
|
+
check(typeof declined.invoke === 'function', 'webUi().invoke 是函数');
|
|
399
|
+
check(typeof declined.stream === 'function', 'webUi().stream 是函数');
|
|
400
400
|
const panelIds = (declined.panels ?? []).map((p) => p.id);
|
|
401
401
|
check(panelIds.length > 0, '声明了面板');
|
|
402
402
|
check(panelIds.every((id) => /^[a-z0-9-]+$/.test(id)), '面板 id 合法', panelIds.join(','));
|
|
@@ -583,8 +583,8 @@ try {
|
|
|
583
583
|
check(stored?.value === '', 'clearKey 把值写空');
|
|
584
584
|
|
|
585
585
|
section('浏览器面板 bundle');
|
|
586
|
-
const
|
|
587
|
-
const bundle = (await import(pathToFileURL(join(pkgDir, 'dist', '
|
|
586
|
+
const webUiSrc = readFileSync(join(pkgDir, 'dist', 'webui.js'), 'utf8');
|
|
587
|
+
const bundle = (await import(pathToFileURL(join(pkgDir, 'dist', 'webui.js')).href)).default;
|
|
588
588
|
check(!!bundle?.panels?.gallery?.mount, 'bundle 提供 gallery.mount');
|
|
589
589
|
check(!!bundle?.panels?.status?.mount, 'bundle 提供 status.mount');
|
|
590
590
|
const declaredIds = panelIds.slice().sort().join(',');
|
|
@@ -593,14 +593,14 @@ try {
|
|
|
593
593
|
|
|
594
594
|
section('文案完整性');
|
|
595
595
|
const dictKeys = (name) => {
|
|
596
|
-
const start =
|
|
597
|
-
const end =
|
|
598
|
-
const body =
|
|
596
|
+
const start = webUiSrc.indexOf(`const ${name} = {`);
|
|
597
|
+
const end = webUiSrc.indexOf('\n};', start);
|
|
598
|
+
const body = webUiSrc.slice(start, end);
|
|
599
599
|
return new Set([...body.matchAll(/^\s*([A-Za-z0-9_]+):/gm)].map((m) => m[1]));
|
|
600
600
|
};
|
|
601
601
|
const zhKeys = dictKeys('ZH');
|
|
602
602
|
const enKeys = dictKeys('EN');
|
|
603
|
-
const usedKeys = new Set([...
|
|
603
|
+
const usedKeys = new Set([...webUiSrc.matchAll(/\btx\.([A-Za-z0-9_]+)/g)].map((m) => m[1]));
|
|
604
604
|
for (const key of usedKeys) {
|
|
605
605
|
if (!zhKeys.has(key)) fail('中文文案缺键', `tx.${key}`);
|
|
606
606
|
if (!enKeys.has(key)) fail('英文文案缺键', `tx.${key}`);
|
|
@@ -609,6 +609,54 @@ try {
|
|
|
609
609
|
for (const key of enKeys) if (!zhKeys.has(key)) fail('英文有、中文没有的键', key);
|
|
610
610
|
pass(`文案键:中文 ${zhKeys.size}、英文 ${enKeys.size},代码用到 ${usedKeys.size}`);
|
|
611
611
|
|
|
612
|
+
section('契约与自描述(框架 0.2)');
|
|
613
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
614
|
+
const manifest = pkg.cortico ?? {};
|
|
615
|
+
check(pkg.version === '0.2.0', 'version 抬到 0.2.0', String(pkg.version));
|
|
616
|
+
check(manifest.api === 6, 'cortico.api 是 6', String(manifest.api));
|
|
617
|
+
check(manifest.webUiClient === 'dist/webui.js', 'webUiClient 指向 dist/webui.js', String(manifest.webUiClient));
|
|
618
|
+
check(manifest.describe === 'describe.json', 'describe 指向 describe.json', String(manifest.describe));
|
|
619
|
+
for (const need of ['dist', 'describe.json']) {
|
|
620
|
+
check((pkg.files ?? []).includes(need), `files 带上 ${need}`);
|
|
621
|
+
}
|
|
622
|
+
check(existsSync(join(pkgDir, 'dist', 'webui.js')), 'dist/webui.js 在包里');
|
|
623
|
+
check(!existsSync(join(pkgDir, 'dist', 'console.js')), '旧的 dist/console.js 已经清掉');
|
|
624
|
+
check(/export default/.test(webUiSrc), '面板脚本有 export default');
|
|
625
|
+
check(!/^\s*import\s/m.test(webUiSrc), '面板脚本不 import 任何东西');
|
|
626
|
+
check(/\bctx\.ui\./.test(webUiSrc) && /\bctx\.invoke\(/.test(webUiSrc), '面板只用 ctx.ui / ctx.invoke');
|
|
627
|
+
|
|
628
|
+
const describeDecl = definition.describe;
|
|
629
|
+
check(!!describeDecl, 'definition 带上 describe');
|
|
630
|
+
check(Array.isArray(describeDecl?.tools), 'describe.tools 是数组');
|
|
631
|
+
check(Array.isArray(describeDecl?.events), 'describe.events 是数组');
|
|
632
|
+
const descToolNames = (describeDecl?.tools ?? []).map((t) => t.name);
|
|
633
|
+
check(
|
|
634
|
+
descToolNames.join(',') === names.slice().join(','),
|
|
635
|
+
'describe 里的工具名与 tools() 完全一致',
|
|
636
|
+
`${descToolNames.join(',')} vs ${names.join(',')}`,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const describePath = manifest.describe ?? 'describe.json';
|
|
640
|
+
let describeFile = null;
|
|
641
|
+
try {
|
|
642
|
+
describeFile = JSON.parse(readFileSync(join(pkgDir, describePath), 'utf8'));
|
|
643
|
+
pass(`${describePath} 是合法 JSON`);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
fail(`${describePath} 读不了或不是合法 JSON`, String(err?.message ?? err));
|
|
646
|
+
}
|
|
647
|
+
if (describeFile) {
|
|
648
|
+
const fileToolNames = (describeFile.tools ?? []).map((t) => t.name);
|
|
649
|
+
check(
|
|
650
|
+
fileToolNames.join(',') === names.slice().join(','),
|
|
651
|
+
'describe.json 里的工具名与定义一致',
|
|
652
|
+
`${fileToolNames.join(',')} vs ${names.join(',')}`,
|
|
653
|
+
);
|
|
654
|
+
check((describeFile.events ?? []).length === (describeDecl?.events ?? []).length, 'describe.json 的事件条数与定义一致');
|
|
655
|
+
const envPrompt = (describeFile.prompts ?? []).find((p) => p.role === 'envPrompt');
|
|
656
|
+
check(!!envPrompt, 'describe.json 带上 envPrompt 角色');
|
|
657
|
+
check(/image_draw/.test(envPrompt?.content ?? ''), 'envPrompt 正文里有工具说明');
|
|
658
|
+
}
|
|
659
|
+
|
|
612
660
|
section('作品页挂载');
|
|
613
661
|
const gallery = makeEnv(world, 'gallery');
|
|
614
662
|
bundle.panels.gallery.mount(gallery.ctx);
|
|
@@ -746,6 +794,28 @@ try {
|
|
|
746
794
|
check(qqRules.done(qqPatched?.text ?? ''), '改完认成「已打过」(幂等)');
|
|
747
795
|
check(qqPatched.text.includes("import { existsSync } from 'node:fs';"), '导入本来就够时不多改一行');
|
|
748
796
|
|
|
797
|
+
// 上游爱改工具说明的措辞(0.1.16 就把 image 那句换成了「如 /home/user/pics/a.png」):
|
|
798
|
+
// 一条对不上不该让整条补丁失败;但一句都认不出来仍然要报。
|
|
799
|
+
const hintRules = patchMod.rulesFor('qq-image-hints');
|
|
800
|
+
check(typeof hintRules?.done === 'function' && typeof hintRules?.apply === 'function', 'QQ 说明补丁有 done/apply');
|
|
801
|
+
const hintFixture = [
|
|
802
|
+
" parameters: [{ name: 'url', description: '可选:直接给图片链接(事件里带的图)或本地文件路径;给了就不再翻缓冲。' }],",
|
|
803
|
+
" description: '给某个会话发一张图。`image` 给本地文件路径或 http(s) 链接(本机下载后再发),二者挑一个。',",
|
|
804
|
+
" parameters: [{ name: 'image', description: '本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。' }],",
|
|
805
|
+
'',
|
|
806
|
+
].join('\n');
|
|
807
|
+
check(!hintRules.done(hintFixture), '夹具还是「只认绝对路径」的说明(有东西可改)');
|
|
808
|
+
const hinted = hintRules.apply(hintFixture);
|
|
809
|
+
check(!hinted?.error, '上游换了说明措辞也照改,不报失败', String(hinted?.error ?? 'ok'));
|
|
810
|
+
check((hinted?.text ?? '').includes('或本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg);给了就不再翻缓冲。'), '认得出的那两处照改');
|
|
811
|
+
check(!(hinted?.text ?? '').includes('相对扩展目录'), '旧说法不再残留在说明里');
|
|
812
|
+
check(hintRules.done(hinted?.text ?? ''), '改完认成「已打过」(幂等)');
|
|
813
|
+
const reworded = hintFixture.replace("'本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。'", "'图片本地路径或链接。'");
|
|
814
|
+
const rewordedOut = hintRules.apply(reworded);
|
|
815
|
+
check(!rewordedOut?.error && hintRules.done(rewordedOut?.text ?? ''), '上游整句换掉时跳过那一处,不算失败也不算没打', String(rewordedOut?.error ?? 'ok'));
|
|
816
|
+
check(Boolean(hintRules.apply('export const nope = 1;\n')?.error), '一句都认不出来时仍然报失败');
|
|
817
|
+
check(!hintRules.done('export const nope = 1;\n'), '文件整个换了写法时不谎报「已打过」');
|
|
818
|
+
|
|
749
819
|
// pet_show_image 也要能认相对路径(按桌宠工作区解析)。
|
|
750
820
|
const petSpecs = specs.filter((s) => s.id.startsWith('pet-workspace-'));
|
|
751
821
|
check(petSpecs.length === 3 && petSpecs.every((s) => s.optional !== true), '桌宠工作区相对路径那 3 处是必备补丁', JSON.stringify(petSpecs.map((s) => s.id)));
|
|
@@ -833,7 +903,7 @@ try {
|
|
|
833
903
|
check(Array.isArray(restored?.restored?.restored) && String(restored?.restored?.skipped ?? '').includes('COOPANION_CHAT_IMAGE_PATCH'), '检查模式下 restore 不动安装目录', JSON.stringify(restored?.restored));
|
|
834
904
|
const st = await world.invoke('status', 'state', []);
|
|
835
905
|
check(Array.isArray(st?.patch?.backups), '状态里能读到备份列表', JSON.stringify(st?.patch?.backups));
|
|
836
|
-
const badge = (world.
|
|
906
|
+
const badge = (world.webUi('zh').badges ?? []).find((b) => b.label === '发图补丁');
|
|
837
907
|
check(!!badge, '控制台徽章里有「发图补丁」');
|
|
838
908
|
check(badge?.value === '未检查', '检查模式下徽章显示「未检查」', String(badge?.value));
|
|
839
909
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* describe.json(框架 0.2 的自描述)生成与核对:
|
|
3
|
+
* node --experimental-transform-types tools/describe.mts 生成(内容真变了才写盘)并核对
|
|
4
|
+
* node --experimental-transform-types tools/describe.mts --check 只核对,不写盘
|
|
5
|
+
*
|
|
6
|
+
* 判据直接读这套安装里的框架源码(`<安装>/resources/app/build/cortico/src/extensions/`),不靠手抄:
|
|
7
|
+
* `parseExtensionManifest()` 过 manifest、`exportDescribe()` 从定义生成自描述、
|
|
8
|
+
* `checkDescribe()` 复核包里那份与代码一致、`dryMountWorld()` 在假部署里造实例查工具与配置路径。
|
|
9
|
+
* 框架位置按 `COOPANION_APP_ROOT` → 从本包往上找 `resources/app` 的顺序定位;
|
|
10
|
+
* 用 tsx 跑会因为 esbuild 起子进程被沙箱拦(spawn EPERM);node 自带的类型擦除不吃参数属性,
|
|
11
|
+
* 所以必须带 --experimental-transform-types。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const pkgDir = fileURLToPath(new URL('..', import.meta.url));
|
|
19
|
+
const checkOnly = process.argv.includes('--check');
|
|
20
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'image-describe-'));
|
|
21
|
+
|
|
22
|
+
/** 找这套安装的 `resources/app`:显式变量优先,其次从本包目录往上找。 */
|
|
23
|
+
function findAppRoot(): string {
|
|
24
|
+
const explicit = process.env.COOPANION_APP_ROOT;
|
|
25
|
+
if (explicit && existsSync(join(explicit, 'build', 'cortico', 'src', 'extensions', 'dry-mount.ts'))) return explicit;
|
|
26
|
+
let dir = pkgDir;
|
|
27
|
+
for (let i = 0; i < 8; i += 1) {
|
|
28
|
+
const cand = join(dir, 'resources', 'app');
|
|
29
|
+
if (existsSync(join(cand, 'build', 'cortico', 'src', 'extensions', 'dry-mount.ts'))) return cand;
|
|
30
|
+
const parent = dirname(dir);
|
|
31
|
+
if (parent === dir) break;
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const appRoot = findAppRoot();
|
|
38
|
+
if (!appRoot) {
|
|
39
|
+
console.error('找不到这套安装的 resources/app —— 用 COOPANION_APP_ROOT 指一下(例如 D:\\Coopanion\\resources\\app)');
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
const frameworkDir = join(appRoot, 'build', 'cortico', 'src', 'extensions');
|
|
43
|
+
const { checkDescribe, exportDescribe, DEFAULT_DESCRIBE_FILE } = await import(pathToFileURL(join(frameworkDir, 'describe-export.ts')).href);
|
|
44
|
+
const { EXTENSION_API_VERSIONS, parseExtensionManifest } = await import(pathToFileURL(join(frameworkDir, 'manifest.ts')).href);
|
|
45
|
+
const { dryMountWorld } = await import(pathToFileURL(join(frameworkDir, 'dry-mount.ts')).href);
|
|
46
|
+
|
|
47
|
+
let passed = 0;
|
|
48
|
+
let warned = 0;
|
|
49
|
+
let failed = 0;
|
|
50
|
+
const ok = (line: string): void => { passed++; console.log(` ok ${line}`); };
|
|
51
|
+
const bad = (line: string): void => { failed++; console.log(` FAIL ${line}`); };
|
|
52
|
+
const hint = (line: string): void => { warned++; console.log(` 提醒 ${line}`); };
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
56
|
+
const { default: def } = await import(pathToFileURL(join(pkgDir, 'src', 'index.js')).href);
|
|
57
|
+
const opts = { scratchDir, packageDir: pkgDir };
|
|
58
|
+
|
|
59
|
+
console.log('manifest');
|
|
60
|
+
const parsed = parseExtensionManifest(manifest);
|
|
61
|
+
if (!parsed.ok) bad(`框架解析 manifest 失败:${parsed.reasons.join(';')}`);
|
|
62
|
+
else {
|
|
63
|
+
const expected = EXTENSION_API_VERSIONS[parsed.manifest.kind];
|
|
64
|
+
ok(`框架解析 manifest 通过(kind=${parsed.manifest.kind},api=${parsed.manifest.api},框架期望 ${expected})`);
|
|
65
|
+
if (parsed.manifest.api !== expected) bad(`api=${parsed.manifest.api} 与框架的 ${expected} 不等,装配层会拒绝加载。`);
|
|
66
|
+
if (parsed.manifest.id !== undefined && def?.id !== parsed.manifest.id) {
|
|
67
|
+
bad(`manifest 里的 id(${parsed.manifest.id})与 def.id(${def?.id})不一致`);
|
|
68
|
+
}
|
|
69
|
+
const client = parsed.manifest.webUiClient;
|
|
70
|
+
if (client && !existsSync(join(pkgDir, client))) bad(`manifest 声明的 ${client} 不在包里`);
|
|
71
|
+
else if (client) ok(`manifest 声明的面板产物 ${client} 在包里`);
|
|
72
|
+
else hint('manifest 没有声明 webUiClient:这个 World 没有浏览器面板。');
|
|
73
|
+
}
|
|
74
|
+
for (const warning of parsed.warnings ?? []) hint(warning);
|
|
75
|
+
|
|
76
|
+
console.log('\n自描述');
|
|
77
|
+
const file = await exportDescribe('world', def, opts);
|
|
78
|
+
if (!file) {
|
|
79
|
+
bad('定义里没有 describe:框架 0.2 起每个 World 都要声明全部工具与事件。');
|
|
80
|
+
} else {
|
|
81
|
+
const rel = manifest.cortico?.describe ?? DEFAULT_DESCRIBE_FILE;
|
|
82
|
+
const target = join(pkgDir, rel);
|
|
83
|
+
const next = `${JSON.stringify(file, null, 2)}\n`;
|
|
84
|
+
const toolNames = (file.tools ?? []).map((tool) => tool.name);
|
|
85
|
+
ok(`定义生成的工具表:${toolNames.length ? toolNames.join(', ') : '(空)'};事件表:${(file.events ?? []).length} 个`);
|
|
86
|
+
if (checkOnly) {
|
|
87
|
+
if (!existsSync(target)) bad(`${rel} 不存在(去掉 --check 生成它)`);
|
|
88
|
+
else ok(`${rel} 在包里(${readFileSync(target, 'utf8').length} 字节)`);
|
|
89
|
+
} else if (existsSync(target) && readFileSync(target, 'utf8') === next) {
|
|
90
|
+
ok(`${rel} 已是最新,未改动`);
|
|
91
|
+
} else {
|
|
92
|
+
writeFileSync(target, next);
|
|
93
|
+
ok(`写入 ${rel}(${next.length} 字节)`);
|
|
94
|
+
}
|
|
95
|
+
const report = await checkDescribe('world', def, manifest.cortico?.describe, opts);
|
|
96
|
+
for (const line of report.ok) ok(line);
|
|
97
|
+
for (const line of report.warnings) hint(line);
|
|
98
|
+
for (const line of report.failures) bad(line);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('\n假部署装配');
|
|
102
|
+
const report = await dryMountWorld(def, { ...opts, hasWebUiClient: Boolean(manifest.cortico?.webUiClient) });
|
|
103
|
+
for (const line of report.ok) ok(line);
|
|
104
|
+
for (const line of report.warnings) hint(line);
|
|
105
|
+
for (const line of report.failures) bad(line);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
bad(`未捕获的异常:${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(`\n结果:${passed} 项通过,${warned} 个提醒,${failed} 个失败`);
|
|
113
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/tools/drymount.mts
CHANGED
|
@@ -100,7 +100,7 @@ try {
|
|
|
100
100
|
console.log('用框架自带的判据检查');
|
|
101
101
|
console.log(` 包目录:${pkgDir}`);
|
|
102
102
|
console.log(` 世界:${def?.id} / ${def?.label}`);
|
|
103
|
-
console.log(` manifest:kind=${manifest.cortico?.kind} api=${manifest.cortico?.api}
|
|
103
|
+
console.log(` manifest:kind=${manifest.cortico?.kind} api=${manifest.cortico?.api} webUiClient=${manifest.cortico?.webUiClient ?? '(无)'} describe=${manifest.cortico?.describe ?? '(无)'}`);
|
|
104
104
|
|
|
105
105
|
const parsed = parseExtensionManifest(manifest);
|
|
106
106
|
if (!parsed.ok) {
|
|
@@ -116,10 +116,18 @@ try {
|
|
|
116
116
|
}
|
|
117
117
|
for (const warning of parsed.warnings ?? []) hint(warning);
|
|
118
118
|
|
|
119
|
-
const clientFile = manifest.cortico?.
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
else if (
|
|
119
|
+
const clientFile = manifest.cortico?.webUiClient;
|
|
120
|
+
const hasWebUiClient = typeof clientFile === 'string' && clientFile.length > 0;
|
|
121
|
+
if (hasWebUiClient && !existsSync(join(pkgDir, clientFile))) bad(`manifest 声明的 ${clientFile} 不在包里`);
|
|
122
|
+
else if (hasWebUiClient) ok(`manifest 声明的 ${clientFile} 在包里`);
|
|
123
|
+
|
|
124
|
+
const describeFile = manifest.cortico?.describe;
|
|
125
|
+
if (typeof describeFile === 'string' && describeFile.length > 0) {
|
|
126
|
+
if (existsSync(join(pkgDir, describeFile))) ok(`manifest 声明的 ${describeFile} 在包里`);
|
|
127
|
+
else bad(`manifest 声明的 ${describeFile} 不在包里`);
|
|
128
|
+
} else {
|
|
129
|
+
bad('manifest 没有声明 describe(0.2 起自描述是必需的)');
|
|
130
|
+
}
|
|
123
131
|
|
|
124
132
|
const { taken, skipped } = collectToolNames([def], { scratchDir, packageDir: pkgDir });
|
|
125
133
|
for (const extra of skipped) bad(`收集工具名时构造失败 ${extra}`);
|
|
@@ -128,7 +136,7 @@ try {
|
|
|
128
136
|
if (conflicts.size === 0) ok(`其它已装扩展里没有同名工具(扫了 ${extensionsDir})`);
|
|
129
137
|
else for (const [name, owner] of conflicts) bad(`工具名「${name}」已被 ${owner} 占用`);
|
|
130
138
|
|
|
131
|
-
const report = await dryMountWorld(def, { scratchDir, packageDir: pkgDir,
|
|
139
|
+
const report = await dryMountWorld(def, { scratchDir, packageDir: pkgDir, hasWebUiClient, takenToolNames: conflicts });
|
|
132
140
|
for (const line of report.ok) ok(line);
|
|
133
141
|
for (const line of report.warnings) hint(line);
|
|
134
142
|
for (const line of report.failures) bad(line);
|
package/tools/patch.mjs
CHANGED
|
@@ -142,7 +142,7 @@ const QQ_FIXTURE_TS = [
|
|
|
142
142
|
'export const qqSendImage = {',
|
|
143
143
|
" name: 'qq_send_image',",
|
|
144
144
|
" description: '给某个会话发一张图。`image` 给本地文件路径或 http(s) 链接(本机下载后再发),二者挑一个。',",
|
|
145
|
-
" parameters: [{ name: 'image', description: '
|
|
145
|
+
" parameters: [{ name: 'image', description: '本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。' }],",
|
|
146
146
|
'};',
|
|
147
147
|
'export class QQWorld implements World {',
|
|
148
148
|
" private packageDir = '.';",
|
package/tools/prepublish.mjs
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* 静态部分(不联网、不需要 npm 账号、不需要装 Coopanion):
|
|
5
5
|
* package.json 必填字段、npm 包名与版本号、`type: module`、
|
|
6
6
|
* `cortico` 声明按宿主 `src/extensions/manifest.ts` 的规则复刻校验、
|
|
7
|
-
* 关键词、入口文件存在、`files`
|
|
7
|
+
* 关键词、入口文件存在、`files` 覆盖到入口与面板产物、`cortico.describe` 指向的自描述在包里且是合法 JSON、
|
|
8
8
|
* 发布物里没有 `*.coopanion-orig` / `.chat-image-patch.json` / 本机路径、
|
|
9
9
|
* 每个要发布的 `.js`/`.mjs` 过一遍 `node --check`。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* 宿主相关的三套测试只在找得到**契约版本对得上**的本机 Coopanion 安装时才跑(0.1 的安装没有
|
|
12
|
+
* `describe-export.ts`、契约号也更低:自动发现到这种旧安装时只提醒并跳过,不算失败;
|
|
13
|
+
* 显式 `--app`/`COOPANION_APP_ROOT` 指到不支持的宿主才算失败):
|
|
12
14
|
* `tools/check.mjs`(离线,永远跑)、`tools/patch.mjs`(补丁自测)、
|
|
13
|
-
* `tools/drymount.mts
|
|
15
|
+
* `tools/drymount.mts`(用框架自己的判据真跑装配检查)、
|
|
16
|
+
* `tools/describe.mts --check`(核对 describe.json 与代码里的自描述一致)。
|
|
14
17
|
*
|
|
15
18
|
* 跑法:
|
|
16
19
|
* node tools/prepublish.mjs # 自动找宿主
|
|
@@ -44,8 +47,10 @@ const section = (title) => console.log(`\n${title}`);
|
|
|
44
47
|
const PACKAGE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
45
48
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
46
49
|
const EXTENSION_KINDS = ['world', 'provider', 'bot'];
|
|
47
|
-
const EXTENSION_API = { world:
|
|
50
|
+
const EXTENSION_API = { world: 6, provider: 6, bot: 7 };
|
|
48
51
|
const EXTENSION_KEYWORDS = { world: 'cortico-world', provider: 'cortico-provider', bot: 'cortico-bot' };
|
|
52
|
+
/** 与宿主 `describe-export.ts` 的 `DEFAULT_DESCRIBE_FILE` 一致。 */
|
|
53
|
+
const DEFAULT_DESCRIBE_FILE = 'describe.json';
|
|
49
54
|
const ICON_TYPES = ['.png', '.webp', '.svg'];
|
|
50
55
|
|
|
51
56
|
/** 包内相对路径:只用 `/`,不认盘符、`\`、`..`、空段。 */
|
|
@@ -148,19 +153,38 @@ if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
|
148
153
|
} else if (kindOk) {
|
|
149
154
|
ok(`keywords 含 "${EXTENSION_KEYWORDS[kind]}"`);
|
|
150
155
|
}
|
|
151
|
-
const client = block.
|
|
156
|
+
const client = block.webUiClient;
|
|
157
|
+
/* 0.2 之前的键名:框架只认新名,旧键留着等于没声明。 */
|
|
158
|
+
for (const [legacy, now] of [['consoleClient', 'webUiClient'], ['consoleStyle', 'webUiStyle']]) {
|
|
159
|
+
if (block[legacy] !== undefined) bad(`cortico.${legacy} 是 0.1 的旧键名,框架 0.2 只认 cortico.${now}:改个名`);
|
|
160
|
+
}
|
|
152
161
|
if (client !== undefined) {
|
|
153
|
-
if (!safeRelativeFile(client)) bad(`
|
|
154
|
-
else if (!/\.m?js$/.test(client)) bad(`
|
|
155
|
-
else if (!existsSync(join(pkgDir, client))) bad(`
|
|
156
|
-
else ok(`
|
|
157
|
-
} else hint('没有
|
|
158
|
-
const style = block.
|
|
162
|
+
if (!safeRelativeFile(client)) bad(`webUiClient 必须是包内相对路径(只用 /):${JSON.stringify(client)}`);
|
|
163
|
+
else if (!/\.m?js$/.test(client)) bad(`webUiClient 必须是 .js / .mjs:${client}`);
|
|
164
|
+
else if (!existsSync(join(pkgDir, client))) bad(`webUiClient 指向的 ${client} 不在包里`);
|
|
165
|
+
else ok(`webUiClient=${client} 在包里`);
|
|
166
|
+
} else hint('没有 webUiClient:这个扩展不会有 WebUI 面板');
|
|
167
|
+
const style = block.webUiStyle;
|
|
159
168
|
if (style !== undefined) {
|
|
160
|
-
if (client === undefined) bad('有
|
|
161
|
-
else if (!safeRelativeFile(style) || !/\.css$/.test(style)) bad(`
|
|
162
|
-
else if (!existsSync(join(pkgDir, style))) bad(`
|
|
163
|
-
else ok(`
|
|
169
|
+
if (client === undefined) bad('有 webUiStyle 却没有 webUiClient:宿主会报错');
|
|
170
|
+
else if (!safeRelativeFile(style) || !/\.css$/.test(style)) bad(`webUiStyle 必须是包内相对 .css:${JSON.stringify(style)}`);
|
|
171
|
+
else if (!existsSync(join(pkgDir, style))) bad(`webUiStyle 指向的 ${style} 不在包里`);
|
|
172
|
+
else ok(`webUiStyle=${style} 在包里`);
|
|
173
|
+
}
|
|
174
|
+
const describePath = block.describe;
|
|
175
|
+
if (describePath === undefined) {
|
|
176
|
+
bad(`没有 cortico.describe:0.2 起自描述是必需的,宿主 check:extension 会判失败(补一个 "${DEFAULT_DESCRIBE_FILE}")`);
|
|
177
|
+
} else if (!safeRelativeFile(describePath) || !/\.json$/.test(describePath)) {
|
|
178
|
+
bad(`cortico.describe 必须是包内相对 .json:${JSON.stringify(describePath)}`);
|
|
179
|
+
} else if (!existsSync(join(pkgDir, describePath))) {
|
|
180
|
+
bad(`cortico.describe 指向的 ${describePath} 不在包里`);
|
|
181
|
+
} else {
|
|
182
|
+
try {
|
|
183
|
+
const describeFile = JSON.parse(readFileSync(join(pkgDir, describePath), 'utf8'));
|
|
184
|
+
ok(`describe=${describePath} 在包里且是合法 JSON(${(describeFile.tools ?? []).length} 个工具 / ${(describeFile.events ?? []).length} 个事件)`);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
bad(`cortico.describe 指向的 ${describePath} 不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
|
|
187
|
+
}
|
|
164
188
|
}
|
|
165
189
|
const icon = block.icon;
|
|
166
190
|
if (icon !== undefined) {
|
|
@@ -192,12 +216,13 @@ ok(`会发布的文件 ${shipped.length} 个,展开后约 ${(totalBytes / 1024
|
|
|
192
216
|
|
|
193
217
|
const mustShip = [...new Set([
|
|
194
218
|
...entryPoints.map(clean),
|
|
195
|
-
...(pkg.cortico?.
|
|
219
|
+
...(pkg.cortico?.webUiClient ? [clean(pkg.cortico.webUiClient)] : []),
|
|
220
|
+
...(pkg.cortico?.describe ? [clean(pkg.cortico.describe)] : []),
|
|
196
221
|
])];
|
|
197
222
|
for (const need of mustShip) {
|
|
198
223
|
if (!shippedRel.includes(need)) bad(`${need} 没有被 files 覆盖:发出去的包里会缺这个文件`);
|
|
199
224
|
}
|
|
200
|
-
if (!mustShip.some((need) => !shippedRel.includes(need))) ok('
|
|
225
|
+
if (!mustShip.some((need) => !shippedRel.includes(need))) ok('入口、面板产物与自描述都在 files 里');
|
|
201
226
|
|
|
202
227
|
for (const pattern of [/\.coopanion-orig$/, /\.chat-image-patch\.json$/, /\.env$/i, /\.log$/i]) {
|
|
203
228
|
const hits = shippedRel.filter((file) => pattern.test(file));
|
|
@@ -248,11 +273,35 @@ function runSuite(title, args, env) {
|
|
|
248
273
|
|
|
249
274
|
runSuite('离线检查(tools/check.mjs)', [join('tools', 'check.mjs')]);
|
|
250
275
|
|
|
251
|
-
const
|
|
276
|
+
const hostManifest = join('build', 'cortico', 'src', 'extensions', 'manifest.ts');
|
|
277
|
+
const hostDescribe = join('build', 'cortico', 'src', 'extensions', 'describe-export.ts');
|
|
278
|
+
|
|
279
|
+
/** 宿主 `manifest.ts` 里 `EXTENSION_API_VERSIONS.world` 声明的契约号;读不出来算 0。 */
|
|
280
|
+
function hostDeclaredWorld(root) {
|
|
281
|
+
try {
|
|
282
|
+
const text = readFileSync(join(root, hostManifest), 'utf8');
|
|
283
|
+
const at = text.indexOf('EXTENSION_API_VERSIONS');
|
|
284
|
+
const m = at >= 0 ? /world:\s*(\d+)/.exec(text.slice(at, at + 400)) : null;
|
|
285
|
+
return m ? Number(m[1]) : 0;
|
|
286
|
+
} catch { return 0; }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 宿主能不能跑后面三项:既要有 0.2 才有的 `describe-export.ts`,
|
|
291
|
+
* 声明的 world 契约号也要和本包一致(0.1 的安装是 v5)。
|
|
292
|
+
*/
|
|
293
|
+
function hostUsable(root) {
|
|
294
|
+
if (!root || !existsSync(join(root, hostManifest)) || !existsSync(join(root, hostDescribe))) return false;
|
|
295
|
+
const want = Number(block.api ?? 0);
|
|
296
|
+
return !want || hostDeclaredWorld(root) === want;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const explicitApp = opt('app') || process.env.COOPANION_APP_ROOT || '';
|
|
300
|
+
const appRoot = explicitApp || (() => {
|
|
252
301
|
let dir = pkgDir;
|
|
253
302
|
for (let i = 0; i < 8; i += 1) {
|
|
254
303
|
const cand = join(dir, 'resources', 'app');
|
|
255
|
-
if (existsSync(join(cand,
|
|
304
|
+
if (existsSync(join(cand, hostManifest))) return cand;
|
|
256
305
|
const parent = dirname(dir);
|
|
257
306
|
if (parent === dir) break;
|
|
258
307
|
dir = parent;
|
|
@@ -260,15 +309,27 @@ const appRoot = opt('app') || process.env.COOPANION_APP_ROOT || (() => {
|
|
|
260
309
|
return '';
|
|
261
310
|
})();
|
|
262
311
|
|
|
312
|
+
/** 为什么跳过宿主那三项;空串=能跑。 */
|
|
313
|
+
const skipReason = (() => {
|
|
314
|
+
if (!appRoot) return '没找到本机 Coopanion 安装(用 --app <安装>\\resources\\app 指一下)';
|
|
315
|
+
if (!hostUsable(appRoot)) {
|
|
316
|
+
return `宿主 ${appRoot} 只声明 world 契约 v${hostDeclaredWorld(appRoot) || '?'},本包要 v${block.api}`
|
|
317
|
+
+ '(0.1 的安装没有 describe-export.ts):用 --app <安装>\\resources\\app 或环境变量 COOPANION_APP_ROOT 指到 0.2 的安装';
|
|
318
|
+
}
|
|
319
|
+
return '';
|
|
320
|
+
})();
|
|
321
|
+
|
|
263
322
|
if (flag('only-static')) {
|
|
264
|
-
hint('--only-static
|
|
265
|
-
} else if (
|
|
266
|
-
if (
|
|
267
|
-
else
|
|
323
|
+
hint('--only-static:跳过补丁自测、dry-mount 与自描述核对');
|
|
324
|
+
} else if (skipReason) {
|
|
325
|
+
if (explicitApp) bad(skipReason);
|
|
326
|
+
else if (flag('quiet-host')) console.log(`宿主相关三项跳过:${skipReason}`);
|
|
327
|
+
else hint(`${skipReason}:跳过补丁自测、dry-mount 与自描述核对`);
|
|
268
328
|
} else {
|
|
269
329
|
console.log(`\n宿主:${appRoot}`);
|
|
270
330
|
runSuite('补丁自测(tools/patch.mjs)', [join('tools', 'patch.mjs'), '--app', appRoot], { COOPANION_APP_ROOT: appRoot });
|
|
271
331
|
runSuite('dry-mount(tools/drymount.mts)', ['--experimental-transform-types', join('tools', 'drymount.mts')], { COOPANION_APP_ROOT: appRoot });
|
|
332
|
+
runSuite('自描述核对(tools/describe.mts --check)', ['--experimental-transform-types', join('tools', 'describe.mts'), '--check'], { COOPANION_APP_ROOT: appRoot });
|
|
272
333
|
}
|
|
273
334
|
|
|
274
335
|
if (!hadStateFile && existsSync(stateFile)) {
|