dsh-qqbot-buddy 1.0.0-alpha.0 → 1.0.0-alpha.2

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 CHANGED
@@ -4,9 +4,10 @@
4
4
  支持配置档(Profile)/作用域绑定、可管理的工具与技能、SQLite + FTS5 长期记忆与知识库、
5
5
  群友库、人格卡与表情包,并提供多机器人隔离运行。
6
6
 
7
- - 适用版本:`1.0.0-alpha.0`(v2 分支)
7
+ - 适用版本:`1.0.0-alpha.2`(v2 分支)
8
8
  - 运行环境:**Node.js ≥ 24**(使用内置 `node:sqlite`)、dsh 0.2.x
9
9
  - 依赖:QQ 官方机器人平台的 `appId` / `appSecret`(WebSocket 接入,无需公网回调)
10
+ - npm:[dsh-qqbot-buddy](https://www.npmjs.com/package/dsh-qqbot-buddy)(当前 alpha 走 `@next` 标签)
10
11
  - 许可证:MIT
11
12
 
12
13
  ---
@@ -15,7 +16,7 @@
15
16
 
16
17
  | 模块 | 说明 |
17
18
  | --- | --- |
18
- | 独立控制台 | 随插件进程启动的 Web 管理台(默认 `127.0.0.1:6198`),token 登录、SSE 实时事件、开机即用,无需额外部署前端或数据库 |
19
+ | 独立控制台 | 随插件进程启动的 Web 管理台(默认 `127.0.0.1:6198`),token 登录、SSE 实时事件、开机即用,无需额外部署前端或数据库;与 dsh Web 双向快捷入口(控制台顶栏 → dsh Web;dsh Web 右下角 → QQ 控制台) |
19
20
  | 多机器人 | 每个 bot 独立工作目录 `<workspace>/bots/<botId>`,凭据、会话、日志互不影响;同一进程可管理多个 bot |
20
21
  | 配置档与绑定 | 三层策略:`global < bot < profile`;按 `群聊 / 私聊 / 指定 openid` 绑定不同配置档(精确 > 类型通配 > bot 通配 > 默认档),切换配置档不丢记忆 |
21
22
  | 提示词管理 | 人设卡、人格卡、口头禅、能力边界、工具指引;控制台提供**提示词拼合预览**,按真实顺序查看每段内容与字符数 |
@@ -63,7 +64,23 @@
63
64
  - 已安装 dsh(`dsh --version` 可用),并初始化过至少一个 profile
64
65
  - QQ 官方机器人平台的 bot(获取 `appId`、`appSecret`,并在平台开启所需的群/私聊权限)
65
66
 
66
- ### 方式一:从 GitHub 安装(推荐)
67
+ ### 方式一:npm 安装(推荐)
68
+
69
+ ```bash
70
+ # 装到默认 web profile(当前是 alpha 版本,使用 @next 标签)
71
+ dsh plugin --profile web add dsh-qqbot-buddy@next
72
+
73
+ # 或装到指定 profile
74
+ dsh plugin --profile qqbot add dsh-qqbot-buddy@next
75
+
76
+ # 或使用安装器(自动校验/初始化 profile、登记 bundle、自检)
77
+ npx -y --package=dsh-qqbot-buddy@next dsh-qqbot-buddy-install --profile qqbot --spec dsh-qqbot-buddy@next
78
+ ```
79
+
80
+ > 稳定版发布后可直接 `dsh plugin add dsh-qqbot-buddy`(不带 `@next`)。
81
+ > 安装完成后**重启 dsh**,控制台随插件一起启动。
82
+
83
+ ### 方式二:从 GitHub 安装(无需 npm 账号)
67
84
 
68
85
  ```bash
69
86
  # 安装到默认 web profile
@@ -77,9 +94,8 @@ npx -y github:Ethereal-ljq/dsh-qqbot-buddy#v2 --dry-run
77
94
  ```
78
95
 
79
96
  安装器会完成:初始化/校验 profile → 安装插件与运行期依赖 → 登记进 `dsh.profile.bundles` → 自检。
80
- 安装完成后**重启 dsh**,控制台随插件一起启动。
81
97
 
82
- ### 方式二:本地开发安装
98
+ ### 方式三:本地开发安装
83
99
 
84
100
  ```bash
85
101
  git clone -b v2 https://github.com/Ethereal-ljq/dsh-qqbot-buddy.git
@@ -191,7 +207,8 @@ QQ 开放平台 (WebSocket)
191
207
  ## 开发
192
208
 
193
209
  ```bash
194
- npm test # 单元测试(node --test,434 例)
210
+ npm test # 单元测试(node --test,437 例)
211
+ npm run verify:recall # 真机验证记忆/知识库自动注入(需要控制台在线)
195
212
  npm run console:build # tsc 严格检查 + vite 构建控制台
196
213
  npm run console:dev # 控制台前端开发模式(代理到 6198)
197
214
  node scripts/dev-console.mjs # 只启动控制台后端(不连 QQ)
@@ -234,4 +251,4 @@ scripts/ 安装器、迁移、探针等工具
234
251
 
235
252
  ## 许可证
236
253
 
237
- [MIT](LICENSE)
254
+ [MIT](LICENSE)
@@ -31,11 +31,23 @@ Web 管理控制台。你不需要额外部署数据库、消息队列或前端
31
31
 
32
32
  ## 1. 安装、升级与卸载
33
33
 
34
- ### 1.1 从 GitHub 安装(推荐)
34
+ ### 1.1 安装(npm 推荐 / GitHub 备选)
35
+
36
+ **方式 A:npm(推荐)**
37
+
38
+ dsh plugin --profile web add dsh-qqbot-buddy@next
39
+ # 或指定 profile
40
+ dsh plugin --profile qqbot add dsh-qqbot-buddy@next
41
+ # 或使用安装器(自动初始化/校验 profile、登记 bundle、自检)
42
+ npx -y --package=dsh-qqbot-buddy@next dsh-qqbot-buddy-install --profile qqbot --spec dsh-qqbot-buddy@next
43
+
44
+ 当前发布的是 `1.0.0-alpha.0`,使用 `@next` 标签;稳定版发布后可直接 `dsh plugin add dsh-qqbot-buddy`。
45
+
46
+ **方式 B:从 GitHub 安装(无需 npm 账号)**
35
47
 
36
48
  npx -y github:Ethereal-ljq/dsh-qqbot-buddy#v2
37
49
 
38
- 安装器会:把插件包装进 dsh profile(默认 `web`)→ 登记到 `dsh.profile.bundles` →
50
+ 安装器会:把插件装进 dsh profile(默认 `web`)→ 登记到 `dsh.profile.bundles` →
39
51
  用 `dsh --profile web --dump-config` 自检插件已进入插件树。安装过程**不构建前端、不联网拉前端依赖**。
40
52
 
41
53
  常用参数:
@@ -57,10 +69,10 @@ Web 管理控制台。你不需要额外部署数据库、消息队列或前端
57
69
  `--local` 会跳过 `node_modules`、`.git`、`.work` 和顶层 `test`,不会经过 pnpm。
58
70
  前端源码改动后需要 `npm run build:web` 重建 `web/dist` 再本地安装。
59
71
 
60
- > 全新 DSH_HOME 无需手工初始化:安装器检测到 profile 不存在或缺少
61
- > `@deepseek-ai/dsh-base` / `@deepseek-ai/dsh-web-app` 时,会自动用 dsh 随附模板初始化
62
- > (等价的命令是 `dsh --profile web --dump-default-config`),然后再安装本插件。
63
- > 若你看到「初始化 profile(使用 dsh 随附模板)」说明走了这条路径。
72
+ > 全新 DSH_HOME 无需手工初始化:安装器检测到 profile 不存在时,会用 `npm pack` 打出的
73
+ > 插件包执行 `dsh plugin --profile <name> add <tgz>` 完成 profile 初始化并装齐运行期依赖,
74
+ > 再把插件目录覆盖为纯文件副本(不依赖仓库 `node_modules`)。远程安装则由 `dsh plugin add`
75
+ > 自动创建 profile。若你看到「初始化 profile(dsh plugin add)」说明走了这条路径。
64
76
 
65
77
  ### 1.3 安装后首次启动
66
78
 
@@ -167,6 +179,12 @@ Web 管理控制台。你不需要额外部署数据库、消息队列或前端
167
179
  > 顶栏右上角有 **「dsh Web ↗」** 按钮:一键打开当前 dsh 进程的 Web 界面(链接自带本进程的鉴权 token,无需再手抄)。
168
180
  > 注意:该 token 每次重启 dsh 都会变,因此按钮只在当前进程生命周期内有效;不要把带 token 的链接转发给别人。
169
181
 
182
+ **反向入口(dsh Web → 控制台)**:插件同时带一个 dsh Web 客户端入口——安装本插件的
183
+ web profile 会在页面右下角显示悬浮的 **「QQ 控制台」** 按钮,点击经同源路由 `/qqbot-dashboard`
184
+ 302 到控制台(端口/地址跟随配置,不需要改前端)。若 web profile 只用于放入口、控制台跑在
185
+ 机器人 profile 里,可在该 profile 的 patch 配置里设 `console.enabled: false`(不重复启动控制台)
186
+ 并把 `console.port` 指向已有控制台端口即可。
187
+
170
188
  ![控制台总览](images/console-overview.png)
171
189
 
172
190
  *图 1:控制台总览——机器人运行状态、SSE 事件流与配置规模一屏可见。*
@@ -439,6 +457,38 @@ v2 暂未提供口头禅编辑 UI。v1 → v2 迁移时,旧 `phrases` / `promp
439
457
 
440
458
  *图 5:表情包页——按 (bot, hash) 去重,显示当日发送量与配额。*
441
459
 
460
+ ## 7.5 验证记忆与知识库是否自动注入
461
+
462
+ 不用真的在群里聊,控制台提供两条验证路径:
463
+
464
+ **方式一:一键黑盒验证(推荐)**
465
+
466
+ node scripts/verify-recall.mjs
467
+ # 或:npm run verify:recall
468
+ # 指定机器人 / 问句 / 保留测试数据
469
+ node scripts/verify-recall.mjs --bot bot_xxx --query "记忆暗号是什么?知识库暗号是什么?" --keep
470
+
471
+ 脚本会通过控制台 API 写入带唯一暗号的临时记忆 + 知识库文档,然后:
472
+ 预览接口检查 `qqbot:memory` / `qqbot:knowledge` 段 → reset 测试 peer → 触发一轮真实模型对话 →
473
+ 读取会话文件里的 assistant 回复,确认两个暗号都被模型用上;结束自动删除测试数据。
474
+ 退出码:0 通过;2 召回层未命中;3 模型回复没用到暗号。
475
+
476
+ **方式二:控制台 / API 单独看召回结果**
477
+
478
+ 1. 控制台「提示词预览」输入问句 → 查看「长期记忆」「知识库」两段是否有内容;
479
+ 2. 或直接调接口:
480
+
481
+ curl -H "cookie: <登录会话>" \
482
+ "http://127.0.0.1:6198/api/preview/prompt?bot=bot_xxx&query=记忆暗号是什么"
483
+
484
+ 3. 也可以调 `POST /api/memories/search`、`POST /api/kb/search` 看原始检索命中(用来区分"没入库"还是"召回没命中")。
485
+
486
+ **排查顺序**:入库 → 原始检索命中 → 预览段出现 → 模型回复引用;
487
+ 哪一步断了就查对应设置(记忆/知识库开关、topK、字符预算、query 是否太口语化)。
488
+
489
+ **说明**:召回会用「整句 → 去疑问词的核心短语 → 拆词」多级候选,
490
+ 所以「你还记得我的暗号吗」「知识库暗号是什么」这类自然问句也能命中,不必背关键词。
491
+
442
492
  ## 8. 数据、备份、恢复与迁移
443
493
 
444
494
  ### 8.1 数据都在哪
package/lib/client.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * dsh Web 客户端插件:在侧边栏底部(sidebar.footer.action 槽位)加一个
3
+ * 「QQ」快捷入口,点击后跳到 QQBot 控制台。
4
+ *
5
+ * 入口地址不走硬编码端口:服务端注册了同源路由 /qqbot-dashboard,会读取本插件
6
+ * 当前配置的 console 地址并 302 过去,这样改端口/监听地址都不需要改前端。
7
+ *
8
+ * 说明:这是 dsh Web 的 client module(window.__ModuleLoader__ 协议),
9
+ * 只在安装了本插件的 web profile 里出现;纯前端、无构建依赖。
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-qqbot-buddy',
13
+ factory: (require) => {
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
17
+ const jsx = require('react/jsx-runtime');
18
+
19
+ const inject = ['slots'];
20
+ const PAGE = 'QQBot 控制台';
21
+
22
+ /** 侧边栏底部的 QQ 快捷入口(外链走同源 302)。 */
23
+ function QqbotDashboardAction() {
24
+ return jsx.jsxs('a', {
25
+ href: '/qqbot-dashboard',
26
+ target: '_blank',
27
+ rel: 'noreferrer',
28
+ title: PAGE + '(在新标签页打开)',
29
+ 'aria-label': PAGE,
30
+ className: 'qqbot-dashboard-entry',
31
+ style: {
32
+ position: 'fixed',
33
+ right: '16px',
34
+ bottom: '16px',
35
+ zIndex: 40,
36
+ pointerEvents: 'auto',
37
+ display: 'inline-flex',
38
+ alignItems: 'center',
39
+ justifyContent: 'center',
40
+ gap: '4px',
41
+ padding: '6px 10px',
42
+ borderRadius: '999px',
43
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.35))',
44
+ background: 'var(--dsw-alias-bg-layer-2, rgba(20,20,20,.72))',
45
+ color: 'var(--dsw-alias-label-primary, inherit)',
46
+ textDecoration: 'none',
47
+ fontSize: '12px',
48
+ fontWeight: 600,
49
+ letterSpacing: '0.02em',
50
+ lineHeight: 1,
51
+ boxShadow: '0 2px 10px rgba(0,0,0,.18)',
52
+ },
53
+ children: [
54
+ jsx.jsx('span', { 'aria-hidden': 'true', children: 'QQ 控制台' }),
55
+ ],
56
+ });
57
+ }
58
+
59
+ function apply(ctx) {
60
+ // shell.overlay 是覆盖层列表槽位:不会被折叠侧栏的 hover mask 挡住,
61
+ // 用 fixed 定位放在左下角,任何页面都能点。
62
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
63
+ name: 'shell.overlay',
64
+ id: 'qqbot-dashboard',
65
+ order: 20,
66
+ }, QqbotDashboardAction));
67
+ }
68
+
69
+ exports.apply = apply;
70
+ exports.inject = inject;
71
+ return module.exports;
72
+ },
73
+ });
@@ -36,6 +36,51 @@ function pushTerm(out, seen, value) {
36
36
  out.push(term);
37
37
  }
38
38
 
39
+ // 疑问/口语后缀:自然问句("记忆暗号是什么")直接整句 phrase 查 FTS 会零命中,
40
+ // 需要剥掉疑问词得到核心短语("记忆暗号")再查。
41
+ const QUESTION_SUFFIX = /(?:是什么|是多少|多少|是谁|谁|是哪个|哪个|哪里|哪儿|怎么样|怎么|为什么|为何|是否|能不能|可不可以|记不记得|还记得|记得|知道|告诉我|说一下|一下|请告诉我|请问|吗|呢|呀|啊|吧|哦|嘛|来着|了)$/u;
42
+
43
+ /**
44
+ * 生成检索候选短语(由宽到窄),供召回层逐个尝试。
45
+ * 例:"记忆暗号是什么?" → ["记忆暗号是什么", "记忆暗号"]
46
+ * "你还记得我的暗号吗" → [..., "暗号", ...]
47
+ * @param {string} text
48
+ * @param {{max?: number}} [options]
49
+ * @returns {string[]}
50
+ */
51
+ export function queryCandidates(text, { max = 12 } = {}) {
52
+ const norm = typeof text === 'string' ? text.normalize('NFKC').trim().replace(/[??!!。,,、::;;\s]+$/u, '') : '';
53
+ if (norm === '') return [];
54
+ const out = [];
55
+ const seen = new Set();
56
+ const push = (value) => {
57
+ const term = String(value || '').trim();
58
+ if (term.length < 2) return;
59
+ const key = term.toLowerCase();
60
+ if (seen.has(key)) return;
61
+ seen.add(key);
62
+ out.push(term);
63
+ };
64
+ // 一段文本 + 反复剥疑问/口语后缀("记忆暗号是什么" → "记忆暗号")
65
+ const addVariants = (value) => {
66
+ let current = String(value || '').trim();
67
+ if (current === '') return;
68
+ push(current);
69
+ for (let i = 0; i < 5; i += 1) {
70
+ const next = current.replace(QUESTION_SUFFIX, '').trim();
71
+ if (next === current) break;
72
+ push(next);
73
+ current = next;
74
+ }
75
+ };
76
+ addVariants(norm);
77
+ // 多句/多问句:按标点切开,每段各自剥疑问词(一句里问两件事时也能得到两个核心短语)
78
+ for (const segment of norm.split(/[??!!。,,、::;;\s]+/u)) addVariants(segment);
79
+ // 拆词(含连接词/的 等)
80
+ for (const term of queryTerms(norm)) addVariants(term);
81
+ return out.slice(0, max);
82
+ }
83
+
39
84
  /**
40
85
  * 把一句话拆成检索词。
41
86
  * @param {string} text
@@ -59,4 +104,4 @@ export function queryTerms(text, { maxTerms = MAX_TERMS } = {}) {
59
104
  return out.slice(0, maxTerms);
60
105
  }
61
106
 
62
- export default { queryTerms };
107
+ export default { queryTerms, queryCandidates };
@@ -14,25 +14,26 @@ const DEFAULT_MEMBER_MAX = 3;
14
14
  const DEFAULT_KB_ITEMS = 5;
15
15
  const DEFAULT_MEMBER_CANDIDATES = 200;
16
16
 
17
- import { queryTerms } from '../core/text/terms.js';
17
+ import { queryCandidates, queryTerms } from '../core/text/terms.js';
18
18
 
19
19
  const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
20
20
  const strOf = (value) => (typeof value === 'string' ? value.trim() : '');
21
21
 
22
- export { queryTerms };
22
+ export { queryTerms, queryCandidates };
23
23
 
24
- /** 多词检索:整句优先,空结果时逐词召回并去重(最多 limit 条)。 */
24
+ /** 多短语检索:整句 → 去疑问词的核心短语 → 拆词,逐个召回并去重(最多 limit 条)。 */
25
25
  function searchWithFallback({ store, botId, text, limit, keyOf = (row) => row && row.id }) {
26
26
  const size = Number(limit) > 0 ? Number(limit) : DEFAULT_KB_ITEMS;
27
- const first = typeof store.search === 'function' ? store.search({ botId, query: text, limit: size }) : [];
27
+ if (typeof store.search !== 'function') return [];
28
+ const first = store.search({ botId, query: text, limit: size });
28
29
  if (Array.isArray(first) && first.length > 0) return first;
29
- const terms = queryTerms(text);
30
- if (terms.length <= 1) return [];
30
+ const terms = queryCandidates(text);
31
+ if (terms.length === 0) return [];
31
32
  const merged = [];
32
33
  const seen = new Set();
33
34
  for (const term of terms) {
34
35
  let rows = [];
35
- try { rows = typeof store.search === 'function' ? store.search({ botId, query: term, limit: size }) : []; } catch { rows = []; }
36
+ try { rows = store.search({ botId, query: term, limit: size }) || []; } catch { rows = []; }
36
37
  for (const row of Array.isArray(rows) ? rows : []) {
37
38
  const key = keyOf(row);
38
39
  if (key === undefined || key === null || seen.has(key)) continue;
@@ -188,11 +189,11 @@ export async function recallForPeer({ memory, knowledge, members, settings, botI
188
189
  const budget = Number(cfg.memory.recallChars) || DEFAULT_MEMORY_BUDGET;
189
190
  if (typeof memory.recallForPrompt === 'function') {
190
191
  let recalled = memory.recallForPrompt({ botId, query: text, budgetChars: budget, topK });
191
- // 整句零命中时逐词召回(FTS phrase 匹配对长句不友好)
192
- if ((typeof recalled !== 'string' || recalled.trim() === '') && queryTerms(text).length > 1) {
192
+ // 整句零命中时逐候选短语召回(FTS phrase 匹配对长句/问句不友好)
193
+ if (typeof recalled !== 'string' || recalled.trim() === '') {
193
194
  const seen = new Set();
194
195
  const lines = [];
195
- for (const term of queryTerms(text)) {
196
+ for (const term of queryCandidates(text).slice(1)) {
196
197
  const part = memory.recallForPrompt({ botId, query: term, budgetChars: budget, topK });
197
198
  for (const line of String(part || '').split('\n')) {
198
199
  const key = line.trim();
package/lib/index.js CHANGED
@@ -371,6 +371,52 @@ export async function apply(ctx, config) {
371
371
  }
372
372
  }
373
373
 
374
+ // ── dsh Web → QQBot 控制台快捷入口 ──
375
+ // 客户端插件(lib/client.js)只挂一个 href="/qqbot-dashboard" 的按钮;
376
+ // 这里注册同源 302 路由,读取当前 console 配置并跳转,改端口/地址无需动前端。
377
+ try {
378
+ ctx.inject(['webServer'], (webCtx) => {
379
+ try {
380
+ const resolveConsoleUrl = () => {
381
+ try {
382
+ const address = runtime.console && typeof runtime.console.address === 'function' ? runtime.console.address() : null;
383
+ const url = address && address.url ? String(address.url) : '';
384
+ if (url !== '') return url.endsWith('/') ? url : url + '/';
385
+ } catch { /* 控制台未启动/已关闭 */ }
386
+ // console.enabled=false 只表示「本 profile 不启动控制台」(例如 web profile 只放入口,
387
+ // 控制台跑在机器人 profile 里);跳转仍按配置的 host/port 生成。
388
+ const consoleCfg = cfg.console || {};
389
+ let host = typeof consoleCfg.host === 'string' && consoleCfg.host !== '' ? consoleCfg.host : '127.0.0.1';
390
+ if (host === '0.0.0.0' || host === '::') host = '127.0.0.1';
391
+ const port = Number(consoleCfg.port) || 6198;
392
+ return 'http://' + host + ':' + port + '/';
393
+ };
394
+ webCtx.webServer.register({
395
+ kind: 'exact',
396
+ path: '/qqbot-dashboard',
397
+ handler: (req, res) => {
398
+ const target = resolveConsoleUrl();
399
+ if (target === '') {
400
+ res.statusCode = 503;
401
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
402
+ res.end('QQBot 控制台未启用(dsh-qqbot-buddy 配置 console.enabled)');
403
+ return;
404
+ }
405
+ res.statusCode = 302;
406
+ res.setHeader('location', target);
407
+ res.setHeader('cache-control', 'no-store');
408
+ res.end();
409
+ },
410
+ });
411
+ logger.info('dsh Web 快捷入口已注册: /qqbot-dashboard');
412
+ } catch (err) {
413
+ logger.warn('dsh Web 快捷入口注册失败: ' + errText(err));
414
+ }
415
+ });
416
+ } catch (err) {
417
+ logger.warn('dsh Web 快捷入口注入失败: ' + errText(err));
418
+ }
419
+
374
420
  // ── 机器人宿主 ──
375
421
  try {
376
422
  const mod = await import('./host/agent.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qqbot-buddy",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.0-alpha.2",
4
4
  "description": "DSH QQ 机器人插件 v2:自包含管理控制台、配置档/会话级配置、可管理的工具与技能、SQLite+FTS5 记忆与知识库",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "exports": {
26
26
  ".": "./lib/index.js",
27
27
  "./console": "./lib/console/server.js",
28
+ "./client": "./lib/client.js",
28
29
  "./package.json": "./package.json"
29
30
  },
30
31
  "engines": {
@@ -33,6 +34,7 @@
33
34
  "scripts": {
34
35
  "test": "node --test test/*.test.js",
35
36
  "prepublishOnly": "node --test test/*.test.js",
37
+ "verify:recall": "node scripts/verify-recall.mjs",
36
38
  "setup:dev": "node scripts/setup-dev.mjs",
37
39
  "console:build": "npm --prefix web run build",
38
40
  "console:dev": "npm --prefix web run dev",
@@ -56,6 +58,13 @@
56
58
  "dsh": {
57
59
  "bundle": {
58
60
  "patch": "./cordis.patch.yml"
61
+ },
62
+ "client": {
63
+ "platform": "web",
64
+ "inject": [
65
+ "@deepseek-ai/dsh-client-ui-layout",
66
+ "@deepseek-ai/dsh-client-ui-renderer"
67
+ ]
59
68
  }
60
69
  },
61
70
  "bin": {
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify-recall —— 验证「记忆 / 知识库」是否被自动召回并进入模型回复。
4
+ *
5
+ * 做法(黑盒,不改动业务代码):
6
+ * 1) 通过控制台 API 写入一条记忆 + 一条知识库文档,内容带唯一暗号;
7
+ * 2) 调 GET /api/preview/prompt 看 qqbot:memory / qqbot:knowledge 段是否包含暗号;
8
+ * 3) reset 一个测试 peer → debug-followup 用自然问句提问 → 等待会话文件里的 assistant 回复;
9
+ * 4) 检查回复是否包含两个暗号(证明模型确实拿到了注入内容);
10
+ * 5) 默认清理写入的测试数据。
11
+ *
12
+ * 用法:
13
+ * node scripts/verify-recall.mjs # 默认 http://127.0.0.1:6198
14
+ * node scripts/verify-recall.mjs --base http://127.0.0.1:6198 --keep
15
+ * node scripts/verify-recall.mjs --bot bot_xxx --peer c2c:TEST --query "我的暗号是什么?"
16
+ *
17
+ * 退出码:0 全部通过;2 召回层没命中;3 模型回复没用到暗号;1 环境/接口错误。
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import os from 'node:os';
22
+ import zlib from 'node:zlib';
23
+
24
+ const DEFAULT_BASE = 'http://127.0.0.1:6198';
25
+ const DEFAULT_TOKEN_FILE = path.join(os.homedir(), '.dsh-qqbot', 'v2', 'console-token.txt');
26
+
27
+ function parseArgs(argv) {
28
+ const out = {
29
+ base: process.env.QQBOT_CONSOLE_BASE || DEFAULT_BASE,
30
+ tokenFile: process.env.QQBOT_CONSOLE_TOKEN_FILE || DEFAULT_TOKEN_FILE,
31
+ token: '',
32
+ bot: '',
33
+ peer: '',
34
+ query: '请回答:记忆暗号是什么?知识库暗号是什么?',
35
+ waitMs: 120000,
36
+ keep: false,
37
+ json: false,
38
+ };
39
+ for (let i = 0; i < argv.length; i += 1) {
40
+ const arg = argv[i];
41
+ if (arg === '--base') out.base = argv[++i];
42
+ else if (arg === '--token') out.token = argv[++i];
43
+ else if (arg === '--token-file') out.tokenFile = argv[++i];
44
+ else if (arg === '--bot') out.bot = argv[++i];
45
+ else if (arg === '--peer') out.peer = argv[++i];
46
+ else if (arg === '--query') out.query = argv[++i];
47
+ else if (arg === '--wait') out.waitMs = Math.max(5000, Number(argv[++i]) || 120000);
48
+ else if (arg === '--keep') out.keep = true;
49
+ else if (arg === '--json') out.json = true;
50
+ else if (arg === '--help' || arg === '-h') {
51
+ process.stdout.write('用法: node scripts/verify-recall.mjs [--base URL] [--token TOKEN|--token-file FILE] [--bot ID] [--peer PEER] [--query TEXT] [--wait MS] [--keep] [--json]\n');
52
+ process.exit(0);
53
+ } else {
54
+ process.stderr.write('未知参数: ' + arg + '(--help 查看用法)\n');
55
+ process.exit(1);
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const opts = parseArgs(process.argv.slice(2));
62
+ const log = (...args) => { if (!opts.json) console.log(...args); };
63
+ const fail = (code, message) => {
64
+ if (opts.json) console.log(JSON.stringify({ ok: false, code, error: message }));
65
+ else console.error('❌ ' + message);
66
+ process.exit(code);
67
+ };
68
+
69
+ function readToken() {
70
+ if (opts.token !== '') return opts.token;
71
+ try { return fs.readFileSync(opts.tokenFile, 'utf8').trim(); } catch {
72
+ fail(1, '读不到控制台 token:' + opts.tokenFile + '(可用 --token/--token-file 指定)');
73
+ }
74
+ }
75
+
76
+ const token = readToken();
77
+ const base = opts.base.replace(/\/$/u, '');
78
+ const headersJson = { 'content-type': 'application/json', 'x-qqbot-csrf': '1' };
79
+
80
+ async function login() {
81
+ let res;
82
+ try {
83
+ res = await fetch(base + '/api/auth/login', {
84
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token }),
85
+ });
86
+ } catch (err) {
87
+ fail(1, '连不上控制台 ' + base + ': ' + (err && err.message || err));
88
+ }
89
+ if (!res.ok) fail(1, '控制台登录失败(HTTP ' + res.status + ')');
90
+ const cookie = (res.headers.get('set-cookie') || '').split(';')[0];
91
+ if (!cookie) fail(1, '控制台登录未返回会话 cookie');
92
+ return cookie;
93
+ }
94
+
95
+ function makeClient(cookie) {
96
+ return async function call(method, apiPath, body) {
97
+ const res = await fetch(base + apiPath, {
98
+ method,
99
+ headers: { ...headersJson, cookie },
100
+ body: body === undefined ? undefined : JSON.stringify(body),
101
+ });
102
+ const text = await res.text();
103
+ let json;
104
+ try { json = JSON.parse(text); } catch { json = { raw: text }; }
105
+ return { status: res.status, body: json };
106
+ };
107
+ }
108
+
109
+ function findSessionFile(sessionId) {
110
+ const roots = [
111
+ process.env.DSH_HOME ? path.join(process.env.DSH_HOME, 'sessions') : '',
112
+ path.join(os.homedir(), '.dsh', 'sessions'),
113
+ ].filter(Boolean);
114
+ for (const root of roots) {
115
+ if (!fs.existsSync(root)) continue;
116
+ for (const dir of fs.readdirSync(root)) {
117
+ const file = path.join(root, dir, sessionId, 'session.v4.jsonl.zstd');
118
+ if (fs.existsSync(file)) return file;
119
+ }
120
+ }
121
+ return '';
122
+ }
123
+
124
+ function readAssistantText(file) {
125
+ const events = zlib.zstdDecompressSync(fs.readFileSync(file)).toString('utf8')
126
+ .split('\n').filter(Boolean)
127
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
128
+ .filter(Boolean);
129
+ const texts = [];
130
+ for (const event of events) {
131
+ const message = event.data && event.data.message;
132
+ if (!message || message.role !== 'assistant' || !Array.isArray(message.content)) continue;
133
+ const text = message.content.filter((item) => item && item.type === 'text').map((item) => item.text).join('');
134
+ if (text.trim() !== '') texts.push(text);
135
+ }
136
+ return texts.join('\n');
137
+ }
138
+
139
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
140
+
141
+ async function main() {
142
+ const cookie = await login();
143
+ const call = makeClient(cookie);
144
+ const state = (await call('GET', '/api/state')).body || {};
145
+ const bots = Array.isArray(state.bots) ? state.bots : [];
146
+ const botId = opts.bot || (bots[0] && bots[0].id);
147
+ if (!botId) fail(1, '控制台里没有机器人(先建一个 bot)');
148
+ const defaultProfile = (Array.isArray(state.profiles) ? state.profiles : []).find((item) => item.isDefault);
149
+
150
+ const tag = 'VERIFY' + Math.random().toString(36).slice(2, 8).toUpperCase();
151
+ const memToken = tag + '-MEM';
152
+ const kbToken = tag + '-KB';
153
+ const peer = opts.peer || ('c2c:RECALL-' + tag);
154
+ const report = { ok: false, tag, botId, peer, steps: {} };
155
+ log('机器人: ' + botId);
156
+ log('测试 peer: ' + peer);
157
+ log('测试暗号: ' + memToken + ' / ' + kbToken);
158
+
159
+ // 1) 写入测试记忆与知识库文档
160
+ const mem = await call('POST', '/api/memories', {
161
+ botId, text: '记忆暗号是 ' + memToken, kind: 'fact', source: 'verify-recall',
162
+ });
163
+ if (mem.status !== 200) fail(1, '写入记忆失败(HTTP ' + mem.status + ')');
164
+ const kb = await call('POST', '/api/kb/ingest', {
165
+ botId,
166
+ title: '暗号知识库 ' + kbToken,
167
+ text: '本群知识库暗号是 ' + kbToken + '。这是一次自动注入验证的临时文档。',
168
+ source: 'verify-recall',
169
+ });
170
+ const kbId = kb.body && (kb.body.docId || (kb.body.doc && kb.body.doc.id));
171
+ if (kb.status !== 200 || !kbId) fail(1, '写入知识库失败(HTTP ' + kb.status + ')');
172
+ log('已写入:记忆 #' + mem.body.id + ',知识库 #' + kbId);
173
+
174
+ try {
175
+ // 2) 预览层:prompt 里是否出现暗号
176
+ const previewPath = '/api/preview/prompt?bot=' + encodeURIComponent(botId)
177
+ + (defaultProfile ? '&profile=' + encodeURIComponent(defaultProfile.id) : '')
178
+ + '&query=' + encodeURIComponent(opts.query);
179
+ const preview = (await call('GET', previewPath)).body || {};
180
+ const sections = Array.isArray(preview.sections) ? preview.sections : [];
181
+ const memSection = sections.find((item) => item.name === 'qqbot:memory');
182
+ const kbSection = sections.find((item) => item.name === 'qqbot:knowledge');
183
+ const recall = {
184
+ memory: String(memSection && memSection.text || '').includes(memToken),
185
+ knowledge: String(kbSection && kbSection.text || '').includes(kbToken),
186
+ };
187
+ report.steps.preview = { memory: recall.memory, knowledge: recall.knowledge, memoryChars: memSection && memSection.chars, knowledgeChars: kbSection && kbSection.chars };
188
+ log('预览召回:记忆 ' + (recall.memory ? '✅' : '❌') + ',知识库 ' + (recall.knowledge ? '✅' : '❌'));
189
+
190
+ // 3) 真模型链路:reset -> followup -> 读 assistant 回复
191
+ const reset = await call('POST', '/api/actions/reset-peer', { botId, peer });
192
+ const sessionId = reset.body && reset.body.newSessionId;
193
+ if (!sessionId) fail(1, 'reset-peer 未返回 sessionId');
194
+ await call('POST', '/api/actions/debug-followup', { botId, peer, text: opts.query, senderId: 'VERIFY_RECALL' });
195
+ log('已触发 debug-followup(session ' + sessionId + '),等待模型回复…');
196
+
197
+ let reply = '';
198
+ const deadline = Date.now() + opts.waitMs;
199
+ while (Date.now() < deadline) {
200
+ await sleep(2000);
201
+ const file = findSessionFile(sessionId);
202
+ if (!file) continue;
203
+ try {
204
+ const text = readAssistantText(file);
205
+ if (text.trim() !== '') { reply = text; break; }
206
+ } catch { /* 会话文件可能正在写入,继续等 */ }
207
+ }
208
+ const used = {
209
+ memory: reply.includes(memToken),
210
+ knowledge: reply.includes(kbToken),
211
+ };
212
+ report.steps.model = { replied: reply !== '', memory: used.memory, knowledge: used.knowledge, reply: reply.slice(0, 500) };
213
+ log('模型回复:' + (reply === '' ? '(超时未取到)' : JSON.stringify(reply.slice(0, 200))));
214
+ log('回复使用:记忆 ' + (used.memory ? '✅' : '❌') + ',知识库 ' + (used.knowledge ? '✅' : '❌'));
215
+
216
+ report.ok = recall.memory && recall.knowledge && used.memory && used.knowledge;
217
+ if (!recall.memory || !recall.knowledge) {
218
+ fail(2, '召回层未命中(检查记忆/知识库开关、query 与入库内容)');
219
+ }
220
+ if (!report.ok) {
221
+ fail(3, '召回层通过,但模型回复没有使用暗号(可能模型/上下文问题)');
222
+ }
223
+ if (opts.json) console.log(JSON.stringify(report));
224
+ else console.log('\n✅ 通过:记忆与知识库都已自动注入,并且模型在回复里用到了暗号。');
225
+ } finally {
226
+ if (!opts.keep) {
227
+ await call('DELETE', '/api/memories/' + mem.body.id);
228
+ if (kbId) await call('DELETE', '/api/kb/docs/' + kbId + '?bot=' + encodeURIComponent(botId));
229
+ await call('POST', '/api/actions/reset-peer', { botId, peer });
230
+ log('已清理测试数据(--keep 可保留)');
231
+ }
232
+ }
233
+ }
234
+
235
+ main().catch((err) => fail(1, '执行失败: ' + (err && err.stack || err)));