@zaofan/dsh-qqbot 1.4.1 → 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.
- package/README.md +69 -0
- package/client/qqbot-settings.js +429 -4
- package/dist/channel-tools.d.ts +15 -0
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +59 -1
- package/dist/channel-tools.js.map +1 -1
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +15 -1
- package/dist/config.js.map +1 -1
- package/dist/features/image-path-cache.d.ts +23 -0
- package/dist/features/image-path-cache.d.ts.map +1 -0
- package/dist/features/image-path-cache.js +38 -0
- package/dist/features/image-path-cache.js.map +1 -0
- package/dist/features/image-url-ledger.d.ts +19 -0
- package/dist/features/image-url-ledger.d.ts.map +1 -0
- package/dist/features/image-url-ledger.js +133 -0
- package/dist/features/image-url-ledger.js.map +1 -0
- package/dist/features/local-embed.d.ts +43 -0
- package/dist/features/local-embed.d.ts.map +1 -0
- package/dist/features/local-embed.js +143 -0
- package/dist/features/local-embed.js.map +1 -0
- package/dist/features/sticker-store.d.ts +48 -2
- package/dist/features/sticker-store.d.ts.map +1 -1
- package/dist/features/sticker-store.js +156 -1
- package/dist/features/sticker-store.js.map +1 -1
- package/dist/features/value-sample.d.ts +25 -0
- package/dist/features/value-sample.d.ts.map +1 -0
- package/dist/features/value-sample.js +122 -0
- package/dist/features/value-sample.js.map +1 -0
- package/dist/features/value-score.d.ts +36 -0
- package/dist/features/value-score.d.ts.map +1 -0
- package/dist/features/value-score.js +122 -0
- package/dist/features/value-score.js.map +1 -0
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +4 -0
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +16 -5
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +12 -1
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/sticker-capture.d.ts +8 -2
- package/dist/middleware/sticker-capture.d.ts.map +1 -1
- package/dist/middleware/sticker-capture.js +63 -45
- package/dist/middleware/sticker-capture.js.map +1 -1
- package/dist/model/model-resolver.js +12 -6
- package/dist/model/model-resolver.js.map +1 -1
- package/dist/transport/attachment.d.ts +5 -1
- package/dist/transport/attachment.d.ts.map +1 -1
- package/dist/transport/attachment.js +16 -5
- package/dist/transport/attachment.js.map +1 -1
- package/dist/transport/inbound.d.ts.map +1 -1
- package/dist/transport/inbound.js +351 -9
- package/dist/transport/inbound.js.map +1 -1
- package/dist/transport/inject-rules.d.ts.map +1 -1
- package/dist/transport/inject-rules.js +3 -1
- package/dist/transport/inject-rules.js.map +1 -1
- package/package.json +3 -2
- package/scripts/download-model.mjs +110 -0
- package/settings-host.js +337 -5
package/README.md
CHANGED
|
@@ -253,6 +253,75 @@ npx @deepseek-ai/dsh web --patch /path/to/dsh-qqbot/cordis.dev.yml
|
|
|
253
253
|
|
|
254
254
|
**配置项**(Web 面板 ⑥ 可改, 见下表 `groupAdmin.*`)
|
|
255
255
|
|
|
256
|
+
## 🧠 本地小模型(可选,省 token)(v1.4.5+)
|
|
257
|
+
|
|
258
|
+
让插件先用一个**跑在本机的小模型**给群消息打分(「价值评分」):分数低于门槛、又**没被 @** 的闲聊
|
|
259
|
+
**直接不唤醒 AI** → 那一轮 token 就省下来了;被 @ 的永远放行,**带图的消息一律放行**(群友发图多半是给她看的)。
|
|
260
|
+
|
|
261
|
+
- **模型**:`bge-small-zh-v1.5`(中文专训,ONNX 量化版 **≈ 23MB**,CPU 毫秒级,**零 token、完全离线**,不上传任何内容)
|
|
262
|
+
- **位置**:`{DSH_HOME | ~/.dsh}/models/bge-small-zh/`(用户级,跨工作区共用一份;也可在配置里指别的目录)
|
|
263
|
+
- **缺了也不影响使用**:检测不到模型就**自动静默关闭**评分,插件照常跑(只是不再省 token)
|
|
264
|
+
|
|
265
|
+
### ① 一条命令下载(推荐)
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
node scripts/download-model.mjs # 源码分发(在插件仓库根目录跑)
|
|
269
|
+
node node_modules/@zaofan/dsh-qqbot/scripts/download-model.mjs # npm 装的插件(npm 目录内)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
默认**优先走国内镜像** `hf-mirror.com`(失败自动换官方源),下载完会校验体积并打印后续步骤。可选参数:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
node scripts/download-model.mjs --dir "D:\models\bge-small-zh" # 换目录(填进插件配置 localModel.modelDir)
|
|
276
|
+
node scripts/download-model.mjs --source hf # 强制官方源
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### ② 手动下载(就三个文件)
|
|
280
|
+
|
|
281
|
+
把 `<源>` 换成 `https://hf-mirror.com` 或 `https://huggingface.co`,文件放到 `~/.dsh/models/bge-small-zh/`:
|
|
282
|
+
|
|
283
|
+
| 下载地址 | 存放位置 | 体积参考 |
|
|
284
|
+
|---|---|---|
|
|
285
|
+
| `<源>/BAAI/bge-small-zh-v1.5/resolve/main/onnx/model_quantized.onnx` | `bge-small-zh/onnx/model_quantized.onnx` | ≈ 23MB |
|
|
286
|
+
| `<源>/BAAI/bge-small-zh-v1.5/resolve/main/tokenizer.json` | `bge-small-zh/tokenizer.json` | ≈ 430KB |
|
|
287
|
+
| `<源>/BAAI/bge-small-zh-v1.5/resolve/main/config.json` | `bge-small-zh/config.json` | < 1KB |
|
|
288
|
+
|
|
289
|
+
Windows PowerShell 例子:
|
|
290
|
+
|
|
291
|
+
```powershell
|
|
292
|
+
$dir = "$env:USERPROFILE\.dsh\models\bge-small-zh"
|
|
293
|
+
$base = "https://hf-mirror.com/BAAI/bge-small-zh-v1.5/resolve/main"
|
|
294
|
+
New-Item -ItemType Directory -Force "$dir\onnx" | Out-Null
|
|
295
|
+
Invoke-WebRequest "$base/onnx/model_quantized.onnx" -OutFile "$dir\onnx\model_quantized.onnx"
|
|
296
|
+
Invoke-WebRequest "$base/tokenizer.json" -OutFile "$dir\tokenizer.json"
|
|
297
|
+
Invoke-WebRequest "$base/config.json" -OutFile "$dir\config.json"
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### ③ 交给 AI 做(把下面这段直接粘给你的 AI / dsh 里的她)
|
|
301
|
+
|
|
302
|
+
```text
|
|
303
|
+
请帮我在本机装好 dsh 的 qqbot 插件要用的本地小模型(离线、不上传内容):
|
|
304
|
+
1. 下载 BAAI/bge-small-zh-v1.5 的三个文件到 `~/.dsh/models/bge-small-zh/`:
|
|
305
|
+
onnx/model_quantized.onnx(≈23MB)、tokenizer.json、config.json;
|
|
306
|
+
国内优先用 https://hf-mirror.com,失败再试 https://huggingface.co。
|
|
307
|
+
2. 目录结构必须是:<模型目录>/onnx/model_quantized.onnx、<模型目录>/tokenizer.json、<模型目录>/config.json
|
|
308
|
+
3. 下完自己校验:model_quantized.onnx ≥ 20MB、tokenizer.json ≥ 300KB;
|
|
309
|
+
也可以直接跑插件仓库里的一键脚本:`node scripts/download-model.mjs`
|
|
310
|
+
4. 最后告诉我:插件设置页「本地小模型」这一项该填什么、以及三个评分模式 off / log / block 分别什么行为。
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### 评分模式怎么选
|
|
314
|
+
|
|
315
|
+
| 模式 | 行为 | 建议 |
|
|
316
|
+
|---|---|---|
|
|
317
|
+
| `off` | 完全不评分,所有消息照常唤醒 | 不想掺和 |
|
|
318
|
+
| `log` | **只记分,不拦** | **观察期**:先跑几天,在面板「最近评分」看分准不准 |
|
|
319
|
+
| `block` | 低于门槛(默认 `0.5`)**不唤醒** | 正式使用、省 token |
|
|
320
|
+
|
|
321
|
+
- **会话级覆盖**:dock「⚙ 单会话设置」里可以**按群单独设**模式 / 门槛 / 开关(存 `settings.yaml` 的 `localModel.overrides`),**保存即时生效,无需重启**(v1.4.5 起)。
|
|
322
|
+
- **拦截边界**:被 @ 的永远放行;带图一律放行;**只有被拦在唤醒之前**(没花 token)的那次才会**回滚**本群的回复冷却 —— 冷却本来就是用来省 token 的,token 花了就不算白花。
|
|
323
|
+
- **评分记录**:`{dataRoot}/.qqbot/value-scores.jsonl`,一行一条,字段 `score / worth / gate / min / conf / mention / img / lib / agg / top`。面板能直读,也可以让 AI 读它来维护样例库(`{dataRoot}/.qqbot/value-samples.jsonl`:`{"m":"消息文本","y":1|0}`,y=1 表示"她会想接话")。
|
|
324
|
+
|
|
256
325
|
## 配置项
|
|
257
326
|
|
|
258
327
|
| 配置 | 类型 | 默认值 | 说明 |
|
package/client/qqbot-settings.js
CHANGED
|
@@ -1993,6 +1993,277 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
1993
1993
|
return ac ? (ac.dataDir || '') : ''
|
|
1994
1994
|
}
|
|
1995
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
|
+
}
|
|
1996
2267
|
// ⚙️ 出站方式: 读当前账号(ns)配置
|
|
1997
2268
|
function loadOutMode() {
|
|
1998
2269
|
if (state.tab !== 'out') return
|
|
@@ -2823,6 +3094,16 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
2823
3094
|
}).join('')
|
|
2824
3095
|
+ '</div>'
|
|
2825
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
|
+
}
|
|
2826
3107
|
// 🚀 M3 群发面板(确认草稿 + 任务列表); 「📇群组管理」与「📤群发·广播」共用(2026-09-10 抽函数)
|
|
2827
3108
|
function broadcastPanel() {
|
|
2828
3109
|
if (!state.bcDraft && !(state.bcTasks && state.bcTasks.length)) return ''
|
|
@@ -2901,6 +3182,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
2901
3182
|
+ '<button data-t="mute" class="' + (state.tab === 'mute' ? 'on' : '') + '">🔇 禁言</button>'
|
|
2902
3183
|
+ '<button data-t="out" class="' + (state.tab === 'out' ? 'on' : '') + '">⚙️ 出站</button>'
|
|
2903
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>'
|
|
2904
3186
|
+ '</div>'
|
|
2905
3187
|
var body = tabs
|
|
2906
3188
|
var status = state.msg ? '<div class="dk-msg" style="color:#2f9e44;margin:4px 0">' + esc(state.msg) + '</div>' : ''
|
|
@@ -3068,6 +3350,100 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
3068
3350
|
body += '详细主动=聊天行为与适配主动完全一样, 但额外把 AI 的工具调用(🔧 工具调用)与工具结果也推到 QQ —— 能在 QQ 上看她正在干什么; 消息会明显变多, 看完记得切回适配主动。'
|
|
3069
3351
|
body += '被动=始终以「回复你那条」发出, 连发约4~5条后会被QQ吞掉。完全不出站=本机静默, 不向QQ发任何回复(AI 可用工具随时切回)。本开关对纯web(没绑QQ)的会话不生效。</div>'
|
|
3070
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
|
+
}
|
|
3071
3447
|
} else if (state.tab === 'card') {
|
|
3072
3448
|
// 📝 自定义 markdown 卡片(2026-09-10): 源码 + 实时预览 + 按钮配置 → 发送到选中群/私聊
|
|
3073
3449
|
body += sendSubTabs()
|
|
@@ -3192,6 +3568,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
3192
3568
|
// 📝 卡片: 必须在 paintBody 内绑定(2026-09-10 修复: 原在 tab 点击处先 bind 后 paintBody,
|
|
3193
3569
|
// 绑的是即将被替换的旧 DOM → 发送/存为事件无反应、预览不渲染)
|
|
3194
3570
|
if (state.tab === 'card') bindCardEvents()
|
|
3571
|
+
if (state.tab === 'session') bindLocalModel() // ⚠️ 只绑定: 加载由切 tab 时触发(防重绘循环)
|
|
3195
3572
|
// 📤 广播子页: 绑定群发按钮 + 刷新任务列表(任务列表静默拉取, 不重绘 → 防死循环)
|
|
3196
3573
|
if (state.tab === 'broadcast') { bindBroadcastEvents(); loadBroadcastTasks(false, true); startBroadcastAdvance() }
|
|
3197
3574
|
// 分组(host 持久化, 与 AI 共用): 进选目标/广播页时拉一次, 保证多浏览器与 AI 侧看到的是同一份
|
|
@@ -3208,6 +3585,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
3208
3585
|
state.tab = btn.getAttribute('data-t'); state.msg = ''
|
|
3209
3586
|
if (state.tab === 'join') loadJoins(); else if (state.tab === 'mute') loadMutes()
|
|
3210
3587
|
else if (state.tab === 'out') loadOutMode()
|
|
3588
|
+
else if (state.tab === 'session') { loadLocalModel(); bindLocalModel() }
|
|
3211
3589
|
else if (state.tab === 'bp') loadBotplay()
|
|
3212
3590
|
else if (state.tab === 'chat') { state.chatItems = []; state.chatErr = ''; }
|
|
3213
3591
|
paintBody()
|
|
@@ -3625,6 +4003,33 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
3625
4003
|
return '<a class="dk-link" href="' + u + '" target="_blank" rel="noopener noreferrer" title="' + u + '">' + u + '</a>' + trail
|
|
3626
4004
|
})
|
|
3627
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
|
+
}
|
|
3628
4033
|
function renderChatList(keepScrollOffset) {
|
|
3629
4034
|
var box = document.getElementById('dk-chat-box')
|
|
3630
4035
|
if (!box) return
|
|
@@ -3655,10 +4060,17 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
3655
4060
|
var u = typeof m === 'string' ? m : (m && m.url)
|
|
3656
4061
|
var k = typeof m === 'string' ? 'image' : ((m && m.kind) || 'image')
|
|
3657
4062
|
if (!u) return ''
|
|
4063
|
+
u = chatMediaURL(u) // 本机路径 → 同源直出 URL(兼容 url / 本地路径 / 图库链接)
|
|
3658
4064
|
if (k === 'file') return '<a class="dk-file" href="' + esc(u) + '" target="_blank" rel="noopener" title="' + esc(u) + '">📎 ' + esc((m && m.name) || '文件') + '</a>'
|
|
3659
4065
|
if (k === 'voice') return '<audio class="dk-audio" controls preload="metadata" src="' + esc(u) + '"></audio>'
|
|
3660
4066
|
if (k === 'video') return '<video class="dk-video" controls preload="metadata" src="' + esc(u) + '"></video>'
|
|
3661
|
-
|
|
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="[图片]">'
|
|
3662
4074
|
}).join('')
|
|
3663
4075
|
var txt = it.text ? chatRenderText(it.text) : ''
|
|
3664
4076
|
return '<div class="dk-crow ' + (isOut ? 'out' : 'in') + '">'
|
|
@@ -3673,9 +4085,22 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
|
|
|
3673
4085
|
box.innerHTML = html
|
|
3674
4086
|
// 图片: 单击/右键 → 灯箱放大(右键不再弹浏览器菜单, 避免误触发 onerror 变加载失败)
|
|
3675
4087
|
box.querySelectorAll('img.dk-img').forEach(function (img) {
|
|
3676
|
-
var
|
|
3677
|
-
img.onclick = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(
|
|
3678
|
-
img.oncontextmenu = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(
|
|
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
|
+
}
|
|
3679
4104
|
})
|
|
3680
4105
|
// 滚动: 刷新/切目标 → 滚到底; 上滚加载更早 → 保持视口
|
|
3681
4106
|
if (keepScrollOffset == null) box.scrollTop = box.scrollHeight
|
package/dist/channel-tools.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|