@zaofan/dsh-qqbot 1.9.3 → 1.10.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.
- package/README.md +646 -554
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/features/ext-capabilities.d.ts +9 -0
- package/dist/features/ext-capabilities.d.ts.map +1 -1
- package/dist/features/ext-capabilities.js +2 -0
- package/dist/features/ext-capabilities.js.map +1 -1
- package/dist/features/ext-tool-interaction.d.ts.map +1 -1
- package/dist/features/ext-tool-interaction.js +37 -5
- package/dist/features/ext-tool-interaction.js.map +1 -1
- package/dist/features/extension-store.d.ts.map +1 -1
- package/dist/features/extension-store.js +8 -0
- package/dist/features/extension-store.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +6 -0
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/examples/ext-commands//345/255/220/344/274/232/350/257/235.mjs +598 -0
- package/examples/ext-hooks/session-watch-hook.mjs +53 -119
- package/examples/ext-tools/lib/session_watch_lib.mjs +767 -350
- package/examples/ext-tools/qq_send.mjs +201 -138
- package/examples/ext-tools/session_children.mjs +99 -0
- package/examples/ext-tools/session_create.mjs +219 -201
- package/examples/ext-tools/session_watch.mjs +139 -93
- package/examples/ext-tools/sessions_list.mjs +180 -180
- package/package.json +142 -142
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /子会话 — 创建子会话的「问答式向导」(2026-10-13)
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* /子会话 <会话名> [人设] [工作区] [模型] [推理等级] [回灌模式]
|
|
6
|
+
* —— 除会话名外**参数顺序随意**,能认的自动认;认不出的当工作区再试一次。
|
|
7
|
+
* —— 没给全的参数 → **依次发卡片按钮让主人选**(每次一个参数,选完自动进下一步)。
|
|
8
|
+
*
|
|
9
|
+
* 为什么能一条命令干完(不需要 tools/ 也不需要 botplay):
|
|
10
|
+
* 卡片按钮 data 写成 `ext:子会话:<卡id>:<按钮id>`,插件把它派发给**本命令的 onInteraction**
|
|
11
|
+
* (2026-10-13 起扩展命令也支持这条链路,见 features/ext-tool-interaction.ts)。
|
|
12
|
+
* 发卡用 `ctx.caps.markdownCard`,登记用 `ctx.caps.registerInteractionCard`。
|
|
13
|
+
*
|
|
14
|
+
* 状态:进行中的向导存在模块内存里(handler 与 onInteraction 是同一个模块实例)。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 改完本文件要**重启宿主**才生效(命令不走 /tools-reload)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PAGE = 20; // 每页选项数(4 行 × 5 列;第 5 行留给翻页/跳过/取消)
|
|
20
|
+
const LETTERS = 'ABCDEFGHIJKLMNOPQRST'; // 一页 20 个选项的编号
|
|
21
|
+
const CARD_EXPIRE_MS = 10 * 60_000; // 向导卡 10 分钟有效
|
|
22
|
+
const MAX_PAGE_MS = 30 * 60_000;
|
|
23
|
+
|
|
24
|
+
/** 步骤(顺序即向导顺序) */
|
|
25
|
+
const STEPS = ['agent_preset', 'workspace', 'model', 'effort', 'mode'];
|
|
26
|
+
const STEP_CN = {
|
|
27
|
+
agent_preset: '人设',
|
|
28
|
+
workspace: '工作区',
|
|
29
|
+
model: '模型',
|
|
30
|
+
effort: '推理等级',
|
|
31
|
+
mode: '回灌模式',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 进行中的向导:cardId → state */
|
|
35
|
+
const W = new Map();
|
|
36
|
+
let seq = 0;
|
|
37
|
+
|
|
38
|
+
// ── 小工具 ─────────────────────────────────────────────────────
|
|
39
|
+
const cn = (s) => String(s ?? '').trim();
|
|
40
|
+
const short = (s, n = 26) => (cn(s).length > n ? cn(s).slice(0, n - 1) + '…' : cn(s));
|
|
41
|
+
|
|
42
|
+
/** 从命令 ctx 拿宿主服务(命令侧是全权限:ctx.caps.kernel 或 ctx.kernel) */
|
|
43
|
+
function svc(ctx, name) {
|
|
44
|
+
// 同 sendCard:回调 ctx 是**平铺**的(没有 caps 键),所以逐层兜底
|
|
45
|
+
const k = ctx?.caps?.kernel ?? ctx?.caps ?? ctx?.kernel ?? ctx?.ctx ?? ctx;
|
|
46
|
+
try { return k?.get?.(name); } catch { return undefined; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 从命令 ctx 推数据根(caps.store.path 形如 {dataRoot}/.qqbot-extensions/commands/data/x.json) */
|
|
50
|
+
function dataRootOf(ctx) {
|
|
51
|
+
const p = String(ctx?.caps?.store?.path ?? ctx?.store?.path ?? '');
|
|
52
|
+
const i = p.indexOf('.qqbot-extensions');
|
|
53
|
+
return i > 0 ? p.slice(0, i).replace(/[\\/]+$/, '') : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ★ 诊断日志(2026-10-13 主人要求):写死路径,只为看清"点击到底有没有走到命令"。
|
|
58
|
+
* 每条只记事实:handler 被调 / onInteraction 被调 / 发卡结果 / 状态表里有没有这张卡。
|
|
59
|
+
*/
|
|
60
|
+
const DIAG_FILE = 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot/.qqbot-extensions/data/子会话-diag.log';
|
|
61
|
+
async function diag(msg) {
|
|
62
|
+
try {
|
|
63
|
+
const { appendFileSync, mkdirSync } = await import('node:fs');
|
|
64
|
+
const { dirname } = await import('node:path');
|
|
65
|
+
mkdirSync(dirname(DIAG_FILE), { recursive: true });
|
|
66
|
+
appendFileSync(DIAG_FILE, `${new Date().toISOString()} ${msg}\n`);
|
|
67
|
+
} catch { /* 诊断失败绝不影响主流程 */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 登记「带回调按钮的卡片」—— **命令侧必须自己写这张表**。
|
|
72
|
+
*
|
|
73
|
+
* 为什么:插件给命令组 caps 时**没有注入 `registerInteractionCard`**(capsForCmd 只传了
|
|
74
|
+
* dataRoot / apiCall / kernel…),而那个能力的实现又依赖外部注入 ⇒ 命令调它**等于什么都没发生**。
|
|
75
|
+
* 于是注册表里永远查不到这张卡,插件收到点击后第一步查表就落空 → **直接 ack**(真机表现:
|
|
76
|
+
* 点了只弹 QQ 客户端的"操作成功",既没有新卡也没有回执)。
|
|
77
|
+
*
|
|
78
|
+
* 这里按插件那张表的格式(src/features/ext-tool-cards.ts 的 EXT_TOOL_CARD_FILE)自己写一份:
|
|
79
|
+
* {version:1, cards:[{toolName, cardId, buttonIds, scope, targetId, createdAt, expireAt}]}
|
|
80
|
+
* 插件每次派发都重读这个文件 ⇒ 改完立即生效,不用重启。
|
|
81
|
+
*/
|
|
82
|
+
async function regCardSelf(ctx, cardId, buttonIds) {
|
|
83
|
+
const root = dataRootOf(ctx);
|
|
84
|
+
if (!root) return false;
|
|
85
|
+
try {
|
|
86
|
+
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
87
|
+
const { join, dirname } = await import('node:path');
|
|
88
|
+
const file = join(root, '.qqbot', 'ext-tool-cards.json');
|
|
89
|
+
let table = { version: 1, cards: [] };
|
|
90
|
+
try { table = JSON.parse(readFileSync(file, 'utf8')) ?? table; } catch { /* 首次或坏文件 */ }
|
|
91
|
+
if (!Array.isArray(table.cards)) table.cards = [];
|
|
92
|
+
const mc = ctx?.message ?? {};
|
|
93
|
+
const rt = ctx?.replyTarget ?? {};
|
|
94
|
+
const pe = ctx?.peer ?? {}; // 回调 ctx 只给 peer(2026-10-13 实测)
|
|
95
|
+
const scope = cn(rt.scope || ctx?.scope || pe.scope || mc.kind || ((mc.groupOpenid || pe.groupId || pe.peerId) ? 'group' : 'c2c'));
|
|
96
|
+
const targetId = cn(mc.groupOpenid || rt.targetId || pe.groupId || pe.groupOpenid || pe.peerId || pe.id || mc.senderId);
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
// 清掉过期的 + 本命令同一张卡的旧记录,再追加
|
|
99
|
+
table.cards = table.cards.filter((c) => Number(c?.expireAt ?? 0) > now && !(c?.toolName === '子会话' && c?.cardId === cardId));
|
|
100
|
+
table.cards.push({ toolName: '子会话', cardId, buttonIds, scope, targetId, createdAt: now, expireAt: now + CARD_EXPIRE_MS });
|
|
101
|
+
if (table.cards.length > 200) table.cards = table.cards.slice(-200);
|
|
102
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
103
|
+
writeFileSync(file, JSON.stringify(table, null, 2));
|
|
104
|
+
return true;
|
|
105
|
+
} catch { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 发一张卡(markdown + 按钮键盘),返回是否发出去 */
|
|
109
|
+
async function sendCard(ctx, text, keyboard) {
|
|
110
|
+
// ★ 2026-10-13 实测关键差异:命令 handler 的 ctx 上插件**显式挂了 `caps`**;
|
|
111
|
+
// 而**按钮回调的 ctx 是把能力包平铺在 ctx 自身**(`ctx.api`/`ctx.markdownCard`…),
|
|
112
|
+
// **没有 `caps` 键** ⇒ 只写 `ctx.caps.xxx` 时回调里全是 undefined、四级发卡全跳过。
|
|
113
|
+
// 所以这里统一 `ctx.caps ?? ctx` 兜底,两种形状都能用。
|
|
114
|
+
const caps = ctx?.caps ?? ctx ?? {};
|
|
115
|
+
// 坐标多路兜底:**回调 ctx 里没有 replyTarget / peerId**(2026-10-13 日志实测),
|
|
116
|
+
// 它只有 `peer`(身份对象);handler 侧才有 `message`/`replyTarget`。
|
|
117
|
+
const mc = ctx?.message ?? {};
|
|
118
|
+
const rt = ctx?.replyTarget ?? {};
|
|
119
|
+
const pe = ctx?.peer ?? {};
|
|
120
|
+
const scope = cn(rt.scope || ctx?.scope || pe.scope || mc.kind || ((mc.groupOpenid || pe.groupId || pe.peerId) ? 'group' : 'c2c'));
|
|
121
|
+
const gid = cn(mc.groupOpenid || rt.targetId || rt.openid || rt.id || pe.groupId || pe.groupOpenid || (scope === 'group' ? (pe.peerId || pe.id) : '') || '');
|
|
122
|
+
const uid = cn(mc.senderId || rt.openid || rt.id || pe.openid || pe.userId || '');
|
|
123
|
+
void diag(`[sendCard] peer=${JSON.stringify(pe ?? null).slice(0, 180)} scope=${scope} gid=${gid} uid=${uid}`);
|
|
124
|
+
|
|
125
|
+
// ① 首选:**直连官方接口**,显式 msg_type=2(markdown)+ keyboard。
|
|
126
|
+
// ⚠️ 2026-10-13 实测踩坑:命令侧 caps.markdownCard 用的 sender 是中间件 ctx 的 bot(只有
|
|
127
|
+
// sendText/sendMarkdown),markdownCard 发现没有 sendMarkdownWithKeyboard 会**静默降级成
|
|
128
|
+
// 纯文本、还返回 true** ⇒ 卡片发出来了、按钮全没了。所以这里直接打官方 v2 消息接口
|
|
129
|
+
// (命令 caps 带 appId/appSecret,caps.api() 自动带 token)。
|
|
130
|
+
const api = caps.api;
|
|
131
|
+
const hasKb = !!keyboard;
|
|
132
|
+
void diag(`[sendCard] gid=${gid || '-'} uid=${uid || '-'} kb=${hasKb} hasApi=${typeof api === 'function'} hasMdCard=${typeof caps.markdownCard === 'function'} hasSenderKb=${typeof ctx?.sender?.sendMarkdownWithKeyboard === 'function'} hasMd=${typeof caps.markdown === 'function'}`);
|
|
133
|
+
if (keyboard && typeof api === 'function' && (gid || uid)) {
|
|
134
|
+
const path = gid ? `/v2/groups/${gid}/messages` : `/v2/users/${uid}/messages`;
|
|
135
|
+
const body = { msg_type: 2, markdown: { content: text }, keyboard, ...(gid ? {} : { content: text }) };
|
|
136
|
+
try {
|
|
137
|
+
const r = await api(path, { method: 'POST', body });
|
|
138
|
+
void diag(`[sendCard] ①api 返回=${JSON.stringify(r ?? null).slice(0, 180)}`);
|
|
139
|
+
if (r && r.ok !== false) return true;
|
|
140
|
+
} catch (e) { void diag(`[sendCard] ①api 抛错=${String(e?.message ?? e).slice(0, 180)}`); }
|
|
141
|
+
} else {
|
|
142
|
+
void diag(`[sendCard] ①跳过(无 api 或 无坐标 或 无键盘)`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ② caps.markdownCard(工具侧那种强 sender 才真的带按钮)
|
|
146
|
+
try {
|
|
147
|
+
if (keyboard && typeof caps.markdownCard === 'function') {
|
|
148
|
+
const r2 = await caps.markdownCard(text, keyboard);
|
|
149
|
+
void diag(`[sendCard] ②markdownCard=${r2}`);
|
|
150
|
+
if (r2) return true;
|
|
151
|
+
} else {
|
|
152
|
+
void diag(`[sendCard] ②跳过(无 markdownCard 或 无键盘)`);
|
|
153
|
+
}
|
|
154
|
+
} catch (e) { void diag(`[sendCard] ②抛错=${String(e?.message ?? e).slice(0, 180)}`); }
|
|
155
|
+
try {
|
|
156
|
+
if (keyboard && typeof ctx?.sender?.sendMarkdownWithKeyboard === 'function') {
|
|
157
|
+
await ctx.sender.sendMarkdownWithKeyboard(ctx.replyTarget, text, keyboard);
|
|
158
|
+
void diag(`[sendCard] ③sender 直发 ok`);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
void diag(`[sendCard] ③跳过(sender 没有 sendMarkdownWithKeyboard)`);
|
|
162
|
+
} catch (e) { void diag(`[sendCard] ③抛错=${String(e?.message ?? e).slice(0, 180)}`); }
|
|
163
|
+
try {
|
|
164
|
+
if (typeof caps.markdown === 'function' && (await caps.markdown(text))) {
|
|
165
|
+
void diag(`[sendCard] ④纯文本兜底 ok`);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
} catch { /* ignore */ }
|
|
169
|
+
void diag(`[sendCard] 全部失败 → false`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 按钮(QQ type=1 回调按钮;data 带 ext: 前缀才会派发回来) */
|
|
174
|
+
function btn(id, label, style = 1) {
|
|
175
|
+
return {
|
|
176
|
+
id,
|
|
177
|
+
render_data: { label: short(label, 18), visited_label: short(label, 18), style },
|
|
178
|
+
action: { type: 1, permission: { type: 2 }, data: id, unsupport_tips: '请升级QQ客户端后重试' },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 把一行按钮拼成 QQ 的 inline_keyboard 结构 */
|
|
183
|
+
function keyboardOf(rows, cardId) {
|
|
184
|
+
return {
|
|
185
|
+
content: {
|
|
186
|
+
rows: rows.map((r) => ({ buttons: r.map((b) => ({ ...b, action: { ...b.action, data: `ext:子会话:${cardId}:${b.id}` } })) })),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── 各步的候选值(拿不到就返回空数组,让用户手工传参)──────────────
|
|
192
|
+
|
|
193
|
+
/** 候选值缓存(靠 handler 阶段预取,见下面说明) */
|
|
194
|
+
const OPT_CACHE = new Map();
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 宿主 ctx(kernel)缓存。
|
|
198
|
+
* ★ 2026-10-13 实测:**只有 handler 侧那个 kernel 能取到服务**(回调侧的取不到)。
|
|
199
|
+
* 建会话要用 `sessionController`,而建会话发生在最后一次点击(回调里),
|
|
200
|
+
* 所以必须把 handler 侧那个好 kernel 缓存下来给它用。
|
|
201
|
+
*/
|
|
202
|
+
let KERNEL = null;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* handler 侧有、回调侧没有的东西(挂回灌钩子要用)—— 同样靠 handler 阶段缓存。
|
|
206
|
+
* ★ 2026-10-13:`session_create` 挂"回合结束回灌"钩子时读的是
|
|
207
|
+
* `env.exec.agent.id`(父会话 id)、`env.sender`、`env.replyTarget`,
|
|
208
|
+
* 而命令侧给它传的 env 是 caps ⇒ 这三样全空 ⇒ 钩子挂不上(现象:建完显示"回灌 → 未挂")。
|
|
209
|
+
*/
|
|
210
|
+
const CTX_CACHE = { sender: null, replyTarget: null, agentId: '', sessionId: '' };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 各步的候选值。
|
|
214
|
+
* ★ 为什么要缓存(2026-10-13 诊断日志实测):
|
|
215
|
+
* 同一段"取模型列表"的代码,在 **handler** 里 `kernel.get('llm')` **能拿到**(实测 6 个模型),
|
|
216
|
+
* 在**按钮回调**里却 **拿不到**(`llm=false` → 0 个)—— 两种 ctx 的 `kernel` 不是同一个对象。
|
|
217
|
+
* 所以凡是"只有 handler 侧够得着"的数据(模型、推理等级、预设),**首次取到就缓存**;
|
|
218
|
+
* 回调翻页时直接吃缓存。取到空结果不写缓存,下次还有机会重试。
|
|
219
|
+
*/
|
|
220
|
+
async function optionsOf(ctx, step, st) {
|
|
221
|
+
if (OPT_CACHE.has(step)) return OPT_CACHE.get(step);
|
|
222
|
+
const out = await optionsOfRaw(ctx, step, st);
|
|
223
|
+
if (Array.isArray(out) && out.length > 0) OPT_CACHE.set(step, out);
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 实际的取值实现 */
|
|
228
|
+
async function optionsOfRaw(ctx, step, st) {
|
|
229
|
+
try {
|
|
230
|
+
if (step === 'agent_preset') {
|
|
231
|
+
const ap = svc(ctx, 'agentPresets');
|
|
232
|
+
const list = (await ap?.list?.()) ?? [];
|
|
233
|
+
return list.map((p) => ({ value: String(p.id), label: `${p.id}${p.name ? ' · ' + p.name : ''}${p.broken ? ' ⚠️' : ''}` }));
|
|
234
|
+
}
|
|
235
|
+
if (step === 'workspace') {
|
|
236
|
+
// ★ 2026-10-13 定案:**不依赖任何宿主服务**。
|
|
237
|
+
// 实测 `kernel.get('workspaceRegistry')` 在命令侧拿不到(宿主对未声明服务静默给 undefined);
|
|
238
|
+
// 而插件设置面板那个 `/api/qqbot-settings/workspaces` **压根不用服务**,就是扫这个目录:
|
|
239
|
+
// dsh 把会话按工作区路径归到 `~/.dsh/sessions/--<编码路径>--`,
|
|
240
|
+
// 目录名规则:`-` 分段、段内 `~XXXX` 是 UTF-16 码元、首段是盘符字母要补冒号。
|
|
241
|
+
const { homedir } = await import('node:os');
|
|
242
|
+
const { readdirSync, existsSync } = await import('node:fs');
|
|
243
|
+
const { join, basename } = await import('node:path');
|
|
244
|
+
const sessRoot = join(homedir(), '.dsh', 'sessions');
|
|
245
|
+
const out = [];
|
|
246
|
+
if (existsSync(sessRoot)) {
|
|
247
|
+
for (const ent of readdirSync(sessRoot, { withFileTypes: true })) {
|
|
248
|
+
if (!ent.isDirectory()) continue;
|
|
249
|
+
const segs = String(ent.name).split('-').filter(Boolean);
|
|
250
|
+
if (segs.length === 0) continue;
|
|
251
|
+
const dec = segs.map((s) => s.replace(/~([0-9A-Fa-f]{4})/g, (_m, h) => String.fromCharCode(parseInt(h, 16))));
|
|
252
|
+
const head = /^[A-Za-z]$/.test(dec[0]) ? dec[0] + ':' : dec[0];
|
|
253
|
+
const full = [head].concat(dec.slice(1)).join('\\');
|
|
254
|
+
if (!full) continue;
|
|
255
|
+
out.push({ value: full, label: basename(full) || full });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
void diag(`[ws] 扫 ${sessRoot} 得到 ${out.length} 个工作区`);
|
|
259
|
+
out.sort((a, b) => a.label.localeCompare(b.label));
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
if (step === 'model') {
|
|
263
|
+
// 照抄插件 model-resolver.ts 的取法:宿主 `llm` 服务 → listProviders() → listModels(pid)
|
|
264
|
+
// (workspaceRegistry 拿不到,但 llm 是核心服务,多半挂着;拿不到就返回空、卡片会提示跳过)
|
|
265
|
+
const llm = svc(ctx, 'llm') ?? svc(ctx, 'credentials');
|
|
266
|
+
const out = [];
|
|
267
|
+
try {
|
|
268
|
+
if (llm && typeof llm.listProviders === 'function') {
|
|
269
|
+
const provs = await llm.listProviders();
|
|
270
|
+
for (const p of (Array.isArray(provs) ? provs : [])) {
|
|
271
|
+
const pid = String((p && (p.id ?? p.name)) ?? p ?? '');
|
|
272
|
+
if (!pid) continue;
|
|
273
|
+
try {
|
|
274
|
+
if (typeof llm.listModels !== 'function') continue;
|
|
275
|
+
const ms = await llm.listModels(pid);
|
|
276
|
+
for (const m of (Array.isArray(ms) ? ms : [])) {
|
|
277
|
+
const mid = String((m && (m.id ?? m.model)) ?? m ?? '');
|
|
278
|
+
if (!mid) continue;
|
|
279
|
+
// provider **不进选项文本**(主人 2026-10-13 要求):选项只显示模型名,
|
|
280
|
+
// provider 由卡片正文按 group 做成小标题分段显示。
|
|
281
|
+
out.push({ value: `${pid}/${mid}`, label: mid, group: pid });
|
|
282
|
+
}
|
|
283
|
+
} catch { /* 单个 provider 失败不影响其它 */ }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch { /* ignore */ }
|
|
287
|
+
void diag(`[model] llm=${!!llm} hasListProviders=${typeof llm?.listProviders === 'function'} 得到 ${out.length} 个`);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
if (step === 'effort') {
|
|
291
|
+
// dsh 的推理等级:从模型定义里问;问不到就给常见几档
|
|
292
|
+
const m = svc(ctx, 'llm');
|
|
293
|
+
const vals = (await m?.reasoningEfforts?.()) ?? null;
|
|
294
|
+
const arr = Array.isArray(vals) && vals.length ? vals : ['low', 'medium', 'high'];
|
|
295
|
+
return arr.map((v) => ({ value: String(v), label: String(v) }));
|
|
296
|
+
}
|
|
297
|
+
if (step === 'mode') {
|
|
298
|
+
return [
|
|
299
|
+
{ value: '1', label: '一档 · 末段回灌+转QQ' },
|
|
300
|
+
{ value: '2', label: '二档 · 全部只转QQ' },
|
|
301
|
+
{ value: '3', label: '三档 · 进父上下文不唤醒' },
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
} catch { /* 拿不到就空 */ }
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 一张卡的正文 */
|
|
309
|
+
function cardText(st) {
|
|
310
|
+
const L = [];
|
|
311
|
+
L.push(`### 🧩 创建子会话 · 第 ${st.stepIdx + 1}/${STEPS.length} 步:${STEP_CN[st.step]}`);
|
|
312
|
+
L.push('');
|
|
313
|
+
L.push(`**要说的话**:${short(st.prompt, 80)}`);
|
|
314
|
+
for (const k of STEPS) {
|
|
315
|
+
const v = st.picked[k];
|
|
316
|
+
L.push(`- ${STEP_CN[k]}:${v ? v.label : '(待选)'}`);
|
|
317
|
+
}
|
|
318
|
+
L.push('');
|
|
319
|
+
if (st.error) L.push(`⚠️ ${st.error}`, '');
|
|
320
|
+
const opts = st.options;
|
|
321
|
+
if (!opts.length) {
|
|
322
|
+
L.push('这一项本鲸没拿到候选列表 —— 点「跳过」不设它,或换个写法重发命令。');
|
|
323
|
+
} else {
|
|
324
|
+
const total = Math.max(1, Math.ceil(opts.length / PAGE));
|
|
325
|
+
L.push(`第 ${st.page + 1}/${total} 页(点字母选中):`);
|
|
326
|
+
let lastGroup = null;
|
|
327
|
+
for (let i = 0; i < PAGE; i++) {
|
|
328
|
+
const idx = st.page * PAGE + i;
|
|
329
|
+
if (idx >= opts.length) break;
|
|
330
|
+
const o = opts[idx];
|
|
331
|
+
// 带 group 的选项(如模型按 provider 分类)先插一个小标题 —— provider 不进按钮文字
|
|
332
|
+
if (o.group && o.group !== lastGroup) {
|
|
333
|
+
L.push('', `**── ${o.group} ──**`);
|
|
334
|
+
lastGroup = o.group;
|
|
335
|
+
}
|
|
336
|
+
L.push(`**${LETTERS[i]}**. ${o.label}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return L.join('\n');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 一张卡的按钮(每行 5 个,最多 4 行选项 + 1 行导航) */
|
|
343
|
+
function cardKeyboard(st) {
|
|
344
|
+
const rows = [];
|
|
345
|
+
const opts = st.options;
|
|
346
|
+
const start = st.page * PAGE;
|
|
347
|
+
const cur = opts.slice(start, start + PAGE);
|
|
348
|
+
for (let r = 0; r * 5 < cur.length && r < 4; r++) {
|
|
349
|
+
const row = [];
|
|
350
|
+
for (let c = 0; c < 5; c++) {
|
|
351
|
+
const i = r * 5 + c;
|
|
352
|
+
if (i >= cur.length) break;
|
|
353
|
+
// ⚠️ 按钮 id **绝不能带冒号**:插件解析 button_data 是 `ext:<工具名>:<卡id>:<按钮id>`
|
|
354
|
+
// 之后 split(':') 且**要求恰好 3 段** —— 带冒号的 id 会让它 split 出 4 段、
|
|
355
|
+
// 直接 return null(连日志都不打),现象就是"点了只弹操作成功"。所以用 p0/p1/p2…
|
|
356
|
+
row.push(btn(`p${start + i}`, LETTERS[i]));
|
|
357
|
+
}
|
|
358
|
+
if (row.length) rows.push(row);
|
|
359
|
+
}
|
|
360
|
+
const nav = [];
|
|
361
|
+
if (st.page > 0) nav.push(btn('prev', '◀ 上一页', 1));
|
|
362
|
+
if (start + PAGE < opts.length) nav.push(btn('next', '下一页 ▶', 1));
|
|
363
|
+
nav.push(btn('skip', '跳过', 1));
|
|
364
|
+
rows.push(nav);
|
|
365
|
+
rows.push([btn('cancel', '取消', 4)]);
|
|
366
|
+
return keyboardOf(rows, st.cardId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 发/重发当前步骤的卡 */
|
|
370
|
+
async function paint(ctx, st) {
|
|
371
|
+
st.options = await optionsOf(ctx, st.step, st);
|
|
372
|
+
if (st.page * PAGE >= st.options.length) st.page = Math.max(0, Math.ceil(st.options.length / PAGE) - 1);
|
|
373
|
+
st.cardId = `子会话-${++seq}-${Date.now().toString(36)}`;
|
|
374
|
+
const kb = cardKeyboard(st);
|
|
375
|
+
const ok = await sendCard(ctx, cardText(st), kb);
|
|
376
|
+
const caps = ctx?.caps ?? ctx ?? {};
|
|
377
|
+
try {
|
|
378
|
+
if (typeof caps.registerInteractionCard === 'function') {
|
|
379
|
+
caps.registerInteractionCard({
|
|
380
|
+
cardId: st.cardId,
|
|
381
|
+
// ⚠️ 登记必须是**全部选项**的 id:插件按 (卡id, 按钮id) 查表,没登记的按钮会被判
|
|
382
|
+
// "不属于这张卡"而不派发(翻到第 2 页点 p:20 就死在这儿)。
|
|
383
|
+
buttonIds: [...st.options.map((_, i) => `p${i}`), 'prev', 'next', 'skip', 'cancel'],
|
|
384
|
+
expireAt: Date.now() + CARD_EXPIRE_MS,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
} catch { /* 登记失败 → 按钮点不动,卡还是发出去了 */ }
|
|
388
|
+
// ★ 兜底(2026-10-13 实测定位):**命令侧自己写注册表**。
|
|
389
|
+
// 插件给命令组 caps 时没注入 registerInteractionCard ⇒ 上面那次调用等于没调,
|
|
390
|
+
// 注册表里查不到这张卡 ⇒ 点击被插件第一步就否掉、只 ack。这条路不依赖插件,立即生效。
|
|
391
|
+
try { await regCardSelf(ctx, st.cardId, [...st.options.map((_, i) => `p${i}`), 'prev', 'next', 'skip', 'cancel']); } catch { /* ignore */ }
|
|
392
|
+
// ★ 必须在这里登记:paint 每次都会**换一个新的 cardId**(重新发一张卡)。
|
|
393
|
+
// 只在 handler / prev / next 分支里登记的话,**选中选项后走 advance→paint 的这条路**
|
|
394
|
+
// 新卡就不在状态表里 ⇒ 下一屏点按钮直接报"向导已过期"(2026-10-13 实测)。
|
|
395
|
+
// 放在 paint 里 = 所有调用点自动登记,不会再漏。
|
|
396
|
+
W.set(st.cardId, st);
|
|
397
|
+
if (W.size > 30) {
|
|
398
|
+
for (const k of [...W.keys()].slice(0, W.size - 30)) if (k !== st.cardId) W.delete(k);
|
|
399
|
+
}
|
|
400
|
+
void diag(`[paint] step=${st.step} page=${st.page} 选项数=${st.options.length} 发卡=${ok ? 'ok' : 'FAIL'} 状态表=${W.size}`);
|
|
401
|
+
if (!ok) st.error = '卡片没发出去(当前环境可能不支持富文本卡片)';
|
|
402
|
+
return ok;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 全部参数齐了 → 真正建会话(复用现成工具的逻辑,不重写) */
|
|
406
|
+
async function createNow(ctx, st) {
|
|
407
|
+
const args = {
|
|
408
|
+
// ★ 2026-10-13 主人定:**不传 name**(会话名让宿主自动生成);把「要说的话」当初始任务,
|
|
409
|
+
// wake=true ⇒ 建完立刻在新会话里跑一轮 —— 这句话就把新建的子会话唤醒了。
|
|
410
|
+
task: st.prompt,
|
|
411
|
+
wake: true,
|
|
412
|
+
agent_preset: st.picked.agent_preset?.value,
|
|
413
|
+
workspace: st.picked.workspace?.value,
|
|
414
|
+
mode: st.picked.mode?.value,
|
|
415
|
+
model: st.picked.model?.value,
|
|
416
|
+
effort: st.picked.effort?.value,
|
|
417
|
+
};
|
|
418
|
+
// 直接调「创建子会话」扩展工具的 run —— 参数天然对齐,逻辑零重写
|
|
419
|
+
try {
|
|
420
|
+
const libUrl = new URL(`../tools/session_create.mjs?ts=${Date.now()}`, import.meta.url);
|
|
421
|
+
const mod = await import(libUrl.href);
|
|
422
|
+
const env = ctx?.caps ?? {};
|
|
423
|
+
// ★ session_create 要的是**有 `.get` 的宿主 ctx**(它读 `env.ctx` 或 `env.ctx.root`);
|
|
424
|
+
// 命令的 ctx 没有 `.get` ⇒ 之前报"拿不到宿主 ctx(需要 expose:'all')"。
|
|
425
|
+
// 而按钮回调那个 ctx 的 kernel 取不到服务 ⇒ 用 handler 阶段缓存下来的 KERNEL。
|
|
426
|
+
const kernel = KERNEL ?? ctx?.caps?.kernel ?? ctx?.kernel ?? null;
|
|
427
|
+
void diag(`[create] kernel=${!!kernel} hasGet=${typeof kernel?.get === 'function'}`);
|
|
428
|
+
// 回灌钩子还要三件套:父会话 id(`exec`)+ QQ 发送器 + 回复目标 —— 全用 handler 阶段缓存的。
|
|
429
|
+
const parentId = CTX_CACHE.agentId || CTX_CACHE.sessionId || '';
|
|
430
|
+
void diag(`[create] kernel=${!!kernel} parentId=${parentId || '-'} sender=${!!CTX_CACHE.sender} replyTarget=${!!CTX_CACHE.replyTarget}`);
|
|
431
|
+
const out = await mod.default.run(args, {
|
|
432
|
+
...env,
|
|
433
|
+
kernel,
|
|
434
|
+
ctx: kernel,
|
|
435
|
+
sender: CTX_CACHE.sender ?? env.sender,
|
|
436
|
+
replyTarget: CTX_CACHE.replyTarget ?? env.replyTarget,
|
|
437
|
+
exec: { agent: { id: parentId }, sessionId: parentId },
|
|
438
|
+
});
|
|
439
|
+
const msg = typeof out === 'string' ? out : (out?.msg ?? JSON.stringify(out ?? {}));
|
|
440
|
+
return String(msg);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
return '建会话失败:' + String(e?.message ?? e);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 推进到下一步;没了就建 */
|
|
447
|
+
async function advance(ctx, st) {
|
|
448
|
+
st.error = '';
|
|
449
|
+
while (st.stepIdx < STEPS.length && st.picked[STEPS[st.stepIdx]]) st.stepIdx += 1;
|
|
450
|
+
if (st.stepIdx >= STEPS.length) {
|
|
451
|
+
W.delete(st.cardId);
|
|
452
|
+
return await createNow(ctx, st);
|
|
453
|
+
}
|
|
454
|
+
st.step = STEPS[st.stepIdx];
|
|
455
|
+
st.page = 0;
|
|
456
|
+
await paint(ctx, st);
|
|
457
|
+
return '';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── 参数解析(除名字外,任意顺序自动匹配)────────────────────────
|
|
461
|
+
const MODE_RE = /^(?:[123]|[一二三](?:档)?|mode[123]?)$/i;
|
|
462
|
+
const EFFORT_SET = new Set(['low', 'medium', 'high', 'minimal', 'none', '低', '中', '高']);
|
|
463
|
+
|
|
464
|
+
async function parseArgs(ctx, tokens) {
|
|
465
|
+
const rest = tokens.slice();
|
|
466
|
+
const picked = {};
|
|
467
|
+
// 回灌模式
|
|
468
|
+
for (let i = rest.length - 1; i >= 0; i--) {
|
|
469
|
+
const t = rest[i];
|
|
470
|
+
if (MODE_RE.test(t)) {
|
|
471
|
+
const m = /^[一1]/.test(t) ? '1' : (/^[二2]/.test(t) ? '2' : '3');
|
|
472
|
+
picked.mode = { value: m, label: `${m} 档` };
|
|
473
|
+
rest.splice(i, 1);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// 推理等级
|
|
478
|
+
for (let i = 0; i < rest.length; i++) {
|
|
479
|
+
if (EFFORT_SET.has(rest[i].toLowerCase())) {
|
|
480
|
+
const v = rest[i].toLowerCase();
|
|
481
|
+
picked.effort = { value: v, label: v };
|
|
482
|
+
rest.splice(i, 1);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// 模型:含 / 的,或能对上模型列表的
|
|
487
|
+
const models = await optionsOf(ctx, 'model', {});
|
|
488
|
+
for (let i = 0; i < rest.length; i++) {
|
|
489
|
+
const t = rest[i];
|
|
490
|
+
if (t.includes('/') || models.some((m) => m.value === t || m.value.endsWith('/' + t))) {
|
|
491
|
+
const hit = models.find((m) => m.value === t || m.value.endsWith('/' + t) || m.label.includes(t));
|
|
492
|
+
picked.model = hit ?? { value: t, label: t };
|
|
493
|
+
rest.splice(i, 1);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// 人设:能对上预设列表的
|
|
498
|
+
const presets = await optionsOf(ctx, 'agent_preset', {});
|
|
499
|
+
for (let i = 0; i < rest.length; i++) {
|
|
500
|
+
const t = rest[i].toLowerCase();
|
|
501
|
+
const hit = presets.find((p) => p.value.toLowerCase() === t || p.label.toLowerCase().includes(t));
|
|
502
|
+
if (hit) { picked.agent_preset = hit; rest.splice(i, 1); break; }
|
|
503
|
+
}
|
|
504
|
+
// 剩下第一个当工作区(模糊)
|
|
505
|
+
// ★ 2026-10-13 主人定:**第一个参数就是"要说的话"(对话内容)**,会话名让宿主自动生成。
|
|
506
|
+
// 所以摘完能识别的参数(人设/工作区/模型/推理等级/回灌模式)后,**剩下的全部**拼成 prompt,
|
|
507
|
+
// 不再像以前那样"把剩下第一个当工作区"。
|
|
508
|
+
const prompt = rest.join(' ').trim();
|
|
509
|
+
if (prompt) picked.__prompt = prompt;
|
|
510
|
+
return picked;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export default {
|
|
514
|
+
name: ['子会话', 'subsession'],
|
|
515
|
+
description: '创建子会话(会话名之外参数顺序随意;没给全的会依次发卡片让你点选)',
|
|
516
|
+
usage: '/子会话 <会话名> [人设] [工作区] [模型] [推理等级] [回灌模式]',
|
|
517
|
+
|
|
518
|
+
async handler(ctx) {
|
|
519
|
+
const raw = cn(ctx?.command?.raw);
|
|
520
|
+
// ★ 把 handler 侧那个「能取到服务的 kernel」缓存下来(建会话发生在回调里,那边取不到)
|
|
521
|
+
KERNEL = ctx?.caps?.kernel ?? ctx?.kernel ?? KERNEL;
|
|
522
|
+
// 顺手缓存回灌钩子要用的三件套(回调侧全都没有)
|
|
523
|
+
CTX_CACHE.sender = ctx?.sender ?? ctx?.caps?.sender ?? CTX_CACHE.sender;
|
|
524
|
+
CTX_CACHE.replyTarget = ctx?.replyTarget ?? ctx?.caps?.replyTarget ?? CTX_CACHE.replyTarget;
|
|
525
|
+
try {
|
|
526
|
+
const st = ctx?.state ?? {};
|
|
527
|
+
const bs = ctx?.bot ?? {};
|
|
528
|
+
const cand = st.agentId ?? st.sessionId ?? st.agent?.id ?? bs.agentId ?? bs.sessionId ?? '';
|
|
529
|
+
if (cand) { CTX_CACHE.agentId = String(cand); CTX_CACHE.sessionId = String(st.sessionId ?? cand); }
|
|
530
|
+
} catch { /* ignore */ }
|
|
531
|
+
// ★★ 正解(读插件 middleware-setup.ts:357-369 得出):
|
|
532
|
+
// 命令能力包里 `caps.agent` = **manager.findByPeer(scope, peerId).agent** ⇒ 就是"本会话的 agent";
|
|
533
|
+
// `caps.sender` 那颗其实来自 `ctx.bot`。这两样正是回灌钩子要的「父会话 id + 发送器」。
|
|
534
|
+
let capInfo = '-';
|
|
535
|
+
try {
|
|
536
|
+
const caps = ctx?.caps ?? {};
|
|
537
|
+
// ★★ 正解(2026-10-13 改插件后新增):`caps.peerSessionId`
|
|
538
|
+
// = manager.sessionIdFor(scope, peerId) = `session/event` 里那个 `session.id`
|
|
539
|
+
// —— **回灌钩子比的就是它**(不是 agent.id;那个一直拿不到、拿到也不是同一个 id)。
|
|
540
|
+
const psid = String(caps.peerSessionId ?? '');
|
|
541
|
+
if (psid) { CTX_CACHE.agentId = psid; CTX_CACHE.sessionId = psid; }
|
|
542
|
+
if (!CTX_CACHE.sender) CTX_CACHE.sender = ctx?.bot ?? caps.sender ?? null;
|
|
543
|
+
capInfo = `peerSessionId=${psid || '-'} agentId=${String(caps.agent?.id ?? '-')}`;
|
|
544
|
+
} catch (e) { capInfo = 'err=' + String(e?.message ?? e).slice(0, 60); }
|
|
545
|
+
void diag(`[handler] kernel=${!!KERNEL} sender=${!!CTX_CACHE.sender} replyTarget=${!!CTX_CACHE.replyTarget} agentId=${CTX_CACHE.agentId || '-'} ${capInfo}`);
|
|
546
|
+
if (!raw) return '用法:/子会话 <要说的话> [人设] [工作区] [模型] [推理等级] [回灌模式]\n例:/子会话 帮我看看这个报错 温柔鲸鱼娘 鲸鱼娘 一档';
|
|
547
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
548
|
+
const picked = await parseArgs(ctx, tokens);
|
|
549
|
+
const prompt = String(picked.__prompt ?? '').trim();
|
|
550
|
+
if (!prompt) return '要告诉子会话什么呀?用法:/子会话 <要说的话> [人设] [工作区] [模型] [推理等级] [回灌模式]';
|
|
551
|
+
|
|
552
|
+
const st = { prompt, picked, stepIdx: 0, step: STEPS[0], page: 0, options: [], cardId: '', error: '' };
|
|
553
|
+
// 已有值就跳过对应步骤
|
|
554
|
+
while (st.stepIdx < STEPS.length && st.picked[STEPS[st.stepIdx]]) st.stepIdx += 1;
|
|
555
|
+
|
|
556
|
+
if (st.stepIdx >= STEPS.length) {
|
|
557
|
+
return await createNow(ctx, st); // 参数齐了,直接建
|
|
558
|
+
}
|
|
559
|
+
st.step = STEPS[st.stepIdx];
|
|
560
|
+
W.set('pending', st); // 先占位,paint 后 cardId 才有值
|
|
561
|
+
await paint(ctx, st);
|
|
562
|
+
W.delete('pending');
|
|
563
|
+
W.set(st.cardId, st);
|
|
564
|
+
return ''; // 卡已经发了,不再回文本
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
async onInteraction(ctx, info) {
|
|
568
|
+
const cardId = cn(info?.cardId);
|
|
569
|
+
const bid = cn(info?.buttonId);
|
|
570
|
+
const st = W.get(cardId);
|
|
571
|
+
void diag(`[onInteraction] card=${cardId} btn=${bid} 状态表命中=${!!st} 表内keys=[${[...W.keys()].join(',')}] hasApi=${typeof ctx?.caps?.api === 'function'}`);
|
|
572
|
+
if (!st) return '这个向导已经过期了,重新发一次 /子会话 吧~';
|
|
573
|
+
if (st.startedAt && Date.now() - st.startedAt > MAX_PAGE_MS) { W.delete(cardId); return '向导放太久了,重新发一次吧~'; }
|
|
574
|
+
st.startedAt = st.startedAt ?? Date.now();
|
|
575
|
+
|
|
576
|
+
if (bid === 'cancel') { W.delete(cardId); return '已取消,子会话没有创建。'; }
|
|
577
|
+
if (bid === 'skip') { st.stepIdx += 1; return await advance(ctx, st) || '好,跳过这一项。'; }
|
|
578
|
+
if (bid === 'prev' || bid === 'next') {
|
|
579
|
+
st.page = Math.max(0, st.page + (bid === 'next' ? 1 : -1));
|
|
580
|
+
const ok = await paint(ctx, st);
|
|
581
|
+
W.delete(cardId);
|
|
582
|
+
W.set(st.cardId, st);
|
|
583
|
+
return ok
|
|
584
|
+
? `已翻到第 ${st.page + 1} 页 ✅(新卡片在上面)`
|
|
585
|
+
: `翻页失败:新卡片没发出去(第 ${st.page + 1} 页)—— 可能是拿不到群号或接口报错`;
|
|
586
|
+
}
|
|
587
|
+
if (/^p\d+$/.test(bid)) {
|
|
588
|
+
const idx = Number(bid.slice(1));
|
|
589
|
+
const opt = st.options[idx];
|
|
590
|
+
if (!opt) return '这个选项没了,翻一页再选~';
|
|
591
|
+
st.picked[st.step] = opt;
|
|
592
|
+
st.stepIdx += 1;
|
|
593
|
+
const done = await advance(ctx, st);
|
|
594
|
+
return done || '';
|
|
595
|
+
}
|
|
596
|
+
return '';
|
|
597
|
+
},
|
|
598
|
+
};
|