@zaofan/dsh-qqbot 1.4.1 → 1.4.6

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.
Files changed (66) hide show
  1. package/README.md +69 -0
  2. package/README_EN.md +60 -0
  3. package/client/qqbot-settings.js +429 -4
  4. package/dist/channel-tools.d.ts +15 -0
  5. package/dist/channel-tools.d.ts.map +1 -1
  6. package/dist/channel-tools.js +59 -1
  7. package/dist/channel-tools.js.map +1 -1
  8. package/dist/config.d.ts +32 -0
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +15 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/features/image-path-cache.d.ts +23 -0
  13. package/dist/features/image-path-cache.d.ts.map +1 -0
  14. package/dist/features/image-path-cache.js +38 -0
  15. package/dist/features/image-path-cache.js.map +1 -0
  16. package/dist/features/image-url-ledger.d.ts +19 -0
  17. package/dist/features/image-url-ledger.d.ts.map +1 -0
  18. package/dist/features/image-url-ledger.js +133 -0
  19. package/dist/features/image-url-ledger.js.map +1 -0
  20. package/dist/features/local-embed.d.ts +43 -0
  21. package/dist/features/local-embed.d.ts.map +1 -0
  22. package/dist/features/local-embed.js +143 -0
  23. package/dist/features/local-embed.js.map +1 -0
  24. package/dist/features/sticker-store.d.ts +48 -2
  25. package/dist/features/sticker-store.d.ts.map +1 -1
  26. package/dist/features/sticker-store.js +156 -1
  27. package/dist/features/sticker-store.js.map +1 -1
  28. package/dist/features/value-sample.d.ts +25 -0
  29. package/dist/features/value-sample.d.ts.map +1 -0
  30. package/dist/features/value-sample.js +122 -0
  31. package/dist/features/value-sample.js.map +1 -0
  32. package/dist/features/value-score.d.ts +36 -0
  33. package/dist/features/value-score.d.ts.map +1 -0
  34. package/dist/features/value-score.js +122 -0
  35. package/dist/features/value-score.js.map +1 -0
  36. package/dist/gateway/bootstrap.d.ts.map +1 -1
  37. package/dist/gateway/bootstrap.js +4 -0
  38. package/dist/gateway/bootstrap.js.map +1 -1
  39. package/dist/gateway/debounce.d.ts.map +1 -1
  40. package/dist/gateway/debounce.js +16 -5
  41. package/dist/gateway/debounce.js.map +1 -1
  42. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  43. package/dist/gateway/middleware-setup.js +12 -1
  44. package/dist/gateway/middleware-setup.js.map +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/middleware/sticker-capture.d.ts +8 -2
  49. package/dist/middleware/sticker-capture.d.ts.map +1 -1
  50. package/dist/middleware/sticker-capture.js +63 -45
  51. package/dist/middleware/sticker-capture.js.map +1 -1
  52. package/dist/model/model-resolver.js +12 -6
  53. package/dist/model/model-resolver.js.map +1 -1
  54. package/dist/transport/attachment.d.ts +5 -1
  55. package/dist/transport/attachment.d.ts.map +1 -1
  56. package/dist/transport/attachment.js +16 -5
  57. package/dist/transport/attachment.js.map +1 -1
  58. package/dist/transport/inbound.d.ts.map +1 -1
  59. package/dist/transport/inbound.js +351 -9
  60. package/dist/transport/inbound.js.map +1 -1
  61. package/dist/transport/inject-rules.d.ts.map +1 -1
  62. package/dist/transport/inject-rules.js +3 -1
  63. package/dist/transport/inject-rules.js.map +1 -1
  64. package/package.json +3 -2
  65. package/scripts/download-model.mjs +112 -0
  66. package/settings-host.js +337 -5
@@ -0,0 +1,112 @@
1
+ /**
2
+ * download-model.mjs — 一键下载插件用的**本地小模型**(bge-small-zh-v1.5, ONNX 量化版 ≈ 23MB)
3
+ *
4
+ * 用途: 插件用它在本地给群消息打"值不值得回"的分(价值评分)→ 低分闲聊不唤醒 AI = 省 token。
5
+ * 模型只在本机 CPU 跑,**完全离线**,不上传任何内容。
6
+ *
7
+ * 用法:
8
+ * node scripts/download-model.mjs # 下到默认位置 {DSH_HOME|~/.dsh}/models/bge-small-zh
9
+ * node scripts/download-model.mjs --dir D:\m\bge # 自定义目录(填进插件配置 localModel.modelDir)
10
+ * node scripts/download-model.mjs --source hf # 强制官方源(默认: 先试国内镜像 hf-mirror.com)
11
+ *
12
+ * 需要: Node ≥ 18(dsh 本身要求 Node ≥ 22, 所以一定有)
13
+ */
14
+ import { mkdirSync, createWriteStream, statSync, existsSync, renameSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { Readable } from 'node:stream';
18
+ import { pipeline } from 'node:stream/promises';
19
+
20
+ // ⚠️ 仓库必须是 **Xenova**(transformers.js 转换版, 才有 onnx/ 量化权重); BAAI 官方仓库没有 onnx 目录
21
+ const REPO = 'Xenova/bge-small-zh-v1.5';
22
+ /** 需要下载的文件(相对仓库路径 → 相对模型目录路径),带最小体积校验(KB) */
23
+ const FILES = [
24
+ { repo: 'onnx/model_quantized.onnx', local: 'onnx/model_quantized.onnx', minKB: 20000, label: '模型权重(量化 ONNX)' },
25
+ { repo: 'tokenizer.json', local: 'tokenizer.json', minKB: 300, label: '分词器' },
26
+ { repo: 'tokenizer_config.json', local: 'tokenizer_config.json', minKB: 0.2, label: '分词器配置' },
27
+ { repo: 'config.json', local: 'config.json', minKB: 0.3, label: '模型配置' },
28
+ ];
29
+ const MIRRORS = {
30
+ mirror: 'https://hf-mirror.com', // 国内镜像(默认优先)
31
+ hf: 'https://huggingface.co', // 官方源
32
+ };
33
+
34
+ function arg(name, def) {
35
+ const i = process.argv.indexOf(`--${name}`);
36
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : def;
37
+ }
38
+
39
+ const dshHome = (process.env.DSH_HOME && process.env.DSH_HOME.trim()) || join(homedir(), '.dsh');
40
+ const modelDir = arg('dir', join(dshHome, 'models', 'bge-small-zh'));
41
+ const source = arg('source', '');
42
+ const bases = source ? [MIRRORS[source]].filter(Boolean) : [MIRRORS.mirror, MIRRORS.hf];
43
+
44
+ const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)}MB`;
45
+
46
+ /** 下载单文件(支持多源回退 + 进度打印 + 大小校验) */
47
+ async function fetchOne(base, file) {
48
+ const url = `${base}/${REPO}/resolve/main/${file.repo}`;
49
+ const res = await fetch(url, { redirect: 'follow' });
50
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
51
+ const total = Number(res.headers.get('content-length') || 0);
52
+ const dest = join(modelDir, file.local);
53
+ mkdirSync(dirname(dest), { recursive: true });
54
+ const tmp = `${dest}.part`;
55
+ let got = 0;
56
+ let lastPrint = 0;
57
+ const stream = Readable.fromWeb(res.body);
58
+ stream.on('data', (chunk) => {
59
+ got += chunk.length;
60
+ const now = Date.now();
61
+ if (now - lastPrint > 400) {
62
+ lastPrint = now;
63
+ const pct = total ? ` ${Math.round((got / total) * 100)}%` : '';
64
+ process.stdout.write(`\r ${file.label}: ${mb(got)}${total ? ` / ${mb(total)}` : ''}${pct} `);
65
+ }
66
+ });
67
+ await pipeline(stream, createWriteStream(tmp));
68
+ process.stdout.write('\r');
69
+ const size = statSync(tmp).size;
70
+ if (size < file.minKB * 1024) {
71
+ throw new Error(`文件太小(${mb(size)}), 可能下载不完整`);
72
+ }
73
+ renameSync(tmp, dest);
74
+ console.log(` ✓ ${file.local} ${mb(size)}`);
75
+ }
76
+
77
+ console.log(`\n本地小模型下载: ${REPO}`);
78
+ console.log(`目标目录: ${modelDir}`);
79
+ console.log(`下载源: ${bases.join(' → ')}(失败自动换下一个)\n`);
80
+
81
+ let failed = 0;
82
+ for (const file of FILES) {
83
+ const dest = join(modelDir, file.local);
84
+ if (existsSync(dest) && statSync(dest).size >= file.minKB * 1024) {
85
+ console.log(` ↷ ${file.local} 已存在(${mb(statSync(dest).size)}), 跳过`);
86
+ continue;
87
+ }
88
+ let ok = false;
89
+ for (const base of bases) {
90
+ try {
91
+ await fetchOne(base, file);
92
+ ok = true;
93
+ break;
94
+ } catch (err) {
95
+ console.log(` ✗ ${file.local} 从 ${base} 下载失败: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
97
+ }
98
+ if (!ok) failed += 1;
99
+ }
100
+
101
+ if (failed > 0) {
102
+ console.log(`\n有 ${failed} 个文件没下成功。可以:`);
103
+ console.log(' · 换个源重试: node scripts/download-model.mjs --source hf');
104
+ console.log(' · 或手动下载(README「本地小模型」一节有三个文件的直链)');
105
+ process.exit(1);
106
+ }
107
+
108
+ console.log('\n✔ 全部就绪。');
109
+ console.log('接下来:');
110
+ console.log(' 1. 插件设置页 → 本地小模型 → 勾选「启用」、模式选 block(低分不唤醒)、门槛 0.5');
111
+ console.log(` 2. 模型目录留空即用默认位置; 换了别处就填: ${modelDir}`);
112
+ console.log(' 3. 群里发两条闲聊试试 —— 面板「最近评分」能看到分数和 gate 模式\n');
package/settings-host.js CHANGED
@@ -1206,6 +1206,195 @@ export function apply(ctx) {
1206
1206
  } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1207
1207
  });
1208
1208
 
1209
+ // ── 本地小模型(2026-09-13 主人定): 状态检测 + 一键下载 ──
1210
+ // 模型: bge-small-zh-v1.5 (ONNX q8 ≈ 23MB, 中文专训); 放用户级 {DSH_HOME|~/.dsh}/models/bge-small-zh
1211
+ // (跨工作区共享一份), 可用 localModel.modelDir 覆盖。下载走 node:https + 镜像兜底。
1212
+ const LOCAL_MODEL_FILES = ['onnx/model_quantized.onnx', 'tokenizer.json', 'tokenizer_config.json', 'config.json'];
1213
+ const LOCAL_MODEL_BASES = [
1214
+ 'https://hf-mirror.com/Xenova/bge-small-zh-v1.5/resolve/main',
1215
+ 'https://huggingface.co/Xenova/bge-small-zh-v1.5/resolve/main',
1216
+ ];
1217
+ function localModelDirOf(bot) {
1218
+ const cfgDir = String((bot && bot.cfg && bot.cfg.localModel && bot.cfg.localModel.modelDir) || '').trim();
1219
+ if (cfgDir) return cfgDir;
1220
+ const dshHome = (process.env.DSH_HOME && process.env.DSH_HOME.trim()) || join(homedir(), '.dsh');
1221
+ return join(dshHome, 'models', 'bge-small-zh');
1222
+ }
1223
+ /** 下载单文件(跟随重定向); 失败抛错 */
1224
+ async function downloadFile(url, dest, hops = 0) {
1225
+ const { default: https } = await import('node:https');
1226
+ return await new Promise((resolve, reject) => {
1227
+ const req = https.get(url, { headers: { 'user-agent': 'dsh-qqbot', accept: '*/*' } }, (resp) => {
1228
+ const code = resp.statusCode || 0;
1229
+ if (code >= 300 && code < 400 && resp.headers.location && hops < 6) {
1230
+ resp.resume();
1231
+ const next = new URL(resp.headers.location, url).toString();
1232
+ return downloadFile(next, dest, hops + 1).then(resolve, reject);
1233
+ }
1234
+ if (code !== 200) {
1235
+ resp.resume();
1236
+ return reject(new Error(`HTTP ${code}`));
1237
+ }
1238
+ const tmp = dest + '.part';
1239
+ const ws = createWriteStream(tmp);
1240
+ resp.pipe(ws);
1241
+ ws.on('finish', () => ws.close(() => {
1242
+ try { renameSync(tmp, dest); } catch (e) { return reject(e); }
1243
+ resolve(dest);
1244
+ }));
1245
+ ws.on('error', reject);
1246
+ });
1247
+ req.on('error', reject);
1248
+ req.setTimeout(180000, () => req.destroy(new Error('下载超时(180s)')));
1249
+ });
1250
+ }
1251
+ route(ctx, 'GET', '/api/qqbot-settings/local-model/status', async (req, res) => {
1252
+ try {
1253
+ const u = new URL(req.url ?? '/', 'http://x');
1254
+ const bot = nsBot(NSQ(u));
1255
+ const dir = localModelDirOf(bot);
1256
+ const missing = LOCAL_MODEL_FILES.filter((rel) => !existsSync(join(dir, rel)));
1257
+ let bytes = 0;
1258
+ for (const rel of LOCAL_MODEL_FILES) { try { bytes += statSync(join(dir, rel)).size; } catch { /* 缺文件不计数 */ } }
1259
+ const cfg = (bot && bot.cfg && bot.cfg.localModel) || {};
1260
+ writeJson(res, 200, {
1261
+ ok: true,
1262
+ enabled: cfg.enabled !== false,
1263
+ modelDir: dir,
1264
+ available: missing.length === 0,
1265
+ missing,
1266
+ bytes,
1267
+ // ⚠️ 2026-09-13 修(主人反馈"选了低分不唤醒, 过一会又跳回去"): 原来这里漏返回 valueGate,
1268
+ // 面板读回 undefined → 一律按默认 'log' 渲染 → 看着像"保存没生效"(其实配置里已是 block)。
1269
+ valueGate: cfg.valueGate === 'off' || cfg.valueGate === 'block' ? cfg.valueGate : 'log',
1270
+ valueMinScore: typeof cfg.valueMinScore === 'number' ? cfg.valueMinScore : 0.5,
1271
+ });
1272
+ } catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
1273
+ });
1274
+ route(ctx, 'POST', '/api/qqbot-settings/local-model/download', async (req, res) => {
1275
+ try {
1276
+ const body = await readJsonBody(req);
1277
+ const bot = nsBot(String((body && body.ns) || ''));
1278
+ const dir = localModelDirOf(bot);
1279
+ mkdirSync(join(dir, 'onnx'), { recursive: true });
1280
+ const done = [];
1281
+ for (const rel of LOCAL_MODEL_FILES) {
1282
+ const dest = join(dir, rel);
1283
+ if (existsSync(dest)) { done.push(rel + ': 已存在'); continue; }
1284
+ let lastErr = '';
1285
+ let ok = false;
1286
+ for (const base of LOCAL_MODEL_BASES) {
1287
+ try { await downloadFile(base + '/' + rel, dest); ok = true; lastErr = ''; break; }
1288
+ catch (e) { lastErr = String((e && e.message) || e); }
1289
+ }
1290
+ if (!ok) return writeJson(res, 500, { ok: false, error: `${rel} 下载失败: ${lastErr}`, done, modelDir: dir });
1291
+ done.push(rel + ': OK');
1292
+ }
1293
+ writeJson(res, 200, { ok: true, done, modelDir: dir });
1294
+ } catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
1295
+ });
1296
+ // 价值样例库读写(2026-09-13 主人定): {dataRoot}/.qqbot/value-samples.jsonl —— 用户可直接编辑
1297
+ function valueSamplesPathOf(bot) {
1298
+ const dataRoot = (bot && bot.cfg && typeof bot.cfg.dataRoot === 'string' && bot.cfg.dataRoot)
1299
+ ? bot.cfg.dataRoot
1300
+ : ((bot && bot.cwd) || '');
1301
+ return dataRoot ? join(dataRoot, '.qqbot', 'value-samples.jsonl') : '';
1302
+ }
1303
+ route(ctx, 'GET', '/api/qqbot-settings/value-samples', async (req, res) => {
1304
+ try {
1305
+ const u = new URL(req.url ?? '/', 'http://x');
1306
+ const p = valueSamplesPathOf(nsBot(NSQ(u)));
1307
+ if (!p) return writeJson(res, 400, { ok: false, error: '找不到数据目录' });
1308
+ const exists = existsSync(p);
1309
+ const text = exists ? readFileSync(p, 'utf8') : '';
1310
+ const lines = text.split('\n').filter((l) => l.trim());
1311
+ let pos = 0;
1312
+ let neg = 0;
1313
+ let bad = 0;
1314
+ for (const l of lines) {
1315
+ try { const o = JSON.parse(l); if (o.y === 1) pos += 1; else neg += 1; } catch { bad += 1; }
1316
+ }
1317
+ writeJson(res, 200, { ok: true, text, total: lines.length, pos, neg, bad, isDefault: !exists, path: p });
1318
+ } catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
1319
+ });
1320
+ route(ctx, 'POST', '/api/qqbot-settings/value-samples', async (req, res) => {
1321
+ try {
1322
+ const body = await readJsonBody(req);
1323
+ const p = valueSamplesPathOf(nsBot(String((body && body.ns) || '')));
1324
+ if (!p) return writeJson(res, 400, { ok: false, error: '找不到数据目录' });
1325
+ const lines = String((body && body.text) || '').split('\n').filter((l) => l.trim());
1326
+ const bad = [];
1327
+ const clean = [];
1328
+ lines.forEach((l, i) => {
1329
+ try {
1330
+ const o = JSON.parse(l);
1331
+ const m = String(o && o.m ? o.m : '').trim();
1332
+ if (!m) throw new Error('缺 m 字段');
1333
+ clean.push(JSON.stringify({ m, y: o.y === 1 ? 1 : 0, src: typeof o.src === 'string' ? o.src : 'manual' }));
1334
+ } catch (e) { bad.push(`第${i + 1}行 ${String((e && e.message) || e)}`); }
1335
+ });
1336
+ if (bad.length) return writeJson(res, 400, { ok: false, error: `有 ${bad.length} 行格式不对 → ${bad.slice(0, 3).join('; ')}` });
1337
+ mkdirSync(dirname(p), { recursive: true });
1338
+ writeFileSync(p, clean.join('\n') + (clean.length ? '\n' : ''), 'utf8');
1339
+ const pos = clean.filter((l) => JSON.parse(l).y === 1).length;
1340
+ writeJson(res, 200, { ok: true, saved: clean.length, pos, neg: clean.length - pos, path: p });
1341
+ } catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
1342
+ });
1343
+ // 「让ai写」(2026-09-13 主人要的): 模拟一条用户消息唤醒她(照抄定时任务的做法: 伪造 msg → handleInbound)
1344
+ route(ctx, 'POST', '/api/qqbot-settings/value-samples/let-ai-write', async (req, res) => {
1345
+ try {
1346
+ const body = await readJsonBody(req);
1347
+ const ns = String((body && body.ns) || '');
1348
+ const mod = await import('@zaofan/dsh-qqbot/channel-tools');
1349
+ if (typeof mod.askAiToWriteSamples !== 'function') {
1350
+ return writeJson(res, 500, { ok: false, error: '线上插件还是旧版本(需重启宿主加载新代码)' });
1351
+ }
1352
+ const scope = String((body && body.scope) || 'group') === 'c2c' ? 'c2c' : 'group';
1353
+ let peerId = String((body && body.peerId) || '');
1354
+ if (!peerId) {
1355
+ // 兜底(免得非要先选目标): 从最近评分记录里取最近活跃的群
1356
+ try {
1357
+ const b2 = nsBot(ns);
1358
+ const dr = (b2 && b2.cfg && typeof b2.cfg.dataRoot === 'string' && b2.cfg.dataRoot) ? b2.cfg.dataRoot : ((b2 && b2.cwd) || '');
1359
+ const pf = dr ? join(dr, '.qqbot', 'value-scores.jsonl') : '';
1360
+ if (pf && existsSync(pf)) {
1361
+ const rows = readFileSync(pf, 'utf8').split('\n').filter((l) => l.trim());
1362
+ for (let i = rows.length - 1; i >= 0; i--) {
1363
+ try { const o = JSON.parse(rows[i]); if (o && o.gid) { peerId = String(o.gid); break; } } catch { /* 坏行跳过 */ }
1364
+ }
1365
+ }
1366
+ } catch { /* 兜底失败就按"没有目标"返回 */ }
1367
+ }
1368
+ const r = await mod.askAiToWriteSamples(ns, scope, peerId);
1369
+ writeJson(res, 200, r);
1370
+ } catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
1371
+ });
1372
+
1373
+ // 价值评分记录(观察期): {dataRoot}/.qqbot/value-scores.jsonl 尾 N 条
1374
+ // gid= 时**只给该群的记录**(2026-09-13 主人要求: 评分要跟随面板"当前会话"命中的群, 与群管理页一致)
1375
+ route(ctx, 'GET', '/api/qqbot-settings/value-scores', async (req, res) => {
1376
+ try {
1377
+ const u = new URL(req.url ?? '/', 'http://x');
1378
+ const bot = nsBot(NSQ(u));
1379
+ const gid = (u.searchParams.get('gid') || '').trim();
1380
+ const limit = Math.max(1, Math.min(100, Number(u.searchParams.get('limit')) || 20));
1381
+ const dataRoot = (bot && bot.cfg && typeof bot.cfg.dataRoot === 'string' && bot.cfg.dataRoot)
1382
+ ? bot.cfg.dataRoot
1383
+ : ((bot && bot.cwd) || '');
1384
+ const p = dataRoot ? join(dataRoot, '.qqbot', 'value-scores.jsonl') : '';
1385
+ if (!p || !existsSync(p)) return writeJson(res, 200, { ok: true, items: [], total: 0, totalAll: 0, gid });
1386
+ let lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
1387
+ const totalAll = lines.length;
1388
+ if (gid) {
1389
+ lines = lines.filter((l) => {
1390
+ try { return String(JSON.parse(l).gid || '') === gid; } catch { return false; }
1391
+ });
1392
+ }
1393
+ const items = lines.slice(-limit).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
1394
+ writeJson(res, 200, { ok: true, items, total: lines.length, totalAll, gid });
1395
+ } catch (e) { writeJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
1396
+ });
1397
+
1209
1398
  // 本地成员清单(官方成员列表未开放 → 用机器人见过的发言者兜底; 供禁言"选人"与成员 Tab 展示)
1210
1399
  route(ctx, 'GET', '/api/qqbot-settings/group/members_local', async (req, res) => {
1211
1400
  try {
@@ -1697,6 +1886,123 @@ export function apply(ctx) {
1697
1886
  function chatLocalRawUrl(p) {
1698
1887
  return '/api/qqbot-settings/chat/raw-media?p=' + encodeURIComponent(p);
1699
1888
  }
1889
+ // ── 图片台账(兜底) ──────────────────────────────────────────────────────────
1890
+ // 格式与 dist/features/image-url-ledger.js 完全一致: `{表情包目录}/image-url-ledger.jsonl`,
1891
+ // 一行一条 {id,url,at}; 查表从后往前取最新的那条。上限 1000 条(写侧维护)。
1892
+ // ⚠️ 渲染链是同步的 → 这里自带一份**同步只读**实现(按 mtime+size 缓存), 不去 await 那个模块。
1893
+ const CHAT_LEDGER_NAME = 'image-url-ledger.jsonl';
1894
+ const chatLedgerCache = new Map();
1895
+ // `…\表情包\lib\candidate\x.jpg` → `…\表情包`(找不到 lib 段就退回上一级)
1896
+ function chatLedgerDirOf(imgPath) {
1897
+ const p = String(imgPath || '');
1898
+ const s = p.indexOf('\\') >= 0 ? '\\' : '/';
1899
+ const parts = p.split(/[\\/]/);
1900
+ const i = parts.lastIndexOf('lib');
1901
+ return i > 0 ? parts.slice(0, i).join(s) : dirname(p);
1902
+ }
1903
+ function chatLedgerIndex(dir) {
1904
+ const file = join(dir, CHAT_LEDGER_NAME);
1905
+ let key = 'missing';
1906
+ try { const st = statSync(file); key = st.mtimeMs + ':' + st.size; } catch (e) { /* 台账还没建 */ }
1907
+ const hit = chatLedgerCache.get(file);
1908
+ if (hit && hit.key === key) return hit.index;
1909
+ const index = new Map();
1910
+ try {
1911
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
1912
+ const s = line.trim();
1913
+ if (!s) continue;
1914
+ try {
1915
+ const o = JSON.parse(s);
1916
+ if (o && o.id && o.url) index.set(String(o.id), String(o.url)); // 后出现的覆盖 = 最新链接
1917
+ } catch (e) { /* 坏行跳过 */ }
1918
+ }
1919
+ } catch (e) { /* 没有台账文件 */ }
1920
+ chatLedgerCache.set(file, { key, index });
1921
+ return index;
1922
+ }
1923
+ // 图片文件路径 → 台账里的 QQ 链接(查不到返回 '')
1924
+ function chatLedgerImageUrl(imgPath) {
1925
+ try {
1926
+ const id = chatSrcShortName(imgPath).replace(/\.[a-z0-9]{1,6}$/i, '');
1927
+ if (!id) return '';
1928
+ return chatLedgerIndex(chatLedgerDirOf(imgPath)).get(id) || '';
1929
+ } catch (e) { return ''; }
1930
+ }
1931
+ // 反向: QQ 链接 → { id, dir } —— 历史消息里那条链接以前被我们收进过图库, 现在还能对上号,
1932
+ // 就说明**本地有这张图** → 前端可以改走本地(sticker-img / raw-media), 不必依赖会过期的 rkey。
1933
+ function chatLedgerDirs() {
1934
+ const dirs = new Set();
1935
+ for (const k of chatLedgerCache.keys()) dirs.add(dirname(k));
1936
+ try { const dd = getStickerStore().dataDir; if (dd) dirs.add(String(dd)); } catch (e) { /* 图库不可用 */ }
1937
+ return [...dirs];
1938
+ }
1939
+ function chatLedgerOfUrl(url) {
1940
+ const u = String(url || '');
1941
+ if (!/^https?:\/\//i.test(u)) return null;
1942
+ for (const dir of chatLedgerDirs()) {
1943
+ const idx = chatLedgerIndex(dir);
1944
+ for (const [id, link] of idx) if (link === u) return { id, dir };
1945
+ }
1946
+ return null;
1947
+ }
1948
+ // 图库 id → 同源缩略图接口(sticker-img 按 id 解析, 与所在层无关)
1949
+ function chatStickerImgUrl(id, stickerDir) {
1950
+ if (!id) return '';
1951
+ return '/api/qqbot-settings/sticker-img?id=' + encodeURIComponent(id)
1952
+ + (stickerDir ? '&dataDir=' + encodeURIComponent(stickerDir) : '');
1953
+ }
1954
+ // 图库里的真实路径: 文件名就是图库 id, 但**文件会搬层**(candidate→library→trash),
1955
+ // 消息里记的是"当时"的路径 → 这里问 store 拿当前路径(2026-09-13 主人实测的 [图加载失败] 就是这个)
1956
+ function chatStickerRealPath(imgPath) {
1957
+ try {
1958
+ const id = chatSrcShortName(imgPath).replace(/\.[a-z0-9]{1,6}$/i, '');
1959
+ if (!/^[0-9a-f]{6,}$/i.test(id)) return '';
1960
+ const dir = chatLedgerDirOf(imgPath);
1961
+ const store = getStickerStore(dir);
1962
+ if (!store || typeof store.get !== 'function' || !store.get(id)) return '';
1963
+ const p = typeof store.pathOf === 'function' ? store.pathOf(id) : '';
1964
+ return p && chatLocalFileExists(p) ? p : '';
1965
+ } catch (e) { return ''; }
1966
+ }
1967
+ function chatLocalFileExists(p) {
1968
+ try { return statSync(p).isFile(); } catch (e) { return false; }
1969
+ }
1970
+ // 附件行里的**本机目标**(2026-09-13 主人要求: dock 聊天界面图片要同时兼容 url 与本地路径/图库链接)
1971
+ // 背景: 入站消息的图片从 QQ 链接改成本地路径后(`- Image: 名 (1086×1448) → D:\…\a.jpg`),
1972
+ // dock 里图片直接不显示了 —— 这里把本机目标转成同源直出 URL 给 <img> 用。
1973
+ // 支持三种: ① Windows 绝对路径 ② /api/qqbot-settings/... 本插件接口(含 sticker-img?id=)
1974
+ // ③ 其它绝对路径(POSIX)。返回 { url, kind, name, fb } 或 null。
1975
+ // 顺序: ①图库查**当前真实路径**(搬层也能对上) ②文件不在 → 台账 QQ 链接 ③都不行 → 原路径(前端再兜)
1976
+ function chatLocalTargetOf(raw, hasVoiceCtx) {
1977
+ const l = String(raw || '');
1978
+ // 优先取 "→ " 之后到行尾(路径里可能有空格); 没有箭头就在行内找绝对路径
1979
+ const arrow = l.match(/→\s*([\s\S]+?)\s*$/);
1980
+ let cand = arrow ? arrow[1].trim() : '';
1981
+ if (!cand) {
1982
+ const m2 = l.match(/([A-Za-z]:[\\/][^\s]+)/);
1983
+ cand = m2 ? m2[1] : '';
1984
+ }
1985
+ if (!cand) return null;
1986
+ // ① 已经是本插件自己的接口路径 → 原样用(相对路径, 浏览器同源能取)
1987
+ if (/^\/api\/qqbot-settings\//i.test(cand)) {
1988
+ return { url: cand, kind: chatAttachmentKind(l, hasVoiceCtx, undefined) || 'image', name: chatSrcShortName(cand), fb: '' };
1989
+ }
1990
+ // ② 本机文件 → 先问图库拿**当前真实路径**(candidate→library 搬层后, 消息里那条路径已失效 ——
1991
+ // 主人实测的 [图加载失败] 就是这么来的), 文件真不在才退台账的 QQ 链接
1992
+ if (!/^[A-Za-z]:[\\/]/.test(cand) && !/^\\\\/.test(cand) && !cand.startsWith('/')) return null;
1993
+ const lk = chatLocalExtKind(cand) || chatAttachmentKind(l, hasVoiceCtx, undefined) || 'image';
1994
+ const realPath = chatStickerRealPath(cand);
1995
+ const usePath = realPath || cand;
1996
+ const alive = chatLocalFileExists(usePath);
1997
+ const qqUrl = chatLedgerImageUrl(cand) || chatLedgerImageUrl(usePath);
1998
+ const sid = (chatSrcShortName(cand).match(/^([0-9a-f]{6,})\.[a-z0-9]{1,6}$/i) || [])[1] || '';
1999
+ return {
2000
+ url: (!alive && qqUrl) ? qqUrl : chatLocalRawUrl(usePath),
2001
+ fb: alive ? (qqUrl || chatStickerImgUrl(sid, chatLedgerDirOf(usePath))) : '',
2002
+ kind: lk,
2003
+ name: chatSrcShortName(cand),
2004
+ };
2005
+ }
1700
2006
  // 附件行/URL 归属类型: 行内**显式类型标签**优先, 其次文件名/URL fname 扩展名, 最后行标签与上下文
1701
2007
  // ⚠️ 2026-09-10 根因修复(主人实测: "视频还是附件形式, 图片也变成附件了"):
1702
2008
  // QQ 群聊里图片/视频的 content_type 实测可能是 `'file'`(不是 image/jpeg) —— 官方取值表:
@@ -1762,7 +2068,12 @@ export function apply(ctx) {
1762
2068
  const lp = raw.match(/^([A-Za-z]:[\\/].+)$/);
1763
2069
  if (lp) {
1764
2070
  const lk = chatLocalExtKind(lp[1]);
1765
- if (lk) { images.push({ url: chatLocalRawUrl(lp[1]), kind: lk }); continue; }
2071
+ if (lk) {
2072
+ const rp = chatStickerRealPath(lp[1]) || lp[1]; // 图库搬层也能对上当前路径
2073
+ const led = lk === 'image' ? chatLedgerImageUrl(lp[1]) : '';
2074
+ images.push({ url: chatLocalRawUrl(rp), kind: lk, fb: led });
2075
+ continue;
2076
+ }
1766
2077
  }
1767
2078
  // 附件描述行: "- Image: …" / "- Video: …" / "- Voice: …" / "- File: …" / "- Attachment URLs: …"
1768
2079
  // / "[图片: …]" / "[视频: …]" / "[附件: …]" / "[Attachment: name -> …]" / Layer1 的英文标签行
@@ -1772,8 +2083,22 @@ export function apply(ctx) {
1772
2083
  for (const u of urls) {
1773
2084
  const k = chatAttachmentKind(raw, hasVoiceCtx, undefined);
1774
2085
  // 图片/音频须是 QQ 媒体域; 文件(ftn.qq.com 等任意域)直接收
2086
+ // ⚠️ 图片带兜底: 这条链接以前被收进过图库 → 本地有文件, rkey 过期了也能显示(fb → sticker-img)
2087
+ const led = k === 'image' ? chatLedgerOfUrl(u) : null;
2088
+ const fb = led ? chatStickerImgUrl(led.id, led.dir) : '';
1775
2089
  if (k === 'file') { images.push({ url: u, kind: 'file', name: chatFileDisplayName(u) }); got++; }
1776
- else if (QQ_MEDIA_RE.test(u)) { images.push({ url: u, kind: k }); got++; }
2090
+ else if (QQ_MEDIA_RE.test(u)) { images.push({ url: u, kind: k, fb }); got++; }
2091
+ }
2092
+ if (!got) {
2093
+ // ⚠️ 2026-09-13 新增(主人实测: "web 界面出问题了/图片不显示了"): 目标可能是**本机路径**
2094
+ // —— 图片入站改成给本地路径(`- Image: 名 (1086×1448) → D:\…\lib\candidate\x.jpg`)后,
2095
+ // 这里原来只认 http(s) URL, 于是图片被当成文本丢掉。现在转成同源直出 URL 正常显示。
2096
+ const lt = chatLocalTargetOf(raw, hasVoiceCtx);
2097
+ if (lt) {
2098
+ if (lt.kind === 'file') images.push({ url: lt.url, kind: 'file', name: lt.name || '文件', fb: lt.fb || '' });
2099
+ else images.push({ url: lt.url, kind: lt.kind, fb: lt.fb || '' });
2100
+ got += 1;
2101
+ }
1777
2102
  }
1778
2103
  if (!got) {
1779
2104
  // Layer 1 的英文描述行(`[Image: 名 800×600]` / `[Video: 名]` / `[File: 名 (1.2MB)]`)不含 URL,
@@ -1788,17 +2113,24 @@ export function apply(ctx) {
1788
2113
  }
1789
2114
  continue;
1790
2115
  }
1791
- // 整行就是一个 QQ 媒体 URL(纯图片/音频消息体) → 当媒体
2116
+ // 整行就是一个 QQ 媒体 URL(纯图片/音频消息体) → 当媒体(图片同样带本地兜底)
1792
2117
  const urls = raw.match(QQ_URL_RE) || [];
1793
2118
  if (urls.length === 1 && QQ_MEDIA_RE.test(raw) && raw === urls[0]) {
1794
- images.push({ url: urls[0], kind: chatAttachmentKind(raw, hasVoiceCtx, 'image') });
2119
+ const k1 = chatAttachmentKind(raw, hasVoiceCtx, 'image');
2120
+ const led1 = k1 === 'image' ? chatLedgerOfUrl(urls[0]) : null;
2121
+ images.push({ url: urls[0], kind: k1, fb: led1 ? chatStickerImgUrl(led1.id, led1.dir) : '' });
1795
2122
  continue;
1796
2123
  }
1797
2124
  // 普通行: 行内嵌的 QQ 媒体长 URL 抽走(防撑爆气泡), 其余保留
1798
2125
  if (urls.length) {
1799
2126
  let rest = raw;
1800
2127
  for (const u of urls) {
1801
- if (QQ_MEDIA_RE.test(u)) { images.push({ url: u, kind: chatAttachmentKind(raw, hasVoiceCtx, 'image') }); rest = rest.split(u).join(''); }
2128
+ if (QQ_MEDIA_RE.test(u)) {
2129
+ const k2 = chatAttachmentKind(raw, hasVoiceCtx, 'image');
2130
+ const led2 = k2 === 'image' ? chatLedgerOfUrl(u) : null;
2131
+ images.push({ url: u, kind: k2, fb: led2 ? chatStickerImgUrl(led2.id, led2.dir) : '' });
2132
+ rest = rest.split(u).join('');
2133
+ }
1802
2134
  }
1803
2135
  rest = rest.trim();
1804
2136
  if (rest) out.push(rest);