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.
- package/ENV_PROMPT.md +2 -2
- package/README.md +77 -101
- package/describe.json +1 -1
- package/dist/webui.js +15 -13
- package/package.json +2 -2
- package/src/chat-image-patch.js +93 -262
- package/src/config.js +2 -2
- package/src/world.js +23 -11
- package/tools/check.mjs +67 -64
- package/tools/patch.mjs +178 -398
package/src/chat-image-patch.js
CHANGED
|
@@ -1,30 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* QQ 扩展里那两处「图片路径」补丁(第三方包:认不出原文就报失败,没装就跳过)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 所以原先针对客户端 bundle / `chat.ts` / `record()` 的那 4 处补丁**全部删掉了**。
|
|
4
|
+
* 为什么还需要补丁:`qq_send_image` / `qq_view_image` 的本地路径过去一律按**扩展目录**解析,
|
|
5
|
+
* 于是模型拿工作区里的相对路径(如 `blobs/xx.jpg`)去发就报「本地没有这个文件」。这里改成
|
|
6
|
+
* **先按桌宠工作区(`memoryDir`)解析、扩展目录兜底**,顺带把两个工具的参数说明改成同一口径
|
|
7
|
+
* —— 模型只看得到工具说明。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
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
|
-
* 状态页报出来 ——
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
{ id: '
|
|
360
|
-
|
|
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
|
|
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: '
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
44
|
+
// 这里只给**旧版**第三方 QQ 扩展(`cortico-world-qq-better-alont1`,world `qqbot`)打补丁:
|
|
45
|
+
// 它的 `qq_send_image` / `qq_view_image` 只按扩展目录解析相对路径,这里改成先按桌宠工作区
|
|
46
|
+
// (记忆目录)解析、扩展目录兜底。第三方包升级会把这些文件覆写回去,所以靠扩展模块的顶层
|
|
47
|
+
// 代码每次启动重打一遍 —— `loadExtensions()` 无论世界开没开都会 import 本模块。
|
|
47
48
|
//
|
|
48
|
-
//
|
|
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
|
-
//
|
|
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('
|
|
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
|
}
|