@sidleo3/dsh-chat 0.0.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.
Files changed (47) hide show
  1. package/client/bot-list.js +243 -0
  2. package/client/bot-settings.js +175 -0
  3. package/client/bot-shared-settings.js +561 -0
  4. package/client/chat-ui.js +134 -0
  5. package/client/context-enhancement.js +435 -0
  6. package/client/delivery-targets.js +334 -0
  7. package/client/diagnostics.js +160 -0
  8. package/client/i18n.js +371 -0
  9. package/client/index.js +77 -0
  10. package/client/list-order.js +144 -0
  11. package/client/rpc.js +52 -0
  12. package/client/scoped-mode-editor.js +111 -0
  13. package/client/section.js +250 -0
  14. package/client/session-badges.js +263 -0
  15. package/client/styles.js +960 -0
  16. package/client/version-panel.js +97 -0
  17. package/cordis.patch.yml +5 -0
  18. package/host/bot-model.mjs +53 -0
  19. package/host/bot-settings.mjs +247 -0
  20. package/host/channel-registry.mjs +237 -0
  21. package/host/commands.mjs +857 -0
  22. package/host/deferred.mjs +291 -0
  23. package/host/delivery.mjs +377 -0
  24. package/host/file-log.mjs +169 -0
  25. package/host/guidance.mjs +73 -0
  26. package/host/index.mjs +7 -0
  27. package/host/interactions.mjs +330 -0
  28. package/host/json-store.mjs +144 -0
  29. package/host/log-tail.mjs +63 -0
  30. package/host/panel.mjs +1012 -0
  31. package/host/paths.mjs +50 -0
  32. package/host/plugin.mjs +873 -0
  33. package/host/prompt-context.mjs +70 -0
  34. package/host/rpc.mjs +147 -0
  35. package/host/session-keys.mjs +25 -0
  36. package/host/session-store.mjs +187 -0
  37. package/host/sessions.mjs +1348 -0
  38. package/host/tools.mjs +283 -0
  39. package/lib/client.js +4431 -0
  40. package/lib/index.js +5676 -0
  41. package/package.json +63 -0
  42. package/shared/access-policy.mjs +263 -0
  43. package/shared/channel-rail.mjs +156 -0
  44. package/shared/context-enhancement.mjs +415 -0
  45. package/shared/contract.mjs +120 -0
  46. package/shared/panel-sections.mjs +76 -0
  47. package/shared/reply-reference.mjs +115 -0
@@ -0,0 +1,857 @@
1
+ /**
2
+ * 机器人命令内核(hub 所有,渠道共用)。
3
+ *
4
+ * 命令操作的都是渠道无关的东西(会话绑定、模型、推理等级、Agent Preset、
5
+ * 渠道与机器人状态),所以实现一次即可让所有渠道复用;渠道只负责"把文本交进来、
6
+ * 把回复发出去"。
7
+ *
8
+ * @module dsh-chat/host/commands
9
+ */
10
+
11
+ import * as accessPolicy from '../shared/access-policy.mjs';
12
+
13
+ import { botModelForSelection, describeBotModel, normalizeBotModel } from './bot-model.mjs';
14
+ import { chatKeyLabel } from './session-keys.mjs';
15
+ import { CONTRACT_VERSION } from '../shared/contract.mjs';
16
+
17
+ /** 命令名前缀。 */
18
+ const PREFIX = '/';
19
+
20
+ /** 一行的最大长度(列表类输出不至于刷屏)。 */
21
+ const MAX_LINE = 120;
22
+
23
+ function line(text) {
24
+ const value = String(text ?? '').replace(/\s+$/u, '');
25
+ return value.length > MAX_LINE ? `${value.slice(0, MAX_LINE)}…` : value;
26
+ }
27
+
28
+ /** 只有属主能用的命令(菜单里对非属主隐藏;执行时仍会再判一次)。 */
29
+ const OWNER_ONLY_COMMANDS = new Set(['allow', 'deny', 'diag', 'retitle']);
30
+
31
+ /** 历史回看里每条消息的字符上限(避免一条命令刷屏)。 */
32
+ const MAX_HISTORY_CHARS = 160;
33
+
34
+ function clip(text) {
35
+ const value = String(text ?? '').replace(/\s+/gu, ' ').trim();
36
+ return value.length > MAX_HISTORY_CHARS ? `${value.slice(0, MAX_HISTORY_CHARS)}…` : value;
37
+ }
38
+
39
+ /** 把 `provider/model` 之外的空格参数拆开,保留引号内的整体。 */
40
+ function parseArgs(text) {
41
+ const raw = text.slice(1);
42
+ const match = /^(\S+)\s*(.*)$/su.exec(raw);
43
+ if (!match) return { name: '', args: [] };
44
+ const name = match[1].toLowerCase();
45
+ const rest = match[2].trim();
46
+ if (!rest) return { name, args: [] };
47
+ const args = rest.match(/"[^"]*"|\S+/gu) ?? [];
48
+ return { name, args: args.map((arg) => (arg.startsWith('"') && arg.endsWith('"') ? arg.slice(1, -1) : arg)) };
49
+ }
50
+
51
+ /** 数字序号(1 起)→ 下标。 */
52
+ function indexOf(value) {
53
+ if (!/^\d{1,3}$/u.test(value)) return null;
54
+ const index = Number(value) - 1;
55
+ return index >= 0 ? index : null;
56
+ }
57
+
58
+ /**
59
+ * 创建命令注册表。
60
+ *
61
+ * @param options - { logger, services },services 为 { sessions, bots, channels, agentPresets }。
62
+ * @returns { register, handle, list, names }。
63
+ */
64
+ export function createCommandRegistry({ logger = console, services = {} } = {}) {
65
+ /** @type {Map<string, object>} */
66
+ const commands = new Map();
67
+ const aliasIndex = new Map();
68
+
69
+ function register(definition) {
70
+ const { name, summary, usage, scope = 'both', execute } = definition;
71
+ if (!/^[a-z][a-z0-9-]{0,31}$/u.test(name ?? '')) {
72
+ throw new TypeError(`命令名不合法:${String(name)}`);
73
+ }
74
+ if (typeof execute !== 'function') throw new TypeError(`命令 ${name} 缺少 execute。`);
75
+ if (commands.has(name)) throw new Error(`命令 ${name} 重复注册。`);
76
+ const record = Object.freeze({
77
+ name,
78
+ summary: String(summary ?? ''),
79
+ usage: usage ?? `/${name}`,
80
+ scope,
81
+ execute,
82
+ aliases: Object.freeze([...(definition.aliases ?? [])]),
83
+ });
84
+ commands.set(name, record);
85
+ for (const alias of record.aliases) aliasIndex.set(alias, name);
86
+ return () => {
87
+ if (commands.get(name) !== record) return;
88
+ commands.delete(name);
89
+ for (const alias of record.aliases) aliasIndex.delete(alias);
90
+ };
91
+ }
92
+
93
+ function lookup(name) {
94
+ return commands.get(name) ?? commands.get(aliasIndex.get(name));
95
+ }
96
+
97
+ /**
98
+ * 解析并执行一条命令。
99
+ *
100
+ * @param options - {
101
+ * text, channelId, botId, key, conversationType, senderId,
102
+ * channelLabel?, botLabel?,
103
+ * }。
104
+ * @returns `{ handled, reply }`;不是命令时 handled=false。
105
+ */
106
+ async function handle(options) {
107
+ const text = typeof options?.text === 'string' ? options.text.trim() : '';
108
+ if (!text.startsWith(PREFIX)) return { handled: false };
109
+ const { name, args } = parseArgs(text);
110
+ const command = lookup(name);
111
+ if (!command) {
112
+ return {
113
+ handled: true,
114
+ reply: `未知命令 ${PREFIX}${name}。发送 ${PREFIX}help 查看可用命令。`,
115
+ };
116
+ }
117
+ if (command.scope !== 'both' && command.scope !== options.conversationType) {
118
+ return { handled: true, reply: `命令 ${PREFIX}${command.name} 不能在当前会话类型下使用。` };
119
+ }
120
+ const context = {
121
+ ...options,
122
+ args,
123
+ rawArgs: args.join(' '),
124
+ services,
125
+ log: logger,
126
+ };
127
+ try {
128
+ const result = await command.execute(context);
129
+ // 命令可以返回字符串(纯文本),也可以返回 `{ reply, menu, panel }`:
130
+ // 菜单卡片这类结构化结果要原样带出去,否则只能退化成文本。
131
+ if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
132
+ return {
133
+ handled: true,
134
+ reply: typeof result.reply === 'string' ? result.reply : '',
135
+ ...(Array.isArray(result.menu) && result.menu.length > 0 ? { menu: result.menu } : {}),
136
+ // 控制面板状态:渠道有卡片能力就渲染成可交互卡,没有就用 reply 里的文本。
137
+ ...(result.panel && typeof result.panel === 'object' ? { panel: result.panel } : {}),
138
+ };
139
+ }
140
+ return { handled: true, reply: result ?? '' };
141
+ } catch (error) {
142
+ const message = error?.message ?? String(error);
143
+ logger.warn?.(`[dsh-chat] 命令 ${command.name} 执行失败:${message}`);
144
+ return { handled: true, reply: `命令执行失败:${message}` };
145
+ }
146
+ }
147
+
148
+ /** @returns 当前可用命令(按名字排序)。 */
149
+ function list() {
150
+ return Object.freeze([...commands.values()].sort((left, right) => left.name.localeCompare(right.name)));
151
+ }
152
+
153
+ return { register, handle, list, names: () => [...commands.keys()] };
154
+ }
155
+
156
+ async function boundSession(context) {
157
+ const { services, channelId, botId, key } = context;
158
+ return services.sessions?.bindings?.get?.(channelId, botId, key)?.sessionId ?? null;
159
+ }
160
+
161
+ /**
162
+ * 模型目录(`session/modelCatalog`)。
163
+ *
164
+ * 真形状:`{ default, routableProviders, groups: [{ id, name, models: [{ id, name,
165
+ * reasoning?: { efforts: [{ id, name }], defaultEffort } }] }] }`。
166
+ * **provider 是 `group.id`**——曾经照 `group.provider`/`providerId` 读,结果 rows 里
167
+ * provider 全是 undefined(`/models` 会印出 `undefined/xxx`,控制面板则一个选项都拼不出来)。
168
+ */
169
+ async function modelCatalog(context) {
170
+ const catalog = await context.services.sessions.invoke('session', 'modelCatalog', {});
171
+ const rows = [];
172
+ for (const group of catalog?.groups ?? []) {
173
+ const provider = group.id ?? group.provider ?? group.providerId;
174
+ for (const model of group.models ?? []) {
175
+ rows.push({
176
+ provider,
177
+ providerName: group.name ?? group.providerName ?? group.displayName ?? provider,
178
+ model: model.id ?? model.model,
179
+ name: model.name ?? model.id,
180
+ efforts: (model.reasoning?.efforts ?? []).map((effort) => ({
181
+ id: effort.id, label: effort.name ?? effort.label ?? effort.id,
182
+ })),
183
+ defaultEffort: model.reasoning?.defaultEffort ?? null,
184
+ });
185
+ }
186
+ }
187
+ return { catalog, rows };
188
+ }
189
+
190
+ /**
191
+ * 当前会话的模型选择。
192
+ *
193
+ * 真形状是 `projections.values.modelSelection = { lastUsed, next }`(照顶层读 provider/model
194
+ * 永远拿不到)。**取 `next`**:`next = pending ?? lastUsed`,而 `selectModel` 只写 `pending`,
195
+ * 要到下一轮请求才刷新 `lastUsed`——用 `lastUsed` 会把"刚切完的模型"读成旧的。
196
+ */
197
+ function selectionOf(item) {
198
+ const projection = item?.projections?.values?.modelSelection;
199
+ return projection?.next ?? projection?.lastUsed ?? null;
200
+ }
201
+
202
+ /**
203
+ * 读某个会话的模型选择。
204
+ *
205
+ * **读失败与"没有显式选择"必须分开**:混为一谈会把一次 RPC 失败讲成"你从没选过模型",
206
+ * 用户看到的是一句与事实相反的话,日志里也没有线索(仓库约定:失败必须可见)。
207
+ */
208
+ async function readSelection(context, sessionId) {
209
+ try {
210
+ const list = await context.services.sessions.invoke('session', 'list', { _request: {} });
211
+ const item = list?.items?.find((entry) => entry.sessionId === sessionId);
212
+ return { selection: selectionOf(item), failed: false };
213
+ } catch (error) {
214
+ context.log?.warn?.(`[dsh-chat] 读取会话模型选择失败(${sessionId}):${error?.message ?? error}`);
215
+ return { selection: null, failed: true };
216
+ }
217
+ }
218
+
219
+ /**
220
+ * 机器人默认模型(没有会话时 `/model`、`/reasoning` 改的就是它)。
221
+ *
222
+ * DSH 的模型选择是会话级的(`session/create` 没有模型参数、`selectModel` 必须带 sessionId),
223
+ * 而未绑定的聊天还没有会话——于是把"先挑好模型"存成机器人级默认,由建会话时应用。
224
+ */
225
+ function botModelOf(context) {
226
+ return normalizeBotModel(context.services.bots?.read?.(context.channelId, context.botId)?.model);
227
+ }
228
+
229
+ /** `/diag` 每个日志最多回几行:一屏能看完,细节去设置页的诊断面板。 */
230
+ const DIAG_LOG_LINES = 8;
231
+
232
+ /** 诊断文本里的单行截断(日志行可能很长,别把消息撑爆)。 */
233
+ function clipText(value, max = 160) {
234
+ const text = String(value ?? '').replace(/\s+/gu, ' ').trim();
235
+ return text.length > max ? `${text.slice(0, max)}…` : text;
236
+ }
237
+
238
+ function findModel(rows, token) {
239
+ const byIndex = indexOf(token);
240
+ if (byIndex !== null) return rows[byIndex] ?? null;
241
+ const [provider, model] = String(token).split('/');
242
+ if (!provider || !model) return null;
243
+ return rows.find((row) => row.provider === provider && row.model === model) ?? null;
244
+ }
245
+
246
+ /**
247
+ * 注册内置命令。
248
+ *
249
+ * @param registry - 命令注册表。
250
+ * @param options - { hubVersion }。
251
+ */
252
+ export function registerBuiltinCommands(registry, { hubVersion = '0.0.1', listCommands = null } = {}) {
253
+ registry.register({
254
+ name: 'help',
255
+ aliases: ['h'],
256
+ summary: '显示机器人支持的命令与用法',
257
+ execute: () => {
258
+ const rows = registry.list().map((command) => line(`${command.usage} — ${command.summary}`));
259
+ return ['可用命令:', ...rows].join('\n');
260
+ },
261
+ });
262
+
263
+ /** 当前会话类型对应的策略作用域键。 */
264
+ const scopeKeyOf = (context) => (context.conversationType === 'group' ? 'group' : 'direct');
265
+ const scopeLabelOf = (context) => (context.conversationType === 'group' ? '群聊' : '私聊');
266
+
267
+ /** 读当前策略(没有就按默认:仅名单、命令默认不允许)。 */
268
+ function currentPolicy(context) {
269
+ const record = context.services.bots.read(context.channelId, context.botId);
270
+ return accessPolicy.normalizeAccessPolicy(record.accessPolicy)
271
+ ?? accessPolicy.defaultAccessPolicy();
272
+ }
273
+
274
+ /** 改一个作用域的名单;返回**完整**策略(保存路径要求两段都在)。 */
275
+ function withAllowlist(context, mutate) {
276
+ const policy = currentPolicy(context);
277
+ const key = scopeKeyOf(context);
278
+ const scope = policy[key];
279
+ return {
280
+ ...policy,
281
+ [key]: {
282
+ ...scope,
283
+ allowlist: { users: mutate(scope.allowlist.users) },
284
+ open: {
285
+ ...scope.open,
286
+ // 名单变动时同步清掉 open 里的例外,避免"已移除却还能执行命令"。
287
+ commandPermissionOverrides: mutate(scope.open.commandPermissionOverrides),
288
+ },
289
+ },
290
+ };
291
+ }
292
+
293
+ registry.register({
294
+ name: 'menu',
295
+ // `/m` 是常用入口的短写(dsh-im 也是这个)。
296
+ aliases: ['m'],
297
+ summary: '打开控制面板(选模型/推理等级/预设/工作区),并列出全部命令',
298
+ execute: async (context) => {
299
+ const rows = typeof listCommands === 'function' ? listCommands() : [];
300
+ const items = rows
301
+ // 菜单不该出现在菜单里;属主专属命令不给非属主看。
302
+ .filter((row) => row.name !== 'menu')
303
+ .filter((row) => row.scope === 'both' || row.scope === context.conversationType)
304
+ .filter((row) => context.isOwner === true || !OWNER_ONLY_COMMANDS.has(row.name))
305
+ .map((row) => ({ label: `${PREFIX}${row.name}`, command: `${PREFIX}${row.name}` }));
306
+ /**
307
+ * 控制面板状态:有卡片能力的渠道(飞书)据此渲染**可交互卡**——下拉直接选模型、
308
+ * 推理等级、Agent 预设、工作区,选完立即生效;没有卡片能力的渠道(微信)用下面
309
+ * 的文本清单,行为与以前一致。
310
+ */
311
+ let panel = null;
312
+ if (typeof context.services.panel?.read === 'function') {
313
+ panel = await context.services.panel.read({
314
+ channelId: context.channelId, botId: context.botId, key: context.key,
315
+ // 工作区候选含属主其它会话的绝对路径:非属主(比如群里被授权执行命令的成员)不给。
316
+ isOwner: context.isOwner === true,
317
+ // 「本会话的访问策略」要知道私聊还是群聊;漏传那一项就会在卡上消失。
318
+ conversationType: context.conversationType ?? null,
319
+ }).catch((error) => {
320
+ context.log?.warn?.(`[dsh-chat] 读取控制面板状态失败:${error?.message ?? error}`);
321
+ return null;
322
+ });
323
+ }
324
+ if (items.length === 0 && !panel) return '当前没有可用命令。';
325
+ return {
326
+ ...(panel ? { panel } : {}),
327
+ menu: items,
328
+ // 没有卡片能力的渠道(微信)直接把这个文本列表发出去。
329
+ reply: [
330
+ '可用命令:',
331
+ ...items.map((item, index) => line(`${index + 1}. ${item.label}`)),
332
+ '也可以直接发文字命令。',
333
+ ].join('\n'),
334
+ };
335
+ },
336
+ });
337
+
338
+ /**
339
+ * `/diag`:把「一屏现场」用文字回出来。
340
+ *
341
+ * 与设置页的 `diagnostics.read` 同一份数据(连接状态 + 最近错误 + 日志尾部)——
342
+ * 手机上排查时不必去开电脑;只给属主,里面有机器人 id 与日志内容。
343
+ */
344
+ registry.register({
345
+ name: 'diag',
346
+ summary: '查看连接状态、最近错误与日志尾部(仅属主)',
347
+ execute: async (context) => {
348
+ if (context.isOwner !== true) return '诊断里有机器人 id 与日志内容,只有属主能看。';
349
+ if (typeof context.services.diagnostics?.read !== 'function') return '这个部署没有开启诊断。';
350
+ let data;
351
+ try {
352
+ data = await context.services.diagnostics.read();
353
+ } catch (error) {
354
+ context.log?.warn?.(`[dsh-chat] 诊断读取失败:${error?.message ?? error}`);
355
+ return `诊断读取失败:${error?.message ?? error}`;
356
+ }
357
+ const lines = ['🩺 诊断'];
358
+ if (data?.dataDir) lines.push(`数据目录:${data.dataDir}`);
359
+ // 超时之后那一轮的补发还没完成时,这里能看到"还在盯什么"。
360
+ if ((data?.deferred ?? []).length > 0) {
361
+ lines.push(`待补发:${data.deferred.length} 条`);
362
+ for (const row of data.deferred) {
363
+ lines.push(` · ${clipText(row.key)} 会话=${clipText(row.sessionId)}`
364
+ + `${row.lastError ? ` · ⚠️ ${clipText(row.lastError)}` : ''}`);
365
+ }
366
+ }
367
+ for (const channel of data?.channels ?? []) {
368
+ lines.push('', `渠道 ${channel.label ?? channel.id}:${channel.status ?? '未知'}`
369
+ + `${channel.error ? `(最近错误:${clipText(channel.error)})` : ''}`);
370
+ if (channel.statusError) lines.push(` ⚠️ 状态读取失败:${clipText(channel.statusError)}`);
371
+ for (const bot of channel.bots ?? []) {
372
+ const handled = Number.isFinite(bot.handled) ? ` · 已处理 ${bot.handled} 条` : '';
373
+ const last = bot.lastHandledAt ? ` · 最后 ${clipText(bot.lastHandledAt)}` : '';
374
+ const bad = bot.errorMessage ?? bot.error ?? null;
375
+ lines.push(` · ${bot.name ?? bot.botId ?? '未命名'} ${bot.connected === true ? '已连接' : '未连接'}`
376
+ + `${handled}${last}${bad ? ` · ⚠️ ${clipText(bad)}` : ''}`);
377
+ }
378
+ }
379
+ // 日志只挑 WARN/ERROR(一屏能看完);一条都没有时给最后几行当"还活着"的证据。
380
+ for (const log of data?.logs ?? []) {
381
+ const name = String(log?.path ?? '').split('/').pop() ?? 'log';
382
+ if (!log?.exists) {
383
+ lines.push('', `${name}:还没有日志文件`);
384
+ continue;
385
+ }
386
+ const bad = (log.lines ?? []).filter((row) => /\b(WARN|ERROR)\b/.test(row));
387
+ const picked = (bad.length > 0 ? bad : (log.lines ?? [])).slice(-DIAG_LOG_LINES);
388
+ lines.push('', `${name}${bad.length > 0 ? `(最近 ${picked.length} 条 WARN/ERROR)` : '(尾部)'}:`);
389
+ for (const row of picked) lines.push(` ${clipText(row)}`);
390
+ }
391
+ return lines.join('\n');
392
+ },
393
+ });
394
+
395
+ registry.register({
396
+ name: 'whoami',
397
+ summary: '查看你的平台标识、是否属主,以及本次消息的访问判定',
398
+ execute: (context) => {
399
+ const decision = accessPolicy.evaluateAccess({
400
+ policy: currentPolicy(context),
401
+ conversationType: context.conversationType,
402
+ senderIds: [context.senderId],
403
+ isOwner: context.isOwner === true,
404
+ });
405
+ return [
406
+ `你的平台 id:${context.senderId ?? '未知'}`,
407
+ `是否属主:${context.isOwner === true ? '是' : '否'}`,
408
+ `当前会话:${scopeLabelOf(context)}`,
409
+ `本次判定:${decision.allowed ? '放行' : '拦截'}(${decision.reason})`,
410
+ context.isOwner === true
411
+ ? `属主始终可用。用 ${PREFIX}allow 查看/维护${scopeLabelOf(context)}名单。`
412
+ : null,
413
+ ].filter(Boolean).join('\n');
414
+ },
415
+ });
416
+
417
+ registry.register({
418
+ name: 'allow',
419
+ summary: '查看或维护当前会话类型的访问名单(仅属主)',
420
+ usage: '/allow [平台id] [--commands]',
421
+ execute: async (context) => {
422
+ if (context.isOwner !== true) return '只有属主能维护访问名单。';
423
+ const scope = scopeKeyOf(context);
424
+ const { policy } = { policy: currentPolicy(context) };
425
+ const id = context.args.find((arg) => !arg.startsWith('--'));
426
+ if (!id) {
427
+ const users = policy[scope].allowlist.users;
428
+ if (users.length === 0) return `${scopeLabelOf(context)}名单是空的(当前只有属主可用)。`;
429
+ return [
430
+ `${scopeLabelOf(context)}名单(${users.length} 人):`,
431
+ ...users.map((user, index) => line(
432
+ `${index + 1}. ${user.id}${user.canExecuteCommands ? '(可执行命令)' : ''}`,
433
+ )),
434
+ `用 ${PREFIX}allow <平台id> [--commands] 添加,${PREFIX}deny <平台id> 移除。`,
435
+ ].join('\n');
436
+ }
437
+ const withCommands = context.args.includes('--commands');
438
+ const next = withAllowlist(context, (users) => [
439
+ ...users.filter((user) => user.id !== id),
440
+ { id, canExecuteCommands: withCommands },
441
+ ]);
442
+ await context.services.bots.write(context.channelId, context.botId, {
443
+ accessPolicy: accessPolicy.validateAccessPolicy(next),
444
+ });
445
+ return `已把 ${id} 加入${scopeLabelOf(context)}名单${withCommands ? '(允许执行命令)' : ''}。`;
446
+ },
447
+ });
448
+
449
+ registry.register({
450
+ name: 'deny',
451
+ summary: '把某人移出当前会话类型的访问名单(仅属主)',
452
+ usage: '/deny <平台id>',
453
+ execute: async (context) => {
454
+ if (context.isOwner !== true) return '只有属主能维护访问名单。';
455
+ const id = context.args[0];
456
+ if (!id) return `用法:${PREFIX}deny <平台id>`;
457
+ const before = currentPolicy(context);
458
+ const next = withAllowlist(context, (users) => users.filter((user) => user.id !== id));
459
+ const key = scopeKeyOf(context);
460
+ const removed = before[key].allowlist.users.some((user) => user.id === id)
461
+ || before[key].open.commandPermissionOverrides.some((user) => user.id === id);
462
+ if (!removed) return `${id} 本来就不在${scopeLabelOf(context)}名单里。`;
463
+ await context.services.bots.write(context.channelId, context.botId, {
464
+ accessPolicy: accessPolicy.validateAccessPolicy(next),
465
+ });
466
+ return `已把 ${id} 移出${scopeLabelOf(context)}名单。`;
467
+ },
468
+ });
469
+
470
+ registry.register({
471
+ name: 'version',
472
+ summary: '查看 dsh-chat 插件版本',
473
+ execute: () => `dsh-chat ${hubVersion}(渠道契约 v${CONTRACT_VERSION})`,
474
+ });
475
+
476
+ registry.register({
477
+ name: 'status',
478
+ summary: '查看当前机器人、会话与运行状态',
479
+ execute: async (context) => {
480
+ const { services, channelId, botId, key } = context;
481
+ const channel = services.channels?.list?.().find((item) => item.id === channelId);
482
+ const record = services.bots?.read?.(channelId, botId) ?? {};
483
+ const bound = services.sessions?.bindings?.get?.(channelId, botId, key);
484
+ let running = null;
485
+ if (bound?.sessionId) {
486
+ running = await context.services.sessions.isRunning(bound.sessionId).catch(() => null);
487
+ }
488
+ return [
489
+ `渠道:${channel?.label ?? channelId}(${channel?.status ?? '未知'})`,
490
+ `机器人:${context.botLabel ?? botId}`,
491
+ `会话:${bound?.sessionId ?? '未绑定(发一条消息即可创建)'}`,
492
+ `运行中:${running === null ? '未知' : running ? '是' : '否'}`,
493
+ `工作区:${record.workspace ?? '未设置'}`,
494
+ `模型:${describeBotModel(record.model) ? `机器人默认 ${describeBotModel(record.model)}` : '未设机器人默认(跟随 Host 默认)'}`,
495
+ `Agent Preset:${record.agentPreset ?? '跟随 Host 默认'}`,
496
+ ].join('\n');
497
+ },
498
+ });
499
+
500
+ registry.register({
501
+ name: 'new',
502
+ summary: '解除当前聊天的会话绑定,下一条消息开启新会话',
503
+ execute: async (context) => {
504
+ await context.services.sessions.reset({
505
+ channelId: context.channelId, botId: context.botId, key: context.key,
506
+ });
507
+ return '已解除当前会话绑定,下一条消息将开启新会话。';
508
+ },
509
+ });
510
+
511
+ registry.register({
512
+ name: 'stop',
513
+ summary: '停止当前聊天正在运行的任务',
514
+ execute: async (context) => {
515
+ const sessionId = await boundSession(context);
516
+ if (!sessionId) return '当前聊天还没有绑定会话。';
517
+ const result = await context.services.sessions.cancel({
518
+ channelId: context.channelId, botId: context.botId, key: context.key,
519
+ });
520
+ return result?.accepted ? '已请求停止当前任务。' : '当前没有正在运行的任务。';
521
+ },
522
+ });
523
+
524
+ registry.register({
525
+ name: 'compact',
526
+ summary: '压缩当前会话的上下文(会话太长时用)',
527
+ execute: async (context) => {
528
+ const sessionId = await boundSession(context);
529
+ if (!sessionId) return '当前聊天还没有会话(先发一条消息即可创建)。';
530
+ const result = await context.services.sessions.runCommand({
531
+ channelId: context.channelId,
532
+ botId: context.botId,
533
+ key: context.key,
534
+ line: '/compact',
535
+ });
536
+ if (!result?.matched) {
537
+ return '当前部署没有注册 /compact 命令(需要在 profile 里启用压缩插件)。';
538
+ }
539
+ if (result.kind === 'success') {
540
+ return `✅ 上下文已压缩。${result.text ? `\n${result.text}` : ''}`;
541
+ }
542
+ return `⚠️ 压缩未完成:${result.text || '未知原因'}`;
543
+ },
544
+ });
545
+
546
+ registry.register({
547
+ name: 'history',
548
+ summary: '回看最近几轮对话',
549
+ usage: '/history [轮数]',
550
+ execute: async (context) => {
551
+ const requested = indexOf(context.args[0]);
552
+ const turns = requested === null ? 5 : Math.min(requested + 1, 20);
553
+ const { messages } = await context.services.sessions.history({
554
+ channelId: context.channelId,
555
+ botId: context.botId,
556
+ key: context.key,
557
+ // 一轮大致对应"用户 + 助手"两条消息,多取几条保证凑得齐。
558
+ maxMessages: turns * 2 + 2,
559
+ });
560
+ if (messages.length === 0) return '这个会话还没有对话历史。';
561
+ const lines = [];
562
+ let index = 0;
563
+ for (const message of messages) {
564
+ if (message.role === 'user') {
565
+ index += 1;
566
+ lines.push(`${index}. 你:${line(clip(message.text))}`);
567
+ } else {
568
+ lines.push(` bot:${line(clip(message.text))}`);
569
+ }
570
+ }
571
+ return [`最近 ${index} 轮(最多回看 20 轮):`, ...lines].join('\n');
572
+ },
573
+ });
574
+
575
+ registry.register({
576
+ name: 'session',
577
+ summary: '查看当前会话;带会话 id 时切换绑定',
578
+ usage: '/session [会话id]',
579
+ execute: async (context) => {
580
+ const { services } = context;
581
+ if (context.args.length === 0) {
582
+ const bound = services.sessions.bindings.get(context.channelId, context.botId, context.key);
583
+ if (!bound) return '当前聊天未绑定会话(发一条消息即可创建)。';
584
+ const running = await services.sessions.isRunning(bound.sessionId).catch(() => null);
585
+ return `当前会话:${bound.sessionId}${running ? '(运行中)' : ''}`;
586
+ }
587
+ const target = context.args[0];
588
+ let exists = false;
589
+ try {
590
+ exists = await services.sessions.sessionExists(target);
591
+ } catch (error) {
592
+ // `sessionExists` 只把 not-found 折成 false,其余是真失败——不能说成"找不到会话"。
593
+ context.log?.warn?.(`[dsh-chat] 校验会话失败(${target}):${error?.message ?? error}`);
594
+ return `校验会话失败(${error?.message ?? error}),稍后再试。`;
595
+ }
596
+ if (!exists) return `找不到会话 ${target}。`;
597
+ await services.sessions.bindings.bind(context.channelId, context.botId, context.key, {
598
+ sessionId: target,
599
+ });
600
+ return `已切换到会话 ${target}。`;
601
+ },
602
+ });
603
+
604
+ /**
605
+ * `/retitle`:给这台机器人**历史绑定过的**会话补上「渠道 ·」前缀。
606
+ *
607
+ * 前缀是 P6 加的,只对"下一次发消息"的会话生效——长期不说话的旧会话标题一直是旧的。
608
+ * 这是个一次性动作(幂等),放在命令里而不是启动时自动跑:什么时候动用户的历史会话,
609
+ * 应该由用户自己决定。
610
+ */
611
+ registry.register({
612
+ name: 'retitle',
613
+ aliases: ['fixtitles'],
614
+ summary: '给历史会话补上「渠道 ·」标题前缀(仅属主)',
615
+ execute: async (context) => {
616
+ if (context.isOwner !== true) return '改会话标题只限属主。';
617
+ if (typeof context.services.sessions?.boundSessions !== 'function'
618
+ || typeof context.services.sessions?.markSessionChannel !== 'function') {
619
+ return '这个部署不支持批量回填会话标题。';
620
+ }
621
+ const channelLabel = String(context.channelLabel ?? '').trim();
622
+ if (!channelLabel) return '拿不到渠道名,无法回填标题。';
623
+ const rows = context.services.sessions.boundSessions(context.channelId, context.botId);
624
+ if (rows.length === 0) return '这台机器人还没有绑定过任何会话。';
625
+
626
+ const counts = { renamed: 0, skipped: 0, 'no-title': 0, failed: 0 };
627
+ for (const row of rows) {
628
+ // 聊天名只能按**绑定键**给(`p2p:ou_x` → 「私聊 ou_x」),hub 拿不到昵称——那是渠道的事。
629
+ // 下一次这个聊天发消息时,渠道会带着真名再标一次(前缀可升级,见 markSessionChannel)。
630
+ // 串行:一次 rename 就够轻,串行能避免把 DSH 的会话列表打满。
631
+ // eslint-disable-next-line no-await-in-loop
632
+ const outcome = await context.services.sessions.markSessionChannel(row.sessionId, {
633
+ channelLabel,
634
+ chatLabel: chatKeyLabel(row.key),
635
+ });
636
+ if (Object.hasOwn(counts, outcome ?? '')) counts[outcome] += 1;
637
+ }
638
+ context.log?.info?.(`[dsh-chat] 会话标题回填:${JSON.stringify(counts)}`);
639
+ const detail = [
640
+ counts.renamed > 0 ? `补上 ${counts.renamed} 个` : null,
641
+ counts.skipped > 0 ? `已有前缀 ${counts.skipped} 个` : null,
642
+ counts['no-title'] > 0 ? `还没有标题 ${counts['no-title']} 个(等它跑完一轮再执行一次)` : null,
643
+ counts.failed > 0 ? `失败 ${counts.failed} 个(细节见日志)` : null,
644
+ ].filter(Boolean).join('、');
645
+ return `检查了 ${rows.length} 个绑定会话:${detail || '没有需要处理的'}。`;
646
+ },
647
+ });
648
+
649
+ registry.register({
650
+ name: 'models',
651
+ summary: '按序号列出当前可用的模型',
652
+ execute: async (context) => {
653
+ const { rows } = await modelCatalog(context);
654
+ if (rows.length === 0) return '当前 Host 没有可用模型。';
655
+ const body = rows.map((row, index) => line(
656
+ `${index + 1}. ${row.provider}/${row.model}${row.name && row.name !== row.model ? `(${row.name})` : ''}`
657
+ + `${row.efforts.length > 0 ? ` · 推理等级 ${row.efforts.map((effort) => effort.id).join('/')}` : ''}`,
658
+ ));
659
+ return ['可用模型:', ...body, `用 /model <序号或 provider/模型id> [推理等级] 切换。`].join('\n');
660
+ },
661
+ });
662
+
663
+ registry.register({
664
+ name: 'model',
665
+ summary: '查看或切换当前会话使用的模型',
666
+ usage: '/model [序号或 provider/模型id] [推理等级]',
667
+ execute: async (context) => {
668
+ const sessionId = await boundSession(context);
669
+ const botModel = botModelOf(context);
670
+ if (context.args.length === 0) {
671
+ if (!sessionId) {
672
+ return botModel
673
+ ? `还没有会话:机器人默认模型 ${describeBotModel(botModel)}(下一条消息新建的会话用它)。`
674
+ + '用 /model <序号或 provider/模型id> 就能现在就改。'
675
+ : '还没有会话,也还没设过机器人默认模型(当前跟随 Host 默认)。'
676
+ + '用 /model <序号或 provider/模型id> 设一个,下一条消息新建的会话就用它。';
677
+ }
678
+ const { selection, failed } = await readSelection(context, sessionId);
679
+ if (failed) return '读不到当前会话的模型选择(Host 暂时不可用),稍后再试。';
680
+ return selection
681
+ ? `当前模型:${selection.provider}/${selection.model}${selection.reasoningEffort ? `(推理等级 ${selection.reasoningEffort})` : ''}`
682
+ : '当前会话没有显式选择模型(跟随 Host 默认)。';
683
+ }
684
+ const { rows } = await modelCatalog(context);
685
+ const target = findModel(rows, context.args[0]);
686
+ if (!target) return `找不到模型 ${context.args[0]};用 /models 查看可用列表。`;
687
+ const effort = context.args[1];
688
+ if (effort && !target.efforts.some((item) => item.id === effort)) {
689
+ return `模型 ${target.provider}/${target.model} 不支持推理等级 ${effort}。`;
690
+ }
691
+ if (!sessionId) {
692
+ // 没有会话 → 写机器人默认模型(机器人级设置,与工作区/预设同一条口径:只限属主)。
693
+ if (context.isOwner !== true) {
694
+ return '还没有会话:这时改的是机器人默认模型(机器人级设置),只有属主能改。';
695
+ }
696
+ const next = botModelForSelection(botModel, {
697
+ provider: target.provider, model: target.model, reasoningEffort: effort || null,
698
+ });
699
+ await context.services.bots.write(context.channelId, context.botId, { model: next });
700
+ return `机器人默认模型已设为 ${target.provider}/${target.model}`
701
+ + `${next.reasoningEffort ? `(推理等级 ${next.reasoningEffort})` : ''}`
702
+ + '(还没有会话:下一条消息新建的会话用它)。';
703
+ }
704
+ const selected = await context.services.sessions.invoke('session', 'selectModel', {
705
+ request: {
706
+ sessionId,
707
+ provider: target.provider,
708
+ model: target.model,
709
+ ...(effort ? { reasoningEffort: effort } : {}),
710
+ },
711
+ });
712
+ const value = selected?.selected ?? {};
713
+ return `已切换为 ${value.provider ?? target.provider}/${value.model ?? target.model}`
714
+ + `${value.reasoningEffort ? `(推理等级 ${value.reasoningEffort})` : ''}。`;
715
+ },
716
+ });
717
+
718
+ registry.register({
719
+ name: 'reasonings',
720
+ aliases: ['reasoninglist'],
721
+ summary: '列出当前模型支持的推理等级',
722
+ execute: async (context) => {
723
+ const { rows } = await modelCatalog(context);
724
+ const sessionId = await boundSession(context);
725
+ const current = rows.find((row) => row.efforts.length > 0) ?? rows[0];
726
+ if (!current) return '当前 Host 没有可用模型。';
727
+ const efforts = current.efforts.length > 0 ? current.efforts : [];
728
+ if (efforts.length === 0) return `模型 ${current.provider}/${current.model} 不支持推理等级。`;
729
+ return [
730
+ `模型 ${current.provider}/${current.model} 支持的推理等级:`,
731
+ ...efforts.map((effort, index) => line(`${index + 1}. ${effort.id}${effort.label ? `(${effort.label})` : ''}`)),
732
+ `默认:${current.defaultEffort ?? '—'}`,
733
+ `用 /reasoning <序号或等级id> 切换,/reasoning --default 恢复默认。`,
734
+ sessionId ? '' : '(当前聊天还没有会话,切换会在有会话后生效。)',
735
+ ].filter(Boolean).join('\n');
736
+ },
737
+ });
738
+
739
+ registry.register({
740
+ name: 'reasoning',
741
+ summary: '查看或切换当前模型的推理等级',
742
+ usage: '/reasoning [序号或等级id|--default]',
743
+ execute: async (context) => {
744
+ const sessionId = await boundSession(context);
745
+ const botModel = botModelOf(context);
746
+ if (context.args.length === 0) {
747
+ if (!sessionId) {
748
+ return botModel
749
+ ? `还没有会话:机器人默认模型 ${describeBotModel(botModel)}(下一条消息新建的会话用它)。`
750
+ : '还没有会话,也还没设过机器人默认模型:先 /model 选一个模型。';
751
+ }
752
+ const { selection, failed } = await readSelection(context, sessionId);
753
+ if (failed) return '读不到当前会话的模型选择(Host 暂时不可用),稍后再试。';
754
+ if (!selection) return '当前会话没有显式选择模型。';
755
+ return `当前模型 ${selection.provider}/${selection.model},推理等级 ${selection.reasoningEffort ?? '(默认)'}。`;
756
+ }
757
+ if (!sessionId) {
758
+ // 没有会话 → 改机器人默认模型的推理等级。
759
+ if (context.isOwner !== true) {
760
+ return '还没有会话:这时改的是机器人默认模型(机器人级设置),只有属主能改。';
761
+ }
762
+ if (!botModel) return '还没有会话,也没设过机器人默认模型:先 /model 选一个模型。';
763
+ const { rows } = await modelCatalog(context);
764
+ const current = rows.find((row) => row.provider === botModel.provider && row.model === botModel.model);
765
+ if (!current) return `机器人默认模型 ${botModel.provider}/${botModel.model} 不在可用列表里。`;
766
+ const wanted = context.args[0] === '--default'
767
+ ? null
768
+ : (indexOf(context.args[0]) !== null
769
+ ? current.efforts[indexOf(context.args[0])]?.id
770
+ : context.args[0]);
771
+ if (wanted && !current.efforts.some((item) => item.id === wanted)) {
772
+ return `找不到推理等级 ${context.args[0]};用 /reasonings 查看可用列表。`;
773
+ }
774
+ await context.services.bots.write(context.channelId, context.botId, {
775
+ model: { ...botModel, reasoningEffort: wanted ?? null },
776
+ });
777
+ return wanted
778
+ ? `机器人默认推理等级已设为 ${wanted}(还没有会话:下一条消息新建的会话用它)。`
779
+ : `机器人默认模型已恢复 ${current.provider}/${current.model} 的默认推理等级`
780
+ + `${current.defaultEffort ? `(${current.defaultEffort})` : ''}(对新会话生效)。`;
781
+ }
782
+ const { selection, failed } = await readSelection(context, sessionId);
783
+ if (failed) return '读不到当前会话的模型选择(Host 暂时不可用),稍后再试。';
784
+ if (!selection) return '当前会话没有显式选择模型,无法单独设置推理等级。';
785
+ const { rows } = await modelCatalog(context);
786
+ const current = rows.find((row) => row.provider === selection.provider && row.model === selection.model);
787
+ if (!current) return '当前模型不在可用列表里。';
788
+ if (context.args[0] === '--default') {
789
+ await context.services.sessions.invoke('session', 'selectModel', {
790
+ request: { sessionId, provider: current.provider, model: current.model },
791
+ });
792
+ return `已恢复 ${current.provider}/${current.model} 的默认推理等级${current.defaultEffort ? `(${current.defaultEffort})` : ''}。`;
793
+ }
794
+ const index = indexOf(context.args[0]);
795
+ const effort = index !== null ? current.efforts[index]?.id : context.args[0];
796
+ if (!effort || !current.efforts.some((item2) => item2.id === effort)) {
797
+ return `找不到推理等级 ${context.args[0]};用 /reasonings 查看可用列表。`;
798
+ }
799
+ await context.services.sessions.invoke('session', 'selectModel', {
800
+ request: {
801
+ sessionId, provider: current.provider, model: current.model, reasoningEffort: effort,
802
+ },
803
+ });
804
+ return `已切换推理等级为 ${effort}。`;
805
+ },
806
+ });
807
+
808
+ registry.register({
809
+ name: 'presets',
810
+ aliases: ['presetlist'],
811
+ summary: '列出当前 Host 可用的 Agent Preset',
812
+ execute: async (context) => {
813
+ const presets = context.services.agentPresets;
814
+ if (!presets?.remoteExportList) return '当前 Host 不支持读取 Agent Preset 列表。';
815
+ const { presets: rows } = await presets.remoteExportList();
816
+ if (!rows || rows.length === 0) return '当前 Host 没有可用 Agent Preset。';
817
+ const record = context.services.bots.read(context.channelId, context.botId);
818
+ return [
819
+ '可用 Agent Preset:',
820
+ ...rows.map((row, index) => line(
821
+ `${index + 1}. ${row.id}${row.isDefault ? '(Host 默认)' : ''}`
822
+ + `${record.agentPreset === row.id ? '(当前机器人)' : ''}`
823
+ + `${row.name && row.name !== row.id ? ` · ${row.name}` : ''}`,
824
+ )),
825
+ '用 /preset <序号或 id> 设置,/preset --default 跟随 Host 默认。',
826
+ ].join('\n');
827
+ },
828
+ });
829
+
830
+ registry.register({
831
+ name: 'preset',
832
+ summary: '查看或设置当前机器人的 Agent Preset(对新会话生效)',
833
+ usage: '/preset [序号或 id|--default]',
834
+ execute: async (context) => {
835
+ const { services } = context;
836
+ const record = services.bots.read(context.channelId, context.botId);
837
+ if (context.args.length === 0) {
838
+ return record.agentPreset
839
+ ? `当前机器人 Agent Preset:${record.agentPreset}`
840
+ : '当前机器人跟随 Host 默认 Agent Preset。';
841
+ }
842
+ if (context.args[0] === '--default') {
843
+ await services.bots.write(context.channelId, context.botId, { agentPreset: null });
844
+ return '已清除机器人级 Agent Preset,之后的新会话跟随 Host 默认。';
845
+ }
846
+ if (!services.agentPresets?.remoteExportList) return '当前 Host 不支持设置 Agent Preset。';
847
+ const { presets: rows } = await services.agentPresets.remoteExportList();
848
+ const index = indexOf(context.args[0]);
849
+ const target = index !== null ? rows[index]?.id : context.args[0];
850
+ if (!target || !rows.some((row) => row.id === target)) {
851
+ return `找不到 Agent Preset ${context.args[0]};用 /presets 查看列表。`;
852
+ }
853
+ await services.bots.write(context.channelId, context.botId, { agentPreset: target });
854
+ return `已设置 Agent Preset 为 ${target};当前聊天需要先发送 /new,再发一条消息才会用新预设创建会话。`;
855
+ },
856
+ });
857
+ }