cortico-world-image 0.2.0 → 0.3.1

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.
@@ -1,30 +1,21 @@
1
1
  /**
2
- * 「桌宠把图片发到「对话」页」这件事的运行时补丁。
2
+ * QQ 扩展里那两处「图片路径」补丁(第三方包:认不出原文就报失败,没装就跳过)。
3
3
  *
4
- * 为什么还需要补丁:框架 0.1.23 起,「say 带图」这条链路本身已经是原生的——
5
- * `chat.ts` 的 `chatItem()` 里 `wireImages()` 会把事件上的 `blobs` 里 `image/*` 映射成 `images`,
6
- * `ChatItem` 的 say 变体带着 `images?: WireImage[]`,客户端 bundle 的 say 那段也会画
7
- * `chat-thumb` 缩略图,`world.ts` 的 `record(meta, text, blobs = [])` 本来就能把附件写进事件。
8
- * 所以原先针对客户端 bundle / `chat.ts` / `record()` 的那 4 处补丁**全部删掉了**。
4
+ * 为什么还需要补丁:`qq_send_image` / `qq_view_image` 的本地路径过去一律按**扩展目录**解析,
5
+ * 于是模型拿工作区里的相对路径(如 `blobs/xx.jpg`)去发就报「本地没有这个文件」。这里改成
6
+ * **先按桌宠工作区(`memoryDir`)解析、扩展目录兜底**,顺带把两个工具的参数说明改成同一口径
7
+ * —— 模型只看得到工具说明。
9
8
  *
10
- * 留下来的补丁只做框架原生**没有**的那件事:`pet_say.images` 只认 `[blob …]` 句柄,
11
- * 不认文件路径,所以「把本地一张图贴进对话页」仍然需要一个按路径收图的工具
12
- * `pet_show_image`(挂在桌宠世界上,见 `pet-show-image-tool`),
13
- * 以及它认「相对桌宠工作区」路径的那几处。
9
+ * 桌宠包本身**不再打任何补丁**(0.3.0 起)。官方 `pet_say` 从桌宠包 0.2.0 起就收
10
+ * `images: Array<handle>`,而本扩展 `image_draw` / `image_edit` / `image_show` 的回执本来就带
11
+ * `blobs`,框架会把它们渲染成 `[blob …]` 句柄交给模型 —— 模型直接
12
+ * `pet_say({ script: '', images: ['<句柄>'] })` 就能把图贴到「对话」页,不必再往桌宠包里注入工具。
13
+ * 早先版本注入过 `pet_show_image` 与它的相对路径支持(`world.ts` 5 处 + `definition.ts` 2 处),
14
+ * `revertObsoletePetPatches()` 负责把那样的旧安装用 `.coopanion-orig` 还原成原样。
14
15
  *
15
16
  * 这些文件都在安装目录里,而程序会自动更新、把变动覆写掉,所以每次启动都由本模块
16
17
  * 重新检查一遍:认得出的原文就改写,已经打过的跳过,认不出来就记一条失败并在控制台
17
- * 状态页报出来 —— 绝不猜着改。(文件同时留一份 `.coopanion-orig` 备份,只存第一次的原文。)
18
- *
19
- * 补丁一律幂等:靠 detect 文本(以及插进 `tools()` 那段的 `/*COOPANION_CHAT_IMAGE_TOOL*\/` 标记)。
20
- *
21
- * 还有一处跟「图片路径」有关的补丁落在 QQ 扩展(`cortico-world-qq-better-alont1`)上:
22
- * `qq_send_image` / `qq_view_image` 的本地路径过去一律按**扩展目录**解析,于是桌宠拿
23
- * `blobs/xx.jpg`(工作区里的图)去发就报「本地没有这个文件」,只能写死绝对路径。
24
- * 这里把它改成**先按桌宠工作区(`memoryDir` = `<botDir>/workspace`)解析、扩展目录兜底**,
25
- * 顺带把两个工具的参数说明改成同样口径 —— 模型只看得到工具说明。
26
- * 那是第三方包(pnpm 装在 `.pnpm/` 下、升级会换目录),所以同样走
27
- * 「认得出原文才改 + 留备份 + 可还原」,装不到就记 `absent`、不算失败。
18
+ * 状态页报出来 —— 绝不猜着改。补丁一律幂等:靠 detect 文本判断。
28
19
  */
29
20
  import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
30
21
  import { basename, dirname, join, resolve } from 'node:path';
@@ -142,158 +133,6 @@ function withImport(text, mod, want) {
142
133
  return { text: text.replace(cur.whole, () => next) };
143
134
  }
144
135
 
145
- /** 我们插进 `tools()` 的那一小段(带标记,认得出自己插过什么)。 */
146
- const TOOL_PIECE = '{ /*COOPANION_CHAT_IMAGE_TOOL*/ ...CHAT_IMAGE_TOOL, handler: (args) => this.showImage(args) }';
147
-
148
- /** 自己插过的旧写法(曾经把 `.map(…)` 又包了一层 `[...]`,结果 6 个工具全变成 null)。 */
149
- const TOOL_PIECE_OLD = [
150
- /, \{ \/\*COOPANION_CHAT_IMAGE_TOOL\*\/ \.\.\.CHAT_IMAGE_TOOL, handler: \(args\) => this\.showImage\(args\) \}/,
151
- /, \{ \.\.\.CHAT_IMAGE_TOOL, handler: \(args\) => this\.showImage\(args\) \}/,
152
- ];
153
-
154
- /** 剥掉我们插过的那一段,拿回「原始的 return 表达式」。 */
155
- function unwrapToolsExpr(expr) {
156
- let e = String(expr).trim();
157
- for (const re of TOOL_PIECE_OLD) {
158
- const m = re.exec(e);
159
- if (m) { e = e.slice(0, m.index).trim(); break }
160
- }
161
- if (e.startsWith('[...[].concat(')) {
162
- e = e.slice('[...[].concat('.length).trim();
163
- if (e.endsWith(')]')) e = e.slice(0, -2).trim();
164
- else if (e.endsWith(')')) e = e.slice(0, -1).trim();
165
- return e;
166
- }
167
- if (e.startsWith('[') && e.endsWith(']')) return e.slice(1, -1).trim();
168
- if (e.startsWith('[')) return e.slice(1).trim();
169
- return e;
170
- }
171
-
172
- /**
173
- * `tools()` 里那句 `return …;` —— 0.1.19 是 `DESKTOP_PET_TOOL_DECLS.map(…)`,
174
- * 0.1.21 变成 `petToolDecls(this.appLanguage).map(…)`,所以整句按原文抓,
175
- * 再用 `[].concat(…)` **摊平**着把我们的工具接上去。
176
- *
177
- * 为什么不写 `[${expr}, TOOL]`:`expr` 本身就是数组,那么写会变成嵌套数组,
178
- * 框架读出来的 6 个工具名会全变 null(0.1.21 上真翻过这个车)。
179
- * 已经打过的(含旧的嵌套写法)先剥掉再重写,所以重复跑是安全的。
180
- */
181
- function patchToolsReturn(text) {
182
- const head = /tools\(\): ToolDef\[\] \{/.exec(text);
183
- if (!head) return { error: '没找到 tools() 方法' };
184
- const seg = text.slice(head.index, head.index + 3000);
185
- const rm = /^([ \t]*)return ([^\n]*);$/m.exec(seg);
186
- if (!rm) return { error: '没找到 tools() 里的 return' };
187
- const expr = unwrapToolsExpr(rm[2]);
188
- if (!expr || /CHAT_IMAGE_TOOL/.test(expr)) return { error: `认不出原始的 return 表达式:${rm[2].slice(0, 80)}` };
189
- const next = `${rm[1]}return [...[].concat(${expr}), ${TOOL_PIECE}];`;
190
- const start = head.index + rm.index;
191
- return { text: text.slice(0, start) + next + text.slice(start + rm[0].length) };
192
- }
193
-
194
- const WORLD_EXTRA = [
195
- '/** coopanion-chat-image: `pet_show_image` 认的图片后缀,文件头嗅探优先。 */',
196
- "const IMAGE_MIME_BY_EXT: Record<string, string> = {",
197
- " '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif',",
198
- '};',
199
- '',
200
- '/** coopanion-chat-image: 按文件头认图片,认不出来才退回后缀。 */',
201
- 'function sniffImageMime(bytes: Buffer): string | null {',
202
- ' if (bytes.length > 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return \'image/png\';',
203
- ' if (bytes.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return \'image/jpeg\';',
204
- " if (bytes.length > 12 && bytes.subarray(0, 4).toString('latin1') === 'RIFF' && bytes.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp';",
205
- " if (bytes.length > 6 && bytes.subarray(0, 6).toString('latin1').startsWith('GIF8')) return 'image/gif';",
206
- ' return null;',
207
- '}',
208
- '',
209
- '/** coopanion-chat-image: 贴图工具;tags 里没有 speak,所以 CosyVoice 那类只认 `pet_say` 的扩展不会碰它。 */',
210
- "const CHAT_IMAGE_TOOL: Omit<ToolDef, 'handler'> = {",
211
- " name: 'pet_show_image',",
212
- " tags: ['act'],",
213
- " description: '把本地一张图片贴到「对话」页(桌宠这一侧),配文可选。图先画好/存好再调用;只认 PNG、JPEG、WebP、GIF,单张不超过 8 MB。',",
214
- ' parameters: {',
215
- " type: 'object',",
216
- ' properties: {',
217
- " path: { type: 'string', description: '图片文件的完整路径。' },",
218
- " caption: { type: 'string', maxLength: 200, description: '配文,一句话;不写就只贴图。' },",
219
- ' },',
220
- " required: ['path'],",
221
- ' },',
222
- '};',
223
- ].join('\n');
224
-
225
- const SHOW_IMAGE_METHOD = [
226
- ' /**',
227
- ' * coopanion-chat-image: 把一张本地图片贴到「对话」页。',
228
- ' *',
229
- ' * 图片随事件存成附件(blobs),页面用 `chat.blob` 取回;只写对话记录,不碰气泡窗口,',
230
- ' * 所以桌宠窗口没连接也能用。',
231
- ' */',
232
- ' private async showImage(args: Record<string, unknown>): Promise<ToolOutcome> {',
233
- " const raw = typeof args.path === 'string' ? args.path.trim() : '';",
234
- " const caption = typeof args.caption === 'string' ? args.caption.trim().slice(0, 200) : '';",
235
- " if (!raw) return { text: '[pet_show_image 没执行] 要给图片的完整路径。', failed: true };",
236
- " if (!isAbsolute(raw)) return { text: '[pet_show_image 没执行] 需要完整路径(例如 C:\\\\Users\\\\...\\\\pic.png)。', failed: true };",
237
- ' let bytes: Buffer;',
238
- ' try {',
239
- ' bytes = readFileSync(raw);',
240
- ' } catch (err) {',
241
- ' return { text: `[pet_show_image 没执行] 这个文件读不到:${(err as Error).message}`, failed: true };',
242
- ' }',
243
- ' const mime = sniffImageMime(bytes) ?? IMAGE_MIME_BY_EXT[extname(raw).toLowerCase()];',
244
- " if (!mime) return { text: '[pet_show_image 没执行] 这不是 PNG / JPEG / WebP / GIF 图片。', failed: true };",
245
- ' if (bytes.length > IMAGE_MAX_BYTES) {',
246
- ' return { text: `[pet_show_image 没执行] 这张图 ${(bytes.length / 1048576).toFixed(1)} MB,超过 ${IMAGE_MAX_BYTES / 1048576} MB 了。`, failed: true };',
247
- ' }',
248
- " if (!this.host) return { text: '[pet_show_image 没执行] 现在没连着对话记录。', failed: true };",
249
- " const name = basename(raw).slice(0, 120) || 'image';",
250
- ' const cursor = await this.record(',
251
- " { kind: 'say', beats: caption ? [{ text: caption }] : [] },",
252
- ' caption,',
253
- ' [{ bytes, mime, name, fallbackText: `对话页配图:${name}(${mime},${bytes.length} 字节)` }],',
254
- ' );',
255
- " if (cursor === null) return { text: '[pet_show_image 没执行] 对话记录没能保存。', failed: true };",
256
- " const shown = this.chat.size ? '对话页正开着,图已经贴上去了。' : '对话页没开,打开就能看到。';",
257
- ' return { text: `已把 ${name}(${mime},${(bytes.length / 1024).toFixed(0)} KB)贴到「对话」页${caption ? `,配文「${caption}」` : \'\'}。${shown}` };',
258
- ' }',
259
- ].join('\n');
260
-
261
- // ---------------------------------------------------------------------------
262
- // 桌宠自己那份:`pet_show_image` 也认相对工作区的路径
263
- // ---------------------------------------------------------------------------
264
-
265
- /**
266
- * 上面两段载荷(`WORLD_EXTRA` 的工具表 + `SHOW_IMAGE_METHOD` 的实现)**故意保持旧写法**,
267
- * 由下面这条补丁在原文里就地改写。这样「全新安装」和「早先打过旧版补丁的安装」走的是同一条路
268
- * (旧文本 → 新文本),不会出现两种终态。
269
- *
270
- * 注意:`SHOW_IMAGE_METHOD` 里是 `this.record(meta, text, blobs)` 三参调用 —— 框架 0.1.23 起
271
- * `record()` 原生就收第三个 `blobs` 参数,所以这里不再需要 `pet-record-blobs` 那处签名补丁。
272
- */
273
- const PET_WORKSPACE_OPTION_ANCHOR = ' avatarFile?: string;';
274
- const PET_WORKSPACE_OPTION = [
275
- ' /** coopanion-chat-image: 桌宠工作区(相对图片路径按它解析);装配层给,没给就只能用绝对路径。 */',
276
- ' workspaceDir?: () => string;',
277
- ].join('\n');
278
-
279
- /** 装配层在 `definition.ts` 里补的那一行(`join` 那儿的 import 本来就有)。 */
280
- const PET_WORKSPACE_WIRE_ANCHOR = ' avatarFile: join(ctx.botDir, AVATAR_FILE),';
281
- const PET_WORKSPACE_WIRE = " workspaceDir: () => join(ctx.botDir, 'workspace'),";
282
-
283
- const PET_WORKSPACE_DONE = 'raw = join(ws, raw);';
284
- const PET_RAW_OLD = " const raw = typeof args.path === 'string' ? args.path.trim() : '';";
285
- const PET_RAW_NEW = " let raw = typeof args.path === 'string' ? args.path.trim() : '';";
286
- const PET_PATH_GUARD_OLD = " if (!isAbsolute(raw)) return { text: '[pet_show_image 没执行] 需要完整路径(例如 C:\\\\Users\\\\...\\\\pic.png)。', failed: true };";
287
- const PET_PATH_GUARD_NEW = [
288
- ' if (!isAbsolute(raw)) {',
289
- " const ws = (this.opts.workspaceDir?.() ?? '').trim();",
290
- " if (!ws) return { text: '[pet_show_image 没执行] 相对路径没有工作区可拼;请给完整路径(例如 C:\\\\Users\\\\...\\\\pic.png)。', failed: true };",
291
- ' raw = join(ws, raw);',
292
- ' }',
293
- ].join('\n');
294
- const PET_PATH_HINT_OLD = "description: '图片文件的完整路径。'";
295
- const PET_PATH_HINT_NEW = "description: '图片路径:绝对路径,或相对桌宠工作区的路径(例如 blobs/xx.jpg)。'";
296
-
297
136
  // ---------------------------------------------------------------------------
298
137
  // QQ 扩展那两处:本地图片路径按桌宠工作区解析(第三方包,认不出原文就报失败)
299
138
  // ---------------------------------------------------------------------------
@@ -351,101 +190,43 @@ const hintNeedsWork = (text, h) => !text.includes(h.to) && [].concat(h.from).som
351
190
  * `apply` 返回新文本或 `{ error }`。全部按顺序跑,互不依赖。
352
191
  */
353
192
  export function patchSpecs(appRoot) {
354
- const pet = join(appRoot, 'node_modules', PET_PKG, 'src');
355
193
  const qq = qqWorldFile(appRoot);
356
194
  const specs = [
357
- { id: 'pet-fs-import', file: join(pet, 'world.ts'), what: '桌宠包导入 readFileSync' },
358
- { id: 'pet-path-import', file: join(pet, 'world.ts'), what: '桌宠包导入 basename/extname/isAbsolute' },
359
- { id: 'pet-show-image-tool', file: join(pet, 'world.ts'), what: 'pet_show_image 工具与它的实现' },
360
- // 这三处让 pet_show_image 也认「相对桌宠工作区」的路径;最后一条依赖上面那条先插好方法。
361
- { id: 'pet-workspace-option', file: join(pet, 'world.ts'), what: '桌宠包选项加 workspaceDir' },
362
- { id: 'pet-workspace-relpaths', file: join(pet, 'world.ts'), what: 'pet_show_image 认工作区相对路径' },
363
- { id: 'pet-workspace-wire', file: join(pet, 'definition.ts'), what: '装配层把工作区喂给桌宠世界' },
364
- // 这两处落在第三方 QQ 扩展上:没装就记 absent(不算失败),装了才改。
365
- { id: 'qq-image-relpaths', file: qq, what: 'QQ 发图/看图认工作区相对路径', optional: true },
366
- { id: 'qq-image-hints', file: qq, what: 'QQ 发图/看图的说明写明相对工作区', optional: true },
195
+ // 这两处落在第三方 QQ 扩展(qq-better)上:没装、或那个世界没开,就记 absent(不算失败)。
196
+ // 官方 `cortico-world-qq` 发图按 blob 句柄,不解析本地路径,用不着补丁。
197
+ { id: 'qq-image-relpaths', file: qq, what: 'QQ 发图/看图认工作区相对路径', optional: true, worldId: 'qqbot' },
198
+ { id: 'qq-image-hints', file: qq, what: 'QQ 发图/看图的说明写明相对工作区', optional: true, worldId: 'qqbot' },
367
199
  ];
368
200
  return specs;
369
201
  }
370
202
 
203
+ /**
204
+ * 宿主配置里那个世界开没开(`worlds.<id>.enabled`)。
205
+ *
206
+ * 补丁改的是某个第三方扩展的源码:那个世界没在跑,改了也只是给一份不加载的文件留个备份,
207
+ * 状态页还会写「2 处都在」——看起来像在维护一个根本不生效的东西。所以这里读一下开关。
208
+ * 配置读不到时返回 `{ known: false, enabled: true }`:不猜,照旧打补丁。
209
+ */
210
+ export function worldSwitch(appRoot, worldId) {
211
+ if (!worldId) return { known: false, enabled: true };
212
+ const files = [];
213
+ if (appRoot) files.push(join(dirname(dirname(resolve(appRoot))), 'data', 'home', 'companion', 'config.json'));
214
+ files.push(join(process.cwd(), 'data', 'home', 'companion', 'config.json'));
215
+ for (const file of files) {
216
+ try {
217
+ const world = JSON.parse(readFileSync(file, 'utf8'))?.worlds?.[worldId];
218
+ if (world && typeof world === 'object') return { known: true, enabled: world.enabled !== false };
219
+ } catch {
220
+ // 读不到就试下一个。
221
+ }
222
+ }
223
+ return { known: false, enabled: true };
224
+ }
225
+
371
226
  /** 一处补丁的改写规则(拆出来是为了能在副本上单测)。 */
372
227
  export function rulesFor(id) {
373
228
  const nl = (text) => (text.includes('\r\n') ? '\r\n' : '\n');
374
229
  switch (id) {
375
- case 'pet-fs-import':
376
- return {
377
- done: (text) => importHas(text, 'node:fs', ['readFileSync']),
378
- apply: (text) => withImport(text, 'node:fs', ['readFileSync']),
379
- };
380
- case 'pet-path-import':
381
- return {
382
- done: (text) => importHas(text, 'node:path', ['basename', 'extname', 'isAbsolute']),
383
- apply: (text) => withImport(text, 'node:path', ['basename', 'extname', 'isAbsolute']),
384
- };
385
- case 'pet-show-image-tool':
386
- return {
387
- // 必须连标记一起要求:只认「工具表和实现都在」会让上一版写坏的
388
- // `[EXPR, TOOL]`(嵌套数组)被当成已完成,6 个老工具名就一直是 null。
389
- done: (text) =>
390
- text.includes('const CHAT_IMAGE_TOOL') &&
391
- text.includes('this.showImage(args)') &&
392
- text.includes('/*COOPANION_CHAT_IMAGE_TOOL*/'),
393
- apply: (text) => {
394
- let work = text;
395
- // 这两段插过一次就不再插(否则报 Identifier 'CHAT_IMAGE_TOOL' has already been declared)。
396
- if (!work.includes('const IMAGE_MIME_BY_EXT: Record<string, string> = {')) {
397
- const withTool = swap(work, 'export class DesktopPetWorld implements World {', `${asWritten(WORLD_EXTRA, nl(work))}\n\nexport class DesktopPetWorld implements World {`);
398
- if (withTool === null) return { error: '没找到 DesktopPetWorld 的类声明' };
399
- work = withTool;
400
- }
401
- if (!work.includes('private async showImage(args: Record<string, unknown>): Promise<ToolOutcome> {')) {
402
- const withMethod = swap(work, ' private notConnected(tool: string): ToolOutcome {', `${asWritten(SHOW_IMAGE_METHOD, nl(work))}\n\n private notConnected(tool: string): ToolOutcome {`);
403
- if (withMethod === null) return { error: '没找到 notConnected()(插方法的锚点)' };
404
- work = withMethod;
405
- }
406
- const out = patchToolsReturn(work);
407
- if (out.error) return { error: out.error };
408
- return { text: out.text };
409
- },
410
- };
411
- case 'pet-workspace-option':
412
- return {
413
- done: (text) => text.includes('workspaceDir?: () => string;'),
414
- apply: (text) => {
415
- const block = asWritten(PET_WORKSPACE_OPTION, nl(text));
416
- const out = swap(text, PET_WORKSPACE_OPTION_ANCHOR, `${PET_WORKSPACE_OPTION_ANCHOR}\n${block}`);
417
- return out === null ? { error: `没找到 ${PET_WORKSPACE_OPTION_ANCHOR}(桌宠包版本变了)` } : { text: out };
418
- },
419
- };
420
- case 'pet-workspace-relpaths':
421
- return {
422
- // 认「相对路径真的拼上工作区」那段:只认到工具表里的说明不算打过。
423
- done: (text) => text.includes(PET_WORKSPACE_DONE),
424
- apply: (text) => {
425
- let work = text;
426
- if (!importHas(work, 'node:path', ['isAbsolute', 'join'])) {
427
- const p = withImport(work, 'node:path', ['isAbsolute', 'join']);
428
- if (p.error) return { error: `补 node:path 导入失败:${p.error}` };
429
- work = p.text;
430
- }
431
- const letRaw = swap(work, PET_RAW_OLD, PET_RAW_NEW);
432
- if (letRaw === null) return { error: `没找到 ${PET_RAW_OLD.trim()}(pet-show-image-tool 还没打上?)` };
433
- const guard = swap(letRaw, PET_PATH_GUARD_OLD, asWritten(PET_PATH_GUARD_NEW, nl(work)));
434
- if (guard === null) return { error: `没找到只认绝对路径的那行:${PET_PATH_GUARD_OLD.trim()}` };
435
- const hint = swap(guard, PET_PATH_HINT_OLD, PET_PATH_HINT_NEW);
436
- if (hint === null) return { error: `工具说明对不上:${PET_PATH_HINT_OLD}` };
437
- return { text: hint };
438
- },
439
- };
440
- case 'pet-workspace-wire':
441
- return {
442
- done: (text) => text.includes(PET_WORKSPACE_WIRE),
443
- apply: (text) => {
444
- const line = asWritten(PET_WORKSPACE_WIRE, nl(text));
445
- const out = swap(text, PET_WORKSPACE_WIRE_ANCHOR, `${PET_WORKSPACE_WIRE_ANCHOR}\n${line}`);
446
- return out === null ? { error: `没找到 ${PET_WORKSPACE_WIRE_ANCHOR}(桌宠包版本变了)` } : { text: out };
447
- },
448
- };
449
230
  case 'qq-image-relpaths':
450
231
  return {
451
232
  // 助手在 + 两处都换了才算打过(助手没插上就调用它会是 ReferenceError)。
@@ -506,9 +287,39 @@ export function rulesFor(id) {
506
287
  }
507
288
 
508
289
  /**
509
- * 检查并补齐所有补丁。
290
+ * 0.3.0 起不再往桌宠包里打补丁。早先版本注入过 `pet_show_image`(`world.ts` 的工具与实现、
291
+ * `definition.ts` 的自描述与工作区接线),这里把那样的安装用 `.coopanion-orig` 还原成原样。
292
+ * 只认「文件里还有我们的标记」的文件:桌宠包自己升级过(标记没了)就一律不动。
293
+ */
294
+ export function revertObsoletePetPatches(appRoot, { log } = {}) {
295
+ const out = [];
296
+ if (!appRoot) return out;
297
+ const dir = join(appRoot, 'node_modules', PET_PKG, 'src');
298
+ for (const name of ['world.ts', 'definition.ts']) {
299
+ const file = join(dir, name);
300
+ const backup = `${file}.coopanion-orig`;
301
+ if (!existsSync(file) || !existsSync(backup)) continue;
302
+ try {
303
+ const text = readFileSync(file, 'utf8');
304
+ const ours = text.includes('coopanion-chat-image')
305
+ || text.includes('COOPANION_CHAT_IMAGE_TOOL')
306
+ || text.includes("name: 'pet_show_image'");
307
+ if (!ours) continue;
308
+ copyFileSync(backup, file);
309
+ log?.info?.(`图片工坊补丁:桌宠包 ${name} 已还原(0.3.0 起不再注入 pet_show_image)`);
310
+ out.push({ file, reverted: true });
311
+ } catch (err) {
312
+ log?.warn?.(`图片工坊补丁:桌宠包 ${name} 还原失败:${msg(err)}`);
313
+ out.push({ file, reverted: false, error: msg(err) });
314
+ }
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /**
320
+ * 检查并补齐所有补丁(先还原早先版本打在桌宠包上的旧补丁)。
510
321
  *
511
- * 返回 `{ appRoot, results, patched, failed, restartPending, stuck }`;
322
+ * 返回 `{ appRoot, results, patched, failed, restartPending, stuck, reverted }`;
512
323
  * `results[i]` 是 `{ id, what, file, state, error? }`,state 取 `patched`/`done`/`failed`/`missing`
513
324
  * (目标文件不在)——`optional` 的补丁没装那个扩展时记 `absent`,不计失败也不计「缺」。
514
325
  */
@@ -519,10 +330,26 @@ export function applyChatImagePatches(appRoot, { stateDir, log } = {}) {
519
330
  log?.warn?.(`图片工坊补丁:${error}`);
520
331
  return { appRoot, results, patched: [], failed: ['*'], restartPending: false, stuck: false, error };
521
332
  }
333
+ const reverted = revertObsoletePetPatches(appRoot, { log });
522
334
  for (const spec of patchSpecs(appRoot)) {
523
335
  const rules = rulesFor(spec.id);
524
336
  const name = spec.file ? basename(spec.file) : '(没找到)';
525
337
  const backup = spec.file ? `${spec.file}.coopanion-orig` : '';
338
+ // 那个世界没在跑:不改它的文件(换了官方 QQ 扩展时就是这种情形)。
339
+ const sw = worldSwitch(appRoot, spec.worldId);
340
+ if (sw.known && !sw.enabled) {
341
+ results.push({
342
+ id: spec.id,
343
+ what: spec.what,
344
+ file: spec.file,
345
+ backup,
346
+ state: 'absent',
347
+ optional: Boolean(spec.optional),
348
+ worldId: spec.worldId,
349
+ error: `${spec.id}: 世界「${spec.worldId}」没启用(worlds.${spec.worldId}.enabled=false),跳过`,
350
+ });
351
+ continue;
352
+ }
526
353
  if (!spec.file || !existsSync(spec.file)) {
527
354
  const gone = spec.optional ? 'absent' : 'missing';
528
355
  results.push({
@@ -570,7 +397,8 @@ export function applyChatImagePatches(appRoot, { stateDir, log } = {}) {
570
397
  }
571
398
  const sameApp = Boolean(prev && prev.appRoot === resolve(appRoot));
572
399
  const stuck = Boolean(sameApp && prev.requested === true && patched.some((id) => (prev.patched ?? []).includes(id)));
573
- const restartPending = patched.length > 0 && !stuck;
400
+ const changed = patched.length > 0 || reverted.some((r) => r.reverted);
401
+ const restartPending = changed && !stuck;
574
402
  if (stateFile) {
575
403
  try {
576
404
  mkdirSync(dirname(stateFile), { recursive: true });
@@ -579,6 +407,7 @@ export function applyChatImagePatches(appRoot, { stateDir, log } = {}) {
579
407
  appRoot: resolve(appRoot),
580
408
  patched,
581
409
  failed,
410
+ reverted: reverted.filter((r) => r.reverted).map((r) => basename(r.file)),
582
411
  requested: restartPending || (sameApp && prev.requested === true && stuck),
583
412
  }, null, 2)}\n`, 'utf8');
584
413
  } catch (err) {
@@ -588,7 +417,7 @@ export function applyChatImagePatches(appRoot, { stateDir, log } = {}) {
588
417
  if (stuck) {
589
418
  log?.warn?.('图片工坊补丁:上一轮已经为同一处补丁请求过重启,这次仍然缺,先不再重启;请在「图片工坊」状态页点「重打补丁」');
590
419
  }
591
- return { appRoot, results, patched, failed, restartPending, stuck };
420
+ return { appRoot, results, patched, failed, restartPending, stuck, reverted };
592
421
  }
593
422
 
594
423
  /**
@@ -598,6 +427,8 @@ export function patchBackupStatus(appRoot) {
598
427
  const out = [];
599
428
  if (!appRoot || !looksLikeApp(appRoot)) return out;
600
429
  for (const spec of patchSpecs(appRoot)) {
430
+ const sw = worldSwitch(appRoot, spec.worldId);
431
+ if (sw.known && !sw.enabled) continue;
601
432
  const backup = spec.file ? `${spec.file}.coopanion-orig` : '';
602
433
  const hasFile = Boolean(spec.file && existsSync(spec.file));
603
434
  // 可选的第三方扩展没装:整行不出现(状态页按行数算「已打 n/总数」)。
package/src/config.js CHANGED
@@ -28,7 +28,7 @@ const ZH = {
28
28
  lamp: '画图接口',
29
29
  badgeWorks: '作品',
30
30
  badgeKey: '密钥',
31
- badgePatch: '发图补丁',
31
+ badgePatch: 'QQ 发图路径',
32
32
  patchOk: '在',
33
33
  patchPending: '待重启',
34
34
  patchMissing: (n) => `缺 ${n} 处`,
@@ -81,7 +81,7 @@ const EN = {
81
81
  lamp: 'Image API',
82
82
  badgeWorks: 'Pictures',
83
83
  badgeKey: 'API key',
84
- badgePatch: 'Chat images',
84
+ badgePatch: 'QQ image paths',
85
85
  patchOk: 'in place',
86
86
  patchPending: 'restart needed',
87
87
  patchMissing: (n) => `${n} missing`,
package/src/world.js CHANGED
@@ -38,14 +38,21 @@ const asText = (value) => (typeof value === 'string' ? value.trim() : '');
38
38
  const PACKAGE_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
39
39
 
40
40
  // ---------------------------------------------------------------------------
41
- // 自愈补丁:安装目录里的两处代码每次启动都对一遍
41
+ // 自愈补丁:QQ 扩展里的两处代码每次启动都对一遍
42
42
  // ---------------------------------------------------------------------------
43
43
  //
44
- // 桌宠要在控制台「对话」页发图,得改安装目录里的两份文件(客户端 bundle 的 say 渲染、
45
- // 桌宠包的 chat.ts/world.ts)。程序自动更新会把这些文件覆写回去,所以靠扩展模块的
46
- // 顶层代码每次启动重打一遍 —— `loadExtensions()` 无论世界开没开都会 import 本模块。
44
+ // 这里只给**旧版**第三方 QQ 扩展(`cortico-world-qq-better-alont1`,world `qqbot`)打补丁:
45
+ // 它的 `qq_send_image` / `qq_view_image` 只按扩展目录解析相对路径,这里改成先按桌宠工作区
46
+ // (记忆目录)解析、扩展目录兜底。第三方包升级会把这些文件覆写回去,所以靠扩展模块的顶层
47
+ // 代码每次启动重打一遍 —— `loadExtensions()` 无论世界开没开都会 import 本模块。
47
48
  //
48
- // 打好的补丁写回了磁盘,但桌宠包的 TS 是进程启动时 import 的,要重启进程才装载;
49
+ // 官方 `cortico-world-qq`(world `qq`)发图收的是 blob 句柄(`host.blob(...)`),根本不解析
50
+ // 本地路径,用不着补丁;切到它之后 `worlds.qqbot.enabled=false`,这两处会自动记 absent、不碰文件。
51
+ //
52
+ // 0.3.0 起不再往桌宠包里打补丁(改用官方 `pet_say.images`);早先版本打过的旧补丁会在启动时
53
+ // 按 `.coopanion-orig` 备份还原回去。
54
+ //
55
+ // 打好的补丁写回了磁盘,但扩展的代码是进程启动时 import 的,要重启进程才装载;
49
56
  // 重启请求放在 `start()` 里(那时才拿得到控制台端口),并用状态文件防死循环。
50
57
 
51
58
  /** 最近一次补丁检查的结果;「状态」页显示它,也能手动重打或按备份还原。 */
@@ -58,6 +65,8 @@ let PATCH_STATE = {
58
65
  total: 0,
59
66
  stuck: false,
60
67
  restartPending: false,
68
+ /** 这次启动从桌宠包里还原掉的旧补丁文件(0.3.0 起才有)。 */
69
+ reverted: [],
61
70
  error: '',
62
71
  restart: null,
63
72
  /** 手动还原的记录:`{at, restored:[id], failed:[id], byFile:[{file,backup,state}]}`,没还原过就是 null。 */
@@ -77,10 +86,13 @@ function refreshPatchState({ stateDir, log } = {}) {
77
86
  .filter((r) => r.state === 'failed' || r.state === 'missing')
78
87
  .map((r) => ({ id: r.id, what: r.what, error: r.error ?? '' })),
79
88
  done: report.results.filter((r) => r.state === 'done').length,
80
- // 可选补丁(第三方扩展没装)记 absent:不计进总数,免得状态页写「7/9 没打上」。
89
+ // 可选补丁(QQ 扩展没装、或那个世界没开)记 absent:不计进总数,免得状态页写「1/2 没打上」。
81
90
  total: report.results.filter((r) => r.state !== 'absent').length,
91
+ // 切到官方 QQ 扩展后一处都不用打:状态页据此显示「没有要打补丁的 QQ 扩展」。
92
+ skipped: report.results.filter((r) => r.state === 'absent').length,
82
93
  stuck: report.stuck,
83
94
  restartPending: report.restartPending,
95
+ reverted: report.reverted?.filter((r) => r.reverted).map((r) => r.file) ?? [],
84
96
  error: report.error ?? '',
85
97
  restored: report.patched.length ? null : PATCH_STATE.restored,
86
98
  };
@@ -448,7 +460,7 @@ export class ImageWorld {
448
460
  if (spec.note) lines.push(`名字:${spec.note}`);
449
461
  if (spec.prompt) lines.push(`画面:${spec.prompt}`);
450
462
  if (spec.warning) lines.push(spec.warning);
451
- lines.push('图已进入本次对话,也显示在「图片工坊」页。要发给某人或某个群,把上面的绝对路径交给 qq_send_image。');
463
+ lines.push('图已进入本次对话,也显示在「图片工坊」页。要发给某人或某个群,就把上面那行 `[blob …]` 的句柄交给那个世界的发图工具(官方 QQ 扩展是 `qq_draft` 的 `image`;旧版 QQ 扩展是 `qq_send_image`,填文件绝对路径)。');
452
464
  return {
453
465
  text: lines.join('\n'),
454
466
  blobs: [{
@@ -596,7 +608,7 @@ export class ImageWorld {
596
608
  }
597
609
 
598
610
  /**
599
- * 重新对一遍「对话页发图」补丁。
611
+ * 重新对一遍「QQ 发图路径」补丁(顺带还原桌宠包上的旧补丁)。
600
612
  *
601
613
  * 平时用不着(每次启动都会自己打),留给两处:更新覆写后发现没打上,
602
614
  * 或者想看看到底改了哪几处。
@@ -611,10 +623,10 @@ export class ImageWorld {
611
623
  }
612
624
 
613
625
  /**
614
- * 按 `.coopanion-orig` 手动还原:把安装目录里的三份文件覆盖回更新下发的原样。
626
+ * 按 `.coopanion-orig` 手动还原:把改过的文件覆盖回更新下发的原样。
615
627
  *
616
628
  * 备份不删,所以这是可逆的(再点「重打补丁」或下次启动就会补回来)。
617
- * 还原后桌宠那一侧要重启才回到「没有发图功能」,但这里**不自动重启**
629
+ * 还原后 QQ 发图回到「只认扩展目录的相对路径」,要重启才生效;但这里**不自动重启**
618
630
  * ——用户是主动撤销的,等他自己决定。
619
631
  */
620
632
  #restore() {
@@ -809,7 +821,7 @@ export class ImageWorld {
809
821
  });
810
822
  if (PATCH_STATE.error) this.#host?.log?.warn?.(`图片工坊补丁:${PATCH_STATE.error}`);
811
823
  if (PATCH_STATE.failed.length) {
812
- this.#host?.log?.warn?.(`图片工坊补丁:有 ${PATCH_STATE.failed.length} 处没打上(${PATCH_STATE.failed.map((f) => f.id).join(', ')}),桌宠发图可能不生效`);
824
+ this.#host?.log?.warn?.(`图片工坊补丁:有 ${PATCH_STATE.failed.length} 处没打上(${PATCH_STATE.failed.map((f) => f.id).join(', ')}),QQ 发图可能不认工作区相对路径`);
813
825
  }
814
826
  if (PATCH_STATE.restartPending) this.#scheduleRestart('这次启动补上了新补丁');
815
827
  }