@zaofan/dsh-qqbot 1.4.5 → 1.4.7

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 (90) hide show
  1. package/README.md +158 -535
  2. package/README_EN.md +66 -0
  3. package/client/qqbot-settings.js +161 -14
  4. package/dist/channel-tools.d.ts.map +1 -1
  5. package/dist/channel-tools.js +106 -1
  6. package/dist/channel-tools.js.map +1 -1
  7. package/dist/config.js +1 -1
  8. package/dist/config.js.map +1 -1
  9. package/dist/features/agg-score.d.ts +73 -0
  10. package/dist/features/agg-score.d.ts.map +1 -0
  11. package/dist/features/agg-score.js +63 -0
  12. package/dist/features/agg-score.js.map +1 -0
  13. package/dist/features/attitude.d.ts +132 -0
  14. package/dist/features/attitude.d.ts.map +1 -0
  15. package/dist/features/attitude.js +318 -0
  16. package/dist/features/attitude.js.map +1 -0
  17. package/dist/features/bot-reply-memo.d.ts +8 -0
  18. package/dist/features/bot-reply-memo.d.ts.map +1 -0
  19. package/dist/features/bot-reply-memo.js +34 -0
  20. package/dist/features/bot-reply-memo.js.map +1 -0
  21. package/dist/features/emo-samples.d.ts +27 -0
  22. package/dist/features/emo-samples.d.ts.map +1 -0
  23. package/dist/features/emo-samples.js +116 -0
  24. package/dist/features/emo-samples.js.map +1 -0
  25. package/dist/features/four-source.d.ts +114 -0
  26. package/dist/features/four-source.d.ts.map +1 -0
  27. package/dist/features/four-source.js +260 -0
  28. package/dist/features/four-source.js.map +1 -0
  29. package/dist/features/image-url-ledger.d.ts +2 -0
  30. package/dist/features/image-url-ledger.d.ts.map +1 -1
  31. package/dist/features/image-url-ledger.js +23 -0
  32. package/dist/features/image-url-ledger.js.map +1 -1
  33. package/dist/features/intimacy-ledger.d.ts +31 -0
  34. package/dist/features/intimacy-ledger.d.ts.map +1 -0
  35. package/dist/features/intimacy-ledger.js +113 -0
  36. package/dist/features/intimacy-ledger.js.map +1 -0
  37. package/dist/features/local-embed.js +3 -3
  38. package/dist/features/local-embed.js.map +1 -1
  39. package/dist/features/local-signals.d.ts +130 -0
  40. package/dist/features/local-signals.d.ts.map +1 -0
  41. package/dist/features/local-signals.js +445 -0
  42. package/dist/features/local-signals.js.map +1 -0
  43. package/dist/features/people-memo.d.ts +52 -0
  44. package/dist/features/people-memo.d.ts.map +1 -0
  45. package/dist/features/people-memo.js +239 -0
  46. package/dist/features/people-memo.js.map +1 -0
  47. package/dist/features/quote-cache.d.ts +19 -0
  48. package/dist/features/quote-cache.d.ts.map +1 -0
  49. package/dist/features/quote-cache.js +67 -0
  50. package/dist/features/quote-cache.js.map +1 -0
  51. package/dist/features/self-disclosure.d.ts +28 -0
  52. package/dist/features/self-disclosure.d.ts.map +1 -0
  53. package/dist/features/self-disclosure.js +60 -0
  54. package/dist/features/self-disclosure.js.map +1 -0
  55. package/dist/features/tendency-samples.d.ts +22 -0
  56. package/dist/features/tendency-samples.d.ts.map +1 -0
  57. package/dist/features/tendency-samples.js +194 -0
  58. package/dist/features/tendency-samples.js.map +1 -0
  59. package/dist/features/thinking-log.d.ts +22 -0
  60. package/dist/features/thinking-log.d.ts.map +1 -0
  61. package/dist/features/thinking-log.js +70 -0
  62. package/dist/features/thinking-log.js.map +1 -0
  63. package/dist/features/xlsx.d.ts +28 -0
  64. package/dist/features/xlsx.d.ts.map +1 -0
  65. package/dist/features/xlsx.js +159 -0
  66. package/dist/features/xlsx.js.map +1 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +20 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/session/session-manager.d.ts.map +1 -1
  71. package/dist/session/session-manager.js +25 -3
  72. package/dist/session/session-manager.js.map +1 -1
  73. package/dist/transport/events.d.ts +12 -0
  74. package/dist/transport/events.d.ts.map +1 -1
  75. package/dist/transport/events.js +15 -2
  76. package/dist/transport/events.js.map +1 -1
  77. package/dist/transport/inbound.d.ts.map +1 -1
  78. package/dist/transport/inbound.js +344 -20
  79. package/dist/transport/inbound.js.map +1 -1
  80. package/dist/transport/outbound-buffer.d.ts.map +1 -1
  81. package/dist/transport/outbound-buffer.js +8 -0
  82. package/dist/transport/outbound-buffer.js.map +1 -1
  83. package/dist/transport/outbound.d.ts +12 -0
  84. package/dist/transport/outbound.d.ts.map +1 -1
  85. package/dist/transport/outbound.js +170 -5
  86. package/dist/transport/outbound.js.map +1 -1
  87. package/docs/USER-GUIDE.md +429 -0
  88. package/package.json +2 -1
  89. package/scripts/download-model.mjs +3 -1
  90. package/settings-host.js +154 -0
package/README_EN.md CHANGED
@@ -4,6 +4,8 @@ An **enhanced fork** of the QQ Bot IM plugin for [deepseek-harness](https://gith
4
4
 
5
5
  📦 Repo: [gcry13067381632-jpg/dsh-qqbot](https://github.com/gcry13067381632-jpg/dsh-qqbot) (forked from [tencent-connect/dsh-qqbot](https://github.com/tencent-connect/dsh-qqbot))
6
6
 
7
+ > ⭐ **If it works well for you, please star the repo** — it is the only visible signal that someone is using it, and it keeps the project going.
8
+
7
9
  [中文文档](./README.md) | English
8
10
 
9
11
  ## 🐋 This fork (enhanced edition)
@@ -205,6 +207,66 @@ auto-cancelled when the agent is cancelled or dsh exits.
205
207
  > Idea source: QQ-approval design of wang-22-code/dsh-qqbot-bridge (host dsh `approval/request`
206
208
  > standard event — the same wiring used by official dsh-acp and the Web approval dialog).
207
209
 
210
+ ## 🧠 Smart reply (local small model, optional — saves tokens) (v1.4.5+)
211
+
212
+ Let the plugin score incoming group messages **locally first** ("value scoring"): chatter that scores below the
213
+ threshold **and is not @-mentioning the bot** will **not wake the model at all** → those tokens are simply not spent.
214
+ Messages that @ the bot always pass; **messages with images always pass** (people usually send images *to* her).
215
+
216
+ - **Model**: `bge-small-zh-v1.5` (Chinese-tuned, quantized ONNX **≈ 23MB**, millisecond-level on CPU, **fully offline**, zero tokens, nothing is uploaded)
217
+ - **Location**: `{DSH_HOME | ~/.dsh}/models/bge-small-zh/` (user-level, shared across workspaces; a custom dir can be set in the config)
218
+ - **Missing model is fine**: scoring silently disables itself and the plugin keeps working (you just don't get the savings)
219
+
220
+ ### ① One command (recommended)
221
+
222
+ ```bash
223
+ node scripts/download-model.mjs # from the source checkout
224
+ node node_modules/@zaofan/dsh-qqbot/scripts/download-model.mjs # when installed via npm
225
+ ```
226
+
227
+ It prefers the China mirror `hf-mirror.com` and falls back to the official host, verifies file sizes, and prints the next steps.
228
+
229
+ ```bash
230
+ node scripts/download-model.mjs --dir "D:\models\bge-small-zh" # custom dir → put it in localModel.modelDir
231
+ node scripts/download-model.mjs --source hf # force the official host
232
+ ```
233
+
234
+ ### ② Manual download (only three files)
235
+
236
+ Replace `<base>` with `https://hf-mirror.com` or `https://huggingface.co`, and put the files under `~/.dsh/models/bge-small-zh/`:
237
+
238
+ | Download | Put at | Size |
239
+ |---|---|---|
240
+ | `<base>/Xenova/bge-small-zh-v1.5/resolve/main/onnx/model_quantized.onnx` | `bge-small-zh/onnx/model_quantized.onnx` | ≈ 23MB |
241
+ | `<base>/Xenova/bge-small-zh-v1.5/resolve/main/tokenizer.json` | `bge-small-zh/tokenizer.json` | ≈ 430KB |
242
+ | `<base>/Xenova/bge-small-zh-v1.5/resolve/main/config.json` | `bge-small-zh/config.json` | < 1KB |
243
+
244
+ ### ③ Let your AI do it (paste this to the AI / to her)
245
+
246
+ ```text
247
+ Please install the local small model used by the dsh qqbot plugin on this machine (offline; nothing is uploaded):
248
+ 1. Download three files from BAAI/bge-small-zh-v1.5 into `~/.dsh/models/bge-small-zh/`:
249
+ onnx/model_quantized.onnx (~23MB), tokenizer.json, config.json.
250
+ Prefer https://hf-mirror.com, fall back to https://huggingface.co.
251
+ 2. The layout must be: <model dir>/onnx/model_quantized.onnx, <model dir>/tokenizer.json, <model dir>/config.json
252
+ 3. Verify afterwards: model_quantized.onnx ≥ 20MB, tokenizer.json ≥ 300KB.
253
+ The plugin repo ships a one-shot script: `node scripts/download-model.mjs`
254
+ 4. Finally tell me which setting to fill in the plugin settings page, and what the three
255
+ scoring modes off / log / block actually do.
256
+ ```
257
+
258
+ ### Which mode to pick
259
+
260
+ | Mode | Behaviour | Suggested |
261
+ |---|---|---|
262
+ | `off` | no scoring at all, everything wakes her | not interested |
263
+ | `log` | **records scores but never blocks** | **observation period** — watch the panel for a few days |
264
+ | `block` | below the threshold (default `0.5`) **does not wake** | production use, saves tokens |
265
+
266
+ - **Per-session override**: the dock's "⚙ Single-session settings" can set mode / threshold / toggle **per group** (stored in `localModel.overrides` in `settings.yaml`), **applied live, no restart needed** (since v1.4.5).
267
+ - **Boundaries**: @-mentions always pass; messages with images always pass; only a dispatch that was blocked **before waking her** (no tokens spent) rolls the group reply-cooldown back — the cooldown exists to save tokens, so already-spent tokens still count.
268
+ - **Score log**: `{dataRoot}/.qqbot/value-scores.jsonl`, one JSON per line with `score / worth / gate / min / conf / mention / img / lib / agg / top`. The panel reads it directly, and the AI can read it to maintain the sample library (`{dataRoot}/.qqbot/value-samples.jsonl`, `{"m":"text","y":1|0}` — y=1 means "she would want to reply").
269
+
208
270
  ## Configuration
209
271
 
210
272
  | Config | Type | Default | Description |
@@ -348,6 +410,10 @@ export QQBOT_APPID="xxx" QQBOT_SECRET="xxx"
348
410
  npx @deepseek-ai/dsh web --patch /path/to/dsh-qqbot/cordis.dev.yml
349
411
  ```
350
412
 
413
+ ## Support
414
+
415
+ If this plugin saved you some tokens — or made your QQ bot feel alive — a **⭐ Star** is the most concrete way to say thanks. Bugs and feature requests: [Issues](https://github.com/gcry13067381632-jpg/dsh-qqbot/issues).
416
+
351
417
  ## License
352
418
 
353
419
  [MIT](./LICENSE)
@@ -183,7 +183,10 @@ window.__ModuleLoader__.load({
183
183
  h('label', { style: labelStyle }, ' 按固定写法匹配文字(高手用,留空=不用): ',
184
184
  h('input', { className: 'qqs-inp', style: wideStyle, value: sv(r.conditions.contentRegex), placeholder: '如 ^早安', onChange: (e) => condUpd({ contentRegex: e.target.value }) })),
185
185
  h('label', { style: labelStyle }, ' 出现这些词就触发(逗号分隔): ',
186
- h('input', { className: 'qqs-inp', style: wideStyle, value: (r.conditions.contentKeywords || []).join(','), onChange: (e) => condUpd({ contentKeywords: e.target.value.split(/[,,]/).map(function (s) { return s.trim() }).filter(Boolean) }) })),
186
+ // ⚠️ 2026-09-14 修(主人:"我输入不了逗号"):原来是**受控** input(value = 数组 join(',')),
187
+ // 输入"看不到,"时末尾空项被 filter(Boolean) 丢掉、文本又被重建成没逗号的样子
188
+ // → 逗号永远打不进去。改成**非受控**(defaultValue):输入过程自由,onChange 只把解析结果写进配置。
189
+ h('input', { className: 'qqs-inp', style: wideStyle, defaultValue: (r.conditions.contentKeywords || []).join(','), onChange: (e) => condUpd({ contentKeywords: e.target.value.split(/[,,]/).map(function (s) { return s.trim() }).filter(Boolean) }) })),
187
190
  h('label', { style: labelStyle }, ' 多个条件怎么算: ',
188
191
  h('select', { style: inputStyle, value: r.conditions.matchScope || 'any', onChange: (e) => condUpd({ matchScope: e.target.value }) },
189
192
  h('option', { value: 'any' }, '满足一个就行'),
@@ -1993,7 +1996,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
1993
1996
  return ac ? (ac.dataDir || '') : ''
1994
1997
  }
1995
1998
  function outNsQ() { return state.ns ? 'ns=' + encodeURIComponent(state.ns) : '' }
1996
- // ⚙ 单会话设置 · 本地小模型(2026-09-13 主人定)
1999
+ // ⚙ 单会话设置 · 智能回复(本地小模型; 2026-09-13 主人定, 15:2x 改名)
1997
2000
  // 📊 评分列表 HTML(单独抽出: 供"局部刷新"只替换内容, 不整块重绘)
1998
2001
  function scoreListHtml(vs) {
1999
2002
  if (vs === undefined || vs === null) return '<div class="dk-empty" style="padding:8px 0">读取中…</div>'
@@ -2005,11 +2008,32 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2005
2008
  var col = it.worth ? '#2f9e44' : '#c23131'
2006
2009
  out += '<div style="padding:4px 8px;border-bottom:1px solid #f5f5f5;font-size:12px">'
2007
2010
  + '<span style="color:#999">' + hh + '</span> '
2008
- + '<b style="color:' + col + '">' + (typeof it.score === 'number' ? it.score.toFixed(2) : (it.img ? '📷' : '-')) + '</b>'
2011
+ + '<b style="color:' + col + '">' + (typeof it.score === 'number' ? Number(typeof it.scoreAdj === 'number' ? it.scoreAdj : it.score).toFixed(2) : (it.img ? '📷' : '-')) + '</b>'
2012
+ // 好感偏移标记(2026-09-14:**偏移加在分数上,门槛不动**)—— 文案给普通用户看,写成"基础评分+好感偏移"
2013
+ + (it.aggWeighted && Array.isArray(it.aggParts) && it.aggParts.length
2014
+ // 聚合加权(2026-09-14 主人定):判定分是**加权平均**,这里把每一票摊开给人看,
2015
+ // 不然"1.06 是哪来的"没法核对。格式:昵称 价值分×权重(好感占比)
2016
+ ? ' <span style="color:#e8590c;cursor:help" title="' + esc(
2017
+ '综合分 ' + Number(it.scoreAdj).toFixed(2) + ' = 窗口里 ' + it.aggParts.length + ' 条消息的加权平均'
2018
+ + '(价值越高、好感越高,权重越大)\n'
2019
+ + it.aggParts.map(function (p, i) {
2020
+ return (i + 1) + '. ' + p.n + ':价值 ' + Number(p.s).toFixed(2)
2021
+ + ' × 权重 ' + Number(p.w).toFixed(2)
2022
+ + '(好感占比 ' + (Number(p.r) > 0 ? '+' : '') + Number(p.r).toFixed(2) + ')'
2023
+ }).join('\n')
2024
+ + '\n记在谁头上:权重最大的那位(当前是 ' + String(it.sender || '?') + ')'
2025
+ ) + '">[合' + it.aggParts.length + '·加权]</span>'
2026
+ : (it.attOff && typeof it.score === 'number'
2027
+ ? ' <span style="color:#e8590c;cursor:help" title="' + esc('基础评分 ' + Number(it.score).toFixed(2) + ' + 好感偏移 ' + (it.attOff > 0 ? '+' : '−') + Math.abs(Number(it.attOff)).toFixed(2) + ' = 有效分 ' + Number(it.score + it.attOff).toFixed(2) + '(好感档位:' + (it.attTier || '?') + ')') + '">[' + (it.attOff > 0 ? '+' : '−') + Math.abs(Number(it.attOff)).toFixed(2) + ']</span>'
2028
+ : ''))
2029
+ // 门槛(用户设置的基础评分门槛,好感度不改它)
2030
+ + (typeof it.min === 'number'
2031
+ ? ' <span style="color:#888" title="' + esc('基础评分门槛 ' + Number(it.min).toFixed(2) + '(你设置的判定线);好感度以「好感偏移」加减在消息分数上(亲近加分、冷淡减分),不改动门槛') + '">门槛' + Number(it.min).toFixed(2) + '</span>'
2032
+ : '')
2009
2033
  + (it.img ? ' <span style="color:#999" title="图片消息: ' + (it.lib ? '已在库 → 借它的标签当文字评分' : '无文字, 不可评分(默认不拦)') + '">' + (it.lib ? '[库内]' : '[无文字]') + '</span>' : '')
2010
- + (it.mention ? ' <span style="color:#1c7ed6" title="被@, 必回">[@]</span>' : '')
2034
+ + (it.mention ? ' <span style="color:#1c7ed6" title="' + esc(it.mentionForced && !it.worth ? '被@ → 必回(分数没到门槛也回)' : '被@, 必回') + '">[@]</span>' : '')
2011
2035
  + (it.conf !== undefined && it.conf < 0.5 ? ' <span style="color:#999" title="低置信: 最近邻居相似度只有 ' + it.conf + ', 地图上没这类样本 —— 仅作补样本提示, 不再影响拦截">[?]</span>' : '')
2012
- + (it.agg ? ' <span style="color:#e8590c" title="聚合了 ' + it.agg + ' 条消息, 取其中最高分">[合' + it.agg + ']</span>' : '')
2036
+ + (it.agg ? ' <span style="color:#e8590c" title="' + esc(it.aggWeighted ? ('聚合了 ' + it.agg + ' 条消息,**加权平均**综合判断(明细见左边的[合·加权]标记)') : ('聚合了 ' + it.agg + ' 条消息, 取其中最高分')) + '">[合' + it.agg + ']</span>' : '')
2013
2037
  + ' <span style="color:#555">' + esc(String(it.sender || '?')) + ':</span> '
2014
2038
  + esc(String(it.text || '').slice(0, 56))
2015
2039
  + '<div style="color:#bbb;font-size:11px;margin-left:14px">近邻: ' + esc((it.top || []).join(' · ')) + '</div>'
@@ -2020,6 +2044,89 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2020
2044
  return out
2021
2045
  }
2022
2046
  // 局部刷新评分列表(不调 paintBody → 不闪、不打断滚动、不丢输入框焦点)
2047
+ // 熟识度 + 好感度(2026-09-14 主人"直接推进"后分两列展示, 两个维度别混)
2048
+ // ── 分列 + 图标(2026-09-14 主人:"好感度的文字太多了, 改成有分列, 图标显示") ──
2049
+ // 列: # | 群友 | 熟识度 | 好感度 | 这次 | 活跃
2050
+ // 原来一行塞着"(内心亲近 +0.5 / 两好相凑 ×1.2)(来过2天/消息65/点名35/接话3 · 3分钟前)"两串中文,
2051
+ // 现在压成图标 + 数字,**全文退进 title 悬浮提示** —— 想细看还看得到, 只是不再堵眼睛。
2052
+ var AFF_TIER_ICON = { '陌生人': '👤', '眼熟': '👋', '熟人': '🤝' };
2053
+ var AFF_TIER_COLOR = { '陌生人': '#868e96', '眼熟': '#1c7ed6', '熟人': '#2f9e44' };
2054
+ var ATT_TIER_ICON = { '很亲近': '💖', '亲近': '💗', '中立': '😐', '冷淡': '🧊', '疏远': '❄️' };
2055
+ var ATT_TIER_COLOR = { '很亲近': '#e64980', '亲近': '#f06595', '中立': '#868e96', '冷淡': '#4c6ef5', '疏远': '#364fc7' };
2056
+ var DELTA_ICON = {
2057
+ 'near': ['💗', '内心亲近 +0.5'],
2058
+ 'refuse': ['🚫', '内心拒绝 −0.5'],
2059
+ 'yield': ['🕊️', '让步(心里不肯仍照顾) +0.8'],
2060
+ 'harsh': ['🥶', '又烦又冷(重罚) −0.5'],
2061
+ 'amp:cold-praise': ['❗×1.5', '对方夸她还掉好感(不领情) ×1.5'],
2062
+ 'amp:flatter': ['🎁×1.5', '对方冷她还涨好感(讨好) ×1.5'],
2063
+ 'amp:both-warm': ['➕×1.2', '两好相凑 ×1.2'],
2064
+ 'amp:both-cold': ['❄️×1.2', '两冷相叠 ×1.2']
2065
+ };
2066
+ var AFF_COLS = { n: 20, aff: 76, att: 92, why: 104, act: 150 };
2067
+ function affinityHeadHtml() {
2068
+ var s = function (w, t) { return '<span style="flex:0 0 ' + w + 'px">' + t + '</span>' }
2069
+ return '<div style="display:flex;gap:8px;align-items:center;font-size:11px;color:#adb5bd;padding-bottom:3px;border-bottom:1px solid #e9ecef">'
2070
+ + '<span style="flex:0 0 ' + AFF_COLS.n + 'px;text-align:right">#</span>'
2071
+ + '<span style="flex:1 1 auto;min-width:0">群友</span>'
2072
+ + s(AFF_COLS.aff, '熟识度') + s(AFF_COLS.att, '好感度') + s(AFF_COLS.why, '这次') + s(AFF_COLS.act, '活跃')
2073
+ + '</div>'
2074
+ }
2075
+ function affinityRowHtml(i, x, a) {
2076
+ var age = x.lastAt ? Math.round((Date.now() - x.lastAt) / 60000) : 0
2077
+ var tier = x.tier || '陌生人'
2078
+ var color = AFF_TIER_COLOR[tier] || '#868e96'
2079
+ var affTip = '熟识度 ' + (x.score != null ? x.score : '-') + '/100 · 档位: ' + tier
2080
+ + '(来过 ' + (x.reviews || 0) + ' 天 / 消息 ' + x.msgs + ' / 被点名 ' + x.mentions + ' / 接话 ' + x.replies + ')'
2081
+ var affCell = '<span style="flex:0 0 ' + AFF_COLS.aff + 'px" title="' + esc(affTip) + '">'
2082
+ + '<b style="color:' + color + '">' + (x.score != null ? x.score : '-') + '</b> <span>' + (AFF_TIER_ICON[tier] || '👤') + '</span></span>'
2083
+ var at = (a && a.tier) || ''
2084
+ var aNum = a ? (a.a >= 0 ? '+' : '') + Number(a.a).toFixed(2) : '—'
2085
+ var aColor = !a ? '#adb5bd' : (a.a > 0.0001 ? '#2f9e44' : (a.a < -0.0001 ? '#e03131' : '#868e96'))
2086
+ var aTip = a
2087
+ ? ('好感度 ' + a.a + '(占范围 ' + (a.ratio != null ? (a.ratio * 100).toFixed(1) + '%' : '?') + ') · 档位: ' + (at || '中立')
2088
+ + (a.why ? ';最近一次: ' + a.why : ''))
2089
+ : '还没有好感度记录(她还没对他形成态度)'
2090
+ var aCell = '<span style="flex:0 0 ' + AFF_COLS.att + 'px" title="' + esc(aTip) + '">'
2091
+ + '<b style="color:' + aColor + '">' + aNum + '</b> <span style="color:' + (ATT_TIER_COLOR[at] || '#adb5bd') + '">' + (ATT_TIER_ICON[at] || '⚪') + '</span></span>'
2092
+ var codes = (a && a.codes && a.codes.length) ? a.codes : []
2093
+ var marks = codes.map(function (c) {
2094
+ var d = DELTA_ICON[c]
2095
+ return d ? '<span title="' + esc(d[1]) + '">' + d[0] + '</span>' : ''
2096
+ }).filter(Boolean)
2097
+ var whyCell = '<span style="flex:0 0 ' + AFF_COLS.why + 'px" title="' + esc((a && a.why) || '暂无拆解') + '">'
2098
+ + (marks.length ? marks.join(' ') : '<span style="color:#ced4da">—</span>') + '</span>'
2099
+ var actTip = '来过 ' + (x.reviews || 0) + ' 天 · 消息 ' + x.msgs + ' · 被点名 ' + x.mentions + ' 次 · 接她的话 ' + x.replies + ' 次'
2100
+ + (age < 120 ? ' · 最近 ' + age + ' 分钟前' : '')
2101
+ var actCell = '<span style="flex:0 0 ' + AFF_COLS.act + 'px;color:#adb5bd;font-size:11px" title="' + esc(actTip) + '">'
2102
+ + '📅' + (x.reviews || 0) + ' 💬' + x.msgs + ' 📣' + x.mentions + ' ↩' + x.replies
2103
+ + (age < 120 ? ' <span style="color:#868e96">🕐' + age + '分</span>' : '') + '</span>'
2104
+ return '<div style="display:flex;gap:8px;align-items:center;font-size:12px;color:#495057;padding:2px 0;border-bottom:1px dashed #f1f3f5">'
2105
+ + '<span style="flex:0 0 ' + AFF_COLS.n + 'px;text-align:right;color:#adb5bd">' + (i + 1) + '</span>'
2106
+ + '<span style="flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#343a40" title="' + esc(x.key) + '">' + esc(x.name || x.key) + '</span>'
2107
+ + affCell + aCell + whyCell + actCell + '</div>'
2108
+ }
2109
+ function loadAffinity() {
2110
+ if (state.tab !== 'session') return
2111
+ var box = panel ? panel.querySelector('#dk-aff-box') : null
2112
+ var q = state.ns ? '?' + outNsQ() : ''
2113
+ Promise.all([
2114
+ fetch('/api/qqbot-settings/affinity' + q).then(function (r) { return r.json() }).catch(function () { return {} }),
2115
+ fetch('/api/qqbot-settings/attitude' + q).then(function (r) { return r.json() }).catch(function () { return {} })
2116
+ ]).then(function (rs) {
2117
+ var aff = (rs[0] && rs[0].items) || []
2118
+ var att = (rs[1] && rs[1].items) || []
2119
+ var attBy = {}
2120
+ att.forEach(function (x) { attBy[x.key] = x })
2121
+ if (aff.length === 0) { state.affHtml = '还没有数据 —— 群里聊几句就出来了'; }
2122
+ else {
2123
+ state.affHtml = affinityHeadHtml() + aff.map(function (x, i) {
2124
+ return affinityRowHtml(i, x, attBy[x.key])
2125
+ }).join('')
2126
+ }
2127
+ if (box) box.innerHTML = state.affHtml
2128
+ }).catch(function () { if (box) box.textContent = '熟识度/好感度读取失败' })
2129
+ }
2023
2130
  function refreshScoreList() {
2024
2131
  if (state.tab !== 'session') return
2025
2132
  fetch('/api/qqbot-settings/value-scores' + scoreQuery())
@@ -2106,6 +2213,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2106
2213
  .then(function (r) { return r.json() })
2107
2214
  .then(function (d) { state.valueScores = d && d.ok ? d : { items: [], total: 0 }; paintBody() })
2108
2215
  .catch(function () { state.valueScores = { items: [], total: 0 }; paintBody() })
2216
+ .then(function () { loadAffinity() })
2109
2217
  .then(function () { state.lmBusy = false })
2110
2218
  // 每 10 秒局部刷新一次评分列表(只换 #dk-vs-box 内容, 不整块重绘 → 不闪)
2111
2219
  if (state.lmTimer) clearInterval(state.lmTimer)
@@ -2222,6 +2330,35 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2222
2330
  vsToggle.textContent = state.vsOpen ? '收起' : '展开编辑'
2223
2331
  if (state.vsOpen) loadValueSamplesEditor()
2224
2332
  }
2333
+ // 📊 最近评分 折叠(2026-09-14 主人要求:这两块默认收起,别占地方)
2334
+ var vsViewToggle = panel.querySelector('#dk-vs-view-toggle')
2335
+ if (vsViewToggle) vsViewToggle.onclick = function () {
2336
+ state.vsViewOpen = !state.vsViewOpen
2337
+ var b = panel.querySelector('#dk-vs-box')
2338
+ if (b) b.style.display = state.vsViewOpen ? 'block' : 'none'
2339
+ vsViewToggle.textContent = state.vsViewOpen ? '收起' : '展开'
2340
+ }
2341
+ // 🧠 熟识度 / 💗 好感度 折叠
2342
+ var affToggle = panel.querySelector('#dk-aff-toggle')
2343
+ if (affToggle) affToggle.onclick = function () {
2344
+ state.affOpen = !state.affOpen
2345
+ var b = panel.querySelector('#dk-aff-box')
2346
+ if (b) b.style.display = state.affOpen ? 'block' : 'none'
2347
+ affToggle.textContent = state.affOpen ? '收起' : '展开'
2348
+ }
2349
+ // 📊 导出 Excel(2026-09-14 主人要"一键导出分享")—— 直接走 GET,让浏览器下载
2350
+ var affExport = panel.querySelector('#dk-aff-export')
2351
+ if (affExport) affExport.onclick = function () {
2352
+ var url = '/api/qqbot-settings/export.xlsx' + (state.ns ? '?' + outNsQ() : '')
2353
+ var a = document.createElement('a')
2354
+ a.href = url
2355
+ a.download = ''
2356
+ document.body.appendChild(a)
2357
+ a.click()
2358
+ document.body.removeChild(a)
2359
+ affExport.textContent = '📊 已导出'
2360
+ setTimeout(function () { affExport.textContent = '📊 导出 Excel' }, 2500)
2361
+ }
2225
2362
  var vsSaveBtn = panel.querySelector('#dk-vs-save')
2226
2363
  if (vsSaveBtn) vsSaveBtn.onclick = function () { saveValueSamplesEditor() }
2227
2364
  // 🤖 让ai写(2026-09-13 主人要的): 模拟一条用户消息唤醒她 → 她自己读聊天记录写样例
@@ -2242,7 +2379,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2242
2379
  if (h) h.textContent = d && d.ok ? ('✓ ' + d.msg) : ('失败: ' + ((d && d.error) || (d && d.msg) || '未知'))
2243
2380
  }).catch(function (e) { if (h) h.textContent = '异常: ' + e.message })
2244
2381
  }
2245
- // ⚙ 单会话设置: 次级标签切换(🧠 本地小模型 / 📄 空白样板)
2382
+ // ⚙ 单会话设置: 次级标签切换(🧠 智能回复 / 📄 空白样板)
2246
2383
  panel.querySelectorAll('[data-sttab]').forEach(function (sb) {
2247
2384
  sb.onclick = function () {
2248
2385
  var v = sb.getAttribute('data-sttab')
@@ -3094,11 +3231,11 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3094
3231
  }).join('')
3095
3232
  + '</div>'
3096
3233
  }
3097
- // ⚙ 单会话设置 · 次级标签栏(2026-09-13 主人要求: 本地小模型独立成子标签, 另留一个空标签当样板)
3234
+ // ⚙ 单会话设置 · 次级标签栏(2026-09-13 主人要求: 智能回复独立成子标签, 另留一个空标签当样板)
3098
3235
  function sessionSubTabs() {
3099
3236
  var st = state.stTab || 'lm'
3100
3237
  return '<div class="dk-row" style="gap:4px;margin:4px 0 6px;flex-wrap:wrap;border-bottom:1px dashed #e2d9ff;padding-bottom:6px">'
3101
- + [['lm', '🧠 本地小模型'], ['blank', '📄 空白样板']]
3238
+ + [['lm', '🧠 智能回复'], ['blank', '📄 空白样板']]
3102
3239
  .map(function (s) {
3103
3240
  return '<button class="dk-btn' + (st === s[0] ? ' on' : '') + '" data-sttab="' + s[0] + '">' + s[1] + '</button>'
3104
3241
  }).join('')
@@ -3352,7 +3489,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3352
3489
  body += '<span class="dk-msg" id="dk-out-hint" style="color:#2f9e44;margin:4px 0"></span>'
3353
3490
  } else if (state.tab === 'session') {
3354
3491
  // ⚙ 单会话设置(2026-09-13 主人定): 放"每个会话单独生效"的设置。
3355
- // 次级标签: 🧠 本地小模型 / 📄 空白样板(留作以后加新设置的模板)。
3492
+ // 次级标签: 🧠 智能回复 / 📄 空白样板(留作以后加新设置的模板)。
3356
3493
  body += sessionSubTabs()
3357
3494
  var st = state.stTab || 'lm'
3358
3495
  if (st === 'blank') {
@@ -3368,7 +3505,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3368
3505
  body += '<div class="dk-empty">读取中…(若长时间不动, 点「🔄 重新检测」)</div>'
3369
3506
  } else {
3370
3507
  var lmMb = lm.bytes ? (Math.round((lm.bytes / 1048576) * 10) / 10) : 0
3371
- var lmState = lm.available ? ('✅ 已就绪' + (lmMb ? ' · ' + lmMb + ' MB' : '')) : ('⚠️ 未安装(缺 ' + (lm.missing || []).length + ' 个文件)')
3508
+ var lmState = lm.available ? ('✅ 已就绪' + (lmMb ? ' · ' + lmMb + ' MB' : '')) : ('⚠️ 未安装(缺 ' + (lm.missing || []).length + ' 个文件) —— 点右边「⬇️ 下载模型」即可')
3372
3509
  // ⚠️ 2026-09-13 会话级: 评分模式/门槛/启用 **优先取当前会话的覆盖项**(overrides[group:xxx]),
3373
3510
  // 没有则继承账号级默认; 界面用"继承/已单独设置"标出来。
3374
3511
  var lmCfg = state.lmCfg || {}
@@ -3382,7 +3519,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3382
3519
  : (typeof lm.valueMinScore === 'number' ? lm.valueMinScore : 0.5))
3383
3520
  var onV = (typeof ov.enabled === 'boolean') ? ov.enabled
3384
3521
  : ((typeof lmCfg.enabled === 'boolean') ? lmCfg.enabled : (lm.enabled !== false))
3385
- body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:10px 0 2px">🧠 本地小模型 · bge-small-zh-v1.5</div>'
3522
+ body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:10px 0 2px">🧠 智能回复(本地小模型) · bge-small-zh-v1.5</div>'
3386
3523
  // 本会话标识 + 继承状态(让"单会话"名副其实: 一眼看出这套值是谁的)
3387
3524
  body += '<div class="dk-row" style="gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 4px">'
3388
3525
  + '<span class="dk-msg" style="font-size:12px;color:#7c6bd6">本会话: ' + esc(curHitLabel()) + '</span>'
@@ -3391,14 +3528,14 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3391
3528
  + (hasOv ? '<button class="dk-btn" id="dk-lm-reset" style="font-size:11px;padding:1px 6px" title="删除本会话的单独设置, 恢复继承账号默认">↩ 恢复继承</button>' : '')
3392
3529
  + '</div>'
3393
3530
  body += '<div class="dk-row" style="gap:8px;flex-wrap:wrap;align-items:center">'
3394
- + '<label style="display:inline-flex;align-items:center;gap:4px;font-size:12px;cursor:pointer"><input type="checkbox" id="dk-lm-on"' + (onV ? ' checked' : '') + '> 启用(省 token)</label>'
3531
+ + '<label style="display:inline-flex;align-items:center;gap:4px;font-size:12px;cursor:pointer"><input type="checkbox" id="dk-lm-on"' + (onV ? ' checked' : '') + '> 启用智能回复(省 token)</label>'
3395
3532
  + '<span class="dk-msg" style="font-size:12px;color:' + (lm.available ? '#2f9e44' : '#c23131') + '">' + esc(lmState) + '</span>'
3396
3533
  + '</div>'
3397
3534
  body += '<div class="dk-row" style="gap:6px;margin:4px 0;flex-wrap:wrap">'
3398
3535
  + '<input class="qqs-inp" id="dk-lm-dir" style="flex:1;min-width:200px" placeholder="模型目录(留空=默认 ~/.dsh/models/bge-small-zh)" value="' + esc(lm.modelDir || '') + '">'
3399
3536
  + '<button class="dk-btn" id="dk-lm-save">💾 保存</button>'
3400
3537
  + '<button class="dk-btn" id="dk-lm-check">🔄 重新检测</button>'
3401
- + (lm.available ? '' : '<button class="dk-btn ok" id="dk-lm-dl">⬇️ 下载模型</button>')
3538
+ + (lm.available ? '' : '<button class="dk-btn ok" id="dk-lm-dl">⬇️ 下载模型(自动)</button>')
3402
3539
  + '</div>'
3403
3540
  // 评分模式 + 门槛(2026-09-13): off=不评分 / log=只记录(默认观察期) / block=低分不唤醒
3404
3541
  body += '<div class="dk-row" style="gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0 2px">'
@@ -3415,11 +3552,21 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3415
3552
  // (只换这个 div 的内容, 不整块 paintBody) → 既不闪, 又能自动更新。
3416
3553
  body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:10px 0 2px;gap:8px;align-items:center;flex-wrap:wrap">'
3417
3554
  + '<span>📊 最近评分(观察期)</span>'
3555
+ + '<button class="dk-btn" id="dk-vs-view-toggle" style="font-size:12px;padding:1px 8px">' + (state.vsViewOpen ? '收起' : '展开') + '</button>'
3418
3556
  + '<span class="dk-msg" style="font-size:11px;color:#7c6bd6" id="dk-vs-scope">' + esc(curHitLabel()) + '</span>'
3419
3557
  + '<button class="dk-btn" id="dk-vs-reload" style="font-size:12px;padding:1px 8px">🔄 刷新</button>'
3420
3558
  + '<span class="dk-msg" style="font-size:11px;color:#999">每 10 秒自动更新</span>'
3421
3559
  + '</div>'
3422
- body += '<div id="dk-vs-box">' + scoreListHtml(state.valueScores) + '</div>'
3560
+ body += '<div id="dk-vs-box" style="display:' + (state.vsViewOpen ? 'block' : 'none') + '">' + scoreListHtml(state.valueScores) + '</div>'
3561
+ // 💗 熟识度(她记你多牢) + 好感度(她对你什么态度) —— 两个维度分两列, 别混
3562
+ // 熟识度: {dataRoot}/.qqbot/affinity.json | 好感度: {dataRoot}/.qqbot/attitude.json
3563
+ body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:10px 0 2px;gap:8px;align-items:center;flex-wrap:wrap">'
3564
+ + '<span>🧠 熟识度(记你多牢) | 💗 好感度(对你什么态度)(观察期·只统计)</span>'
3565
+ + '<button class="dk-btn" id="dk-aff-export" style="font-size:12px;padding:1px 8px" title="导成 Excel(三张表:熟识度 / 好感度 / 口径说明),可以直接转发给别人">📊 导出 Excel</button>'
3566
+ + '<button class="dk-btn" id="dk-aff-toggle" style="font-size:12px;padding:1px 8px">' + (state.affOpen ? '收起' : '展开') + '</button>'
3567
+ + '</div>'
3568
+ // 分列 + 图标(2026-09-14): 注意这里**不能**再加 white-space:pre-wrap —— 现在是逐行的 flex 布局 HTML
3569
+ body += '<div class="dk-msg" id="dk-aff-box" style="font-size:12px;color:#666;display:' + (state.affOpen ? 'block' : 'none') + '">' + (state.affHtml || '加载中…') + '</div>'
3423
3570
  // ✍️ 样例库编辑(2026-09-13 主人问"哪里写样本"): 直接改 jsonl, 保存后下一条消息生效
3424
3571
  body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:12px 0 2px;gap:8px;align-items:center;flex-wrap:wrap">'
3425
3572
  + '<span>✍️ 样例库(决定她的开口标准)</span>'
@@ -1 +1 @@
1
- {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAyElE,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,wBAAwB,CAAC;AAC1C,eAAO,MAAM,MAAM,UAAY,CAAC;AAShC,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAI/D;AAkFD,mDAAmD;AACnD,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAivDvD;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,GAAG,KAAK,EACtB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAwCvC"}
1
+ {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AA2ElE,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,wBAAwB,CAAC;AAC1C,eAAO,MAAM,MAAM,UAAY,CAAC;AAShC,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAI/D;AAkFD,mDAAmD;AACnD,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAk1DvD;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,GAAG,KAAK,EACtB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAwCvC"}
@@ -22,6 +22,8 @@ import * as broadcastQueue from './features/broadcast.js';
22
22
  import { wakeSessionAgent, safeAppendUserMessage } from './features/group-hub.js';
23
23
  import { managersOf, findManagerByPeer, findManagerBySessionId } from './features/session-registry.js';
24
24
  import { handleInbound } from './transport/inbound.js';
25
+ import { getQuote } from './features/quote-cache.js';
26
+ import { appendMemoLine, deleteMemo, listMemos, readMemo } from './features/people-memo.js';
25
27
  /** 诊断日志路径: 默认关闭; 需要排查时设环境变量 QQBOT_DIAG_FILE 指向日志文件 */
26
28
  const DIAG_FILE = process.env.QQBOT_DIAG_FILE || '';
27
29
  /** 大文件异步发送阈值: 本地文件 >= 5MB 时走后台任务(SDK 分片上传耗时, 避免阻塞 LLM 回合) */
@@ -770,7 +772,8 @@ export async function apply(ctx) {
770
772
  // ⚠️ 描述只讲两个分支的效果, 不给场景规则——选哪个由模型按当前对话氛围自行权衡(写死规则会导致静默率过高)。
771
773
  const replyGateTool = defineTool({
772
774
  name: 'reply_gate',
773
- description: '回复闸门: 开始回复前先调用它, 决定本回合开口还是静默。传 reply:true → 正常继续, 按当前对话决定说什么; 传 reply:false → 本回合立即静默终止, 你不会再输出任何内容(这条消息就像没发生过)。选 true 还是 false 由你根据当前对话自己判断: 觉得该回应就 true, 觉得安静待着更好就 false。',
775
+ description: '回复闸门: 开始回复前先调用它, 决定本回合开口还是静默。传 reply:true → 正常继续, 按当前对话决定说什么; 传 reply:false → 本回合立即静默终止, 你不会再输出任何内容(这条消息就像没发生过)。选 true 还是 false 由你根据当前对话自己判断: 觉得该回应就 true, 觉得安静待着更好就 false。' +
776
+ '\n⚠️ 传 reply:false 后**不要再调用任何工具**(包括记笔记、记喜好、发表情包): 本回合已经关闭, 那些调用会被直接中止 —— 想记录的话, 留到下一次开口的回合里做。',
774
777
  parameters: {
775
778
  reply: { type: 'boolean', required: true, description: 'true=正常开口回复; false=静默终止本回合(不再输出任何内容)' },
776
779
  reason: { type: 'string', required: true, description: '简短判定理由, 如"未@不插嘴" / "被@需回复" / "私聊"' },
@@ -2018,11 +2021,113 @@ export async function apply(ctx) {
2018
2021
  }
2019
2022
  },
2020
2023
  });
2024
+ // quote_view: 查看引用内容(2026-09-13 主人定, 省 token) ——
2025
+ // 引用原文超过 60 字时, 入站上下文只给**前 60 字**, 全文落在
2026
+ // `{dataRoot}/.qqbot/quote-cache.json`(**每会话最多 10 条**), 需要时用本工具取完整原文。
2027
+ // ⚠️ 工具描述**刻意写四个字**(主人要求): 描述进 system prompt, 越短越省。
2028
+ const quoteViewTool = defineTool({
2029
+ name: 'quote_view',
2030
+ description: '查看引用内容',
2031
+ parameters: {
2032
+ idx: { type: 'integer', description: '引用编号(默认最新一条)' },
2033
+ },
2034
+ output: {
2035
+ schema: {
2036
+ type: 'object', additionalProperties: false,
2037
+ properties: {
2038
+ ok: { type: 'boolean', required: true },
2039
+ msg: { type: 'string', required: true },
2040
+ text: { type: 'string' },
2041
+ },
2042
+ },
2043
+ render: (_a, v) => [
2044
+ { type: 'text', text: v.ok ? (v.text || '') : `查询失败: ${v.msg}` },
2045
+ ],
2046
+ },
2047
+ async execute(args, exec) {
2048
+ try {
2049
+ const s = findSessionRec(channelOf(exec), exec);
2050
+ const mgr = s?.ch?.manager;
2051
+ const rec = s?.rec;
2052
+ const dataRoot = String(mgr?.dataRoot || '');
2053
+ if (!dataRoot || !rec?.scope || !rec?.peerId)
2054
+ return { ok: false, msg: '未找到当前 QQ 会话' };
2055
+ const rawIdx = args.idx;
2056
+ const idx = typeof rawIdx === 'number' && Number.isFinite(rawIdx) ? rawIdx : undefined;
2057
+ const e = getQuote(dataRoot, `${rec.scope}:${rec.peerId}`, idx);
2058
+ if (!e)
2059
+ return { ok: false, msg: '没有缓存的引用(短引用不缓存; 或这条引用是更早的、已被 10 条上限挤掉)' };
2060
+ return { ok: true, msg: `引用#${e.id}`, text: `引用#${e.id}${e.sender ? `(${e.sender})` : ''}: ${e.text}` };
2061
+ }
2062
+ catch (err) {
2063
+ return { ok: false, msg: err instanceof Error ? err.message : String(err) };
2064
+ }
2065
+ },
2066
+ });
2067
+ // people_memo: 群友小传(2026-09-13 主人定)—— 文字版好感度的本体。
2068
+ // 记一行(同一人一天最多一条) / 查看 / 删除(群里一句"别记人家"就删)。描述刻意写短。
2069
+ const peopleMemoTool = defineTool({
2070
+ name: 'people_memo',
2071
+ description: '群友小传: 记一行/查看/删除。当群友说出自己的喜好/经历/身份等"旁人能复述的事实"时, 顺手记一条(同一人一天最多 3 条); 想归到八栏就带前缀, 如"喜好:最近在玩XX"(不带前缀则进记事区); 只写事实, 不写评价与猜测',
2072
+ parameters: {
2073
+ who: { type: 'string', required: true, description: '群友昵称或 openid' },
2074
+ line: { type: 'string', description: '要记的一行; 不传=只看' },
2075
+ del: { type: 'boolean', description: 'true=删掉这个人的小传' },
2076
+ },
2077
+ output: {
2078
+ schema: {
2079
+ type: 'object', additionalProperties: false,
2080
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
2081
+ },
2082
+ render: (_a, v) => [{ type: 'text', text: v.msg }],
2083
+ },
2084
+ async execute(args, exec) {
2085
+ try {
2086
+ const a = args;
2087
+ const s = findSessionRec(channelOf(exec), exec);
2088
+ const mgr = s?.ch?.manager;
2089
+ const rec = s?.rec;
2090
+ const dataRoot = String(mgr?.dataRoot || '');
2091
+ if (!dataRoot)
2092
+ return { ok: false, msg: '未找到当前会话的数据根' };
2093
+ // who → openid: 优先在群成员台账里按昵称找
2094
+ let key = String(a.who || '').trim();
2095
+ if (!/^[0-9A-Fa-f]{16,}$/.test(key) && rec?.scope === 'group' && rec.peerId) {
2096
+ try {
2097
+ const dataDir = join(dataRoot, '表情包');
2098
+ const mems = readGroupMembers(dataDir, rec.peerId) || [];
2099
+ const hit = mems.find((m) => m && m.name === key) || mems.find((m) => m && typeof m.name === 'string' && m.name.includes(key));
2100
+ if (hit && hit.mid)
2101
+ key = hit.mid;
2102
+ }
2103
+ catch { /* 用昵称兜底当 key */ }
2104
+ }
2105
+ if (a.del) {
2106
+ const r = deleteMemo(dataRoot, key);
2107
+ return { ok: r.ok, msg: r.ok ? '好, 已经把 ' + a.who + ' 那份小传删掉了, 以后也不记他' : r.msg };
2108
+ }
2109
+ if (a.line) {
2110
+ const r = appendMemoLine(dataRoot, key, a.line, { name: a.who, force: false });
2111
+ return { ok: r.ok, msg: r.ok ? '记下了: ' + a.line.slice(0, 60) : r.msg };
2112
+ }
2113
+ const cur = readMemo(dataRoot, key);
2114
+ if (cur)
2115
+ return { ok: true, msg: cur.slice(0, 1200) };
2116
+ const all = listMemos(dataRoot);
2117
+ return { ok: true, msg: '还没有 ' + a.who + ' 的小传。现有 ' + all.length + ' 份: ' + all.slice(0, 8).map((x) => x.key).join(', ') };
2118
+ }
2119
+ catch (e) {
2120
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
2121
+ }
2122
+ },
2123
+ });
2021
2124
  // 逐个注册并记录结果(便于线上定位是哪个工具失败)
2022
2125
  const toolDefs = [
2023
2126
  { name: 'send_media', tool: sendMediaTool },
2024
2127
  { name: 'recall_message', tool: recallTool },
2025
2128
  { name: 'text_break', tool: textBreakTool },
2129
+ { name: 'quote_view', tool: quoteViewTool },
2130
+ { name: 'people_memo', tool: peopleMemoTool },
2026
2131
  { name: 'tools_reload', tool: toolsReloadTool },
2027
2132
  { name: 'reply_gate', tool: replyGateTool },
2028
2133
  { name: 'outbound_mode', tool: outboundModeTool },