@zaofan/dsh-qqbot 1.10.47 → 1.11.0

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 (45) hide show
  1. package/README.md +40 -3
  2. package/client/qqbot-settings.js +694 -12
  3. package/dist/features/ext-capabilities.d.ts +2 -0
  4. package/dist/features/ext-capabilities.d.ts.map +1 -1
  5. package/dist/features/ext-capabilities.js +38 -0
  6. package/dist/features/ext-capabilities.js.map +1 -1
  7. package/dist/features/ext-reload.d.ts +2 -2
  8. package/dist/features/ext-reload.d.ts.map +1 -1
  9. package/dist/features/ext-reload.js +30 -8
  10. package/dist/features/ext-reload.js.map +1 -1
  11. package/dist/features/ext-ui-seed.d.ts +25 -0
  12. package/dist/features/ext-ui-seed.d.ts.map +1 -0
  13. package/dist/features/ext-ui-seed.js +187 -0
  14. package/dist/features/ext-ui-seed.js.map +1 -0
  15. package/dist/features/ext-ui.d.ts +42 -0
  16. package/dist/features/ext-ui.d.ts.map +1 -0
  17. package/dist/features/ext-ui.js +207 -0
  18. package/dist/features/ext-ui.js.map +1 -0
  19. package/dist/features/extension-store.d.ts.map +1 -1
  20. package/dist/features/extension-store.js +3 -0
  21. package/dist/features/extension-store.js.map +1 -1
  22. package/dist/gateway/bootstrap.d.ts.map +1 -1
  23. package/dist/gateway/bootstrap.js +18 -1
  24. package/dist/gateway/bootstrap.js.map +1 -1
  25. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  26. package/dist/gateway/middleware-setup.js +11 -1
  27. package/dist/gateway/middleware-setup.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +131 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/session/session-manager.d.ts.map +1 -1
  32. package/dist/session/session-manager.js +2 -1
  33. package/dist/session/session-manager.js.map +1 -1
  34. package/docs/extension-contract.md +400 -255
  35. package/examples/ext-commands//346/211/251/345/261/225/347/244/272/344/276/213.mjs +84 -0
  36. package/examples/ext-hooks/demo-store.mjs +75 -0
  37. package/examples/ext-ui/README.md +95 -0
  38. package/examples/ext-ui/demo/dock/card.css +55 -0
  39. package/examples/ext-ui/demo/dock/card.html +95 -0
  40. package/examples/ext-ui/demo/dock/card.js +261 -0
  41. package/examples/ext-ui/demo/settings/card.css +61 -0
  42. package/examples/ext-ui/demo/settings/card.html +38 -0
  43. package/examples/ext-ui/demo/settings/card.js +87 -0
  44. package/package.json +145 -144
  45. package/settings-host.js +585 -4
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 扩展示例.mjs — `/扩展示例` 斜杠命令(2026-10-26 主人要求的验收示例)
3
+ *
4
+ * 演示点:一条命令同时读**两级配置**
5
+ * · 文本1 → profile 级(全局一份,影响所有机器人) ui/demo-settings 里改
6
+ * · 文本2 → 数据根(每实例一份,只影响当前机器人) ui/demo-dock 里改
7
+ *
8
+ * 放到 {dataRoot}/.qqbot-extensions/commands/ 后重启宿主生效(扩展命令需重启)。
9
+ */
10
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+
14
+ /** 读一个 json,坏文件当空对象 */
15
+ function readJson(p) {
16
+ try {
17
+ if (!existsSync(p)) return {};
18
+ const j = JSON.parse(readFileSync(p, 'utf8'));
19
+ return j && typeof j === 'object' ? j : {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ /** profile 根:优先环境变量,退到 ~/.dsh/profiles/<第一个> */
26
+ function profileRootOf() {
27
+ const envDir = process.env.DSH_PROFILE_DIR || process.env.DSH_PROFILE_ROOT;
28
+ if (envDir && existsSync(envDir)) return envDir;
29
+ try {
30
+ const base = join(homedir(), '.dsh', 'profiles');
31
+ if (existsSync(base)) {
32
+ const names = readdirSync(base).filter((n) => !n.startsWith('.'));
33
+ if (names.length) return join(base, names[0]);
34
+ }
35
+ } catch { /* ignore */ }
36
+ return join(homedir(), '.dsh', 'profiles', 'web');
37
+ }
38
+
39
+ export default {
40
+ name: '扩展示例',
41
+ description: '发一条(文本1)(文本2)—— 演示 profile/data 双源扩展配置',
42
+ handler: async (ctx) => {
43
+ // ★ 2026-10-27 修(主人实测:dock 里存了「哈哈我喜欢你」但命令读到默认值):
44
+ // ctx.cwd 是**工作区**(D:\...\鲸鱼娘),配置却在**数据根**
45
+ // (D:\...\鲸鱼娘\dshqqbot\.qqbot-extensions\...)—— 差了 dshqqbot 一级,
46
+ // 于是读到不存在的路径、回退成默认提示。优先用能力包给的 dataRoot。
47
+ const caps = ctx?.caps ?? ctx;
48
+ let dataRoot = String(caps?.dataRoot || ctx?.dataRoot || '').trim();
49
+ if (!dataRoot) {
50
+ const base = String(ctx?.cwd || process.cwd());
51
+ const guess = join(base, 'dshqqbot');
52
+ dataRoot = existsSync(guess) ? guess : base;
53
+ }
54
+ const extId = 'demo';
55
+
56
+ // 文本1:**本实例优先 → 全局兜底**(2026-10-27 主人要求)
57
+ // 理由:设置页里那张卡片能切「改全局 / 改当前账号」,用户给某个机器人单独配了
58
+ // 文本1(如 3 号机配 666),那就该用他的;没配的机器人才用全局那份。
59
+ const profileCfg = readJson(join(profileRootOf(), '.qqbot-extensions', '.state', 'ext-config', extId + '.json'));
60
+ const dataCfg = readJson(join(dataRoot, '.qqbot-extensions', '.state', 'ext-config', extId + '.json'));
61
+
62
+ const t1 = String(dataCfg.text1 || '').trim()
63
+ || String(profileCfg.text1 || '').trim()
64
+ || '(文本1:还没在「通用设置」里改过)';
65
+ const t2 = String(dataCfg.text2 || '').trim() || '(文本2:还没在 dock 球里改过)';
66
+
67
+ const out = `(${t1})(${t2})`;
68
+
69
+ // ctx 是能力包平铺:text() 直接往当前会话发一条纯文本
70
+ try {
71
+ if (typeof ctx?.text === 'function') {
72
+ await ctx.text(out);
73
+ return { ok: true, msg: '已发送' };
74
+ }
75
+ if (typeof ctx?.markdown === 'function') {
76
+ await ctx.markdown(out);
77
+ return { ok: true, msg: '已发送(markdown)' };
78
+ }
79
+ } catch (e) {
80
+ return { ok: false, msg: '发送失败: ' + (e instanceof Error ? e.message : String(e)) };
81
+ }
82
+ return { ok: false, msg: '当前 ctx 没有发送能力' };
83
+ },
84
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * demo-store.mjs — 扩展示例的**自有存储**后端(2026-10-26)
3
+ *
4
+ * 演示点:扩展不一定要用 ext-config 那套固定 JSON —— 可以自己挑存储位置和格式,
5
+ * 通过 env.registerRoute() 注册 HTTP 路由,让自己的 UI 卡片用 qqbotExt.api() 取。
6
+ *
7
+ * 这里用的存储:{dataRoot}/.qqbot/demo-store.json(自己挑的地方,跟插件数据放一起)
8
+ * 想换 SQLite / 别的目录,只改本文件的读写实现即可 —— 卡片侧完全不用动。
9
+ *
10
+ * 注册的路由:
11
+ * GET /store → { ok, data: {...}, file }
12
+ * POST /store → 浅合并写回,返回新内容
13
+ * GET /ping → 探活
14
+ *
15
+ * 卡片里(见 ui/demo-dock/card.js):
16
+ * qqbotExt.api('/store').then(r => r.data)
17
+ */
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
+
21
+ export default async function (env) {
22
+ const caps = env?.caps ?? env;
23
+ if (!caps || typeof caps.registerRoute !== 'function') {
24
+ env?.log?.('[demo-store] 这个版本的宿主没有 registerRoute,跳过了');
25
+ return;
26
+ }
27
+
28
+ const extId = 'demo';
29
+ // ★ 2026-10-27 修(主人实测:dock 在 3 号机里读到的还是鲸鱼娘那份文件):
30
+ // hooks 是「插件启动时跑一次」,env.dataRoot 是**插件级**的(主实例那份),
31
+ // 不是当前机器人实例的。所以数据根必须**随请求走** ——
32
+ // 卡片通过 api() 把 dataRoot 带上来,dispatch 会把它放进 query 交给 handler。
33
+ // 这里 env.dataRoot 只当兜底。
34
+ const defaultRoot = String(env?.dataRoot || env?.cwd || process.cwd());
35
+ const storeFileOf = (root) => join(root || defaultRoot, '.qqbot', 'demo-store.json');
36
+
37
+ function readStoreAt(file) {
38
+ try {
39
+ if (!existsSync(file)) return {};
40
+ const j = JSON.parse(readFileSync(file, 'utf8'));
41
+ return j && typeof j === 'object' ? j : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+ function writeStoreAt(file, o) {
47
+ try {
48
+ mkdirSync(dirname(file), { recursive: true });
49
+ writeFileSync(file, JSON.stringify(o, null, 2), 'utf8');
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ const rootOf = (q) => String((q && q.dataRoot) || '').trim() || defaultRoot;
56
+ const fileOf = (q) => storeFileOf(rootOf(q));
57
+
58
+ // ── 注册路由(第 4 个参数是扩展 id,卡片用 api() 时会带上它)──
59
+ const r1 = caps.registerRoute('GET', '/store', async ({ query } = {}) => ({
60
+ ok: true, data: readStoreAt(fileOf(query)), file: fileOf(query), dataRoot: rootOf(query),
61
+ }), extId);
62
+ const r2 = caps.registerRoute('POST', '/store', async ({ query, body } = {}) => {
63
+ const f = fileOf(query);
64
+ const cur = readStoreAt(f);
65
+ const next = Object.assign({}, cur, body && typeof body === 'object' ? body : {});
66
+ return writeStoreAt(f, next)
67
+ ? { ok: true, data: next, file: f, dataRoot: rootOf(query) }
68
+ : { ok: false, reason: '写入失败:' + f };
69
+ }, extId);
70
+ const r3 = caps.registerRoute('GET', '/ping', async ({ query } = {}) => ({
71
+ ok: true, data: { at: Date.now(), dataRoot: rootOf(query), file: fileOf(query) },
72
+ }), extId);
73
+
74
+ env?.log?.('[demo-store] 已注册 3 条路由 (' + [r1, r2, r3].join(',') + '),默认存储: ' + storeFileOf(defaultRoot));
75
+ }
@@ -0,0 +1,95 @@
1
+ # 扩展 UI 卡片(ext-ui)
2
+
3
+ 给扩展一个**声明式 UI 入口** —— 不用改主包任何文件,就能往面板上加自己的卡片。
4
+
5
+ > 起因:以前扩展想加界面,只能按字符串锚点往 `client/qqbot-settings.js`(535KB 手写 bundle)
6
+ > 里塞代码。版本一换锚点就失效,而且只对写补丁的那台机器有效。
7
+
8
+ ---
9
+
10
+ ## 放哪儿
11
+
12
+ 任选其一(同名时**数据根优先**):
13
+
14
+ ```
15
+ {dataRoot}/.qqbot-extensions/ui/<extId>/card.html
16
+ {dataRoot}/extension-packs/<包名>/ui/<extId>/card.html
17
+ ```
18
+
19
+ `<extId>` 只能用 `[A-Za-z0-9_-]`,最长 64,且必须有 `card.html` 才会被扫到。
20
+
21
+ 三个文件(后两个可选):
22
+
23
+ ```
24
+ card.html 结构(必填,元信息写在这个文件的头部注释里)
25
+ card.css 样式(可选,自动加进 Shadow DOM)
26
+ card.js 逻辑(可选,ESM 风格的 IIFE 更稳)
27
+ ```
28
+
29
+ ---
30
+
31
+ ## 元信息
32
+
33
+ `card.html` 的**前 2KB 内**放:
34
+
35
+ ```html
36
+ <!-- qqbot-ui: { "slot": "settings", "title": "本地代答", "order": 10 } -->
37
+ ```
38
+
39
+ | 字段 | 说明 |
40
+ |---|---|
41
+ | `slot` | `settings`(设置页底部卡片条)或 `dock`(悬浮台页签)。缺省 `settings` |
42
+ | `title` | 卡片/页签标题。缺省用目录名 |
43
+ | `order` | 排序,小的靠前。缺省 100 |
44
+
45
+ ---
46
+
47
+ ## 运行环境
48
+
49
+ `card.js` 以 `new Function('qqbotExt', 'shadowRoot', 源码)` 执行,两个变量直接可用:
50
+
51
+ ```js
52
+ qqbotExt.extId 自己的 id
53
+ qqbotExt.api(path, opts) 调 /api/ext/<extId>/… (扩展自己注册的路由)
54
+ qqbotExt.toast(msg) 轻提示
55
+ shadowRoot 本卡片的 ShadowRoot
56
+ ```
57
+
58
+ **注意事项**:
59
+
60
+ - 用 `shadowRoot.getElementById()`,**不要**用 `document.getElementById()` —— 那查到的是外面页面。
61
+ - 脚本抛错只让这张卡显示一行红字,不会影响设置页本体(外面套了 try/catch)。
62
+ - CSS 完全隔离:你写 `button{...}` 碰不到面板,面板的样式也进不来。
63
+
64
+ ---
65
+
66
+ ## 横向滑动
67
+
68
+ 卡片条本身就是横向可滑的(`overflow-x:auto` + `scroll-snap` + 手机惯性滚动)。
69
+ 单卡默认宽 `220~320px`,挂多少张都不会挤爆布局。
70
+
71
+ 想控制宽度,在 `card.css` 里改 `:host`:
72
+
73
+ ```css
74
+ :host { width: 300px; } /* 固定宽;不给就跟随面板的 min/max */
75
+ ```
76
+
77
+ ---
78
+
79
+ ## 最小可用示例
80
+
81
+ 本目录就是 —— `local-llm/` 三个文件抄过去改改就能跑。
82
+
83
+ 测试(不用重启宿主,直接跑扫描器):
84
+
85
+ ```bash
86
+ node -e 'import("./dist/features/ext-ui.js").then(m=>console.log(m.listExtUi("你的数据根")))'
87
+ ```
88
+
89
+ ---
90
+
91
+ ## 相关
92
+
93
+ - 扫描与安全校验:`src/features/ext-ui.ts`
94
+ - 后端路由:`settings-host.js` 的 `/api/qqbot-settings/ext-ui/list` 与 `/ext-ui/file`
95
+ - 设计稿:`参考文档/扩展UI挂载点_设计稿_20261026.md`
@@ -0,0 +1,55 @@
1
+ /* 扩展示例 · 两处卡片共用的样式(Shadow DOM 内,不影响面板其他地方) */
2
+ :host { display: block; }
3
+ .card {
4
+ background: #fff; border: 1px solid #e6e8ec; border-radius: 12px;
5
+ padding: 10px 12px; font: 13px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
6
+ color: #1f2329; box-shadow: 0 1px 2px rgba(20,30,60,.05);
7
+ }
8
+ .hd { display: flex; align-items: center; gap: 6px; font-weight: 700; margin-bottom: 8px; }
9
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: #bbb; flex: none; }
10
+ .dot.on { background: #16a34a; }
11
+ .dot.err { background: #dc2626; }
12
+ .row { display: flex; align-items: center; gap: 6px; margin: 6px 0; font-size: 12px; }
13
+ .lbl { color: #6b7280; flex: none; }
14
+ .val { color: #1f2329; }
15
+ .hint { color: #9aa0a8; font-size: 11px; line-height: 1.5; }
16
+ .txt {
17
+ flex: 1; min-width: 160px; font: inherit; padding: 6px 9px;
18
+ border: 1px solid #d0d5dd; border-radius: 8px; outline: none; color: #1f2329;
19
+ }
20
+ .txt:focus { border-color: #7c6cf0; }
21
+ .acts { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
22
+ .btn {
23
+ font: inherit; font-size: 12px; padding: 5px 12px; border-radius: 8px;
24
+ border: 1px solid #d9c6ff; background: #f1ecff; color: #4a3a9f; cursor: pointer;
25
+ }
26
+ .btn:hover { background: #e9e1ff; }
27
+ .btn:disabled { opacity: .5; cursor: default; }
28
+ .btn.ghost { background: #fff; border-color: #d0d5dd; color: #4b5563; }
29
+
30
+ /* dock 卡片专用:顶部一排子标签 */
31
+ .subtabs { display: flex; gap: 4px; border-bottom: 1px solid #eee; margin-bottom: 10px; }
32
+ .subtabs button {
33
+ font: inherit; font-size: 13px; padding: 6px 14px; border: none; background: none;
34
+ cursor: pointer; color: #666; border-bottom: 2px solid transparent;
35
+ }
36
+ .subtabs button.on { color: #4a3a9f; font-weight: 700; border-bottom-color: #7c6cf0; }
37
+ .pane { display: none; }
38
+ .pane.on { display: block; }
39
+ pre.info {
40
+ background: #f6f7f9; border: 1px solid #e6e8ec; border-radius: 8px;
41
+ padding: 8px 10px; font-size: 11px; line-height: 1.6; max-height: 180px; overflow: auto;
42
+ white-space: pre-wrap; word-break: break-all; margin: 4px 0 0;
43
+ }
44
+
45
+ /* 说明块(2026-10-27):告诉用户这两段文字会出现在 /扩展示例 里 */
46
+ .tip {
47
+ background: #f4f7ff; border: 1px solid #dbe6ff; border-radius: 9px;
48
+ padding: 8px 10px; font-size: 11.5px; line-height: 1.65; color: #3a4a6b; margin: 2px 0 10px;
49
+ }
50
+ .tip b { color: #2b4a8f; }
51
+ .code {
52
+ font-family: ui-monospace, Consolas, monospace; font-size: 11px;
53
+ background: #fff; border: 1px dashed #c9d8f5; border-radius: 6px;
54
+ padding: 3px 7px; margin: 5px 0; color: #1f3b73;
55
+ }
@@ -0,0 +1,95 @@
1
+ <!-- qqbot-ui: { "id": "demo", "slot": "dock", "title": "🔌 扩展示例", "order": 5 } -->
2
+ <!--
3
+ 扩展示例 · dock 卡片(slot=dock → 放 **数据根**)
4
+ {dataRoot}/.qqbot-extensions/ui/demo-dock/card.html
5
+
6
+ ★ 元信息里的 "id": "demo" 是关键 —— 目录叫 demo-dock,但 cosplay 成 extId=demo,
7
+ 这样才能和 settings 那张卡共用同一份配置 id(文本1 在 profile、文本2 在数据根)。
8
+
9
+ dock 卡片的独特之处:qqbotExt.dock.* 能拿到**当前实例 + 当前会话**的真实数据。
10
+ -->
11
+ <div class="card">
12
+ <div class="hd"><span class="dot" id="dot"></span><span>🔌 扩展示例 · 本实例</span></div>
13
+
14
+ <div class="subtabs">
15
+ <button data-p="set" class="on">✏ 改文本2</button>
16
+ <button data-p="ctx">📊 会话数据</button>
17
+ <button data-p="store">📝 自有存储</button>
18
+ <button data-p="silent">💤 静默注入</button>
19
+ <button data-p="file">📄 读写文件</button>
20
+ </div>
21
+
22
+ <!-- 子标签 1:改文本2(数据根级,只影响这个机器人) -->
23
+ <div class="pane on" data-pane="set">
24
+ <div class="row"><span class="lbl">文本2(本实例)</span></div>
25
+ <div class="row"><input class="txt" id="t2" placeholder="填一段文字,例如 我是本机文本" /></div>
26
+ <div class="tip">
27
+ 这里填的文字会出现在 <b>/扩展示例</b> 的输出里 —— 去群里敲一下就能看到:
28
+ <div class="code">/扩展示例 → (文本1)(文本2)</div>
29
+ 本页改的是 <b>文本2</b>(只影响当前这个机器人);<b>文本1</b> 在「通用设置 → QQ机器人 → 扩展示例 · 全局」里改(所有机器人共用)。
30
+ </div>
31
+ <div class="acts">
32
+ <button class="btn" id="save2">保存到本实例</button>
33
+ <button class="btn ghost" id="reload2">重新读取</button>
34
+ </div>
35
+ <div class="row"><span class="val" id="st2"></span></div>
36
+ </div>
37
+
38
+ <!-- 子标签 2:展示 dock 能拿到的会话数据(这是 dock 独有能力的演示) -->
39
+ <div class="pane" data-pane="ctx">
40
+ <div class="row"><span class="hint">下面这些是 dock 卡片独有的能力 —— 能直接读到当前实例和会话的真实数据。</span></div>
41
+ <div class="acts">
42
+ <button class="btn" id="ctxBtn">读上下文</button>
43
+ <button class="btn ghost" id="hisBtn">读最近消息</button>
44
+ </div>
45
+ <pre class="info" id="info">点上面按钮看看</pre>
46
+ </div>
47
+
48
+ <!-- 子标签 3:自有存储(不用 ext-config 那套,走 hooks 注册的 /store 路由) -->
49
+ <div class="pane" data-pane="store">
50
+ <div class="row"><span class="hint">这块数据存在 <b>hooks/demo-store.mjs</b> 自己挑的文件里,通过 <code>api('/store')</code> 读写 —— 想换 SQLite 只改后端,卡片不用动。</span></div>
51
+ <div class="row"><span class="lbl">键</span><input class="txt" id="sk" placeholder="note" style="max-width:120px" /></div>
52
+ <div class="row"><input class="txt" id="sv" placeholder="值,例如 你好自有存储" /></div>
53
+ <div class="acts">
54
+ <button class="btn" id="storeSave">写入自有存储</button>
55
+ <button class="btn ghost" id="storeLoad">读取</button>
56
+ </div>
57
+ <pre class="info" id="storeInfo">点「读取」看看</pre>
58
+ </div>
59
+
60
+ <!-- 子标签 4:静默注入(往当前会话塞一条消息,但不唤醒 AI) -->
61
+ <div class="pane" data-pane="silent">
62
+ <div class="tip">
63
+ 往当前会话塞一条消息,但 <b>不唤醒 AI</b> —— 它 <b>不会</b> 立刻回复,
64
+ 而是等下一次被叫到时才会在上下文里看到这条。<br />
65
+ 用途:给 AI 留个备忘、埋个线索,或者悄悄改一下它下一轮的认知。
66
+ </div>
67
+ <div class="row"><input class="txt" id="silentText" placeholder="要静默注入的文本,例如:记住主人的猫叫豆豆" /></div>
68
+ <div class="acts">
69
+ <button class="btn" id="silentBtn">静默注入</button>
70
+ </div>
71
+ <pre class="info" id="silentInfo">还没试过</pre>
72
+ </div>
73
+
74
+ <!-- 子标签 5:读写电脑上任意文本文件(走 fs.read / fs.write 白名单) -->
75
+ <div class="pane" data-pane="file">
76
+ <div class="tip">
77
+ 读 / 写本机任意文本文件(<b>只放行文本类扩展名</b>:txt / md / json / log / yml / csv / ini / js / ts / html / css,单文件 ≤2MB)。<br />
78
+ 保存时会在旁边留一份 <code>.bak-时间戳</code> 备份,改坏了能捞回来。
79
+ </div>
80
+ <div class="row">
81
+ <input class="txt" id="filePath" placeholder="D:\某个目录\note.txt" />
82
+ <button class="btn ghost" id="filePick" style="flex:none">选择文件…</button>
83
+ <button class="btn ghost" id="filePicker2" style="flex:none" title="用系统对话框选文件,选完直接可读可写(不需要路径)">系统选择器</button>
84
+ <input type="file" id="fileInput" style="display:none" accept=".txt,.md,.json,.log,.yml,.yaml,.csv,.ini,.conf,.js,.ts,.mjs,.cjs,.html,.css" />
85
+ </div>
86
+ <div class="acts">
87
+ <button class="btn" id="fileRead">读取</button>
88
+ <button class="btn" id="fileSave">保存修改</button>
89
+ </div>
90
+ <div class="row">
91
+ <textarea class="txt" id="fileText" rows="8" placeholder="文件内容会显示在这里,可以直接改" style="width:100%;resize:vertical;font-family:ui-monospace,Consolas,monospace;font-size:12px"></textarea>
92
+ </div>
93
+ <pre class="info" id="fileInfo">先填路径再点「读取」</pre>
94
+ </div>
95
+ </div>
@@ -0,0 +1,261 @@
1
+ /* 扩展示例 · dock 卡片逻辑
2
+ dock 卡片的 getConfig()/setConfig() 默认走 **数据根**(每实例一份);
3
+ 另外能用 qqbotExt.dock.* 读当前实例 / 会话的真实数据 —— 这是 dock 独有的。 */
4
+ (function () {
5
+ var $ = function (id) { return shadowRoot.getElementById(id) };
6
+ var dot = $('dot');
7
+ var t2 = $('t2');
8
+ var st2 = $('st2');
9
+ var info = $('info');
10
+
11
+ function show(ok, msg) {
12
+ if (st2) { st2.textContent = msg; st2.style.color = ok ? '#2f9e44' : '#c33' }
13
+ if (dot) dot.className = 'dot ' + (ok ? 'on' : 'err')
14
+ }
15
+ function setInfo(o) {
16
+ if (!info) return
17
+ try { info.textContent = typeof o === 'string' ? o : JSON.stringify(o, null, 2) } catch (e) { info.textContent = String(o) }
18
+ }
19
+
20
+ // ── 子标签切换 ──────────────────────────────────────────
21
+ shadowRoot.querySelectorAll('.subtabs button').forEach(function (btn) {
22
+ btn.addEventListener('click', function () {
23
+ var p = btn.getAttribute('data-p')
24
+ shadowRoot.querySelectorAll('.subtabs button').forEach(function (b) { b.className = (b === btn ? 'on' : '') })
25
+ shadowRoot.querySelectorAll('.pane').forEach(function (el) {
26
+ el.className = 'pane' + (el.getAttribute('data-pane') === p ? ' on' : '')
27
+ })
28
+ })
29
+ })
30
+
31
+ // ── 子标签 1:文本2(数据根级) ─────────────────────────
32
+ function load2() {
33
+ show(true, '读取中…')
34
+ qqbotExt.getConfig().then(function (c) {
35
+ if (t2) t2.value = c.text2 || ''
36
+ show(true, c.text2 ? ('当前:' + c.text2) : '还没设置')
37
+ })
38
+ }
39
+ var save2 = $('save2');
40
+ if (save2) {
41
+ save2.addEventListener('click', function () {
42
+ save2.disabled = true
43
+ qqbotExt.setConfig({ text2: t2 ? t2.value : '' }).then(function (r) {
44
+ save2.disabled = false
45
+ if (r && r.ok) show(true, '已保存到本实例:' + ((r.config && r.config.text2) || ''))
46
+ else show(false, '保存失败:' + ((r && r.reason) || '未知'))
47
+ }).catch(function () { save2.disabled = false; show(false, '保存异常') })
48
+ })
49
+ }
50
+ var reload2 = $('reload2');
51
+ if (reload2) reload2.addEventListener('click', load2)
52
+
53
+ // ── 子标签 2:dock 独有能力 ─────────────────────────────
54
+ var ctxBtn = $('ctxBtn');
55
+ if (ctxBtn) {
56
+ ctxBtn.addEventListener('click', function () {
57
+ if (!qqbotExt.dock) return setInfo('这个卡片没有 dock 上下文(不是 dock 源?)')
58
+ setInfo('读取中…')
59
+ qqbotExt.dock.context().then(function (r) { setInfo(r || '(空)') })
60
+ })
61
+ }
62
+ var hisBtn = $('hisBtn');
63
+ if (hisBtn) {
64
+ hisBtn.addEventListener('click', function () {
65
+ if (!qqbotExt.dock) return setInfo('这个卡片没有 dock 上下文')
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') : '(这个会话还没有历史消息)')
73
+ })
74
+ })
75
+ }
76
+
77
+ // ── 子标签 3:自有存储(走 hooks 注册的 /store 路由)─────────────
78
+ var storeInfo = $('storeInfo');
79
+ var sk = $('sk');
80
+ var sv = $('sv');
81
+ function setStoreInfo(o) {
82
+ if (!storeInfo) return
83
+ try { storeInfo.textContent = typeof o === 'string' ? o : JSON.stringify(o, null, 2) } catch (e) { storeInfo.textContent = String(o) }
84
+ }
85
+ function loadStore() {
86
+ setStoreInfo('读取中…')
87
+ qqbotExt.api('/store').then(function (r) {
88
+ if (!r) return setStoreInfo('没有响应(hooks 里的 demo-store.mjs 没跑?)')
89
+ if (!r.ok) return setStoreInfo('失败:' + (r.reason || '未知'))
90
+ setStoreInfo('存储文件:' + (r.file || '?') + '\n\n' + JSON.stringify(r.data || {}, null, 2))
91
+ })
92
+ }
93
+ var storeLoad = $('storeLoad');
94
+ if (storeLoad) storeLoad.addEventListener('click', loadStore)
95
+ var storeSave = $('storeSave');
96
+ if (storeSave) {
97
+ storeSave.addEventListener('click', function () {
98
+ var key = (sk && sk.value ? sk.value : 'note').trim() || 'note'
99
+ var val = sv ? sv.value : ''
100
+ var patch = {}
101
+ patch[key] = val
102
+ storeSave.disabled = true
103
+ qqbotExt.api('/store', { method: 'POST', body: JSON.stringify(patch) }).then(function (r) {
104
+ storeSave.disabled = false
105
+ if (!r) return setStoreInfo('没有响应')
106
+ if (!r.ok) return setStoreInfo('失败:' + (r.reason || '未知'))
107
+ setStoreInfo('已写入 ' + key + ' = ' + val + '\n\n' + JSON.stringify(r.data || {}, null, 2))
108
+ }).catch(function () { storeSave.disabled = false; setStoreInfo('请求异常') })
109
+ })
110
+ }
111
+
112
+ // ── 子标签 4:静默注入(appendSilent,不唤醒 AI)──────────────
113
+ var silentInfo = $('silentInfo');
114
+ var silentText = $('silentText');
115
+ function setSilent(o) {
116
+ if (!silentInfo) return
117
+ try { silentInfo.textContent = typeof o === 'string' ? o : JSON.stringify(o, null, 2) } catch (e) { silentInfo.textContent = String(o) }
118
+ }
119
+ var silentBtn = $('silentBtn');
120
+ if (silentBtn) {
121
+ silentBtn.addEventListener('click', function () {
122
+ var t = silentText && silentText.value ? silentText.value.trim() : ''
123
+ if (!t) return setSilent('先填要注入的文本')
124
+ if (!qqbotExt.dock || !qqbotExt.dock.peer) {
125
+ return setSilent('当前没选中会话 —— 先在「聊天」里选一个目标')
126
+ }
127
+ silentBtn.disabled = true
128
+ // appendSilent 需要会话的 agent,后端会按 peer 去找
129
+ Promise.resolve(qqbotExt.cap('appendSilent', t)).then(function (r) {
130
+ silentBtn.disabled = false
131
+ setSilent(r === true
132
+ ? ('已静默注入(AI 不会立刻回复,下轮才看到):' + t)
133
+ : ('没注入成功:' + JSON.stringify(r)))
134
+ }).catch(function (e) { silentBtn.disabled = false; setSilent('异常: ' + e) })
135
+ })
136
+ }
137
+
138
+ // ── 子标签 5:读写任意文本文件(fs.read / fs.write)────────────
139
+ var filePath = $('filePath');
140
+ var fileText = $('fileText');
141
+ var fileInfo = $('fileInfo');
142
+ function setFileInfo(o) {
143
+ if (!fileInfo) return
144
+ try { fileInfo.textContent = typeof o === 'string' ? o : JSON.stringify(o, null, 2) } catch (e) { fileInfo.textContent = String(o) }
145
+ }
146
+ // ── 文件选择器(2026-10-27 主人要求)──
147
+ // Electron(DSH 桌面版)的 File 对象上有 **path** 属性 → 能直接拿到真实路径,
148
+ // 填进输入框后就能走 fs.read / fs.write(可改可存)。
149
+ // 纯浏览器(web 版)出于安全拿不到路径,只能读内容做预览;
150
+ // 这种情况退回"预览模式":显示内容 + 提示去桌面版改。
151
+ var filePick = $('filePick');
152
+ var fileInput = $('fileInput');
153
+ if (filePick && fileInput) {
154
+ filePick.addEventListener('click', function () { fileInput.click() })
155
+ fileInput.addEventListener('change', function () {
156
+ var f = fileInput.files && fileInput.files[0]
157
+ if (!f) return
158
+ // Electron 专有属性;浏览器里是 undefined
159
+ var realPath = (typeof f.path === 'string' && f.path) || ''
160
+ if (realPath) {
161
+ if (filePath) filePath.value = realPath
162
+ setFileInfo('已选中:' + realPath + ',正在读取…')
163
+ if (fileRead) fileRead.click()
164
+ } else {
165
+ // 浏览器:拿不到路径,只能预览内容
166
+ var rd = new FileReader()
167
+ rd.onload = function () {
168
+ if (fileText) fileText.value = String(rd.result || '')
169
+ setFileInfo('已读取内容(浏览器拿不到真实路径,只能预览)\n文件名:' + f.name + '\n如果要修改并保存,请用桌面版,或手动把完整路径填进上面的输入框。')
170
+ }
171
+ rd.readAsText(f)
172
+ }
173
+ try { fileInput.value = '' } catch (e) { /* 允许重复选同一个 */ }
174
+ })
175
+ }
176
+
177
+ // ── 系统选择器(File System Access API,2026-10-27)─────────────
178
+ // 纯浏览器(Edge/Chrome 113+,localhost 算安全上下文)能弹**系统对话框**,
179
+ // 返回的是 FileSystemFileHandle —— **没有路径字符串**(浏览器安全模型就不给),
180
+ // 但 handle 本身可以直接读、直接写:
181
+ // getFile().text() 读
182
+ // createWritable().write() 写(用户授权过这个 handle)
183
+ // 所以这条路根本不需要路径,比填路径还省事,而且不会写错位置。
184
+ var filePicker2 = $('filePicker2');
185
+ var _pickedHandle = null;
186
+ if (filePicker2) {
187
+ filePicker2.addEventListener('click', function () {
188
+ if (typeof window.showOpenFilePicker !== 'function') {
189
+ return setFileInfo('这个浏览器不支持 File System Access API(需要 Edge/Chrome 113+,且是 localhost 或 https)')
190
+ }
191
+ window.showOpenFilePicker({
192
+ multiple: false,
193
+ types: [{
194
+ description: '文本文件',
195
+ accept: { 'text/plain': ['.txt', '.md', '.json', '.log', '.yml', '.yaml', '.csv', '.ini', '.conf', '.js', '.ts', '.html', '.css'] },
196
+ }],
197
+ }).then(function (handles) {
198
+ var h = handles && handles[0]
199
+ if (!h) return
200
+ _pickedHandle = h
201
+ if (filePath) filePath.value = '(系统选择器) ' + h.name // 只有名字,没有路径
202
+ return h.getFile().then(function (f) {
203
+ if (f.size > 2 * 1024 * 1024) return setFileInfo('文件太大(>2MB)')
204
+ return f.text().then(function (txt) {
205
+ if (fileText) fileText.value = txt
206
+ setFileInfo('已通过系统选择器打开:' + h.name + '(' + f.size + ' 字节)\n现在点「保存修改」会直接写回这个文件(handle 已授权,不需要路径)。')
207
+ })
208
+ })
209
+ }).catch(function (e) {
210
+ if (e && e.name === 'AbortError') return setFileInfo('已取消选择')
211
+ setFileInfo('选择失败:' + (e && e.message ? e.message : e))
212
+ })
213
+ })
214
+ }
215
+
216
+ var fileRead = $('fileRead');
217
+ if (fileRead) {
218
+ fileRead.addEventListener('click', function () {
219
+ var p = filePath && filePath.value ? filePath.value.trim() : ''
220
+ if (!p) return setFileInfo('先填文件路径')
221
+ setFileInfo('读取中…')
222
+ qqbotExt.cap('fs.read', p).then(function (r) {
223
+ if (!r) return setFileInfo('没有响应(后端白名单里没有 fs.read?)')
224
+ if (!r.ok) return setFileInfo('读取失败:' + (r.reason || '未知'))
225
+ if (fileText) fileText.value = r.text || ''
226
+ setFileInfo('已读取:' + r.path + '(' + r.size + ' 字节)')
227
+ }).catch(function (e) { setFileInfo('异常: ' + e) })
228
+ })
229
+ }
230
+ var fileSave = $('fileSave');
231
+ if (fileSave) {
232
+ fileSave.addEventListener('click', function () {
233
+ var t = fileText ? fileText.value : ''
234
+ // ★ 优先用系统选择器拿到的 handle:直接写回原文件,不需要路径
235
+ if (_pickedHandle && typeof _pickedHandle.createWritable === 'function') {
236
+ fileSave.disabled = true
237
+ _pickedHandle.createWritable().then(function (w) {
238
+ return w.write(t).then(function () { return w.close() })
239
+ }).then(function () {
240
+ fileSave.disabled = false
241
+ setFileInfo('已写回:' + _pickedHandle.name + '(' + t.length + ' 字符,通过系统选择器授权)')
242
+ }).catch(function (e) {
243
+ fileSave.disabled = false
244
+ setFileInfo('写回失败:' + (e && e.message ? e.message : e))
245
+ })
246
+ return
247
+ }
248
+ var p = filePath && filePath.value ? filePath.value.trim() : ''
249
+ if (!p) return setFileInfo('先填文件路径,或用「系统选择器」选一个')
250
+ fileSave.disabled = true
251
+ qqbotExt.cap('fs.write', p, t).then(function (r) {
252
+ fileSave.disabled = false
253
+ if (!r) return setFileInfo('没有响应')
254
+ if (!r.ok) return setFileInfo('保存失败:' + (r.reason || '未知'))
255
+ setFileInfo('已保存:' + r.path + '(' + r.size + ' 字节)\n原文件已备份为 *.bak-时间戳')
256
+ }).catch(function (e) { fileSave.disabled = false; setFileInfo('异常: ' + e) })
257
+ })
258
+ }
259
+
260
+ load2()
261
+ })();