cortico-world-image 0.1.4 → 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 CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  给 Coopanion 桌宠加两件事:**调用图片生成 API 画图,并把图交回对话**;以及**把本地任意一张图贴进控制台的「对话」页**——后者靠一套自愈补丁给桌宠加一个 `pet_show_image` 工具,程序自动更新把这些文件覆写回原样之后,扩展下次启动会自己补回来。
4
4
 
5
- 这是一个 `cortico` 的 **World 扩展**(`kind: world`,`api: 5`),只补一个 `image` 世界,
5
+ 这是一个 `cortico` 的 **World 扩展**(`kind: world`,`api: 6`),只补一个 `image` 世界,
6
6
  不依赖任何第三方 npm 包(纯 ESM JS + Node 内置模块)。
7
7
 
8
8
  > **性质说明**:这是给 Coopanion 写的**非官方**扩展,走的是框架公开的扩展接口
9
- > (`kind: world` + `consoleClient` 面板)。唯一伸手动别人的地方是「对话页发图」那一节的
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/<包>/<版本>/console.js`
181
+ 还有一条是我们这边造成的:扩展面板脚本由服务端按 `/assets/extensions/<包>/<版本>/webui.js`
182
182
  分配,响应头是 `Cache-Control: public, max-age=31536000, immutable`,而 URL 里的版本号来自
183
183
  `package.json`。所以**改了面板 UI 却没抬版本号时,浏览器会一直用旧脚本**(看不到新按钮)。
184
- 改了 `dist/console.js` 就顺手把 `version` 抬一格,或者让用户硬刷新。
184
+ 改了 `dist/webui.js` 就顺手把 `version` 抬一格,或者让用户硬刷新。
185
185
 
186
186
  **程序内那个控制台窗口更顽固**(Coopanion.exe 自己的窗口,不是外部浏览器):它是长驻的 Chromium
187
187
  渲染进程,磁盘缓存在 `<安装>\data\Cache`,而 `immutable` 让它在新鲜期内**连重启都不回源**。
@@ -194,7 +194,7 @@
194
194
 
195
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.1.4/console.js`
197
+ 3. 程序内窗口自动重载 → 新的 URL `/assets/extensions/cortico-world-image/0.2.0/webui.js`
198
198
  取到新脚本,旧 URL 立刻变 404。
199
199
 
200
200
  不想抬版本也行,但要**先完全退出程序**再删缓存目录:`<安装>\data\Cache`、
@@ -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
- 第五份是**发布前自检**:按宿主 `manifest.ts` 的同一套规则验 `package.json`(`type: module`、
383
- `cortico.api` 是否等于当前框架的 `world` 契约、`consoleClient` 是否真在包里、keywords 里有没有
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` 自测与 `drymount.mts` 一起跑)。
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: 5, consoleClient: dist/console.js }
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
- ├─ dist/console.js # 浏览器侧面板 bundle(export default { panels })
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/console.js` 必须抬,否则浏览器缓存旧脚本);
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/console.js` 取这个文件,
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.1.4",
3
+ "version": "0.2.0",
4
4
  "type": "module",
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.",
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": 5,
50
+ "api": 6,
50
51
  "displayName": "图片工坊",
51
- "consoleClient": "dist/console.js"
52
+ "webUiClient": "dist/webui.js",
53
+ "describe": "describe.json"
52
54
  }
53
55
  }
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 = 5` 与 `keywords` 里的 `cortico-world` 见 package.json。
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
- return [
239
- {
240
- name: 'image_draw',
241
- description: 'Draw a picture from a text description through the configured image API. '
242
- + 'The picture is saved to disk, attached to this conversation so you can see it, and shown in the Image Studio console page. '
243
- + '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.',
244
- tags: ['act'],
245
- parameters: {
246
- type: 'object',
247
- properties: {
248
- prompt: {
249
- type: 'string',
250
- description: 'What to draw: subject, style, composition, lighting. Any language.',
251
- },
252
- note: {
253
- type: 'string',
254
- description: 'Short label for this picture in the studio list.',
255
- },
256
- size: {
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
- console(language = 'zh') {
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/console.js 的两个面板真的挂起来跑,
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.console === 'function', '有 console()');
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('控制台声明(对齐 dry-mount)');
396
- const declined = world.console('zh');
397
- check(declined && typeof declined === 'object', 'console() 返回对象');
398
- check(typeof declined.invoke === 'function', 'console().invoke 是函数');
399
- check(typeof declined.stream === 'function', 'console().stream 是函数');
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 consoleSrc = readFileSync(join(pkgDir, 'dist', 'console.js'), 'utf8');
587
- const bundle = (await import(pathToFileURL(join(pkgDir, 'dist', 'console.js')).href)).default;
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 = consoleSrc.indexOf(`const ${name} = {`);
597
- const end = consoleSrc.indexOf('\n};', start);
598
- const body = consoleSrc.slice(start, end);
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([...consoleSrc.matchAll(/\btx\.([A-Za-z0-9_]+)/g)].map((m) => m[1]));
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);
@@ -855,7 +903,7 @@ try {
855
903
  check(Array.isArray(restored?.restored?.restored) && String(restored?.restored?.skipped ?? '').includes('COOPANION_CHAT_IMAGE_PATCH'), '检查模式下 restore 不动安装目录', JSON.stringify(restored?.restored));
856
904
  const st = await world.invoke('status', 'state', []);
857
905
  check(Array.isArray(st?.patch?.backups), '状态里能读到备份列表', JSON.stringify(st?.patch?.backups));
858
- const badge = (world.console('zh').badges ?? []).find((b) => b.label === '发图补丁');
906
+ const badge = (world.webUi('zh').badges ?? []).find((b) => b.label === '发图补丁');
859
907
  check(!!badge, '控制台徽章里有「发图补丁」');
860
908
  check(badge?.value === '未检查', '检查模式下徽章显示「未检查」', String(badge?.value));
861
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);
@@ -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} consoleClient=${manifest.cortico?.consoleClient ?? '(无)'}`);
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?.consoleClient;
120
- const hasConsoleClient = typeof clientFile === 'string' && clientFile.length > 0;
121
- if (hasConsoleClient && !existsSync(join(pkgDir, clientFile))) bad(`manifest 声明的 ${clientFile} 不在包里`);
122
- else if (hasConsoleClient) ok(`manifest 声明的 ${clientFile} 在包里`);
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, hasConsoleClient, takenToolNames: conflicts });
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);
@@ -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
- * 宿主相关的三套测试只在找得到本机 Coopanion 安装时才跑:
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: 5, provider: 5, bot: 6 };
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.consoleClient;
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(`consoleClient 必须是包内相对路径(只用 /):${JSON.stringify(client)}`);
154
- else if (!/\.m?js$/.test(client)) bad(`consoleClient 必须是 .js / .mjs:${client}`);
155
- else if (!existsSync(join(pkgDir, client))) bad(`consoleClient 指向的 ${client} 不在包里`);
156
- else ok(`consoleClient=${client} 在包里`);
157
- } else hint('没有 consoleClient:这个扩展不会有控制台面板');
158
- const style = block.consoleStyle;
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('有 consoleStyle 却没有 consoleClient:宿主会报错');
161
- else if (!safeRelativeFile(style) || !/\.css$/.test(style)) bad(`consoleStyle 必须是包内相对 .css:${JSON.stringify(style)}`);
162
- else if (!existsSync(join(pkgDir, style))) bad(`consoleStyle 指向的 ${style} 不在包里`);
163
- else ok(`consoleStyle=${style} 在包里`);
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?.consoleClient ? [clean(pkg.cortico.consoleClient)] : []),
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('入口与控制台面板都在 files 里');
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 appRoot = opt('app') || process.env.COOPANION_APP_ROOT || (() => {
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, 'build', 'cortico', 'src', 'extensions', 'manifest.ts'))) return 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:跳过补丁自测与 dry-mount');
265
- } else if (!appRoot || !existsSync(join(appRoot, 'build', 'cortico', 'src', 'extensions', 'manifest.ts'))) {
266
- if (flag('quiet-host')) hint('没找到本机 Coopanion 安装:跳过补丁自测与 dry-mount');
267
- else hint('没找到本机 Coopanion 安装(用 --app <安装>\\resources\\app 指一下):跳过补丁自测与 dry-mount');
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)) {