@zaofan/dsh-qqbot 1.4.0 → 1.4.5

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 (86) hide show
  1. package/README.md +85 -0
  2. package/README_EN.md +15 -0
  3. package/client/qqbot-settings.js +452 -6
  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 +41 -0
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +25 -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 +23 -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/session/session-manager.d.ts.map +1 -1
  55. package/dist/session/session-manager.js +4 -2
  56. package/dist/session/session-manager.js.map +1 -1
  57. package/dist/transport/attachment.d.ts +5 -1
  58. package/dist/transport/attachment.d.ts.map +1 -1
  59. package/dist/transport/attachment.js +16 -5
  60. package/dist/transport/attachment.js.map +1 -1
  61. package/dist/transport/inbound.d.ts.map +1 -1
  62. package/dist/transport/inbound.js +396 -15
  63. package/dist/transport/inbound.js.map +1 -1
  64. package/dist/transport/inject-rules.d.ts.map +1 -1
  65. package/dist/transport/inject-rules.js +3 -1
  66. package/dist/transport/inject-rules.js.map +1 -1
  67. package/dist/transport/msg-index.d.ts +29 -0
  68. package/dist/transport/msg-index.d.ts.map +1 -0
  69. package/dist/transport/msg-index.js +74 -0
  70. package/dist/transport/msg-index.js.map +1 -0
  71. package/dist/transport/outbound-buffer.d.ts +8 -2
  72. package/dist/transport/outbound-buffer.d.ts.map +1 -1
  73. package/dist/transport/outbound-buffer.js +37 -6
  74. package/dist/transport/outbound-buffer.js.map +1 -1
  75. package/dist/transport/outbound.d.ts.map +1 -1
  76. package/dist/transport/outbound.js +13 -2
  77. package/dist/transport/outbound.js.map +1 -1
  78. package/dist/transport/rich-media.d.ts +10 -1
  79. package/dist/transport/rich-media.d.ts.map +1 -1
  80. package/dist/transport/rich-media.js +21 -2
  81. package/dist/transport/rich-media.js.map +1 -1
  82. package/dist/types.d.ts +2 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/package.json +3 -2
  85. package/scripts/download-model.mjs +110 -0
  86. package/settings-host.js +337 -5
@@ -215,6 +215,7 @@ window.__ModuleLoader__.load({
215
215
  sticker: v.sticker || {},
216
216
  injectRules: Array.isArray(v.injectRules) ? v.injectRules : [],
217
217
  imageHint: typeof v.imageHint === 'boolean' ? v.imageHint : undefined,
218
+ messageReference: v.messageReference !== false,
218
219
  schedule: v.schedule && Array.isArray(v.schedule.targets) ? v.schedule : { targets: [] },
219
220
  // groupPrompt 必须读回来, 否则每次保存都会把它清空成 ''
220
221
  // undefined(从未设置)→ 显示默认守则; ''(用户明确清空)→ 保持空(无守则)
@@ -283,6 +284,7 @@ window.__ModuleLoader__.load({
283
284
  behavior: v2.behavior || {}, sticker: v2.sticker || {},
284
285
  injectRules: Array.isArray(v2.injectRules) ? v2.injectRules : [],
285
286
  imageHint: typeof v2.imageHint === 'boolean' ? v2.imageHint : undefined,
287
+ messageReference: v2.messageReference !== false,
286
288
  schedule: v2.schedule && Array.isArray(v2.schedule.targets) ? v2.schedule : { targets: [] },
287
289
  groupPrompt: typeof v2.groupPrompt === 'string' ? v2.groupPrompt : (typeof cfg.groupPrompt === 'string' ? cfg.groupPrompt : DEFAULT_GROUP_PROMPT),
288
290
  enableApprovals: v2.enableApprovals === true,
@@ -350,7 +352,8 @@ window.__ModuleLoader__.load({
350
352
  }),
351
353
  h('button', { className: 'qqs-btn', onClick: addRule }, '+ 添加一条提醒'),
352
354
  h('div', { style: boxStyle },
353
- BoolRow({ label: '图片消息自动提示 AI 看图(内置兜底; 不勾=不再注入「请把URL传给识图工具」那条)', value: cfg.imageHint !== false, onChange: function (v) { setCfg(function (c) { return { ...c, imageHint: v } }) } })),
355
+ BoolRow({ label: '图片消息自动提示 AI 看图(内置兜底; 不勾=不再注入「请把URL传给识图工具」那条)', value: cfg.imageHint !== false, onChange: function (v) { setCfg(function (c) { return { ...c, imageHint: v } }) } }),
356
+ BoolRow({ label: '引用消息(默认开): 入站消息带短消息号(本地台账索引,省token) + 引用消息附原文; AI 用 [rf:短号] 引用对方消息', value: cfg.messageReference !== false, onChange: function (v) { setCfg(function (c) { return { ...c, messageReference: v } }) } })),
354
357
 
355
358
  h('div', { style: sectionTitle }, '④ 定时唤醒'),
356
359
  h('p', { style: { fontSize: 12, color: '#888' } }, '已合并到「定时任务」页(顶部 tab)一起编辑——到点主动开口的群/人分组,与她答应你的定时提醒,都在那边管理。'),
@@ -964,6 +967,7 @@ window.__ModuleLoader__.load({
964
967
  },
965
968
  injectRules: Array.isArray(v.injectRules) ? v.injectRules : [],
966
969
  imageHint: typeof v.imageHint === 'boolean' ? v.imageHint : undefined,
970
+ messageReference: v.messageReference !== false,
967
971
  schedule: { targets: targets() },
968
972
  groupPrompt: typeof v.groupPrompt === 'string' ? v.groupPrompt : DEFAULT_GROUP_PROMPT,
969
973
  }
@@ -1989,6 +1993,277 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
1989
1993
  return ac ? (ac.dataDir || '') : ''
1990
1994
  }
1991
1995
  function outNsQ() { return state.ns ? 'ns=' + encodeURIComponent(state.ns) : '' }
1996
+ // ⚙ 单会话设置 · 本地小模型(2026-09-13 主人定)
1997
+ // 📊 评分列表 HTML(单独抽出: 供"局部刷新"只替换内容, 不整块重绘)
1998
+ function scoreListHtml(vs) {
1999
+ if (vs === undefined || vs === null) return '<div class="dk-empty" style="padding:8px 0">读取中…</div>'
2000
+ if (!vs.items || !vs.items.length) return '<div class="dk-empty" style="padding:8px 0">还没有记录 —— 群里有人说句话之后, 这里会列出每条消息的分数</div>'
2001
+ var out = '<div style="max-height:230px;overflow:auto;border:1px solid #eee;border-radius:6px">'
2002
+ vs.items.slice().reverse().forEach(function (it) {
2003
+ var tt = new Date(it.ts || 0)
2004
+ var hh = ('0' + tt.getHours()).slice(-2) + ':' + ('0' + tt.getMinutes()).slice(-2) + ':' + ('0' + tt.getSeconds()).slice(-2)
2005
+ var col = it.worth ? '#2f9e44' : '#c23131'
2006
+ out += '<div style="padding:4px 8px;border-bottom:1px solid #f5f5f5;font-size:12px">'
2007
+ + '<span style="color:#999">' + hh + '</span> '
2008
+ + '<b style="color:' + col + '">' + (typeof it.score === 'number' ? it.score.toFixed(2) : (it.img ? '📷' : '-')) + '</b>'
2009
+ + (it.img ? ' <span style="color:#999" title="图片消息: ' + (it.lib ? '已在库 → 借它的标签当文字评分' : '无文字, 不可评分(默认不拦)') + '">' + (it.lib ? '[库内]' : '[无文字]') + '</span>' : '')
2010
+ + (it.mention ? ' <span style="color:#1c7ed6" title="被@, 必回">[@]</span>' : '')
2011
+ + (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>' : '')
2013
+ + ' <span style="color:#555">' + esc(String(it.sender || '?')) + ':</span> '
2014
+ + esc(String(it.text || '').slice(0, 56))
2015
+ + '<div style="color:#bbb;font-size:11px;margin-left:14px">近邻: ' + esc((it.top || []).join(' · ')) + '</div>'
2016
+ + '</div>'
2017
+ })
2018
+ out += '</div>'
2019
+ out += '<div class="dk-msg" style="font-size:11px;color:#888">共 ' + (vs.total || 0) + ' 条 · 明细文件: {数据根}/.qqbot/value-scores.jsonl</div>'
2020
+ return out
2021
+ }
2022
+ // 局部刷新评分列表(不调 paintBody → 不闪、不打断滚动、不丢输入框焦点)
2023
+ function refreshScoreList() {
2024
+ if (state.tab !== 'session') return
2025
+ fetch('/api/qqbot-settings/value-scores' + scoreQuery())
2026
+ .then(function (r) { return r.json() })
2027
+ .then(function (d) {
2028
+ var next = d && d.ok ? d : { items: [], total: 0 }
2029
+ // ⚠️ 2026-09-13 修(主人反馈"一直被重绘"): 原来每 10 秒**无条件**替换 innerHTML →
2030
+ // 内容没变也会闪一下。现在比较签名(最后3条+总数), 一样就完全不碰 DOM。
2031
+ var sig = (next.total || 0) + '|' + JSON.stringify((next.items || []).slice(-3))
2032
+ if (sig === state.vsSig) { state.valueScores = next; return }
2033
+ state.vsSig = sig
2034
+ state.valueScores = next
2035
+ var box = panel ? panel.querySelector('#dk-vs-box') : null
2036
+ if (box) box.innerHTML = scoreListHtml(state.valueScores)
2037
+ })
2038
+ .catch(function () { /* 静默: 拉取失败保持原样 */ })
2039
+ }
2040
+ // 当前会话命中的 QQ 目标(2026-09-13 主人要求: 评分/操作要跟随"当前会话", 与群管理页一致)
2041
+ function curHitGid() {
2042
+ var hit = state.detectedHit
2043
+ return (hit && hit.scope === 'group' && hit.peerId) ? hit.peerId : ''
2044
+ }
2045
+ function curHitLabel() {
2046
+ var hit = state.detectedHit
2047
+ if (!hit || !hit.peerId) return '(当前会话不是 QQ 会话)'
2048
+ return (hit.scope === 'group' ? '群「' + (hit.name || '…') + '」' : '私聊「' + (hit.name || '…') + '」')
2049
+ }
2050
+ function scoreQuery() {
2051
+ var q = []
2052
+ if (state.ns) q.push(outNsQ())
2053
+ var g = curHitGid()
2054
+ if (g) q.push('gid=' + encodeURIComponent(g))
2055
+ q.push('limit=20')
2056
+ return '?' + q.join('&')
2057
+ }
2058
+ // ✍️ 样例库编辑: 读现有内容(展开时才拉)
2059
+ function loadValueSamplesEditor() {
2060
+ fetch('/api/qqbot-settings/value-samples' + (state.ns ? '?' + outNsQ() : ''))
2061
+ .then(function (r) { return r.json() })
2062
+ .then(function (d) {
2063
+ var stat = panel ? panel.querySelector('#dk-vs-stat') : null
2064
+ var ta = panel ? panel.querySelector('#dk-vs-text') : null
2065
+ if (d && d.ok) {
2066
+ if (stat) stat.textContent = '共 ' + d.total + ' 条(她会接 ' + d.pos + ' / 不会理 ' + d.neg + ')' + (d.isDefault ? ' · 暂用内置默认, 保存后自建' : '')
2067
+ if (ta) ta.value = d.text || ''
2068
+ } else if (stat) stat.textContent = (d && d.error) || '读取失败'
2069
+ })
2070
+ .catch(function () { /* 静默 */ })
2071
+ }
2072
+ function saveValueSamplesEditor() {
2073
+ var ta = panel ? panel.querySelector('#dk-vs-text') : null
2074
+ var hint = panel ? panel.querySelector('#dk-vs-hint') : null
2075
+ if (!ta) return
2076
+ if (hint) hint.textContent = '保存中…'
2077
+ fetch('/api/qqbot-settings/value-samples', {
2078
+ method: 'POST', headers: { 'content-type': 'application/json' },
2079
+ body: JSON.stringify({ ns: state.ns, text: ta.value }),
2080
+ }).then(function (r) { return r.json() }).then(function (d) {
2081
+ if (d && d.ok) {
2082
+ if (hint) hint.textContent = '已保存 ✓ ' + d.saved + ' 条(她会接 ' + d.pos + ' / 不会理 ' + d.neg + ') · 下一条群消息生效'
2083
+ loadValueSamplesEditor()
2084
+ } else if (hint) hint.textContent = '保存失败: ' + ((d && d.error) || '未知')
2085
+ }).catch(function (e) { if (hint) hint.textContent = '异常: ' + e.message })
2086
+ }
2087
+ function loadLocalModel() {
2088
+ if (state.tab !== 'session') return
2089
+ // ⚠️ 2026-09-13 修(主人反馈面板一直闪): 原来 paintBody 的绑定区里也调了 loadLocalModel(),
2090
+ // 而它完成后又调 paintBody() → 无限循环重绘。现在绑定区只做绑定, 这里再加一道防重入锁。
2091
+ if (state.lmBusy) return
2092
+ state.lmBusy = true
2093
+ var nsq = state.ns ? '?' + outNsQ() : ''
2094
+ state.valueScores = null
2095
+ fetch('/api/qqbot-settings/local-model/status' + nsq).then(function (r) { return r.json() }).then(function (d) {
2096
+ state.localModel = d && d.ok ? d : null
2097
+ paintBody()
2098
+ }).catch(function () { state.localModel = null; paintBody() })
2099
+ // ⚠️ 2026-09-13 加固(主人反馈"永远显示只记录"): **直读 settings 配置**拿真实 valueGate/enabled/门槛,
2100
+ // 不再依赖 status 接口有没有带这些字段(host 未重启时旧接口不返回 → 会一直显示默认"只记录")。
2101
+ fetch(READ + (state.ns ? '?' + outNsQ() : '')).then(function (r) { return r.json() }).then(function (d) {
2102
+ state.lmCfg = (d && d.value && d.value.localModel) || null
2103
+ paintBody()
2104
+ }).catch(function () { state.lmCfg = null; paintBody() })
2105
+ fetch('/api/qqbot-settings/value-scores' + scoreQuery())
2106
+ .then(function (r) { return r.json() })
2107
+ .then(function (d) { state.valueScores = d && d.ok ? d : { items: [], total: 0 }; paintBody() })
2108
+ .catch(function () { state.valueScores = { items: [], total: 0 }; paintBody() })
2109
+ .then(function () { state.lmBusy = false })
2110
+ // 每 10 秒局部刷新一次评分列表(只换 #dk-vs-box 内容, 不整块重绘 → 不闪)
2111
+ if (state.lmTimer) clearInterval(state.lmTimer)
2112
+ state.lmTimer = setInterval(refreshScoreList, 10000)
2113
+ if (state.vsOpen) loadValueSamplesEditor() // 重绘后把编辑区内容补回来(展开状态已存 state)
2114
+ }
2115
+ // 保存(全量 patch 合并: settings update 是全量语义, 不能只发单字段)
2116
+ /** 当前会话的覆盖键: "group:<gid>" / "c2c:<openid>"; 未命中(非 QQ 会话) → '' */
2117
+ function curOvKey() {
2118
+ var hit = state.detectedHit
2119
+ return (hit && hit.peerId) ? (hit.scope + ':' + hit.peerId) : ''
2120
+ }
2121
+ function saveLocalModel() {
2122
+ var hint = panel ? panel.querySelector('#dk-lm-hint') : null
2123
+ var onEl = panel ? panel.querySelector('#dk-lm-on') : null
2124
+ var dirEl = panel ? panel.querySelector('#dk-lm-dir') : null
2125
+ var gateEl = panel ? panel.querySelector('input[name="dk-lm-gate"]:checked') : null
2126
+ var minEl = panel ? panel.querySelector('#dk-lm-min') : null
2127
+ var ovKey = curOvKey()
2128
+ // ⚠️ 2026-09-13 会话级(主人要求): 评分模式/门槛/启用 存进 **当前会话** 的覆盖项;
2129
+ // 模型目录是账号级(模型只有一份), 仍写账号默认。
2130
+ if (!ovKey) {
2131
+ if (hint) hint.textContent = '✗ 当前会话不是 QQ 会话 —— 单会话设置需先切到与 bot 的群/私聊(顶栏「当前会话」要命中)'
2132
+ return
2133
+ }
2134
+ var ovNext = {
2135
+ enabled: onEl ? !!onEl.checked : true,
2136
+ valueGate: gateEl ? gateEl.value : 'log',
2137
+ valueMinScore: minEl ? (parseFloat(minEl.value) || 0.5) : 0.5,
2138
+ }
2139
+ if (hint) hint.textContent = '保存中…'
2140
+ fetch(READ + (state.ns ? '?' + outNsQ() : '')).then(function (r) { return r.json() }).then(function (d) {
2141
+ var cur = (d && d.value) || {}
2142
+ var lm = Object.assign({}, cur.localModel || {})
2143
+ if (dirEl) lm.modelDir = String(dirEl.value || '').trim() // 账号级
2144
+ lm.overrides = Object.assign({}, (lm.overrides || {}))
2145
+ lm.overrides[ovKey] = ovNext // 会话级
2146
+ var patch = Object.assign({}, cur, { localModel: lm })
2147
+ return fetch(UPDATE, {
2148
+ method: 'POST', headers: { 'content-type': 'application/json' },
2149
+ body: JSON.stringify({ ns: state.ns || undefined, patch: patch, expectedRevision: d && d.revision }),
2150
+ }).then(function (r2) { return r2.json() })
2151
+ }).then(function (res) {
2152
+ if (res && !res.error) {
2153
+ if (hint) hint.textContent = '已保存 ✓(仅对本会话生效, live)'
2154
+ // 立刻把新值写进本地缓存并渲染(不等接口回读)
2155
+ var lm2 = Object.assign({}, state.lmCfg || {})
2156
+ lm2.overrides = Object.assign({}, (lm2.overrides || {}))
2157
+ lm2.overrides[ovKey] = ovNext
2158
+ if (dirEl) lm2.modelDir = String(dirEl.value || '').trim()
2159
+ state.lmCfg = lm2
2160
+ paintBody()
2161
+ setTimeout(function () { loadLocalModel() }, 2000)
2162
+ } else if (hint) hint.textContent = '保存失败: ' + ((res && res.error) || '未知响应')
2163
+ }).catch(function (e) { if (hint) hint.textContent = '保存异常: ' + e.message })
2164
+ }
2165
+ function bindLocalModel() {
2166
+ if (state.tab !== 'session' || !panel) return
2167
+ var hint = function (t) { var el = panel.querySelector('#dk-lm-hint'); if (el) el.textContent = t }
2168
+ var onEl = panel.querySelector('#dk-lm-on')
2169
+ if (onEl) onEl.onchange = function () { saveLocalModel() }
2170
+ // 评分模式 + 门槛: 改动即自动保存
2171
+ // ⚠️ 2026-09-13 修(主人反馈"低分不唤醒怎么保存不了"): 原来只有"启用"开关绑了 onchange,
2172
+ // 点三个评分模式单选按钮不会保存 → 看着像"选不了"; 现在 radio 与门槛都自动保存。
2173
+ panel.querySelectorAll('input[name="dk-lm-gate"]').forEach(function (r) {
2174
+ r.onchange = function () { saveLocalModel() }
2175
+ })
2176
+ var minInput = panel.querySelector('#dk-lm-min')
2177
+ if (minInput) minInput.onchange = function () { saveLocalModel() }
2178
+ // ↩ 恢复继承: 删掉本会话的覆盖项(2026-09-13 会话级)
2179
+ var resetBtn = panel.querySelector('#dk-lm-reset')
2180
+ if (resetBtn) resetBtn.onclick = function () {
2181
+ var h = panel.querySelector('#dk-lm-hint')
2182
+ var ovKey = curOvKey()
2183
+ if (!ovKey) return
2184
+ if (h) h.textContent = '恢复继承中…'
2185
+ fetch(READ + (state.ns ? '?' + outNsQ() : '')).then(function (r) { return r.json() }).then(function (d) {
2186
+ var cur = (d && d.value) || {}
2187
+ var lm = Object.assign({}, cur.localModel || {})
2188
+ var ovs = Object.assign({}, (lm.overrides || {}))
2189
+ delete ovs[ovKey]
2190
+ lm.overrides = ovs
2191
+ var patch = Object.assign({}, cur, { localModel: lm })
2192
+ return fetch(UPDATE, {
2193
+ method: 'POST', headers: { 'content-type': 'application/json' },
2194
+ body: JSON.stringify({ ns: state.ns || undefined, patch: patch, expectedRevision: d && d.revision }),
2195
+ }).then(function (r2) { return r2.json() })
2196
+ }).then(function (res) {
2197
+ if (res && !res.error) {
2198
+ if (h) h.textContent = '已恢复继承账号默认 ✓'
2199
+ if (state.lmCfg && state.lmCfg.overrides) {
2200
+ var o2 = Object.assign({}, state.lmCfg.overrides)
2201
+ delete o2[ovKey]
2202
+ state.lmCfg = Object.assign({}, state.lmCfg, { overrides: o2 })
2203
+ }
2204
+ paintBody()
2205
+ setTimeout(function () { loadLocalModel() }, 2000)
2206
+ } else if (h) h.textContent = '恢复失败: ' + ((res && res.error) || '未知')
2207
+ }).catch(function (e) { if (h) h.textContent = '异常: ' + e.message })
2208
+ }
2209
+ var saveBtn = panel.querySelector('#dk-lm-save')
2210
+ if (saveBtn) saveBtn.onclick = function () { saveLocalModel() }
2211
+ var chkBtn = panel.querySelector('#dk-lm-check')
2212
+ if (chkBtn) chkBtn.onclick = function () { state.localModel = null; paintBody(); loadLocalModel() }
2213
+ var vsBtn = panel.querySelector('#dk-vs-reload')
2214
+ if (vsBtn) vsBtn.onclick = function () { refreshScoreList() }
2215
+ // ✍️ 样例库编辑区
2216
+ var vsToggle = panel.querySelector('#dk-vs-edit-toggle')
2217
+ if (vsToggle) vsToggle.onclick = function () {
2218
+ var box = panel.querySelector('#dk-vs-editor')
2219
+ if (!box) return
2220
+ state.vsOpen = !state.vsOpen // 存进 state: 重绘后不缩回
2221
+ box.style.display = state.vsOpen ? 'block' : 'none'
2222
+ vsToggle.textContent = state.vsOpen ? '收起' : '展开编辑'
2223
+ if (state.vsOpen) loadValueSamplesEditor()
2224
+ }
2225
+ var vsSaveBtn = panel.querySelector('#dk-vs-save')
2226
+ if (vsSaveBtn) vsSaveBtn.onclick = function () { saveValueSamplesEditor() }
2227
+ // 🤖 让ai写(2026-09-13 主人要的): 模拟一条用户消息唤醒她 → 她自己读聊天记录写样例
2228
+ // 目标 = 面板「当前会话」命中的群/私聊(与群管理页一致); 未命中 QQ 会话则明确提示
2229
+ var aiWriteBtn = panel.querySelector('#dk-vs-aiwrite')
2230
+ if (aiWriteBtn) aiWriteBtn.onclick = function () {
2231
+ var h = panel.querySelector('#dk-vs-hint')
2232
+ var hit = state.detectedHit
2233
+ if (!hit || !hit.peerId) {
2234
+ if (h) h.textContent = '✗ 当前会话不是 QQ 会话(顶栏「当前会话」未命中群/私聊) —— 请先切到与 bot 的群/私聊会话'
2235
+ return
2236
+ }
2237
+ if (h) h.textContent = '发给 AI 中…(' + (hit.scope === 'group' ? '群' : '私聊') + '「' + (hit.name || '') + '」)'
2238
+ fetch('/api/qqbot-settings/value-samples/let-ai-write', {
2239
+ method: 'POST', headers: { 'content-type': 'application/json' },
2240
+ body: JSON.stringify({ ns: hit.ns || state.ns, scope: hit.scope, peerId: hit.peerId }),
2241
+ }).then(function (r) { return r.json() }).then(function (d) {
2242
+ if (h) h.textContent = d && d.ok ? ('✓ ' + d.msg) : ('失败: ' + ((d && d.error) || (d && d.msg) || '未知'))
2243
+ }).catch(function (e) { if (h) h.textContent = '异常: ' + e.message })
2244
+ }
2245
+ // ⚙ 单会话设置: 次级标签切换(🧠 本地小模型 / 📄 空白样板)
2246
+ panel.querySelectorAll('[data-sttab]').forEach(function (sb) {
2247
+ sb.onclick = function () {
2248
+ var v = sb.getAttribute('data-sttab')
2249
+ state.stTab = v
2250
+ paintBody()
2251
+ if (v === 'lm') loadLocalModel()
2252
+ }
2253
+ })
2254
+ var dlBtn = panel.querySelector('#dk-lm-dl')
2255
+ if (dlBtn) dlBtn.onclick = function () {
2256
+ dlBtn.disabled = true
2257
+ hint('下载中…(约 23MB, 1~2 分钟, 面板别关)')
2258
+ fetch('/api/qqbot-settings/local-model/download', {
2259
+ method: 'POST', headers: { 'content-type': 'application/json' },
2260
+ body: JSON.stringify({ ns: state.ns }),
2261
+ }).then(function (r) { return r.json() }).then(function (d) {
2262
+ if (d && d.ok) { hint('下载完成 ✓'); state.localModel = null; paintBody(); loadLocalModel() }
2263
+ else { hint('下载失败: ' + ((d && d.error) || JSON.stringify(d))); dlBtn.disabled = false }
2264
+ }).catch(function (e) { hint('下载异常: ' + e.message); dlBtn.disabled = false })
2265
+ }
2266
+ }
1992
2267
  // ⚙️ 出站方式: 读当前账号(ns)配置
1993
2268
  function loadOutMode() {
1994
2269
  if (state.tab !== 'out') return
@@ -2819,6 +3094,16 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2819
3094
  }).join('')
2820
3095
  + '</div>'
2821
3096
  }
3097
+ // ⚙ 单会话设置 · 次级标签栏(2026-09-13 主人要求: 本地小模型独立成子标签, 另留一个空标签当样板)
3098
+ function sessionSubTabs() {
3099
+ var st = state.stTab || 'lm'
3100
+ 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', '📄 空白样板']]
3102
+ .map(function (s) {
3103
+ return '<button class="dk-btn' + (st === s[0] ? ' on' : '') + '" data-sttab="' + s[0] + '">' + s[1] + '</button>'
3104
+ }).join('')
3105
+ + '</div>'
3106
+ }
2822
3107
  // 🚀 M3 群发面板(确认草稿 + 任务列表); 「📇群组管理」与「📤群发·广播」共用(2026-09-10 抽函数)
2823
3108
  function broadcastPanel() {
2824
3109
  if (!state.bcDraft && !(state.bcTasks && state.bcTasks.length)) return ''
@@ -2897,6 +3182,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2897
3182
  + '<button data-t="mute" class="' + (state.tab === 'mute' ? 'on' : '') + '">🔇 禁言</button>'
2898
3183
  + '<button data-t="out" class="' + (state.tab === 'out' ? 'on' : '') + '">⚙️ 出站</button>'
2899
3184
  + '<button data-t="send" class="' + (['send','broadcast','bp','card'].indexOf(state.tab) >= 0 ? 'on' : '') + '">📤 群发</button>'
3185
+ + '<button data-t="session" class="' + (state.tab === 'session' ? 'on' : '') + '">⚙ 单会话设置</button>'
2900
3186
  + '</div>'
2901
3187
  var body = tabs
2902
3188
  var status = state.msg ? '<div class="dk-msg" style="color:#2f9e44;margin:4px 0">' + esc(state.msg) + '</div>' : ''
@@ -3064,6 +3350,100 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3064
3350
  body += '详细主动=聊天行为与适配主动完全一样, 但额外把 AI 的工具调用(🔧 工具调用)与工具结果也推到 QQ —— 能在 QQ 上看她正在干什么; 消息会明显变多, 看完记得切回适配主动。'
3065
3351
  body += '被动=始终以「回复你那条」发出, 连发约4~5条后会被QQ吞掉。完全不出站=本机静默, 不向QQ发任何回复(AI 可用工具随时切回)。本开关对纯web(没绑QQ)的会话不生效。</div>'
3066
3352
  body += '<span class="dk-msg" id="dk-out-hint" style="color:#2f9e44;margin:4px 0"></span>'
3353
+ } else if (state.tab === 'session') {
3354
+ // ⚙ 单会话设置(2026-09-13 主人定): 放"每个会话单独生效"的设置。
3355
+ // 次级标签: 🧠 本地小模型 / 📄 空白样板(留作以后加新设置的模板)。
3356
+ body += sessionSubTabs()
3357
+ var st = state.stTab || 'lm'
3358
+ if (st === 'blank') {
3359
+ body += '<div style="border:1px dashed #cfc7ee;border-radius:8px;padding:20px 14px;margin:8px 0;text-align:center;line-height:2">'
3360
+ + '<div style="font-size:15px;font-weight:700">📄 空白样板</div>'
3361
+ + '<div style="font-size:12px;color:#888">这块先空着 —— 以后要加"每个会话单独生效"的设置, 照这个子标签加就行。<br>'
3362
+ + '(做法: 加一个子标签值 + 一段渲染分支 + 需要的 host API, 就能长出一个新的子页)</div>'
3363
+ + '</div>'
3364
+ } else {
3365
+ var lm = state.localModel
3366
+ body += '<div class="dk-msg" style="line-height:1.6">让机器人在本机跑一个 23MB 的中文小模型(<b>零 token</b>): 价值评分(她该不该开口) + 表情包语义搜索 —— <b>模型缺失时自动关闭, 不影响任何现有功能</b>。</div>'
3367
+ if (!lm) {
3368
+ body += '<div class="dk-empty">读取中…(若长时间不动, 点「🔄 重新检测」)</div>'
3369
+ } else {
3370
+ var lmMb = lm.bytes ? (Math.round((lm.bytes / 1048576) * 10) / 10) : 0
3371
+ var lmState = lm.available ? ('✅ 已就绪' + (lmMb ? ' · ' + lmMb + ' MB' : '')) : ('⚠️ 未安装(缺 ' + (lm.missing || []).length + ' 个文件)')
3372
+ // ⚠️ 2026-09-13 会话级: 评分模式/门槛/启用 **优先取当前会话的覆盖项**(overrides[group:xxx]),
3373
+ // 没有则继承账号级默认; 界面用"继承/已单独设置"标出来。
3374
+ var lmCfg = state.lmCfg || {}
3375
+ var ovKeyV = curOvKey()
3376
+ var ov = (ovKeyV && lmCfg.overrides && lmCfg.overrides[ovKeyV]) || {}
3377
+ var hasOv = !!(typeof ov.enabled === 'boolean' || ov.valueGate || typeof ov.valueMinScore === 'number')
3378
+ var pickGate = function (v) { return (v === 'off' || v === 'log' || v === 'block') ? v : '' }
3379
+ var gateV = pickGate(ov.valueGate) || pickGate(lmCfg.valueGate) || pickGate(lm.valueGate) || 'log'
3380
+ var minV = (typeof ov.valueMinScore === 'number') ? ov.valueMinScore
3381
+ : ((typeof lmCfg.valueMinScore === 'number') ? lmCfg.valueMinScore
3382
+ : (typeof lm.valueMinScore === 'number' ? lm.valueMinScore : 0.5))
3383
+ var onV = (typeof ov.enabled === 'boolean') ? ov.enabled
3384
+ : ((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>'
3386
+ // 本会话标识 + 继承状态(让"单会话"名副其实: 一眼看出这套值是谁的)
3387
+ body += '<div class="dk-row" style="gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 4px">'
3388
+ + '<span class="dk-msg" style="font-size:12px;color:#7c6bd6">本会话: ' + esc(curHitLabel()) + '</span>'
3389
+ + '<span class="dk-msg" style="font-size:11px;color:' + (hasOv ? '#2f9e44' : '#999') + '">'
3390
+ + (hasOv ? '✔ 已单独设置' : '(暂用账号默认)') + '</span>'
3391
+ + (hasOv ? '<button class="dk-btn" id="dk-lm-reset" style="font-size:11px;padding:1px 6px" title="删除本会话的单独设置, 恢复继承账号默认">↩ 恢复继承</button>' : '')
3392
+ + '</div>'
3393
+ 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>'
3395
+ + '<span class="dk-msg" style="font-size:12px;color:' + (lm.available ? '#2f9e44' : '#c23131') + '">' + esc(lmState) + '</span>'
3396
+ + '</div>'
3397
+ body += '<div class="dk-row" style="gap:6px;margin:4px 0;flex-wrap:wrap">'
3398
+ + '<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
+ + '<button class="dk-btn" id="dk-lm-save">💾 保存</button>'
3400
+ + '<button class="dk-btn" id="dk-lm-check">🔄 重新检测</button>'
3401
+ + (lm.available ? '' : '<button class="dk-btn ok" id="dk-lm-dl">⬇️ 下载模型</button>')
3402
+ + '</div>'
3403
+ // 评分模式 + 门槛(2026-09-13): off=不评分 / log=只记录(默认观察期) / block=低分不唤醒
3404
+ body += '<div class="dk-row" style="gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0 2px">'
3405
+ + '<span style="font-size:12px;color:#666">评分模式:</span>'
3406
+ + [['off', '不评分'], ['log', '只记录(先观察)'], ['block', '低分不唤醒']].map(function (m2) {
3407
+ return '<label style="display:inline-flex;align-items:center;gap:4px;font-size:12px;cursor:pointer"><input type="radio" name="dk-lm-gate" value="' + m2[0] + '"' + (gateV === m2[0] ? ' checked' : '') + '> ' + m2[1] + '</label>'
3408
+ }).join('')
3409
+ + '<span style="font-size:12px;color:#666">门槛:</span>'
3410
+ + '<input class="qqs-inp" id="dk-lm-min" type="number" step="0.01" min="0" max="1" style="width:78px" value="' + minV + '">'
3411
+ + '</div>'
3412
+ body += '<span class="dk-msg" id="dk-lm-hint" style="color:#2f9e44;margin:2px 0"></span>'
3413
+ // 📊 最近评分(观察期): 让主人直接看到"她是怎么被叫醒的"
3414
+ // 2026-09-13 二次修: 列表抽成 scoreListHtml + 独立 div, 由 refreshScoreList 做**局部刷新**
3415
+ // (只换这个 div 的内容, 不整块 paintBody) → 既不闪, 又能自动更新。
3416
+ 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
+ + '<span>📊 最近评分(观察期)</span>'
3418
+ + '<span class="dk-msg" style="font-size:11px;color:#7c6bd6" id="dk-vs-scope">' + esc(curHitLabel()) + '</span>'
3419
+ + '<button class="dk-btn" id="dk-vs-reload" style="font-size:12px;padding:1px 8px">🔄 刷新</button>'
3420
+ + '<span class="dk-msg" style="font-size:11px;color:#999">每 10 秒自动更新</span>'
3421
+ + '</div>'
3422
+ body += '<div id="dk-vs-box">' + scoreListHtml(state.valueScores) + '</div>'
3423
+ // ✍️ 样例库编辑(2026-09-13 主人问"哪里写样本"): 直接改 jsonl, 保存后下一条消息生效
3424
+ 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
+ + '<span>✍️ 样例库(决定她的开口标准)</span>'
3426
+ + '<button class="dk-btn" id="dk-vs-edit-toggle" style="font-size:12px;padding:1px 8px">' + (state.vsOpen ? '收起' : '展开编辑') + '</button>'
3427
+ + '<button class="dk-btn ok" id="dk-vs-aiwrite" style="font-size:12px;padding:1px 8px" title="模拟一条用户消息唤醒她, 让她自己读聊天记录写样例(样例库位置+写法一起发过去)">🤖 让ai写(有聊天记录最好)</button>'
3428
+ + '<span class="dk-msg" style="font-size:11px;color:#999" id="dk-vs-stat"></span>'
3429
+ + '</div>'
3430
+ body += '<div id="dk-vs-editor" style="display:' + (state.vsOpen ? 'block' : 'none') + '">'
3431
+ + '<textarea class="qqs-area" id="dk-vs-text" style="width:100%;min-height:170px;font-family:monospace;font-size:11px" placeholder=\'一行一条 JSON: {"m":"群消息文本","y":1} (y=1 她会想接话 / y=0 她不会理)\'></textarea>'
3432
+ + '<div class="dk-row" style="gap:6px;margin-top:4px;align-items:center;flex-wrap:wrap">'
3433
+ + '<button class="dk-btn ok" id="dk-vs-save">💾 保存样例库</button>'
3434
+ + '<span class="dk-msg" id="dk-vs-hint" style="color:#2f9e44"></span>'
3435
+ + '</div>'
3436
+ + '<div class="dk-msg" style="font-size:11px;color:#888;line-height:1.6">一行一条 JSON。保存后<b>下一条群消息</b>就按新样例判断(自动重载, 约 0.3 秒)。'
3437
+ + '想让她自己学 —— 群里说一句「<b>从最近的聊天记录总结一批样本</b>」, 她会读真实聊天记录提炼后写进来 ✧</div>'
3438
+ + '</div>'
3439
+ body += '<div class="dk-msg" style="font-size:12px;color:#888;line-height:1.6">'
3440
+ + (lm.available
3441
+ ? '模型加载是懒加载: 第一次用到(有群消息需要评分)才载入, 约 0.3 秒, 之后常驻内存(~150MB)。'
3442
+ : '点「⬇️ 下载模型」自动拉取(约 23MB, hf-mirror 优先/官方兜底, 1~2 分钟); 也可以自己下载后放进上面目录: '
3443
+ + '<code>onnx/model_quantized.onnx</code> · <code>tokenizer.json</code> · <code>tokenizer_config.json</code> · <code>config.json</code>')
3444
+ + '</div>'
3445
+ }
3446
+ }
3067
3447
  } else if (state.tab === 'card') {
3068
3448
  // 📝 自定义 markdown 卡片(2026-09-10): 源码 + 实时预览 + 按钮配置 → 发送到选中群/私聊
3069
3449
  body += sendSubTabs()
@@ -3188,6 +3568,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3188
3568
  // 📝 卡片: 必须在 paintBody 内绑定(2026-09-10 修复: 原在 tab 点击处先 bind 后 paintBody,
3189
3569
  // 绑的是即将被替换的旧 DOM → 发送/存为事件无反应、预览不渲染)
3190
3570
  if (state.tab === 'card') bindCardEvents()
3571
+ if (state.tab === 'session') bindLocalModel() // ⚠️ 只绑定: 加载由切 tab 时触发(防重绘循环)
3191
3572
  // 📤 广播子页: 绑定群发按钮 + 刷新任务列表(任务列表静默拉取, 不重绘 → 防死循环)
3192
3573
  if (state.tab === 'broadcast') { bindBroadcastEvents(); loadBroadcastTasks(false, true); startBroadcastAdvance() }
3193
3574
  // 分组(host 持久化, 与 AI 共用): 进选目标/广播页时拉一次, 保证多浏览器与 AI 侧看到的是同一份
@@ -3204,6 +3585,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3204
3585
  state.tab = btn.getAttribute('data-t'); state.msg = ''
3205
3586
  if (state.tab === 'join') loadJoins(); else if (state.tab === 'mute') loadMutes()
3206
3587
  else if (state.tab === 'out') loadOutMode()
3588
+ else if (state.tab === 'session') { loadLocalModel(); bindLocalModel() }
3207
3589
  else if (state.tab === 'bp') loadBotplay()
3208
3590
  else if (state.tab === 'chat') { state.chatItems = []; state.chatErr = ''; }
3209
3591
  paintBody()
@@ -3621,6 +4003,33 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3621
4003
  return '<a class="dk-link" href="' + u + '" target="_blank" rel="noopener noreferrer" title="' + u + '">' + u + '</a>' + trail
3622
4004
  })
3623
4005
  }
4006
+ // ── 媒体地址归一(2026-09-13 主人要求: dock 聊天界面图片要**同时兼容 url 与本地路径/图库链接**) ──
4007
+ // 入站消息的图片已改为给**本地路径**(省 token、看图不用再下载), 浏览器直接拿 D:\… 当然加载不出来,
4008
+ // 于是统一转成同源直出 URL: /api/qqbot-settings/chat/raw-media?p=<本机路径>。
4009
+ // http(s) / 已是 /api/... 的(含 sticker-img?id=xxx 图库链接) 一律原样。
4010
+ function chatMediaURL(u) {
4011
+ var s = String(u || '').trim()
4012
+ if (!s) return s
4013
+ if (/^(https?:|\/\/|\/api\/|data:|blob:)/i.test(s)) return s
4014
+ if (/^[A-Za-z]:[\\/]/.test(s) || /^\\\\/.test(s) || s.charAt(0) === '/') {
4015
+ return '/api/qqbot-settings/chat/raw-media?p=' + encodeURIComponent(s)
4016
+ }
4017
+ return s
4018
+ }
4019
+ // 图库兜底: 消息里记的本地路径是"当时"的(candidate→library 搬层后就失效),
4020
+ // 但文件名正是图库 id(12 位 sha1) → 加载失败时再用 sticker-img?id= 试一次(按 id 解析, 与层无关)
4021
+ function chatStickerFallback(src) {
4022
+ var s = String(src || '')
4023
+ if (s.indexOf('/chat/raw-media?p=') >= 0) {
4024
+ try { s = decodeURIComponent(s.split('p=')[1] || '') } catch (e) { /* 保持原样 */ }
4025
+ }
4026
+ var base = s.replace(/[?#].*$/, '').split(/[\\/]/).pop() || ''
4027
+ var idm = base.match(/^([0-9a-f]{12})\.([a-z0-9]{2,5})$/i)
4028
+ if (!idm) return ''
4029
+ var dd = ''
4030
+ try { dd = curDataDir() || '' } catch (e) { dd = '' }
4031
+ return '/api/qqbot-settings/sticker-img?id=' + encodeURIComponent(idm[1]) + (dd ? '&dataDir=' + encodeURIComponent(dd) : '')
4032
+ }
3624
4033
  function renderChatList(keepScrollOffset) {
3625
4034
  var box = document.getElementById('dk-chat-box')
3626
4035
  if (!box) return
@@ -3651,12 +4060,19 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3651
4060
  var u = typeof m === 'string' ? m : (m && m.url)
3652
4061
  var k = typeof m === 'string' ? 'image' : ((m && m.kind) || 'image')
3653
4062
  if (!u) return ''
4063
+ u = chatMediaURL(u) // 本机路径 → 同源直出 URL(兼容 url / 本地路径 / 图库链接)
3654
4064
  if (k === 'file') return '<a class="dk-file" href="' + esc(u) + '" target="_blank" rel="noopener" title="' + esc(u) + '">📎 ' + esc((m && m.name) || '文件') + '</a>'
3655
4065
  if (k === 'voice') return '<audio class="dk-audio" controls preload="metadata" src="' + esc(u) + '"></audio>'
3656
4066
  if (k === 'video') return '<video class="dk-video" controls preload="metadata" src="' + esc(u) + '"></video>'
3657
- return '<img class="dk-img" loading="lazy" data-lb="' + esc(u) + '" src="' + esc(u) + '" referrerpolicy="no-referrer" alt="[图片]" onerror="this.outerHTML=\'<span style=color:#888>[图加载失败]</span>\'">'
4067
+ // 兜底链(按序试): ① 后端给的 QQ 链接(台账兜底, 本地文件被清了也能显示)
4068
+ // ② 图库 id 链接 sticker-img?id=(路径搬层后的旧路径救不回时用)
4069
+ var fbs = []
4070
+ if (m && m.fb && String(m.fb) !== String(u)) fbs.push(String(m.fb))
4071
+ var sf = chatStickerFallback(m, u)
4072
+ if (sf && sf !== u && fbs.indexOf(sf) < 0) fbs.push(sf)
4073
+ return '<img class="dk-img" loading="lazy" data-lb="' + esc(u) + '"' + (fbs.length ? ' data-fb="' + esc(JSON.stringify(fbs)) + '"' : '') + ' src="' + esc(u) + '" referrerpolicy="no-referrer" alt="[图片]">'
3658
4074
  }).join('')
3659
- var txt = it.text ? '<div>' + linkifyText(esc(mdPlain(it.text))) + '</div>' : ''
4075
+ var txt = it.text ? chatRenderText(it.text) : ''
3660
4076
  return '<div class="dk-crow ' + (isOut ? 'out' : 'in') + '">'
3661
4077
  + '<div class="dk-ava">' + ava + '</div>'
3662
4078
  + '<div class="dk-cmain">'
@@ -3669,14 +4085,44 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3669
4085
  box.innerHTML = html
3670
4086
  // 图片: 单击/右键 → 灯箱放大(右键不再弹浏览器菜单, 避免误触发 onerror 变加载失败)
3671
4087
  box.querySelectorAll('img.dk-img').forEach(function (img) {
3672
- var lb = img.getAttribute('data-lb') || img.src
3673
- img.onclick = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(lb) }
3674
- img.oncontextmenu = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(lb) }
4088
+ var lbOf = function () { return img.getAttribute('data-lb') || img.src }
4089
+ img.onclick = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(lbOf()) }
4090
+ img.oncontextmenu = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(lbOf()) }
4091
+ // 加载失败 → 按兜底链依次重试(QQ 链接 → 图库 id 链接) ② 全都失败才灰字提示
4092
+ img.onerror = function () {
4093
+ var list = []
4094
+ try { list = JSON.parse(img.getAttribute('data-fb') || '[]') || [] } catch (e) { list = [] }
4095
+ var i = Number(img.getAttribute('data-fbi') || '0')
4096
+ if (Array.isArray(list) && i < list.length) {
4097
+ img.setAttribute('data-fbi', String(i + 1))
4098
+ img.setAttribute('data-lb', String(list[i]))
4099
+ img.src = String(list[i])
4100
+ return
4101
+ }
4102
+ img.outerHTML = '<span style="color:#888">[图加载失败]</span>'
4103
+ }
3675
4104
  })
3676
4105
  // 滚动: 刷新/切目标 → 滚到底; 上滚加载更早 → 保持视口
3677
4106
  if (keepScrollOffset == null) box.scrollTop = box.scrollHeight
3678
4107
  else box.scrollTop = box.scrollHeight - keepScrollOffset
3679
4108
  }
4109
+ // 聊天文本渲染(2026-09-13 引用消息功能): 把 [Quoted message begins]…[Quoted message ends] 块
4110
+ // 渲染成"引用样式"气泡(左边框+灰底), 其余正文照常显示。没有引用块则整段照常。
4111
+ function chatRenderText(t) {
4112
+ var src = String(t || '')
4113
+ var re = /\[Quoted message begins\]([\s\S]*?)\[Quoted message ends\]/g
4114
+ var parts = []
4115
+ var m, last = 0
4116
+ while ((m = re.exec(src)) !== null) {
4117
+ if (m.index > last) parts.push('<div>' + linkifyText(esc(mdPlain(src.slice(last, m.index)))) + '</div>')
4118
+ var q = (m[1] || '').replace(/^\s*\n/, '').replace(/\n\s*$/, '')
4119
+ parts.push('<div style="border-left:3px solid #5b7cfa;background:rgba(91,124,250,.10);padding:5px 9px;margin:2px 0 7px;border-radius:6px;color:#9fb0ff;font-size:12px;white-space:pre-wrap">↩ ' + linkifyText(esc(mdPlain(q))) + '</div>')
4120
+ last = m.index + m[0].length
4121
+ }
4122
+ if (last < src.length) parts.push('<div>' + linkifyText(esc(mdPlain(src.slice(last)))) + '</div>')
4123
+ if (!parts.length) parts.push('<div>' + linkifyText(esc(mdPlain(src))) + '</div>')
4124
+ return parts.join('')
4125
+ }
3680
4126
  // 头像字符: 群友取昵称首字; 私聊/无名兜底
3681
4127
  function chatAvaOf(who) {
3682
4128
  var s = String(who || '').trim()
@@ -11,4 +11,19 @@ export declare const inject: string[];
11
11
  export declare function setChannelBridge(b: QQChannel | undefined): void;
12
12
  /** 装载本插件时把 qqChannel 一并注入(由 dsh-qqbot setup 提供) */
13
13
  export declare function apply(ctx: Context): Promise<void>;
14
+ /**
15
+ * host 面板用(2026-09-13 主人要的「让ai写」按钮):
16
+ * **照抄定时任务的做法** —— 伪造一条消息直接调 handleInbound 唤醒该群/私聊的 AI 回合
17
+ * (见 features/scheduler.ts fireTask: 与 SDK 消息同形, messageId 空 → 出站自动走主动推送;
18
+ * senderName 用中性名、不打假 (@you), 防污染主人交互记忆)。
19
+ *
20
+ * 为什么不复用高层轮子: wakeSessionAgent 要先配"群组管理器会话"; injectSynthetic 是塞聚合窗口(要等人停口);
21
+ * 而面板按钮要的是**立刻触发** —— 与"定时任务到点必须触发回合"同理, 直调 handleInbound 最贴合。
22
+ *
23
+ * host 半边(settings-host.js) 通过 import('@zaofan/dsh-qqbot/channel-tools') 调用(manager 只在插件侧)。
24
+ */
25
+ export declare function askAiToWriteSamples(ns: string, scope: 'group' | 'c2c', peerId: string): Promise<{
26
+ ok: boolean;
27
+ msg: string;
28
+ }>;
14
29
  //# sourceMappingURL=channel-tools.d.ts.map
@@ -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;AAwElE,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"}
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"}