@zaofan/dsh-qqbot 1.10.2 → 1.10.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.
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话列表.mjs — `/会话列表` 斜杠命令(扩展命令第五套)
|
|
3
|
+
*
|
|
4
|
+
* ★ 2026-10-14 主人需求(原话):
|
|
5
|
+
* 「再做个 /会话列表 的斜杠命令:先列出工作区选项 → 选了以后再列出会话列表选项(一页不要太多)
|
|
6
|
+
* → 再列出回灌模式。选择会话后可以**绑定任何已有会话**,不一定要建立的子会话,能钩子勾住并回灌吗?
|
|
7
|
+
* 选择成功后,往 qq 发送一条消息模板,该模板的开头包含了会话 id,只要用户复制这条模板、
|
|
8
|
+
* 修改发送就可以将这段话路由到对应会话 —— 这样就可以引用别的内容发送给路由的会话了(比如图片)」
|
|
9
|
+
*
|
|
10
|
+
* 所以本命令干三件事:
|
|
11
|
+
* ① 三步向导:**工作区 → 该工作区的会话 → 回灌模式**;
|
|
12
|
+
* ② 选完**绑上回灌钩子**(`register()` 的 childSessionId 本就认任意会话,不限"建出来的子会话");
|
|
13
|
+
* ③ 往 QQ 发一条**可复制的模板**,开头带会话 id ⇒ 复制改内容即可路由到那个会话。
|
|
14
|
+
*
|
|
15
|
+
* 路由靠 `tools/lib/session_watch_lib.mjs` 的**通用路由**(正文里出现 `[s:短id]` 或 `【会话 id】` 就投递)。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 从 `/子会话` 抄来的四条硬经验(都踩过):
|
|
18
|
+
* 1. `paint()` 里**必须**做三件事:登记插件卡表、**命令侧自写注册表**、`W.set(cardId, st)`。
|
|
19
|
+
* (paint 每次换新 cardId,只在 handler/翻页处登记会漏 ⇒ 选完一屏就报"向导已过期")
|
|
20
|
+
* 2. 按钮 id **绝不能带冒号**:插件把 button_data 拆成 `ext:<工具名>:<卡id>:<按钮id>`,
|
|
21
|
+
* 要求**恰好 3 段**,带冒号会 split 出 4 段被静默丢弃(现象:点了只弹"操作成功")。
|
|
22
|
+
* 3. 命令侧 `ctx.caps` 有值、**按钮回调的 ctx 是把能力平铺在自身**(没有 caps 键)
|
|
23
|
+
* ⇒ 一律写 `ctx?.caps ?? ctx`。
|
|
24
|
+
* 4. `keyboardOf` / `regCardSelf` 里的**工具名必须与本文件一致**,否则插件查表查不到。
|
|
25
|
+
* 5. ★★ **别信"handler 与 onInteraction 是同一个模块实例"**(2026-10-14 真机踩中,见文件头
|
|
26
|
+
* `SLOT` 注释):插件对 `ext-commands` 是**两条独立加载路径**,`tools_reload` 只热刷其中一条
|
|
27
|
+
* ⇒ 同一个文件被实例化成**两份模块**、各持自己的 `W`。
|
|
28
|
+
* 现象:发卡正常(命令侧 `表=2/3`),一点按钮就回「这个向导已经过期了」(回调侧 `keys=[]`)。
|
|
29
|
+
* 对策:回调侧要用的一切(kernel / sender / replyTarget / 父会话 id / 向导状态)
|
|
30
|
+
* 一律走 **`globalThis` 符号槽(`publishShared`/`adoptShared`)+ 磁盘(`saveState`/`loadState`)**,
|
|
31
|
+
* 模块变量只当本地缓存。
|
|
32
|
+
*/
|
|
33
|
+
const PAGE = 20; // 一页 20 个选项(4 行 × 5 列;第 5 行留给导航)
|
|
34
|
+
const LETTERS = 'ABCDEFGHIJKLMNOPQRST';
|
|
35
|
+
const CARD_EXPIRE_MS = 10 * 60_000;
|
|
36
|
+
const MAX_PAGE_MS = 30 * 60_000;
|
|
37
|
+
const TOOL = '会话列表'; // ★ 必须与文件名/注册名一致
|
|
38
|
+
|
|
39
|
+
const STEPS = ['workspace', 'session', 'mode'];
|
|
40
|
+
const STEP_CN = {
|
|
41
|
+
workspace: '工作区',
|
|
42
|
+
session: '会话',
|
|
43
|
+
mode: '回灌模式',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 向导状态表(cardId → state) */
|
|
47
|
+
const W = new Map();
|
|
48
|
+
let seq = 0;
|
|
49
|
+
|
|
50
|
+
/** 字符串兜底 */
|
|
51
|
+
function cn(v) { return v === undefined || v === null ? '' : String(v); }
|
|
52
|
+
function short(s, n) { const t = cn(s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; }
|
|
53
|
+
|
|
54
|
+
/** 诊断日志(沿用 /子会话 的做法:写死路径,排查"点了没反应"最有用) */
|
|
55
|
+
const DIAG_FILE = 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot/.qqbot-extensions/data/会话列表-diag.log';
|
|
56
|
+
async function diag(msg) {
|
|
57
|
+
try {
|
|
58
|
+
const { appendFile, mkdir } = await import('node:fs/promises');
|
|
59
|
+
const { dirname } = await import('node:path');
|
|
60
|
+
await mkdir(dirname(DIAG_FILE), { recursive: true });
|
|
61
|
+
await appendFile(DIAG_FILE, `${new Date().toISOString()} ${msg}\n`);
|
|
62
|
+
} catch { /* 诊断失败绝不影响主流程 */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 扩展数据根(与 /子会话 同口径) */
|
|
66
|
+
function dataRootOf(ctx) {
|
|
67
|
+
try {
|
|
68
|
+
const caps = ctx?.caps ?? ctx ?? {};
|
|
69
|
+
const p = caps?.paths ?? {};
|
|
70
|
+
const cand = p.dataRoot ?? caps.dataRoot ?? ctx?.dataRoot ?? '';
|
|
71
|
+
if (cand) return String(cand);
|
|
72
|
+
} catch { /* ignore */ }
|
|
73
|
+
return 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ★★ 跨模块实例的共享位 —— 2026-10-14 真机 bug 修复(「选了工作区就报向导已过期」)
|
|
78
|
+
*
|
|
79
|
+
* 现象:`/会话列表` 正常发卡、点按钮却回「这个向导已经过期了」,
|
|
80
|
+
* 诊断日志里 `[onInteraction] card=… 命中=false keys=[]`(状态表是**空**的),
|
|
81
|
+
* 而同一条日志里 `[paint] … 表=2 / 表=3`(命令侧写进去过)⇒ **两边的 W 不是同一个 Map**。
|
|
82
|
+
*
|
|
83
|
+
* 真因:插件对 `ext-commands` 是**两条独立加载路径**(斜杠命令注册 / 卡片回调查表),
|
|
84
|
+
* `tools_reload` 只热刷其中一条 ⇒ **同一个文件被实例化成两份模块**,
|
|
85
|
+
* 各自持有自己的 `W` / `KERNEL` / `CTX_CACHE`:
|
|
86
|
+
* · 命令侧实例:paint 写自己的 W(seq 正常递增)—— 看起来一切正常;
|
|
87
|
+
* · 回调侧实例:W 永远空 ⇒ 一点就"过期"。
|
|
88
|
+
*
|
|
89
|
+
* ⇒ 凡是**回调侧要用**的东西(kernel / sender / replyTarget / 父会话 id / 向导状态)
|
|
90
|
+
* **都不能只放模块变量**,必须落到 ① `globalThis` 符号槽(同进程跨实例可见)
|
|
91
|
+
* 或 ② 磁盘(跨实例、甚至跨重启都可见)。
|
|
92
|
+
*/
|
|
93
|
+
const SLOT = Symbol.for('dsh-qqbot.session-list.shared');
|
|
94
|
+
function shared() {
|
|
95
|
+
try { return globalThis[SLOT] ?? (globalThis[SLOT] = {}); } catch { return {}; }
|
|
96
|
+
}
|
|
97
|
+
function publishShared(patch) { try { Object.assign(shared(), patch ?? {}); } catch { /* ignore */ } }
|
|
98
|
+
|
|
99
|
+
/** 向导状态的磁盘副本(回调侧实例用来"认出"命令侧实例发的卡) */
|
|
100
|
+
function stateFileOf(ctx) {
|
|
101
|
+
const root = cn(shared().dataRoot) || dataRootOf(ctx);
|
|
102
|
+
return `${root}/.qqbot/session-list-wizard.json`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function saveState(ctx, cardId, st) {
|
|
106
|
+
try {
|
|
107
|
+
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
108
|
+
const { dirname } = await import('node:path');
|
|
109
|
+
const f = stateFileOf(ctx);
|
|
110
|
+
let all = {};
|
|
111
|
+
try { all = JSON.parse(readFileSync(f, 'utf8')) ?? {}; } catch { /* 首次或坏文件 */ }
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
for (const k of Object.keys(all)) if (Number(all[k]?.expireAt ?? 0) < now) delete all[k];
|
|
114
|
+
all[cardId] = { ...st, expireAt: now + CARD_EXPIRE_MS };
|
|
115
|
+
const keys = Object.keys(all);
|
|
116
|
+
if (keys.length > 20) for (const k of keys.slice(0, keys.length - 20)) delete all[k];
|
|
117
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
118
|
+
writeFileSync(f, JSON.stringify(all));
|
|
119
|
+
return true;
|
|
120
|
+
} catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadState(ctx, cardId) {
|
|
124
|
+
try {
|
|
125
|
+
const { readFileSync } = await import('node:fs');
|
|
126
|
+
const all = JSON.parse(readFileSync(stateFileOf(ctx), 'utf8')) ?? {};
|
|
127
|
+
const st = all[cardId];
|
|
128
|
+
if (!st || Number(st.expireAt ?? 0) < Date.now()) return null;
|
|
129
|
+
return st;
|
|
130
|
+
} catch { return null; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function dropState(ctx, cardId) {
|
|
134
|
+
try {
|
|
135
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
136
|
+
const all = JSON.parse(readFileSync(stateFileOf(ctx), 'utf8')) ?? {};
|
|
137
|
+
if (all[cardId]) { delete all[cardId]; writeFileSync(stateFileOf(ctx), JSON.stringify(all)); }
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 回调侧补齐"命令侧才知道"的上下文(同进程共享位优先,其次回调 ctx 自带的 kernel) */
|
|
142
|
+
function adoptShared(ctx) {
|
|
143
|
+
const s = shared();
|
|
144
|
+
KERNEL = KERNEL ?? s.kernel ?? ctx?.kernel ?? ctx?.caps?.kernel ?? null;
|
|
145
|
+
CTX_CACHE.sender = CTX_CACHE.sender ?? s.sender ?? null;
|
|
146
|
+
CTX_CACHE.replyTarget = CTX_CACHE.replyTarget ?? s.replyTarget ?? null;
|
|
147
|
+
CTX_CACHE.agentId = CTX_CACHE.agentId || cn(s.parentId);
|
|
148
|
+
CTX_CACHE.sessionId = CTX_CACHE.sessionId || cn(s.parentId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 命令侧自写卡表(不依赖插件是否注入 registerInteractionCard) */
|
|
152
|
+
async function regCardSelf(ctx, cardId, buttonIds) {
|
|
153
|
+
const root = dataRootOf(ctx);
|
|
154
|
+
if (!root) return false;
|
|
155
|
+
try {
|
|
156
|
+
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
157
|
+
const { join, dirname } = await import('node:path');
|
|
158
|
+
const file = join(root, '.qqbot', 'ext-tool-cards.json');
|
|
159
|
+
let table = { version: 1, cards: [] };
|
|
160
|
+
try { table = JSON.parse(readFileSync(file, 'utf8')) ?? table; } catch { /* 首次或坏文件 */ }
|
|
161
|
+
if (!Array.isArray(table.cards)) table.cards = [];
|
|
162
|
+
const mc = ctx?.message ?? {};
|
|
163
|
+
const rt = ctx?.replyTarget ?? {};
|
|
164
|
+
const pe = ctx?.peer ?? {};
|
|
165
|
+
const scope = cn(rt.scope || ctx?.scope || pe.scope || mc.kind || ((mc.groupOpenid || pe.groupId || pe.peerId) ? 'group' : 'c2c'));
|
|
166
|
+
const targetId = cn(mc.groupOpenid || rt.targetId || pe.groupId || pe.groupOpenid || pe.peerId || pe.id || mc.senderId);
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
table.cards = table.cards.filter((c) => Number(c?.expireAt ?? 0) > now && !(c?.toolName === TOOL && c?.cardId === cardId));
|
|
169
|
+
table.cards.push({ toolName: TOOL, cardId, buttonIds, scope, targetId, createdAt: now, expireAt: now + CARD_EXPIRE_MS });
|
|
170
|
+
if (table.cards.length > 200) table.cards = table.cards.slice(-200);
|
|
171
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
172
|
+
writeFileSync(file, JSON.stringify(table, null, 2));
|
|
173
|
+
return true;
|
|
174
|
+
} catch { return false; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 发卡(markdown + 按钮键盘);四级兜底,与 /子会话 同款 */
|
|
178
|
+
async function sendCard(ctx, text, keyboard) {
|
|
179
|
+
const caps = ctx?.caps ?? ctx ?? {};
|
|
180
|
+
const mc = ctx?.message ?? {};
|
|
181
|
+
const rt = ctx?.replyTarget ?? {};
|
|
182
|
+
const pe = ctx?.peer ?? {};
|
|
183
|
+
const scope = cn(rt.scope || ctx?.scope || pe.scope || mc.kind || ((mc.groupOpenid || pe.groupId || pe.peerId) ? 'group' : 'c2c'));
|
|
184
|
+
const gid = cn(mc.groupOpenid || rt.targetId || rt.openid || rt.id || pe.groupId || pe.groupOpenid || (scope === 'group' ? (pe.peerId || pe.id) : '') || '');
|
|
185
|
+
const uid = cn(mc.senderId || rt.openid || rt.id || pe.openid || pe.userId || '');
|
|
186
|
+
const api = caps.api;
|
|
187
|
+
if (keyboard && typeof api === 'function' && (gid || uid)) {
|
|
188
|
+
const path = gid ? `/v2/groups/${gid}/messages` : `/v2/users/${uid}/messages`;
|
|
189
|
+
const body = { msg_type: 2, markdown: { content: text }, keyboard, ...(gid ? {} : { content: text }) };
|
|
190
|
+
try {
|
|
191
|
+
const r = await api(path, { method: 'POST', body });
|
|
192
|
+
void diag(`[sendCard] ①api=${JSON.stringify(r ?? null).slice(0, 140)}`);
|
|
193
|
+
if (r && r.ok !== false) return true;
|
|
194
|
+
} catch (e) { void diag(`[sendCard] ①抛错=${short(e?.message ?? e, 140)}`); }
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
if (keyboard && typeof caps.markdownCard === 'function') {
|
|
198
|
+
const r2 = await caps.markdownCard(text, keyboard);
|
|
199
|
+
if (r2) return true;
|
|
200
|
+
}
|
|
201
|
+
} catch { /* ignore */ }
|
|
202
|
+
try {
|
|
203
|
+
if (keyboard && typeof ctx?.sender?.sendMarkdownWithKeyboard === 'function') {
|
|
204
|
+
await ctx.sender.sendMarkdownWithKeyboard(ctx.replyTarget, text, keyboard);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
} catch { /* ignore */ }
|
|
208
|
+
try { if (typeof caps.markdown === 'function' && (await caps.markdown(text))) return true; } catch { /* ignore */ }
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 按钮(id 不能带冒号) */
|
|
213
|
+
function btn(id, label, style = 1) {
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
render_data: { label: short(label, 18), visited_label: short(label, 18), style },
|
|
217
|
+
action: { type: 1, permission: { type: 2 }, data: id, unsupport_tips: '请升级QQ客户端后重试' },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 拼 QQ inline_keyboard;★ 工具名必须是本命令 */
|
|
222
|
+
function keyboardOf(rows, cardId) {
|
|
223
|
+
return {
|
|
224
|
+
content: {
|
|
225
|
+
rows: rows.map((r) => ({ buttons: r.map((b) => ({ ...b, action: { ...b.action, data: `ext:${TOOL}:${cardId}:${b.id}` } })) })),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 加载回灌库(按 mtime 版本化,改完 lib 热刷即生效) */
|
|
231
|
+
async function loadLib() {
|
|
232
|
+
const url = new URL(`../tools/lib/session_watch_lib.mjs?v=${Date.now()}`, import.meta.url);
|
|
233
|
+
return await import(url.href);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 把工作区转义目录名**还原成真实路径**(主人 2026-10-14 要求「显示真实文件夹名字」)。
|
|
238
|
+
*
|
|
239
|
+
* 宿主规则(实测推导,见 `~/.dsh/sessions/`):
|
|
240
|
+
* · 整体被 `--` 包起来,首尾各去掉一层;
|
|
241
|
+
* · **`-` = 路径分隔符**(`\`);
|
|
242
|
+
* · **`~XXXX` = 一个 UTF-16 码元**(中文/特殊字符都这么转义);
|
|
243
|
+
* · 开头的**单字母 + `-`** 是盘符(如 `D-` ⇒ `D:`)。
|
|
244
|
+
* 例:`--D-newwenjianjia-aiwork-~9CB8~9C7C~5A18--` ⇒ `D:\newwenjianjia\aiwork\鲸鱼娘`
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ 已知不可逆点:原路径里若本来就有 `-`,会被同样当成分隔符(宿主转义时没有区分手段)。
|
|
247
|
+
*/
|
|
248
|
+
function wsPath(dir) {
|
|
249
|
+
const t = cn(dir).replace(/^--/, '').replace(/--$/, '');
|
|
250
|
+
const parts = [];
|
|
251
|
+
let cur = '';
|
|
252
|
+
let i = 0;
|
|
253
|
+
while (i < t.length) {
|
|
254
|
+
const ch = t[i];
|
|
255
|
+
if (ch === '-') { parts.push(cur); cur = ''; i += 1; continue; }
|
|
256
|
+
const m = /^~([0-9A-Fa-f]{4})/.exec(t.slice(i, i + 5));
|
|
257
|
+
if (m) { cur += String.fromCharCode(parseInt(m[1], 16)); i += 5; continue; }
|
|
258
|
+
cur += ch;
|
|
259
|
+
i += 1;
|
|
260
|
+
}
|
|
261
|
+
parts.push(cur);
|
|
262
|
+
const segs = parts.filter((p) => p !== '');
|
|
263
|
+
if (segs.length && /^[A-Za-z]$/.test(segs[0])) segs[0] = `${segs[0]}:`;
|
|
264
|
+
return segs.join('\\');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 卡片上显示用:太长时保留**尾部**(尾部才是能分辨的那几级) */
|
|
268
|
+
function wsDisplay(dir, max = 52) {
|
|
269
|
+
const full = wsPath(dir);
|
|
270
|
+
if (full.length <= max) return full;
|
|
271
|
+
return '…' + full.slice(-(max - 1));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── 候选值:**优先问宿主服务**(工作区=会话的 cwd,会话=sessionPersistence.list(),
|
|
275
|
+
// 标题=sessionTitle.get()),拿不到才回退扫盘。主人 2026-10-14 定:
|
|
276
|
+
// 「一定要解析会话吗?不能从 web 界面里拿吗?就像会话排序一样」。──────
|
|
277
|
+
|
|
278
|
+
const OPT_CACHE = new Map();
|
|
279
|
+
const OPT_TTL_MS = 60 * 1000; // 候选缓存:60 秒(新会话能及时冒出来)
|
|
280
|
+
const TTL_CACHE = new Map(); // 标题缓存(带时间戳,5 分钟过期)
|
|
281
|
+
const TITLE_TTL_MS = 5 * 60 * 1000;
|
|
282
|
+
let KERNEL = null;
|
|
283
|
+
const CTX_CACHE = { sender: null, replyTarget: null, agentId: '', sessionId: '' };
|
|
284
|
+
|
|
285
|
+
/** 取宿主服务容器(cordis ctx):handler 里缓存过就直接用,否则依次看 caps.kernel / kernel */
|
|
286
|
+
function kernelOf(ctx) {
|
|
287
|
+
if (KERNEL) return KERNEL;
|
|
288
|
+
const c = ctx ?? {};
|
|
289
|
+
for (const v of [c?.caps?.kernel, c?.kernel]) if (v) return v;
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 从容器里取服务:`kernel.get(name)` 是主路。
|
|
295
|
+
*
|
|
296
|
+
* ⚠️ 踩过的坑(2026-10-14 实测报错 `cannot get property "ctx" without inject`):
|
|
297
|
+
* cordis Context 上的 `ctx` / `caps` 等属性是 **getter**,未 inject 时**读取即抛**。
|
|
298
|
+
* 所以**绝不能**把它们放进数组字面量里"顺手取一下" —— 必须逐个包 try。
|
|
299
|
+
*/
|
|
300
|
+
function svcOf(ctx, name) {
|
|
301
|
+
const k = kernelOf(ctx);
|
|
302
|
+
if (!k) return null;
|
|
303
|
+
try {
|
|
304
|
+
if (typeof k.get === 'function') {
|
|
305
|
+
const v = k.get(name);
|
|
306
|
+
if (v) return v;
|
|
307
|
+
}
|
|
308
|
+
} catch { /* ignore */ }
|
|
309
|
+
for (const key of ['root', 'ctx', 'caps']) {
|
|
310
|
+
try {
|
|
311
|
+
const c = k[key];
|
|
312
|
+
if (!c) continue;
|
|
313
|
+
const v = typeof c.get === 'function' ? c.get(name) : c[name];
|
|
314
|
+
if (v) return v;
|
|
315
|
+
} catch { /* ignore */ }
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 会话 id 归一:宿主持久化里带 `session-` 前缀,实时 agent id 不带 —— 两种都留一手 */
|
|
321
|
+
function bareId(id) { const s = cn(id); return s.startsWith('session-') ? s.slice('session-'.length) : s; }
|
|
322
|
+
function fullId(id) { const s = cn(id); return s.startsWith('session-') ? s : `session-${s}`; }
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 宿主里**全部**会话(含已结束的)。返回 [{id, cwd, createdAt, sizeBytes}];
|
|
326
|
+
* 拿不到服务时返回 null,调用方回退扫盘。
|
|
327
|
+
*/
|
|
328
|
+
async function listAllSessions(ctx) {
|
|
329
|
+
const sp = svcOf(ctx, 'sessionPersistence');
|
|
330
|
+
if (!sp || typeof sp.list !== 'function') {
|
|
331
|
+
void diag(`[svc] 拿不到 sessionPersistence(kernel=${!!kernelOf(ctx)} sp=${!!sp})`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const r = await sp.list();
|
|
336
|
+
const arr = Array.isArray(r) ? r : (r && typeof r === 'object' ? Object.values(r) : []);
|
|
337
|
+
const out = arr.map((it) => {
|
|
338
|
+
const h = it?.header ?? {};
|
|
339
|
+
return {
|
|
340
|
+
id: cn(h.id ?? it?.sessionId ?? it?.id),
|
|
341
|
+
cwd: cn(h.cwd ?? it?.cwd),
|
|
342
|
+
createdAt: Number(h.createdAt ?? it?.createdAt ?? 0) || 0,
|
|
343
|
+
sizeBytes: Number(it?.sizeBytes ?? 0) || 0,
|
|
344
|
+
};
|
|
345
|
+
}).filter((x) => x.id);
|
|
346
|
+
if (out.length === 0) return null;
|
|
347
|
+
return out;
|
|
348
|
+
} catch (e) {
|
|
349
|
+
void diag(`[svc] sessionPersistence.list 抛错=${short(e?.message ?? e, 160)}`);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* live(正在跑)会话表:bare id → {title, cwd}。
|
|
356
|
+
*
|
|
357
|
+
* ★ 标题的唯一可靠来源(2026-10-14 实测定案):
|
|
358
|
+
* `sessions.list()` 每一项的 `log`(内存事件数组)里,**最后一个 `session/title` 事件的
|
|
359
|
+
* `data.title`**。实测 14 个 live 会话全部取到标题("做早饭会话问候""早饭群"…)。
|
|
360
|
+
*
|
|
361
|
+
* ⚠️ 两条死路(别再试):
|
|
362
|
+
* ① `sessionTitle.get(id)` 在本版宿主**是坏的** —— 抛 `session.snapshotEvents is not a function`;
|
|
363
|
+
* ② `sessionPersistence.list()` 的 `header` 里**根本没有 title 字段**
|
|
364
|
+
* (只有 version/id/createdAt/cwd/isSeeded/delegationDepth/agentPreset)。
|
|
365
|
+
* ⇒ 已结束(非 live)的会话**拿不到标题**,只能显示短 id + 创建时间。
|
|
366
|
+
*/
|
|
367
|
+
async function liveMap(ctx) {
|
|
368
|
+
const hit = TTL_CACHE.get('live');
|
|
369
|
+
if (hit && Date.now() - hit.at < 15 * 1000) return hit.v;
|
|
370
|
+
const out = new Map();
|
|
371
|
+
const ss = svcOf(ctx, 'sessions');
|
|
372
|
+
if (ss && typeof ss.list === 'function') {
|
|
373
|
+
try {
|
|
374
|
+
const r = await ss.list();
|
|
375
|
+
const arr = Array.isArray(r) ? r : (r && typeof r === 'object' ? Object.values(r) : []);
|
|
376
|
+
for (const o of arr) {
|
|
377
|
+
const id = bareId(o?.id ?? o?.sessionId ?? '');
|
|
378
|
+
if (!id) continue;
|
|
379
|
+
let title = '';
|
|
380
|
+
const lg = o?.log;
|
|
381
|
+
if (Array.isArray(lg)) {
|
|
382
|
+
for (let i = lg.length - 1; i >= 0; i--) {
|
|
383
|
+
if (String(lg[i]?.type ?? '') === 'session/title') {
|
|
384
|
+
const t = lg[i]?.data?.title;
|
|
385
|
+
if (typeof t === 'string' && t) { title = t; break; }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
out.set(id, { title, cwd: cn(o?.header?.cwd ?? '') });
|
|
390
|
+
}
|
|
391
|
+
} catch (e) { void diag(`[svc] sessions.list 抛错=${short(e?.message ?? e, 160)}`); }
|
|
392
|
+
}
|
|
393
|
+
TTL_CACHE.set('live', { at: Date.now(), v: out });
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 会话数 / 工作区数 摘要(卡片顶部显示,让人一眼知道规模) */
|
|
398
|
+
let LAST_SCOPE = '';
|
|
399
|
+
|
|
400
|
+
async function optionsOf(ctx, step, st) {
|
|
401
|
+
const key = step === 'session' ? `session::${st.picked.workspace?.value ?? ''}` : step;
|
|
402
|
+
const hit = OPT_CACHE.get(key);
|
|
403
|
+
if (hit && Date.now() - hit.at < OPT_TTL_MS) return hit.v;
|
|
404
|
+
const out = await optionsOfRaw(ctx, step, st);
|
|
405
|
+
if (Array.isArray(out) && out.length > 0) OPT_CACHE.set(key, { at: Date.now(), v: out });
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function optionsOfRaw(ctx, step, st) {
|
|
410
|
+
try {
|
|
411
|
+
const lib = await loadLib();
|
|
412
|
+
if (step === 'workspace') {
|
|
413
|
+
const all = await listAllSessions(ctx);
|
|
414
|
+
if (all) {
|
|
415
|
+
const cnt = new Map();
|
|
416
|
+
for (const s of all) if (s.cwd) cnt.set(s.cwd, (cnt.get(s.cwd) ?? 0) + 1);
|
|
417
|
+
const list = [...cnt.entries()].sort((a, b) => b[1] - a[1]);
|
|
418
|
+
LAST_SCOPE = `宿主 ${all.length} 个会话 / ${list.length} 个工作区`;
|
|
419
|
+
void diag(`[opts] workspace 走宿主:${LAST_SCOPE}`);
|
|
420
|
+
return list.map(([cwd, n]) => ({ value: cwd, label: `${wsDisplay(cwd)} · ${n} 个` }));
|
|
421
|
+
}
|
|
422
|
+
const dirs = lib.listWorkspaceDirs();
|
|
423
|
+
LAST_SCOPE = `扫盘 ${dirs.length} 个工作区(宿主服务不可用)`;
|
|
424
|
+
void diag(`[opts] workspace 回退扫盘:${dirs.length} 个`);
|
|
425
|
+
return dirs.map((d) => ({ value: d, label: wsDisplay(wsPath(d)) }));
|
|
426
|
+
}
|
|
427
|
+
if (step === 'session') {
|
|
428
|
+
const ws = st.picked.workspace?.value ?? '';
|
|
429
|
+
const live = await liveMap(ctx);
|
|
430
|
+
const all = await listAllSessions(ctx);
|
|
431
|
+
if (all) {
|
|
432
|
+
const seen = new Set();
|
|
433
|
+
const mine = all.filter((s) => {
|
|
434
|
+
const b = bareId(s.id);
|
|
435
|
+
if (seen.has(b)) return false;
|
|
436
|
+
seen.add(b);
|
|
437
|
+
return !ws || s.cwd === ws;
|
|
438
|
+
});
|
|
439
|
+
// 排序:**能叫出名字的(live 有标题)排最前** → 再按创建时间新在前。
|
|
440
|
+
// 理由:已结束的会话宿主里没有标题(见 liveMap 注释),只能靠时间认。
|
|
441
|
+
const score = (s) => (live.get(bareId(s.id))?.title ? 1 : 0);
|
|
442
|
+
mine.sort((a, b) => (score(b) - score(a)) || (b.createdAt - a.createdAt));
|
|
443
|
+
const shown = mine.slice(0, 80);
|
|
444
|
+
const named = mine.filter((s) => live.get(bareId(s.id))?.title).length;
|
|
445
|
+
LAST_SCOPE = `${mine.length} 个会话(${named} 个在线有名)`;
|
|
446
|
+
void diag(`[opts] session 走宿主 ws=${ws} 命中 ${mine.length},有名 ${named},取前 ${shown.length}`);
|
|
447
|
+
return shown.map((s) => {
|
|
448
|
+
const info = live.get(bareId(s.id));
|
|
449
|
+
const t = info?.title ?? '';
|
|
450
|
+
const when = s.createdAt
|
|
451
|
+
? new Date(s.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
452
|
+
: '—';
|
|
453
|
+
const tag = info ? '🟢' : '⚪';
|
|
454
|
+
const id8 = bareId(s.id).slice(0, 8);
|
|
455
|
+
const head = t ? short(t, 24) : id8;
|
|
456
|
+
return { value: s.id, label: `${tag} ${head}${t ? ` · ${id8}` : ''} · ${when}` };
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const dirs = lib.listSessionDirs(ws || undefined);
|
|
460
|
+
void diag(`[opts] session 回退扫盘 ws=${ws} 得到 ${dirs.length} 个`);
|
|
461
|
+
return dirs.map((s) => {
|
|
462
|
+
const when = s.mtime ? new Date(s.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—';
|
|
463
|
+
const isSub = s.id.startsWith('session-') ? '子会话 ' : '';
|
|
464
|
+
return { value: s.id, label: `${s.short} ${isSub}${when}` };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
if (step === 'mode') {
|
|
468
|
+
return [
|
|
469
|
+
{ value: 1, label: '一档 · 末段回灌 + 转发到 QQ(默认)' },
|
|
470
|
+
{ value: 2, label: '二档 · 全部正文 + 报错 只转 QQ(不进父上下文)' },
|
|
471
|
+
{ value: 3, label: '三档 · 进父上下文但不唤醒 + 转 QQ' },
|
|
472
|
+
];
|
|
473
|
+
}
|
|
474
|
+
} catch (e) { void diag(`[opts] ${step} 抛错=${short(e?.message ?? e, 160)}`); }
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── 卡片渲染 ────────────────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
function cardText(st) {
|
|
481
|
+
const L = [];
|
|
482
|
+
L.push(`### 🗂 会话列表 · 第 ${st.stepIdx + 1}/${STEPS.length} 步:${STEP_CN[st.step]}`);
|
|
483
|
+
L.push('');
|
|
484
|
+
for (const k of STEPS) {
|
|
485
|
+
const v = st.picked[k];
|
|
486
|
+
if (k === 'session') {
|
|
487
|
+
L.push(`- ${STEP_CN[k]}:${v ? `${short(v.label, 40)}(${short(v.value, 20)})` : '(待选)'}`);
|
|
488
|
+
} else {
|
|
489
|
+
L.push(`- ${STEP_CN[k]}:${v ? v.label : '(待选)'}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
L.push('');
|
|
493
|
+
if (LAST_SCOPE && st.step !== 'mode') L.push(`> ${LAST_SCOPE}`);
|
|
494
|
+
if (st.step === 'session') L.push('> 🟢=正在跑 ⚪=已休眠(都能绑,休眠的会被唤醒)');
|
|
495
|
+
L.push('');
|
|
496
|
+
if (st.error) L.push(`⚠️ ${st.error}`, '');
|
|
497
|
+
const opts = st.options;
|
|
498
|
+
if (!opts.length) {
|
|
499
|
+
L.push('这一步没扫到候选 —— 换个工作区试试,或直接说一声让本鲸手工绑。');
|
|
500
|
+
} else {
|
|
501
|
+
const total = Math.max(1, Math.ceil(opts.length / PAGE));
|
|
502
|
+
L.push(`第 ${st.page + 1}/${total} 页(点字母选中):`);
|
|
503
|
+
for (let i = 0; i < PAGE; i++) {
|
|
504
|
+
const idx = st.page * PAGE + i;
|
|
505
|
+
if (idx >= opts.length) break;
|
|
506
|
+
L.push(`**${LETTERS[i]}**. ${short(opts[idx].label, 46)}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return L.join('\n');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function cardKeyboard(st) {
|
|
513
|
+
const rows = [];
|
|
514
|
+
const opts = st.options;
|
|
515
|
+
const start = st.page * PAGE;
|
|
516
|
+
const cur = opts.slice(start, start + PAGE);
|
|
517
|
+
for (let r = 0; r * 5 < cur.length && r < 4; r++) {
|
|
518
|
+
const row = [];
|
|
519
|
+
for (let c = 0; c < 5; c++) {
|
|
520
|
+
const i = r * 5 + c;
|
|
521
|
+
if (i >= cur.length) break;
|
|
522
|
+
row.push(btn(`p${start + i}`, LETTERS[i]));
|
|
523
|
+
}
|
|
524
|
+
if (row.length) rows.push(row);
|
|
525
|
+
}
|
|
526
|
+
const nav = [];
|
|
527
|
+
if (st.page > 0) nav.push(btn('prev', '◀ 上一页', 1));
|
|
528
|
+
if (start + PAGE < opts.length) nav.push(btn('next', '下一页 ▶', 1));
|
|
529
|
+
rows.push(nav.length ? nav : [btn('noop', '·', 1)]);
|
|
530
|
+
rows.push([btn('cancel', '取消', 4)]);
|
|
531
|
+
return keyboardOf(rows, st.cardId);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** 发/重发当前步骤的卡(★ 三件事全在这里做) */
|
|
535
|
+
async function paint(ctx, st) {
|
|
536
|
+
st.options = await optionsOf(ctx, st.step, st);
|
|
537
|
+
if (st.page * PAGE >= st.options.length) st.page = Math.max(0, Math.ceil(st.options.length / PAGE) - 1);
|
|
538
|
+
st.cardId = `${TOOL}-${++seq}-${Date.now().toString(36)}`;
|
|
539
|
+
const kb = cardKeyboard(st);
|
|
540
|
+
const ok = await sendCard(ctx, cardText(st), kb);
|
|
541
|
+
const caps = ctx?.caps ?? ctx ?? {};
|
|
542
|
+
const allIds = [...st.options.map((_, i) => `p${i}`), 'prev', 'next', 'cancel', 'noop'];
|
|
543
|
+
try {
|
|
544
|
+
if (typeof caps.registerInteractionCard === 'function') {
|
|
545
|
+
caps.registerInteractionCard({ cardId: st.cardId, buttonIds: allIds, expireAt: Date.now() + CARD_EXPIRE_MS });
|
|
546
|
+
}
|
|
547
|
+
} catch { /* ignore */ }
|
|
548
|
+
try { await regCardSelf(ctx, st.cardId, allIds); } catch { /* ignore */ }
|
|
549
|
+
W.set(st.cardId, st);
|
|
550
|
+
if (W.size > 30) {
|
|
551
|
+
for (const k of [...W.keys()].slice(0, W.size - 30)) if (k !== st.cardId) W.delete(k);
|
|
552
|
+
}
|
|
553
|
+
publishShared({ dataRoot: dataRootOf(ctx) });
|
|
554
|
+
try { await saveState(ctx, st.cardId, st); } catch { /* ignore */ } // ★ 落盘:回调侧可能是**另一个模块实例**
|
|
555
|
+
void diag(`[paint] step=${st.step} page=${st.page} 选项=${st.options.length} 发出=${ok ? 'ok' : 'FAIL'} 表=${W.size}`);
|
|
556
|
+
if (!ok) st.error = '卡片没发出去(当前环境可能不支持富文本卡片)';
|
|
557
|
+
return ok;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 全部选完 → **绑上回灌钩子** + **发可复制模板**。
|
|
562
|
+
* 「绑定任何已有会话」这件事本身不需要新能力:`register()` 的 childSessionId 就是"任意会话 id"。
|
|
563
|
+
*/
|
|
564
|
+
async function bindNow(ctx, st) {
|
|
565
|
+
adoptShared(ctx);
|
|
566
|
+
const lib = await loadLib();
|
|
567
|
+
const root = kernelOf(ctx);
|
|
568
|
+
const sess = st.picked.session ?? {};
|
|
569
|
+
const ws = st.picked.workspace?.value ?? '';
|
|
570
|
+
const mode = Number(st.picked.mode?.value ?? 1);
|
|
571
|
+
// ★ 父会话 id 优先取**命令侧存进向导状态**的那份:回调侧可能是另一个模块实例,
|
|
572
|
+
// 它自己的 CTX_CACHE 是空的(详见文件头 SLOT 注释)。
|
|
573
|
+
const parentId = cn(st.parentId) || CTX_CACHE.agentId || CTX_CACHE.sessionId || '';
|
|
574
|
+
const sender = CTX_CACHE.sender ?? shared().sender ?? null;
|
|
575
|
+
const replyTarget = CTX_CACHE.replyTarget ?? shared().replyTarget ?? null;
|
|
576
|
+
void diag(`[bind] root=${!!root} parent=${parentId || '-'} child=${short(sess.value, 40)} mode=${mode} fwd=${!!sender && !!replyTarget}`);
|
|
577
|
+
|
|
578
|
+
if (!root || typeof root.on !== 'function') {
|
|
579
|
+
return { text: `⚠️ 拿不到宿主 ctx(需要权限包里的 kernel),这次没绑上。\n会话 id:\`${short(sess.value, 40)}\`` };
|
|
580
|
+
}
|
|
581
|
+
let result = '未挂(未知原因)';
|
|
582
|
+
try {
|
|
583
|
+
result = await lib.register(root, {
|
|
584
|
+
parentAgentId: parentId,
|
|
585
|
+
childSessionId: sess.value,
|
|
586
|
+
label: short(sess.label, 40),
|
|
587
|
+
mode,
|
|
588
|
+
sender,
|
|
589
|
+
replyTarget,
|
|
590
|
+
name: short(sess.label, 40),
|
|
591
|
+
workspace: wsPath(ws),
|
|
592
|
+
});
|
|
593
|
+
} catch (e) {
|
|
594
|
+
result = '绑定抛错:' + short(e?.message ?? e, 120);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const sid = sess.value.startsWith('session-') ? sess.value.slice('session-'.length) : sess.value;
|
|
598
|
+
const key = sid.slice(0, 8);
|
|
599
|
+
const modeCn = mode === 2 ? '二档' : (mode === 3 ? '三档' : '一档');
|
|
600
|
+
const L = [];
|
|
601
|
+
L.push('### ✅ 已选定');
|
|
602
|
+
L.push('');
|
|
603
|
+
L.push(`- 会话:\`${short(sess.value, 44)}\``);
|
|
604
|
+
L.push(`- 工作区:${wsPath(ws)}`);
|
|
605
|
+
L.push(`- 回灌:**${modeCn}** · ${short(result, 60)}`);
|
|
606
|
+
L.push('');
|
|
607
|
+
L.push('**复制下面这行**,把文字改成你要发的内容就能路由给它(图片/引用也可以一起发):');
|
|
608
|
+
L.push('');
|
|
609
|
+
L.push(`\`[s:${key}]\` 在这里写你要发给这个会话的内容`);
|
|
610
|
+
L.push('');
|
|
611
|
+
L.push('> 直接发这句就行;本鲸会把标记剥掉、把内容投递进那个会话。');
|
|
612
|
+
return { text: L.join('\n') };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** 推进到下一步(或收尾) */
|
|
616
|
+
async function advance(ctx, st) {
|
|
617
|
+
while (st.stepIdx < STEPS.length && st.picked[STEPS[st.stepIdx]]) st.stepIdx += 1;
|
|
618
|
+
if (st.stepIdx >= STEPS.length) {
|
|
619
|
+
W.delete(st.cardId);
|
|
620
|
+
void dropState(ctx, st.cardId);
|
|
621
|
+
const r = await bindNow(ctx, st);
|
|
622
|
+
try { const caps = ctx?.caps ?? ctx ?? {}; if (typeof caps.markdown === 'function') { await caps.markdown(r.text); return ''; } } catch { /* ignore */ }
|
|
623
|
+
return r.text;
|
|
624
|
+
}
|
|
625
|
+
st.step = STEPS[st.stepIdx];
|
|
626
|
+
st.page = 0;
|
|
627
|
+
st.error = '';
|
|
628
|
+
await paint(ctx, st);
|
|
629
|
+
return '';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export default {
|
|
633
|
+
name: ['会话列表', 'sessionlist', 'sessions'],
|
|
634
|
+
description: '三步向导(工作区 → 该区会话 → 回灌模式)绑定任意已有会话,并给出可复制的路由模板',
|
|
635
|
+
usage: '/会话列表',
|
|
636
|
+
|
|
637
|
+
async handler(ctx) {
|
|
638
|
+
KERNEL = ctx?.caps?.kernel ?? ctx?.kernel ?? KERNEL;
|
|
639
|
+
CTX_CACHE.sender = ctx?.sender ?? ctx?.caps?.sender ?? CTX_CACHE.sender;
|
|
640
|
+
CTX_CACHE.replyTarget = ctx?.replyTarget ?? ctx?.caps?.replyTarget ?? CTX_CACHE.replyTarget;
|
|
641
|
+
try {
|
|
642
|
+
const caps = ctx?.caps ?? {};
|
|
643
|
+
const psid = String(caps.peerSessionId ?? '');
|
|
644
|
+
if (psid) { CTX_CACHE.agentId = psid; CTX_CACHE.sessionId = psid; }
|
|
645
|
+
if (!CTX_CACHE.sender) CTX_CACHE.sender = ctx?.bot ?? caps.sender ?? null;
|
|
646
|
+
// ★ 把回调侧要用的一切**推给共享位**(回调可能是另一个模块实例,见文件头 SLOT 注释)
|
|
647
|
+
publishShared({
|
|
648
|
+
kernel: KERNEL,
|
|
649
|
+
sender: CTX_CACHE.sender,
|
|
650
|
+
replyTarget: CTX_CACHE.replyTarget,
|
|
651
|
+
parentId: CTX_CACHE.agentId || CTX_CACHE.sessionId || '',
|
|
652
|
+
dataRoot: dataRootOf(ctx),
|
|
653
|
+
});
|
|
654
|
+
void diag(`[handler] kernel=${!!KERNEL} sender=${!!CTX_CACHE.sender} rt=${!!CTX_CACHE.replyTarget} peerSessionId=${psid || '-'}`);
|
|
655
|
+
} catch { /* ignore */ }
|
|
656
|
+
|
|
657
|
+
const st = {
|
|
658
|
+
picked: {},
|
|
659
|
+
stepIdx: 0,
|
|
660
|
+
step: STEPS[0],
|
|
661
|
+
page: 0,
|
|
662
|
+
options: [],
|
|
663
|
+
cardId: '',
|
|
664
|
+
error: '',
|
|
665
|
+
parentId: CTX_CACHE.agentId || CTX_CACHE.sessionId || '',
|
|
666
|
+
startedAt: Date.now(),
|
|
667
|
+
};
|
|
668
|
+
W.set('pending', st);
|
|
669
|
+
await paint(ctx, st);
|
|
670
|
+
W.delete('pending');
|
|
671
|
+
W.set(st.cardId, st);
|
|
672
|
+
return '';
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
async onInteraction(ctx, info) {
|
|
676
|
+
const cardId = cn(info?.cardId);
|
|
677
|
+
const bid = cn(info?.buttonId);
|
|
678
|
+
adoptShared(ctx);
|
|
679
|
+
let st = W.get(cardId);
|
|
680
|
+
if (!st) {
|
|
681
|
+
// ★ 命令侧与回调侧可能是**两个模块实例**(各自的 W 是空的)⇒ 回落到磁盘状态
|
|
682
|
+
st = await loadState(ctx, cardId);
|
|
683
|
+
if (st) W.set(cardId, st);
|
|
684
|
+
}
|
|
685
|
+
void diag(`[onInteraction] card=${cardId} btn=${bid} 命中=${!!st} keys=[${[...W.keys()].join(',')}] kernel=${!!kernelOf(ctx)}`);
|
|
686
|
+
if (!st) return '这个向导已经过期了,重新发一次 /会话列表 吧~';
|
|
687
|
+
if (st.startedAt && Date.now() - st.startedAt > MAX_PAGE_MS) { W.delete(cardId); void dropState(ctx, cardId); return '向导放太久了,重新发一次吧~'; }
|
|
688
|
+
st.startedAt = st.startedAt ?? Date.now();
|
|
689
|
+
|
|
690
|
+
if (bid === 'cancel') { W.delete(cardId); void dropState(ctx, cardId); return '已取消,没有绑定任何会话。'; }
|
|
691
|
+
if (bid === 'noop') return '';
|
|
692
|
+
if (bid === 'prev' || bid === 'next') {
|
|
693
|
+
st.page = Math.max(0, st.page + (bid === 'next' ? 1 : -1));
|
|
694
|
+
const ok = await paint(ctx, st);
|
|
695
|
+
W.delete(cardId);
|
|
696
|
+
W.set(st.cardId, st);
|
|
697
|
+
void dropState(ctx, cardId);
|
|
698
|
+
return ok ? `已翻到第 ${st.page + 1} 页 ✅(新卡片在上面)` : `翻页失败(第 ${st.page + 1} 页)`;
|
|
699
|
+
}
|
|
700
|
+
if (/^p\d+$/.test(bid)) {
|
|
701
|
+
const idx = Number(bid.slice(1));
|
|
702
|
+
const opt = st.options[idx];
|
|
703
|
+
if (!opt) return '这个选项没了,翻一页再选~';
|
|
704
|
+
st.picked[st.step] = opt;
|
|
705
|
+
st.stepIdx += 1;
|
|
706
|
+
void dropState(ctx, cardId);
|
|
707
|
+
const done = await advance(ctx, st);
|
|
708
|
+
return done || '';
|
|
709
|
+
}
|
|
710
|
+
return '';
|
|
711
|
+
},
|
|
712
|
+
};
|