@zaofan/dsh-qqbot 1.11.2 → 1.11.4

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.
@@ -895,6 +895,14 @@ window.__ModuleLoader__.load({
895
895
  context: function () { return fetch(q('context')).then(function (r) { return r.json() }).catch(function () { return null }) },
896
896
  sessions: function () { return fetch(q('sessions')).then(function (r) { return r.json() }).catch(function () { return null }) },
897
897
  history: function (n) { return fetch(q('history') + '&limit=' + (n || 20)).then(function (r) { return r.json() }).catch(function () { return null }) },
898
+ /**
899
+ * 当前会话 **AI 最后一个正文块**(2026-10-27)
900
+ * 不是 QQ 消息流水,是 agent 真正输出的那段文字。
901
+ * 实现:宿主 kernel.get('sessions').get(sid).deriveMessages() 从后往前找 assistant。
902
+ */
903
+ lastAssistantText: function () {
904
+ return fetch(q('lastAssistantText')).then(function (r) { return r.json() }).catch(function () { return null })
905
+ },
898
906
  members: function () { return fetch(q('members')).then(function (r) { return r.json() }).catch(function () { return null }) },
899
907
  }
900
908
  }
@@ -3,7 +3,7 @@
3
3
  > [@zaofan/dsh-qqbot](../README.md) 的**详细文档**:配置项、内置命令、自定义扩展、群管理(含 🔨 锤子:禁言 / 踢人)、本地小模型、好感度系统、数据文件与日志、升级排障、架构与本地开发。
4
4
  > 只想看"它能干什么 + 怎么装" → 回 **[README](../README.md)**。
5
5
 
6
- **章节**:给AI的话 · 架构 · QQ 远程审批 · QQ 群管理(含 🔨 锤子:禁言 / 踢人)· 智能回复(本地小模型)· **好感度与熟识度** · 配置项 · 数据文件与日志 · 内置命令 · 用户扩展 · 富媒体指令 · 核心模块 · 会话路由 · 升级与兼容 · 常见问题 · 设计原则 · 本地开发
6
+ **章节**:给AI的话 · 架构 · QQ 远程审批 · QQ 群管理(含 🔨 锤子:禁言 / 踢人)· 智能回复(本地小模型)· **好感度与熟识度** · 配置项 · 数据文件与日志 · 内置命令 · 用户扩展(含自定义界面卡片)· 富媒体指令 · 核心模块 · 会话路由 · 升级与兼容 · 常见问题 · 设计原则 · 本地开发
7
7
 
8
8
  > 📌 **版本**:本文对应插件 **v1.6.x**(含 1.6.2 → 1.6.5 的新功能与排障)。新增内容都标了版本与补充日期,便于你对照自己装的版本。
9
9
 
@@ -27,6 +27,10 @@
27
27
  - **你可以自己写 markdown、自己写工具、自己写斜杠命令**(不用等人给你做功能):
28
28
  - 卡片正文就是**你直接写的 markdown**(`#` 标题、加粗、`![说明](图片URL)`、代码块)——**本插件没有模板引擎,模板就是你**;要"带按钮的卡片"则走 botplay 事件或 dock 卡片编辑器(按钮回调须由 host 注册)。
29
29
  - 工具/命令写在**账号数据目录**的 `.qqbot-extensions/{tools,commands}/`,**不在插件包内** → **升级/重装插件(换 node_modules)不会覆盖你的扩展**,扩展原样保留。
30
+ - **你还能给她做一个界面**(v1.11.0+):往 `.qqbot-extensions/ui/<目录>/` 放 `card.html`(+ 可选 `card.css`/`card.js`),
31
+ 悬浮球里就多一个页签;放 `{PROFILE_ROOT}/.qqbot-extensions/ui/` 则是设置页的全局页签。
32
+ 卡片跑在 Shadow DOM 里,拿到 `qqbotExt`(读写配置/静默注入/读写文本文件/调宿主服务/自己注册的路由)和 `shadowRoot`。
33
+ 用户说"给我做个面板/工具界面"时,照契约现场写即可 —— 路径和规则详见下面「自定义界面卡片」一节。
30
34
  - 工具 `run(args, env)` 的 `env` 里有 `sender` + `replyTarget`(内置 `send_media` 用的同一个发送器),**工具能自己发 markdown 卡/图/语音/文件**:所以「调接口取数据 → 拼卡片 → 发出去」一个工具就能闭环,不必绕回你。用户说"给我写个点歌工具"时,照契约现场写即可。
31
35
  - 生效方式:工具发 `/tools-reload`(或调 `tools_reload`)即时生效;命令需重启宿主;**同名工具改内容会被注册表跳过 → 换名或重启**。
32
36
 
@@ -475,7 +479,7 @@ contextMemoMaxItems: 20 # 备忘条数上限(超出丢最旧,1~500)
475
479
  |---|---|---|
476
480
  | `qun-cookie.json` | **QQ 群管理「踢人」的登录凭据**(v1.6.2+,2026-10-04 补充)—— 面板和 AI **共用这一份** | ❌ 删了就得重新扫码登录(功能不会坏,只是要重登) |
477
481
  | `表情包/` | 图库本体(图片文件 + `index.json`) | ❌ 删了图库就空了 |
478
- | `.qqbot-extensions/` | 你自己写的扩展(自定义命令 / 工具) | ❌ 你的扩展就在这儿(升级插件不会动它) |
482
+ | `.qqbot-extensions/` | 你自己写的扩展(自定义命令 / 工具 / 界面卡片) | ❌ 你的扩展就在这儿(升级插件不会动它) |
479
483
 
480
484
  ### 诊断日志(在 `~/.dsh/` 下,**可以放心删**)(v1.6.5,2026-10-04 补充)
481
485
 
@@ -539,8 +543,12 @@ contextMemoMaxItems: 20 # 备忘条数上限(超出丢最旧,1~500)
539
543
  └── .qqbot-extensions/
540
544
  ├── commands/ # 自定义斜杠命令(重启后生效)
541
545
  ├── tools/ # 自定义 QQ 通道工具(AI 可调; 写完用 /tools-reload 或让 AI 调 tools_reload 热刷)
542
- └── hooks/ # 启动钩子(插件启动时自动跑一次; 没人调用也跑; v1.8.9+)
546
+ ├── hooks/ # 启动钩子(插件启动时自动跑一次; 没人调用也跑; v1.8.9+)
547
+ └── ui/ # 自定义界面卡片(悬浮球页签; v1.11.0+)
543
548
  ```
549
+
550
+ **设置页那张卡不在这里** —— `slot=settings` 的卡片放 `{PROFILE_ROOT}/.qqbot-extensions/ui/`,
551
+ 全局一份、所有机器人共用(详见下面「自定义界面卡片」)。
544
552
  数据目录 = `dataRoot`(已配置, 例 `D:\my-projects\qqbot-data`)或账号 cwd(未配置时, 向后兼容)。
545
553
 
546
554
  ### 自定义斜杠命令: .qqbot-extensions/commands/xxx.mjs
@@ -822,6 +830,58 @@ if (finallyDone) {
822
830
  8. 不确定的官方字段/事件/错误码去查 <https://bot.q.qq.com/wiki/develop/api-v2/>(按钮回调事件是 `interaction_create`)。
823
831
 
824
832
 
833
+ ### 自定义界面卡片: 给面板/悬浮球加自己的窗口 (v1.11.0+)
834
+
835
+ 不想只加命令和工具、还想**加个界面**?放三个文件进去,设置页顶栏或悬浮球里就多一个页签。
836
+
837
+ ```
838
+ {PROFILE_ROOT}/.qqbot-extensions/ui/<目录>/ ← slot: settings(设置页,全局一份)
839
+ {dataRoot}/.qqbot-extensions/ui/<目录>/ ← slot: dock(悬浮球,每个机器人一份)
840
+ ```
841
+
842
+ 每个目录里放(都可不写,写什么加载什么):
843
+
844
+ ```
845
+ card.html 必需(没有它这个扩展就不出现)
846
+ card.css 可选
847
+ card.js 可选
848
+ ```
849
+
850
+ `card.html` 顶部注释声明元信息:
851
+
852
+ ```html
853
+ <!-- qqbot-ui: { "id": "myui", "slot": "dock", "title": "我的面板", "order": 50 } -->
854
+ ```
855
+
856
+ - `id` 可选,覆盖目录名。同一个扩展的两张卡(settings + dock)给相同的 id,就共用一份配置。
857
+ - `slot` 必填:`settings` 或 `dock`。
858
+ - `title` 页签文字;`order` 排序(小的在前)。
859
+
860
+ **卡片里能用的**(`card.js` 里有两个变量:`qqbotExt` 和 `shadowRoot`):
861
+
862
+ ```js
863
+ qqbotExt.getConfig() / setConfig({...}) // 两级配置(profile 全局 / data 本实例)
864
+ qqbotExt.acct // 当前选中的账号
865
+ qqbotExt.store.load() / save(v) // 数据根下的 KV
866
+ qqbotExt.kernel.get('服务名') // 宿主服务
867
+ qqbotExt.cap('fs.read' | 'fs.write', 路径, 内容) // 读写本机文本文件(≤2MB)
868
+ qqbotExt.cap('appendSilent', 文本) // 往当前会话静默注入一条消息(不唤醒 AI)
869
+ qqbotExt.api('/你的路由') // 你在 hooks 里 registerRoute 的接口
870
+ qqbotExt.theme / isDark() / onThemeChange() // 主题
871
+ qqbotExt.dock.sid / peerId / context() / history(n) / lastAssistantText() // 仅 dock 卡
872
+ ```
873
+
874
+ **颜色跟随宿主主题**:用 `--qqs-*` 变量(`var(--qqs-text, #1f2329)`),亮/暗自动切换。
875
+
876
+ **卡片跑在 Shadow DOM 里** —— 你的样式不会污染面板,崩了也只白自己那张卡;
877
+ 代价是面板的 CSS/组件/DOM 也进不来,得自己写原生元素。
878
+
879
+ **示例**:插件自带 `examples/ext-ui/demo/`(settings + dock 两套),
880
+ 插件启动时会**自动铺**到对应位置(只补不改,不会覆盖你改过的)。
881
+
882
+ > 完整 API、硬边界、扩展包怎么带界面:见
883
+ > [`docs/extension-contract.md`](extension-contract.md) §1 末尾「第六套:`ui/`」。
884
+
825
885
  ## 富媒体指令(AI 回复里写标记,自动变成真消息)
826
886
 
827
887
  让 AI(或你替她)在回复正文里写以下标记,插件会自动拆出来发成真实的 QQ 消息,**标记本身不会显示**:
@@ -93,7 +93,8 @@ qqbotExt.dock.peerId / scope / ns
93
93
  qqbotExt.dock.dataRoot / cwd
94
94
  qqbotExt.dock.context() // 实例 + 会话信息
95
95
  qqbotExt.dock.sessions() // 这个机器人管过哪些会话
96
- qqbotExt.dock.history(n) // 当前会话最近 n 条消息
96
+ qqbotExt.dock.history(n) // 当前会话最近 n 条消息(QQ 消息流水)
97
+ qqbotExt.dock.lastAssistantText() // ★ AI 最后一个正文块(她真正输出的那段话)
97
98
  qqbotExt.dock.members() // 群友台账
98
99
 
99
100
  // 能力(与代码扩展对齐,走白名单 RPC)
@@ -37,10 +37,11 @@
37
37
 
38
38
  <!-- 子标签 2:展示 dock 能拿到的会话数据(这是 dock 独有能力的演示) -->
39
39
  <div class="pane" data-pane="ctx">
40
- <div class="row"><span class="hint">下面这些是 dock 卡片独有的能力 —— 能直接读到当前实例和会话的真实数据。</span></div>
40
+ <div class="row"><span class="hint">下面这些是 dock 卡片独有的能力 —— 能直接读到当前实例和会话的真实数据。<br />
41
+ 「读 AI 最后正文」取的是她 <b>最后一次输出的那段话</b>(不是 QQ 消息流水)。</span></div>
41
42
  <div class="acts">
42
43
  <button class="btn" id="ctxBtn">读上下文</button>
43
- <button class="btn ghost" id="hisBtn">读最近消息</button>
44
+ <button class="btn ghost" id="hisBtn">读 AI 最后正文</button>
44
45
  </div>
45
46
  <pre class="info" id="info">点上面按钮看看</pre>
46
47
  </div>
@@ -64,12 +64,13 @@
64
64
  hisBtn.addEventListener('click', function () {
65
65
  if (!qqbotExt.dock) return setInfo('这个卡片没有 dock 上下文')
66
66
  setInfo('读取中…')
67
- qqbotExt.dock.history(8).then(function (r) {
68
- if (!r || !r.ok) return setInfo(r || '(空)')
69
- var rows = (r.messages || []).map(function (m) {
70
- return '[' + (m.k || 'text') + '] ' + String(m.n || '') + ': ' + String(m.x || '').slice(0, 60)
71
- })
72
- setInfo(rows.length ? rows.join('\n') : '(这个会话还没有历史消息)')
67
+ // ★ 2026-10-27(主人要求):读 **AI 最后一个正文块**,不是 QQ 消息流水。
68
+ // 实现抄 session_watch_lib.mjs 的 lastAssistantText():
69
+ // 宿主 kernel.get('sessions').get(sid).deriveMessages() 从后往前找 assistant。
70
+ qqbotExt.dock.lastAssistantText().then(function (r) {
71
+ if (!r) return setInfo('没有响应')
72
+ if (!r.ok) return setInfo('读不到:' + (r.reason || r.via || '未知') + (r.sid ? ('\nsid=' + r.sid) : ''))
73
+ setInfo('(AI 最后一段正文,' + r.text.length + ' 字符)\n\n' + r.text)
73
74
  })
74
75
  })
75
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaofan/dsh-qqbot",
3
- "version": "1.11.2",
3
+ "version": "1.11.4",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",
package/settings-host.js CHANGED
@@ -5481,6 +5481,43 @@ const WHY_MAP = { busy: '目标会话回合活跃,已等待至回合结束仍超
5481
5481
  return writeJson(res, 200, { ok: true, messages: rows });
5482
5482
  }
5483
5483
 
5484
+ // ★ 2026-10-27(主人要求):取当前会话「AI 最后一个正文块」。
5485
+ // 原来卡片读的是 .qqbot/msg-history(QQ 消息流水),跟 AI 的正文不是一回事。
5486
+ // 实现抄 examples/ext-tools/lib/session_watch_lib.mjs 的 lastAssistantText():
5487
+ // 宿主 kernel.get('sessions').get(sid).deriveMessages() 从后往前找 assistant 角色。
5488
+ // (那条路还支持 turn/end 事件累积,但这里只要"最后一次的正文",直接取更省事。)
5489
+ if (what === 'lastAssistantText') {
5490
+ const sid = String(q?.get('sid') || '').trim();
5491
+ if (!sid) return writeJson(res, 200, { ok: false, reason: '缺 sid(当前会话 id)', text: '' });
5492
+ let text = '';
5493
+ let via = '';
5494
+ try {
5495
+ const sessions = ctx.get?.('sessions');
5496
+ const sess = sessions && typeof sessions.get === 'function' ? sessions.get(sid) : null;
5497
+ if (sess && typeof sess.deriveMessages === 'function') {
5498
+ const msgs = sess.deriveMessages() || [];
5499
+ for (let i = msgs.length - 1; i >= 0; i--) {
5500
+ const m = msgs[i];
5501
+ const role = String(m?.role ?? m?.type ?? '');
5502
+ if (!role.includes('assistant')) continue;
5503
+ // content 可能是字符串、数组(每项带 text)、或对象
5504
+ const c = m?.content;
5505
+ let t = '';
5506
+ if (typeof c === 'string') t = c;
5507
+ else if (Array.isArray(c)) t = c.map((x) => (typeof x === 'string' ? x : String(x?.text ?? ''))).join('');
5508
+ else if (c && typeof c === 'object') t = String(c.text ?? '');
5509
+ t = t.trim();
5510
+ if (t) { text = t; via = 'deriveMessages'; break; }
5511
+ }
5512
+ } else {
5513
+ via = 'sessions.get 拿不到会话';
5514
+ }
5515
+ } catch (e) {
5516
+ via = '异常: ' + (e instanceof Error ? e.message : String(e));
5517
+ }
5518
+ return writeJson(res, 200, { ok: !!text, text, via, sid });
5519
+ }
5520
+
5484
5521
  if (what === 'members') {
5485
5522
  // 群友台账(people-memo 落的那份)
5486
5523
  let out = {};
@@ -5703,7 +5740,11 @@ const WHY_MAP = { busy: '目标会话回合活跃,已等待至回合结束仍超
5703
5740
  dataRoot: dr,
5704
5741
  logger: console,
5705
5742
  selfName: id,
5706
- kind: 'ui',
5743
+ // ★ 2026-10-27:kind 不能传 'ui' —— store 会落到 .qqbot-extensions/ui/data/,
5744
+ // 而 listExtUi 正是扫 ui/ 下每个子目录找 card.html,会把 data/ 当成一个扩展目录。
5745
+ // 用点开头:路径变成 .qqbot-extensions/.ui-data/data/<extId>.json,
5746
+ // 既不在 ui/ 下,也不会被 EXT_ID_RE(不允许 '.')当成扩展 id。
5747
+ kind: '.ui-data',
5707
5748
  appId: String(viewOf(ctx.settings, capNs)?.value?.appId || ''),
5708
5749
  owners: (() => {
5709
5750
  try {