@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
package/host/panel.mjs ADDED
@@ -0,0 +1,1012 @@
1
+ /**
2
+ * 控制面板(hub 所有,渠道共用):一次读取"这台机器人在这个会话里能改什么、当前是什么",
3
+ * 以及"把某个选择应用下去"。
4
+ *
5
+ * 为什么要有它:IM 里的可交互卡片(飞书的下拉)需要一份**渠道无关**的当前状态与可选项。
6
+ * 平台概念(下拉怎么画、回调怎么收)留在渠道;语义(模型是会话级、工作区只对新会话生效、
7
+ * 路径要校验)在 hub 实现一次,所有渠道复用——这也是"新增渠道不改 hub"的一部分。
8
+ *
9
+ * 分工与约束:
10
+ * - 只读 + 应用两个动作,不做 UI;
11
+ * - 一切失败都抛带 `code` 的 Error(渠道转成用户可见文案,绝不静默);
12
+ * - 模型/推理是**会话级**(`session/selectModel`),工作区/预设/是**机器人级**且只对新会话生效。
13
+ *
14
+ * @module dsh-chat/host/panel
15
+ */
16
+
17
+ import { stat } from 'node:fs/promises';
18
+ import { isAbsolute, resolve as resolvePath } from 'node:path';
19
+
20
+ import {
21
+ defaultAccessPolicy, describeAccessScope, normalizeAccessPolicy, validateAccessPolicy,
22
+ } from '../shared/access-policy.mjs';
23
+ import { normalizeContextConfig, TARGET_LIMIT } from '../shared/context-enhancement.mjs';
24
+ import { sectionsFor } from '../shared/panel-sections.mjs';
25
+ import { botModelForSelection, normalizeBotModel } from './bot-model.mjs';
26
+ import { chatKeyLabel } from './session-keys.mjs';
27
+
28
+ function panelError(code, message) {
29
+ const error = new Error(message);
30
+ error.code = code;
31
+ return error;
32
+ }
33
+
34
+ /**
35
+ * 可切换的工作区候选。
36
+ *
37
+ * 与设置页「工作区」下拉**同一来源**(这台机器人用过的目录 + 当前值),抽在这里避免两份逻辑漂移。
38
+ *
39
+ * @param options - { record, sessionStore, channelId, botId }。
40
+ * @returns 去重后的绝对路径数组。
41
+ */
42
+ export function workspaceCandidates({ record, sessionStore, channelId, botId }) {
43
+ const boundPaths = Object.values(sessionStore?.entries?.(channelId, botId) ?? {})
44
+ .map((entry) => entry?.workspacePath)
45
+ .filter((value) => typeof value === 'string' && value);
46
+ return [...new Set([
47
+ ...(typeof record?.workspace === 'string' && record.workspace ? [record.workspace] : []),
48
+ ...boundPaths,
49
+ ])];
50
+ }
51
+
52
+ /**
53
+ * 读一次模型目录(`session/modelCatalog`)。
54
+ *
55
+ * 模块级实现:**面板与设置页的「默认模型」栏用同一份来源**(两处各写一遍必然漂移)。
56
+ *
57
+ * 真形状:`{ default, routableProviders, groups: [{ id, name, models: [{ id, name, reasoning }] }] }`
58
+ * ——provider 是 `group.id`(不是 `provider`/`providerId`)。
59
+ *
60
+ * @param sessions - 会话桥服务(要能 `invoke('session','modelCatalog')`)。
61
+ * @param logger - 读失败时记一条 warn(不静默)。
62
+ * @returns `{ options, hostDefault, failures }`。
63
+ */
64
+ export async function readModelCatalog(sessions, logger = console) {
65
+ const catalog = await sessions.invoke('session', 'modelCatalog', {});
66
+ const options = [];
67
+ /**
68
+ * 目录里的失败清单(`session/modelCatalog` 会为每个拿不到模型的 provider 给一条
69
+ * `failures: [{ id, name, message }]`)。**必须带出去**:否则前端只能显示
70
+ * "当前 Host 没有可用模型",用户和排查的人都不知道为什么(真机上就是这么卡住的)。
71
+ */
72
+ const failures = (catalog?.failures ?? []).map((item) => ({
73
+ id: item?.id ?? '', name: item?.name ?? item?.id ?? '', message: item?.message ?? '',
74
+ }));
75
+ for (const group of catalog?.groups ?? []) {
76
+ const provider = group.id ?? group.provider ?? group.providerId;
77
+ for (const model of group.models ?? []) {
78
+ const id = model.id ?? model.model;
79
+ if (!provider || !id) continue;
80
+ options.push({
81
+ value: `${provider}/${id}`,
82
+ provider,
83
+ model: id,
84
+ name: model.name ?? id,
85
+ providerName: group.name ?? group.providerName ?? provider,
86
+ efforts: (model.reasoning?.efforts ?? []).map((effort) => ({
87
+ id: effort.id, label: effort.name ?? effort.label ?? effort.id,
88
+ })),
89
+ defaultEffort: model.reasoning?.defaultEffort ?? null,
90
+ });
91
+ }
92
+ }
93
+ // `default` 在 Host 没设默认时是 `{}`(schema 是 `{...currentSelection()}`):补全成 null。
94
+ const rawDefault = catalog?.default;
95
+ const hostDefault = rawDefault?.provider && rawDefault?.model ? rawDefault : null;
96
+ return { options, hostDefault, failures };
97
+ }
98
+
99
+ /**
100
+ * 会话键 → 上下文增强的命中身份。
101
+ *
102
+ * 会话键是 hub 自己的约定(`p2p:<平台用户 id>` / `group:<平台群 id>`),而指定设置
103
+ * (`targets[]`)也是按**平台 id** 命中的:私聊按 senderId 命中 `user` 目标、
104
+ * 群聊按 chatId 命中 `group` 目标。所以"本会话用的是哪一份、要不要单独来一份"
105
+ * 可以直接算出来,不用去猜。
106
+ *
107
+ * 认不出的键返回 null:这时宁可不提供这一项,也不能照着错误的方向去改设置。
108
+ *
109
+ * @param key - 会话键。
110
+ * @returns `{ kind: 'user'|'group', id }` 或 null。
111
+ */
112
+ export function conversationTarget(key) {
113
+ const text = typeof key === 'string' ? key : '';
114
+ const separator = text.indexOf(':');
115
+ if (separator <= 0) return null;
116
+ const head = text.slice(0, separator);
117
+ if (head !== 'p2p' && head !== 'group') return null;
118
+ const id = text.slice(separator + 1).trim();
119
+ if (!id) return null;
120
+ return { kind: head === 'group' ? 'group' : 'user', id };
121
+ }
122
+
123
+ /**
124
+ * 会话类型:优先用调用方给的,认不出就从**会话键**推。
125
+ *
126
+ * 为什么要有这条兜底:会话键(`p2p:` / `group:`)是 hub 自己的约定,而"本会话的访问策略"
127
+ * 这类字段必须知道是私聊还是群聊。调用方(命令内核、渠道)漏传一次,字段就会**静默消失**
128
+ * ——真机上出现过:手打 `/menu` 出的卡里没有「访问策略」,点下拉重画一次又有了。
129
+ * 认不出(键不合约定)仍然返回 null:宁可不给这一项,也不能照错的方向改设置。
130
+ */
131
+ export function conversationTypeOf(key, given = null) {
132
+ if (given === 'direct' || given === 'group') return given;
133
+ const text = typeof key === 'string' ? key : '';
134
+ if (text.startsWith('group:')) return 'group';
135
+ if (text.startsWith('p2p:')) return 'direct';
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * 本会话的上下文增强(面板字段 `context`)。
141
+ *
142
+ * 语义:**本会话用哪一份设置**——
143
+ * - `''` 跟随该会话类型的全局设置(= 没有本会话的指定设置);
144
+ * - `own` 本会话有自己的指定设置(内容在设置页编辑);
145
+ * - `copy:<id>` 套用该机器人另一条**同类型**指定设置的字段与提示词。
146
+ *
147
+ * 只给属主:它写的是机器人级配置(与设置页同一份数据),普通成员不该改。
148
+ *
149
+ * @returns 面板状态,或 null(不该/没法提供这一项)。
150
+ */
151
+ function contextPanelState({ record, key, isOwner }) {
152
+ if (isOwner !== true) return null;
153
+ const target = conversationTarget(key);
154
+ if (!target) return null;
155
+ const config = normalizeContextConfig(record.contextEnhancement);
156
+ const scope = target.kind === 'group' ? config.group : config.direct;
157
+ const kindLabel = target.kind === 'group' ? '群聊' : '私聊';
158
+ const own = config.targets.find((item) => item.kind === target.kind && item.id === target.id) ?? null;
159
+ const options = [
160
+ { value: '', label: `跟随${kindLabel}全局(${scope.enabled === true ? '已启用' : '未启用'})` },
161
+ { value: 'own', label: `本会话专属设置(复制${kindLabel}全局作为起点)` },
162
+ ];
163
+ for (const item of config.targets) {
164
+ if (item.kind !== target.kind || item.id === target.id) continue;
165
+ options.push({ value: `copy:${item.id}`, label: `套用「${item.label?.trim() || item.id}」的字段与提示词` });
166
+ }
167
+ return {
168
+ // 下拉里"当前选中的那一项",与 `panel.apply` 的取值一一对应。
169
+ current: own ? 'own' : '',
170
+ scopeEnabled: scope.enabled === true,
171
+ kind: target.kind,
172
+ /** 平台 id:卡片上写出来,用户才知道这条设置是给谁的(也便于与设置页对账)。 */
173
+ identity: target.id,
174
+ label: target.kind === 'group' ? '本群' : '本私聊',
175
+ own: own
176
+ ? { label: own.label?.trim() || null, fields: own.fields.length, guidanceLength: own.guidance.length }
177
+ : null,
178
+ options,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * 本会话的访问策略模式(面板字段 `policy`)。
184
+ *
185
+ * 语义:**这个会话类型**(私聊/群聊)谁可以跟机器人说话——
186
+ * `allowlist` 只有名单内用户(名单为空时等价于"仅属主");`open` 任何人都可以。
187
+ * 写的是机器人级配置(与设置页同一份 `accessPolicy`),所以只给属主。
188
+ *
189
+ * `open` 是**放宽**权限的改动:`apply` 时没带 `confirm:true` 就先不回写,把确认交给渠道渲染。
190
+ */
191
+ function policyPanelState({ record, conversationType, isOwner }) {
192
+ if (isOwner !== true) return null;
193
+ if (conversationType !== 'direct' && conversationType !== 'group') return null;
194
+ const stored = normalizeAccessPolicy(record.accessPolicy);
195
+ const scope = (stored ?? defaultAccessPolicy())[conversationType];
196
+ const kindLabel = conversationType === 'group' ? '群聊' : '私聊';
197
+ return {
198
+ // `stored === null` = 从没设过(口径是"仅属主可用"):如实显示成未设置,不冒充某种模式。
199
+ current: stored ? scope.mode : null,
200
+ label: describeAccessScope(record.accessPolicy, conversationType),
201
+ conversationType,
202
+ kindLabel,
203
+ allowlistCount: scope.mode === 'allowlist' ? scope.allowlist.users.length : null,
204
+ options: [
205
+ {
206
+ value: 'allowlist',
207
+ label: scope.mode === 'allowlist' && scope.allowlist.users.length > 0
208
+ ? `仅名单内可用(当前 ${scope.allowlist.users.length} 人)`
209
+ : '仅名单内可用(名单为空时只有属主能用)',
210
+ },
211
+ {
212
+ value: 'open',
213
+ label: '任何人可用(群聊里任何成员都能 @ 它)',
214
+ },
215
+ ],
216
+ };
217
+ }
218
+
219
+ /**
220
+ * 校验一个工作区路径:必须是已存在的目录,且存绝对路径。
221
+ *
222
+ * @param raw - 用户/卡片给的值。
223
+ * @returns 绝对路径。
224
+ */
225
+ export async function validateWorkspacePath(raw) {
226
+ if (typeof raw !== 'string' || !raw.trim()) {
227
+ throw panelError('chat/workspace-invalid', '工作区需要是一个绝对路径。');
228
+ }
229
+ const given = raw.trim();
230
+ // 必须绝对路径:相对路径会跟着 dsh 的启动目录变,排查时最难查(设置页那条路也是这个口径)。
231
+ if (!isAbsolute(given)) {
232
+ throw panelError('chat/workspace-invalid', `工作区需要是绝对路径:${given}`);
233
+ }
234
+ const target = resolvePath(given);
235
+ let info;
236
+ try {
237
+ info = await stat(target);
238
+ } catch (error) {
239
+ throw panelError('chat/workspace-invalid',
240
+ `目录不存在或读不到:${target}(${error?.code ?? error?.message})`);
241
+ }
242
+ if (!info.isDirectory()) {
243
+ throw panelError('chat/workspace-invalid', `不是目录:${target}`);
244
+ }
245
+ return target;
246
+ }
247
+
248
+ /**
249
+ * 创建控制面板服务。
250
+ *
251
+ * @param options - { settings, sessions, sessionStore, agentPresets?, logger? }。
252
+ * `settings` = 每机器人设置(workspace / agentPreset);`sessions` = 会话桥(invoke/绑定表);
253
+ * `agentPresets` = 可选服务(某些部署没装)。
254
+ * @returns `{ read, apply }`。
255
+ */
256
+ export function createPanelService({
257
+ settings, sessions, sessionStore = null, agentPresets = null, channelRpc = null, logger = console,
258
+ } = {}) {
259
+ if (typeof settings?.read !== 'function') throw new TypeError('控制面板需要每机器人设置存储。');
260
+ if (typeof sessions?.invoke !== 'function') throw new TypeError('控制面板需要会话桥。');
261
+
262
+ function boundSessionId(channelId, botId, key) {
263
+ return sessions.bindings?.get?.(channelId, botId, key)?.sessionId ?? null;
264
+ }
265
+
266
+ /**
267
+ * 当前会话的模型选择。
268
+ *
269
+ * 真形状是 `projections.values.modelSelection = { lastUsed, next }`(**不是**顶层 provider/model),
270
+ * 其中 `next = pending ?? lastUsed`:
271
+ * - `session/selectModel` 只写 `pending`,要等下一轮 `request/header` 才刷新 `lastUsed`;
272
+ * - 所以**刚在卡片上换完模型时 `next` 才是新值、`lastUsed` 还是旧的**(官方 UI 读的也是 `next`)。
273
+ *
274
+ * 因此必须 `next ?? lastUsed`:读反了会出现两个真机症状——刚选完模型卡片仍显示旧模型;
275
+ * 紧接着改推理等级时用旧的 provider/model 调 selectModel,把用户刚选的模型静默改回去。
276
+ */
277
+ async function currentSelection(sessionId) {
278
+ if (!sessionId) return null;
279
+ // 这里**不吞异常**:吞掉会让 read() 里那句 warn 变成死代码,还会让 apply 把
280
+ // "这次 RPC 失败了"讲成"你从没选过模型"。读失败由调用方按各自语义处理。
281
+ const listed = await sessions.invoke('session', 'list', { _request: {} });
282
+ const item = listed?.items?.find((entry) => entry.sessionId === sessionId);
283
+ const projection = item?.projections?.values?.modelSelection;
284
+ const selection = projection?.next ?? projection?.lastUsed ?? null;
285
+ if (!selection?.provider || !selection?.model) return null;
286
+ return {
287
+ provider: selection.provider,
288
+ model: selection.model,
289
+ reasoningEffort: selection.reasoningEffort ?? null,
290
+ };
291
+ }
292
+
293
+ /** 面板内置字段:其它字段一律交给**渠道自己**处理(如飞书的「任务过程展示」)。 */
294
+ const BUILT_IN_FIELDS = new Set([
295
+ 'model', 'reasoning', 'preset', 'workspace', 'session', 'context', 'policy',
296
+ ]);
297
+
298
+ /**
299
+ * 应用「访问策略模式」这个选择。
300
+ *
301
+ * 放宽到 `open`(任何人可用)时必须先确认一次:没带 `confirm: true` 就**不回写**,
302
+ * 而是回一个 `requiresConfirm` + 说明,由渠道渲染二次确认(飞书是卡片上的一行确认按钮)。
303
+ * 这不是"安全边界"(真正的门禁是 owner-only),而是防误触——一次误选就把机器人对所有人开放。
304
+ */
305
+ async function applyPolicyMode({ channelId, botId, conversationType, value, record, field, confirm }) {
306
+ if (conversationType !== 'direct' && conversationType !== 'group') {
307
+ throw panelError('chat/bad-request', '不知道这是私聊还是群聊,没法改访问策略。');
308
+ }
309
+ if (value !== 'open' && value !== 'allowlist') {
310
+ throw panelError('chat/bad-request', `访问策略只支持 allowlist 或 open(收到 ${String(value)})。`);
311
+ }
312
+ const base = normalizeAccessPolicy(record.accessPolicy) ?? defaultAccessPolicy();
313
+ const kindLabel = conversationType === 'group' ? '群聊' : '私聊';
314
+ const before = base[conversationType].mode;
315
+ if (value === 'open' && confirm !== true) {
316
+ return {
317
+ field,
318
+ value,
319
+ // 渠道据此渲染二次确认(不要当成"已生效")。
320
+ requiresConfirm: true,
321
+ confirmPrompt: `把${kindLabel}改成「任何人可用」后,不在名单里的人也能跟机器人对话`
322
+ + `${conversationType === 'group' ? '(群里任何成员 @ 它就行)' : ''}。确定要放开吗?`,
323
+ message: `需要确认:${kindLabel}将改为任何人可用。`,
324
+ };
325
+ }
326
+ if (before === value) {
327
+ return { field, value, message: `${kindLabel}的访问策略本来就是「${
328
+ value === 'open' ? '任何人可用' : '仅名单内可用'}」,没有改动。` };
329
+ }
330
+ // 只改这一个会话类型的 mode,名单与命令权限照旧(用严格校验产出合法策略)。
331
+ const next = validateAccessPolicy({
332
+ ...base,
333
+ [conversationType]: { ...base[conversationType], mode: value },
334
+ });
335
+ const saved = await settings.write(channelId, botId, { accessPolicy: next });
336
+ return {
337
+ field,
338
+ value,
339
+ message: `${kindLabel}的访问策略已改为「${value === 'open' ? '任何人可用' : '仅名单内可用'}」`
340
+ + `(现在:${describeAccessScope(saved?.accessPolicy ?? next, conversationType)};下一条消息生效)。`,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * 应用「本会话的上下文增强」这个选择(三种取值,语义见 `contextPanelState`)。
346
+ *
347
+ * 三种情况都**不碰其他会话的设置**:`own` 是"复制全局"、`copy:<id>` 是"复制另一条",
348
+ * 都只是新增/替换本会话这一条;`''` 只删本会话这一条。
349
+ */
350
+ async function applyContextScope({ channelId, botId, key, value, record, field }) {
351
+ const target = conversationTarget(key);
352
+ if (!target) {
353
+ throw panelError('chat/bad-request', '认不出这个会话的平台 id,没法给它单独设上下文增强。');
354
+ }
355
+ const config = normalizeContextConfig(record.contextEnhancement);
356
+ const scope = target.kind === 'group' ? config.group : config.direct;
357
+ const kindLabel = target.kind === 'group' ? '本群' : '本私聊';
358
+ const own = config.targets.find((item) => item.kind === target.kind && item.id === target.id) ?? null;
359
+ /** 复制出来的那一条:`label` 留空(备注名由用户在设置页起),`enabled` 明确打开。 */
360
+ const copyOf = (source, extra) => ({
361
+ kind: target.kind,
362
+ id: target.id,
363
+ label: '',
364
+ enabled: true,
365
+ fields: [...source.fields],
366
+ guidance: source.guidance,
367
+ merge: source.merge,
368
+ ...(extra ?? {}),
369
+ });
370
+
371
+ if (value === '' || value === null) {
372
+ if (!own) return { field, value: '', message: `${kindLabel}本来就跟随全局,没有改动。` };
373
+ await settings.write(channelId, botId, {
374
+ contextEnhancement: { ...config, targets: config.targets.filter((item) => item !== own) },
375
+ });
376
+ return { field, value: '', message: `已删除${kindLabel}的专属设置,改为跟随全局(下一条消息生效)。` };
377
+ }
378
+
379
+ if (value === 'own') {
380
+ if (own) return { field, value: 'own', message: `${kindLabel}已经是专属设置,内容请在设置页编辑。` };
381
+ if (config.targets.length >= TARGET_LIMIT) {
382
+ throw panelError('chat/context-target-limit',
383
+ `指定设置最多 ${TARGET_LIMIT} 条,先到设置页删掉几条。`);
384
+ }
385
+ await settings.write(channelId, botId, {
386
+ contextEnhancement: { ...config, targets: [...config.targets, copyOf({ ...scope, merge: 'replace' })] },
387
+ });
388
+ return {
389
+ field,
390
+ value: 'own',
391
+ message: scope.enabled === true
392
+ ? `已为${kindLabel}创建专属设置(内容与全局相同),要改内容请到设置页(下一条消息生效)。`
393
+ : `全局的${target.kind === 'group' ? '群聊' : '私聊'}增强本来是关闭的:已为${kindLabel}单独开启,`
394
+ + '来源字段已带好、提示词为空,请到设置页填写(下一条消息生效)。',
395
+ };
396
+ }
397
+
398
+ if (typeof value !== 'string' || !value.startsWith('copy:')) {
399
+ throw panelError('chat/bad-request', `上下文增强不支持这个取值:${String(value)}`);
400
+ }
401
+ const sourceId = value.slice('copy:'.length);
402
+ const source = config.targets.find((item) => item.kind === target.kind && item.id === sourceId);
403
+ if (!source) throw panelError('chat/unknown-context-target', `找不到这条指定设置:${sourceId}`);
404
+ const copied = copyOf(source);
405
+ const targets = own
406
+ ? config.targets.map((item) => (item === own ? copied : item))
407
+ : [...config.targets, copied];
408
+ if (!own && targets.length > TARGET_LIMIT) {
409
+ throw panelError('chat/context-target-limit', `指定设置最多 ${TARGET_LIMIT} 条,先到设置页删掉几条。`);
410
+ }
411
+ await settings.write(channelId, botId, { contextEnhancement: { ...config, targets } });
412
+ return {
413
+ field,
414
+ value,
415
+ message: `已把「${source.label?.trim() || source.id}」的字段与提示词套用到${kindLabel}`
416
+ + '(复制,不影响原来那条;下一条消息生效)。',
417
+ };
418
+ }
419
+
420
+ /**
421
+ * 渠道自带的面板字段(渠道相关的设置,如飞书的「任务过程展示」)。
422
+ *
423
+ * 渠道实现 `panel.fields` 就多一行下拉,不实现就当没有——**hub 不认识渠道语义**,
424
+ * 所以这里只做形状校验与透传(渠道返回 `{ field, label, value, options }`)。
425
+ */
426
+ async function channelPanelFields({ channelId, botId, key, conversationType }) {
427
+ if (typeof channelRpc !== 'function') return { fields: [], failed: false };
428
+ try {
429
+ const result = await channelRpc(channelId, 'panel.fields', {
430
+ botId, key: key ?? null, conversationType: conversationType ?? null,
431
+ });
432
+ if (result?.ok !== true) throw new Error(result?.error?.message ?? '读取失败');
433
+ const fields = Array.isArray(result.value?.fields) ? result.value.fields : [];
434
+ return {
435
+ fields: fields.filter((item) => typeof item?.field === 'string' && item.field
436
+ && Array.isArray(item.options) && item.options.length > 0),
437
+ failed: false,
438
+ };
439
+ } catch (error) {
440
+ // 渠道没实现这个方法(老版本渠道、或这渠道本来就没有这类设置)不算失败,别刷日志。
441
+ if (error?.code === 'chat/unknown-method' || /不支持/.test(String(error?.message))) {
442
+ return { fields: [], failed: false };
443
+ }
444
+ logger.warn?.(`[dsh-chat] 读取渠道面板字段失败:${error?.message ?? error}`);
445
+ return { fields: [], failed: true };
446
+ }
447
+ }
448
+
449
+ /**
450
+ * 渠道自带的**动作按钮**(如飞书的「重连」)。
451
+ *
452
+ * 与"面板字段"的区别:字段是"选一个值存起来",动作是"点一下做一件事"(重连、清缓存…)。
453
+ * 同样只做形状校验与透传——hub 不认识这些动作的语义,`confirm` 文案也由渠道给
454
+ * (飞书用卡片原生的二次确认弹窗渲染它)。
455
+ */
456
+ async function channelPanelActions({ channelId, botId, key, conversationType, isOwner }) {
457
+ if (typeof channelRpc !== 'function') return { actions: [], failed: false };
458
+ try {
459
+ const result = await channelRpc(channelId, 'panel.actions', {
460
+ botId, key: key ?? null, conversationType: conversationType ?? null, isOwner: isOwner === true,
461
+ });
462
+ if (result?.ok !== true) throw new Error(result?.error?.message ?? '读取失败');
463
+ const actions = Array.isArray(result.value?.actions) ? result.value.actions : [];
464
+ return { actions: actions.map(normalizeAction).filter(Boolean), failed: false };
465
+ } catch (error) {
466
+ // 渠道没实现这个方法(老版本渠道、或本来就没有动作)不算失败,别刷日志。
467
+ if (error?.code === 'chat/unknown-method' || /不支持/.test(String(error?.message))) {
468
+ return { actions: [], failed: false };
469
+ }
470
+ logger.warn?.(`[dsh-chat] 读取渠道面板动作失败:${error?.message ?? error}`);
471
+ return { actions: [], failed: true };
472
+ }
473
+ }
474
+
475
+ /**
476
+ * 渠道动作的形状归一化:认不出来就丢掉(宁可不画,也不画一个点了没反应的按钮)。
477
+ *
478
+ * `deferred` 是渠道对**卡片交互**的声明:"这个动作慢,或会断开当前这条长连接"——
479
+ * 渠道要**先把回调应答发出去、再执行**,否则飞书会判定"目标回调服务超时未响应"
480
+ * (真机上点「重连」就是这样:动作本身掐掉了正在送回执的那条长连接)。
481
+ */
482
+ function normalizeAction(input) {
483
+ const action = typeof input?.action === 'string' ? input.action.trim() : '';
484
+ const label = typeof input?.label === 'string' ? input.label.trim() : '';
485
+ if (!action || !label) return null;
486
+ const type = ['default', 'primary', 'danger'].includes(input?.type) ? input.type : 'default';
487
+ const title = typeof input?.confirm?.title === 'string' ? input.confirm.title.trim() : '';
488
+ const text = typeof input?.confirm?.text === 'string' ? input.confirm.text.trim() : '';
489
+ return {
490
+ action,
491
+ label: label.slice(0, 40),
492
+ type,
493
+ // `confirm` 有值 = 点之前先让用户确认一次(危险/影响连接的动作)。
494
+ confirm: title && text ? { title: title.slice(0, 40), text: text.slice(0, 200) } : null,
495
+ deferred: input.deferred === true,
496
+ };
497
+ }
498
+
499
+ /** 改渠道自带的面板字段:透传给渠道落盘,失败照旧抛可见错误。 */
500
+ async function applyChannelField({ channelId, botId, key, conversationType, field, value }) {
501
+ if (typeof channelRpc !== 'function') {
502
+ throw panelError('chat/unknown-field', `面板不支持这个操作:${field}`);
503
+ }
504
+ const result = await channelRpc(channelId, 'panel.apply', {
505
+ botId, key: key ?? null, conversationType: conversationType ?? null, field, value,
506
+ });
507
+ if (result?.ok !== true) {
508
+ throw panelError(
509
+ result?.error?.code ?? 'chat/channel-field-failed',
510
+ result?.error?.message ?? `渠道没能改 ${field}。`,
511
+ );
512
+ }
513
+ return {
514
+ field,
515
+ value: result.value?.value ?? value,
516
+ message: result.value?.message ?? '已生效。',
517
+ };
518
+ }
519
+
520
+ /** 相对时间:会话列表里"多久没动过"比绝对时间戳更好用。 */
521
+ /** 会话标题(只用于错误提示;读不到就返回 null,不编)。 */
522
+ async function sessionLabel(sessionId) {
523
+ const listed = await sessions.invoke('session', 'list', { _request: {} });
524
+ const item = (listed?.items ?? []).find((entry) => entry?.sessionId === sessionId);
525
+ const title = item?.projections?.values?.title;
526
+ return typeof title === 'string' && title.trim() ? title.trim() : null;
527
+ }
528
+
529
+ function sinceLabel(updatedAt) {
530
+ if (!Number.isFinite(updatedAt)) return null;
531
+ const minutes = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
532
+ if (minutes < 1) return '刚刚';
533
+ if (minutes < 60) return `${minutes} 分钟前`;
534
+ const hours = Math.round(minutes / 60);
535
+ if (hours < 24) return `${hours} 小时前`;
536
+ return `${Math.round(hours / 24)} 天前`;
537
+ }
538
+
539
+ /**
540
+ * 这个聊天可以切过去的会话(面板上的「会话」下拉)。
541
+ *
542
+ * 候选 = **同一个工作目录**的会话(跨项目的会话切过来上下文对不上)∪ 当前会话。
543
+ * 排除:子代理会话、从没用过的空会话、以及**已经被这台机器人其它聊天绑定的会话**。
544
+ *
545
+ * 为什么排除"其它聊天用过的会话":会话级增强提示词是**按会话**注入系统提示词的(`systemPrompt`
546
+ * 一个会话只有一个槽位),两个聊天共用一个会话时提示词会互相覆盖——谁最后发消息谁说了算,
547
+ * 用户在 A 群看到的提示词可能已经被 B 群顶掉。所以不提供这种切换(`apply` 里同样拒绝)。
548
+ * 当前会话一定在列表里——否则下拉会显示成"没选"。
549
+ */
550
+ async function sessionOptions({ channelId, botId, key, currentSessionId, workspace, limit = 25 }) {
551
+ let items = [];
552
+ try {
553
+ const listed = await sessions.invoke('session', 'list', { _request: {} });
554
+ items = Array.isArray(listed?.items) ? listed.items : [];
555
+ } catch (error) {
556
+ logger.warn?.(`[dsh-chat] 读取会话列表失败:${error?.message ?? error}`);
557
+ // 读失败也要把"当前绑的是哪个会话"带出去:否则下拉看起来像"没绑定",又是一句谎报。
558
+ return {
559
+ options: currentSessionId
560
+ ? [{ id: currentSessionId, label: String(currentSessionId).slice(0, 12) }]
561
+ : [],
562
+ failed: true,
563
+ };
564
+ }
565
+ /** 这台机器人**其它聊天**绑定过的会话:不列为候选(会话不能被两个聊天共用)。 */
566
+ const usedByOther = new Set();
567
+ for (const [boundKey, entry] of Object.entries(sessionStore?.entries?.(channelId, botId) ?? {})) {
568
+ if (entry?.sessionId && boundKey !== key) usedByOther.add(entry.sessionId);
569
+ }
570
+ const wanted = typeof workspace === 'string' && workspace.trim() ? workspace.trim() : null;
571
+ const usable = items.filter((item) => item?.sessionId
572
+ && item.origin !== 'subagent'
573
+ && item.blank !== true
574
+ && !usedByOther.has(item.sessionId)
575
+ && (item.sessionId === currentSessionId
576
+ || (wanted && item.cwd === wanted)));
577
+ const ordered = usable.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
578
+ const picked = ordered.slice(0, Math.max(1, limit));
579
+ // 当前会话必须带上(它可能排在很后面,甚至是上面那些条件之外的会话)。
580
+ if (currentSessionId && !picked.some((item) => item.sessionId === currentSessionId)) {
581
+ const current = items.find((item) => item.sessionId === currentSessionId);
582
+ picked.unshift(current ?? { sessionId: currentSessionId });
583
+ }
584
+ return {
585
+ /** 被扣下的会话数:正被这台机器人**其它聊天**占着,不能切(限制要说出来,不能只是不显示)。 */
586
+ withheld: items.filter((item) => item?.sessionId
587
+ && item.sessionId !== currentSessionId
588
+ && usedByOther.has(item.sessionId)).length,
589
+ options: picked.map((item) => {
590
+ const title = typeof item.projections?.values?.title === 'string' && item.projections.values.title.trim()
591
+ ? item.projections.values.title.trim()
592
+ : null;
593
+ const since = sinceLabel(item.updatedAt);
594
+ return {
595
+ id: item.sessionId,
596
+ label: [title ?? item.sessionId.slice(0, 12), since].filter(Boolean).join(' · '),
597
+ };
598
+ }),
599
+ failed: false,
600
+ };
601
+ }
602
+
603
+ /**
604
+ * 模型目录(模块级实现,见文件底部 `readModelCatalog`)。
605
+ *
606
+ * 真形状(`session/modelCatalog` 的 schema):`{ default, routableProviders, groups:
607
+ * [{ id, name, models: [{ id, name, reasoning?: { efforts: [{ id, name }], defaultEffort } }] }] }`
608
+ * ——**provider 是 `group.id`**(不是 `provider`/`providerId`;照那些字段读会一个选项都拼不出来,
609
+ * 真机上就是"当前 Host 没有可用模型")。
610
+ */
611
+ async function modelCatalog() {
612
+ return readModelCatalog(sessions, logger);
613
+ }
614
+
615
+ /**
616
+ * Agent Preset 列表。
617
+ *
618
+ * `failed` 必须与"列表为空"分开:读失败时若也返回空列表,`apply` 的校验会落进
619
+ * 「列表为空 → 不校验」这条放行路径,把任意 id 写进设置(fail-open),此后每次建会话
620
+ * 都只能静默退回 Host 默认。设置页那条同字段的写入路径遇到同样情形是 fail-closed。
621
+ */
622
+ async function presetOptions() {
623
+ if (typeof agentPresets?.remoteExportList !== 'function') return { options: [], failed: false };
624
+ try {
625
+ const rows = (await agentPresets.remoteExportList())?.presets ?? [];
626
+ return {
627
+ options: rows.map((row) => ({
628
+ id: row.id, label: row.name && row.name !== row.id ? `${row.id} · ${row.name}` : row.id,
629
+ isDefault: row.isDefault === true,
630
+ })),
631
+ failed: false,
632
+ };
633
+ } catch (error) {
634
+ logger.warn?.(`[dsh-chat] 读取 Agent Preset 列表失败:${error?.message ?? error}`);
635
+ return { options: [], failed: true };
636
+ }
637
+ }
638
+
639
+ return Object.freeze({
640
+ /**
641
+ * 读一次面板状态。
642
+ *
643
+ * @param options - { channelId, botId, key, isOwner }。
644
+ * `isOwner` 决定要不要把工作区**候选清单**给出去:它来自这台机器人的所有会话绑定
645
+ * (含属主其它会话/私聊的绝对路径),而群聊卡片是一条**群里所有人**都能展开的消息——
646
+ * 所以**群会话一律不给**(`key` 的 `group:` 前缀是 hub 自己的约定),
647
+ * 属主在私聊里或设置页改工作区。
648
+ * @returns 面板状态(只含叶子字段,可安全跨 RPC/序列化)。
649
+ */
650
+ async read({ channelId, botId, key, isOwner = false, conversationType = null }) {
651
+ await settings.ready?.();
652
+ // 会话类型以会话键为准(调用方漏传时不能把依赖它的字段丢掉)。
653
+ const scope = conversationTypeOf(key, conversationType);
654
+ const record = settings.read(channelId, botId) ?? {};
655
+ const sessionId = boundSessionId(channelId, botId, key);
656
+ const [
657
+ catalog, presetState, selectionState, sessionState, channelFieldState, channelActionState,
658
+ ] = await Promise.all([
659
+ modelCatalog().catch((error) => {
660
+ logger.warn?.(`[dsh-chat] 读取模型列表失败:${error?.message ?? error}`);
661
+ // 整目录读失败:给这条失败一个显示名,卡片上才不会印出「· :<原因>」这种无名行。
662
+ return { options: [], hostDefault: null, failures: [{ id: '', name: '模型目录', message: String(error?.message ?? error) }] };
663
+ }),
664
+ presetOptions(),
665
+ currentSelection(sessionId).then((selection) => ({ selection, failed: false })).catch((error) => {
666
+ // 不能静默:退化成"跟随 Host 默认"看起来像用户从没选过模型。除日志外还要带出
667
+ // `selectionFailed` —— 卡片据此如实说"读不到",而不是断言一个与事实相反的状态。
668
+ logger.warn?.(`[dsh-chat] 读取会话模型选择失败:${error?.message ?? error}`);
669
+ return { selection: null, failed: true };
670
+ }),
671
+ sessionOptions({
672
+ channelId, botId, key, currentSessionId: sessionId, workspace: record.workspace,
673
+ }),
674
+ channelPanelFields({ channelId, botId, key, conversationType: scope }),
675
+ channelPanelActions({ channelId, botId, key, conversationType: scope, isOwner }),
676
+ ]);
677
+ const options = catalog.options;
678
+ const selection = selectionState.selection;
679
+ /** 机器人默认模型:没有会话时选模型就写它,下一条消息新建的会话应用(见 bot-model.mjs)。 */
680
+ const botDefault = normalizeBotModel(record.model);
681
+ /**
682
+ * 卡片该显示"当前用的是哪个模型":有会话就看**会话选择**(会话没显式选过 = 跟随 Host 默认,
683
+ * 机器人默认模型只影响新建的会话);没有会话才看机器人默认。
684
+ * **读会话失败时不能退回默认**——那会把"读不到"显示成一个具体的模型(谎报)。
685
+ */
686
+ const effective = selectionState.failed
687
+ ? null
688
+ : (selection ?? (sessionId ? null : botDefault));
689
+ const effectiveModel = effective
690
+ ? options.find((item) => item.provider === effective.provider && item.model === effective.model) ?? null
691
+ : null;
692
+ return {
693
+ sessionId,
694
+ bound: typeof sessionId === 'string' && sessionId.length > 0,
695
+ model: {
696
+ current: selection,
697
+ // `true` = 这次读**失败**了(不是"没选过"):卡片必须如实说读不到。
698
+ selectionFailed: selectionState.failed === true,
699
+ // 机器人默认模型(没有会话时选的那个):卡片在未绑定时显示它并允许改。
700
+ botDefault,
701
+ // Host 默认模型:卡片在"跟随 Host 默认"时把具体是哪个模型写出来,用户才知道会用什么。
702
+ hostDefault: catalog.hostDefault,
703
+ failures: catalog.failures ?? [],
704
+ options,
705
+ // 推理等级取决于"当前生效的那个模型":会话内的选择,或(没有会话时)机器人默认。
706
+ efforts: effectiveModel?.efforts ?? [],
707
+ currentEffort: effective?.reasoningEffort ?? null,
708
+ },
709
+ /**
710
+ * 本会话的上下文增强(用哪一份设置):只给属主,且只在认得出会话键时给。
711
+ * 内容(来源字段 / 提示词)在设置页编辑,卡片只决定"本会话用哪一份"。
712
+ */
713
+ context: contextPanelState({ record, key, isOwner }),
714
+ // 渠道自带的面板字段(飞书:任务过程展示)。渠道没实现就是空数组。
715
+ fields: channelFieldState.fields,
716
+ fieldsFailed: channelFieldState.failed === true,
717
+ /**
718
+ * 本会话类型该显示哪些项(设置页里配的,私聊/群聊分开)。
719
+ *
720
+ * 渠道按它决定画不画某一块;hub 仍然把数据都读出来(少一次"字段被谁吞了"的排查)。
721
+ */
722
+ sections: sectionsFor(record, scope),
723
+ /** 本会话类型的访问策略(只给属主,且要知道是私聊还是群聊)。 */
724
+ policy: policyPanelState({ record, conversationType: scope, isOwner }),
725
+ // 渠道自带的动作按钮(飞书:重连)。渠道没实现就是空数组。
726
+ actions: channelActionState.actions,
727
+ actionsFailed: channelActionState.failed === true,
728
+ // 「会话」下拉:当前聊天绑定到哪个会话、可以切到哪些。
729
+ session: {
730
+ current: sessionId,
731
+ options: sessionState.options,
732
+ withheld: sessionState.withheld ?? 0,
733
+ failed: sessionState.failed === true,
734
+ },
735
+ preset: {
736
+ current: record.agentPreset ?? null,
737
+ options: presetState.options,
738
+ // 读不到列表时卡片要如实说明(否则用户看到的是"一个预设都没有",与事实相反)。
739
+ failed: presetState.failed === true,
740
+ },
741
+ workspace: {
742
+ current: record.workspace ?? null,
743
+ /**
744
+ * 候选清单里有属主其它会话的绝对路径:**只给属主,且只在私聊**。
745
+ *
746
+ * 只判 isOwner 不够:属主在群里发 `/menu` 时 `isOwner` 为真,可卡是发到群里的,
747
+ * 任何群成员展开下拉都能读到这些路径。拿不准的渠道(键不是 `group:` 前缀)按私聊算,
748
+ * 但那时 `isOwner` 必须为真。
749
+ */
750
+ options: isOwner === true && !String(key ?? '').startsWith('group:')
751
+ ? workspaceCandidates({ record, sessionStore, channelId, botId })
752
+ : [],
753
+ },
754
+ };
755
+ },
756
+
757
+ /**
758
+ * 执行一个**渠道动作**(面板上的按钮,如飞书的「重连」)。
759
+ *
760
+ * 与 `apply` 分开:动作没有"值",而且大多是机器人级操作(重连会断掉当前长连接)——
761
+ * 渠道自己按 `isOwner` 判定能不能点,hub 只负责透传与把错误抛成可见的 code。
762
+ *
763
+ * @param options - { channelId, botId, key, action, isOwner, conversationType }。
764
+ * @returns `{ action, message }`。
765
+ */
766
+ async act({ channelId, botId, key, action, isOwner = false, conversationType = null }) {
767
+ if (typeof action !== 'string' || !action.trim()) {
768
+ throw panelError('chat/bad-request', 'act 需要 action。');
769
+ }
770
+ if (typeof channelRpc !== 'function') {
771
+ throw panelError('chat/unknown-action', `这个部署不支持渠道动作:${action}`);
772
+ }
773
+ const result = await channelRpc(channelId, 'panel.act', {
774
+ botId, key: key ?? null, conversationType: conversationType ?? null,
775
+ action: action.trim(), isOwner: isOwner === true,
776
+ });
777
+ if (result?.ok !== true) {
778
+ throw panelError(result?.error?.code ?? 'chat/action-failed',
779
+ result?.error?.message ?? `动作「${action}」没执行成功。`);
780
+ }
781
+ return { action: action.trim(), message: result.value?.message ?? '已执行。' };
782
+ },
783
+
784
+ /**
785
+ * 应用一个选择。
786
+ *
787
+ * @param options - { channelId, botId, key, field, value, isOwner }。
788
+ * field ∈ model | reasoning | preset | workspace | session。
789
+ * `isOwner` 由渠道判定后传入:**机器人级**字段(preset / workspace)只限属主——
790
+ * 它们改的是整台机器人的设置,且工作区候选来自这台机器人的**所有**会话
791
+ * (含属主其他会话的绝对路径)。命令门禁放行的普通成员不该能改。
792
+ * @returns `{ field, value, message }`:`message` 是给用户看的结果说明。
793
+ */
794
+ async apply({
795
+ channelId, botId, key, field, value, isOwner = false, conversationType = null, confirm = false,
796
+ }) {
797
+ await settings.ready?.();
798
+ const record = settings.read(channelId, botId) ?? {};
799
+ const sessionId = boundSessionId(channelId, botId, key);
800
+
801
+ /**
802
+ * 模型与推理:有会话时改**会话**(立即生效);没有会话时改**机器人默认模型**
803
+ * (只对下一条消息新建的会话生效)——DSH 不允许给"还不存在的会话"选模型
804
+ * (`session/create` 没有模型参数),所以未绑定时的落点就是机器人设置。
805
+ */
806
+ const botDefault = normalizeBotModel(record.model);
807
+ /**
808
+ * 本会话的上下文增强:改的是**机器人级配置**(`record.contextEnhancement`),
809
+ * 与设置页同一份数据——所以只限属主,和预设/工作区同一条口径。
810
+ */
811
+ if (field === 'context') {
812
+ if (isOwner !== true) {
813
+ throw panelError('chat/owner-only', '上下文增强是机器人级设置,只有属主能改。');
814
+ }
815
+ return applyContextScope({
816
+ channelId, botId, key, value, record, field,
817
+ });
818
+ }
819
+
820
+ /**
821
+ * 访问策略:改的是**机器人级配置**(`record.accessPolicy`),与设置页同一份数据,
822
+ * 所以只限属主;放宽到 open 还要再确认一次(见 `applyPolicyMode`)。
823
+ */
824
+ if (field === 'policy') {
825
+ if (isOwner !== true) {
826
+ throw panelError('chat/owner-only', '访问策略是机器人级设置,只有属主能改。');
827
+ }
828
+ return applyPolicyMode({
829
+ channelId, botId, conversationType: conversationTypeOf(key, conversationType),
830
+ value, record, field, confirm,
831
+ });
832
+ }
833
+
834
+ // 渠道自带字段:交给渠道自己落盘(hub 不认识过程展示之类的语义)。
835
+ if (!BUILT_IN_FIELDS.has(field)) {
836
+ return applyChannelField({
837
+ channelId, botId, key, conversationType, field, value,
838
+ });
839
+ }
840
+
841
+ if (field === 'model' || field === 'reasoning') {
842
+ if (!sessionId) {
843
+ // 未绑定 = 改机器人级设置:与预设/工作区同一条口径,只限属主。
844
+ if (isOwner !== true) {
845
+ throw panelError('chat/owner-only',
846
+ '还没有会话:这里改的是机器人默认模型(机器人级设置),只有属主能改。');
847
+ }
848
+ const { options } = await modelCatalog();
849
+ if (field === 'model') {
850
+ const target = options.find((item) => item.value === value);
851
+ if (!target) throw panelError('chat/unknown-model', `找不到模型 ${value}。`);
852
+ const next = botModelForSelection(botDefault, { provider: target.provider, model: target.model });
853
+ await settings.write(channelId, botId, { model: next });
854
+ return {
855
+ field,
856
+ value: target.value,
857
+ message: `机器人默认模型已设为 ${next.provider}/${next.model}`
858
+ + `${next.reasoningEffort ? ` · 推理 ${next.reasoningEffort}` : ''}`
859
+ + '(还没有会话:下一条消息新建的会话用它)。',
860
+ };
861
+ }
862
+ if (!botDefault) {
863
+ throw panelError('chat/no-model', '还没有选过模型:先选一个机器人默认模型,再改推理等级。');
864
+ }
865
+ const currentModel = options.find((item) => item.provider === botDefault.provider
866
+ && item.model === botDefault.model);
867
+ if (!currentModel) {
868
+ throw panelError('chat/unknown-model',
869
+ `机器人默认模型 ${botDefault.provider}/${botDefault.model} 不在可用列表里。`);
870
+ }
871
+ const wanted = String(value ?? '');
872
+ if (wanted !== '' && !currentModel.efforts.some((effort) => effort.id === wanted)) {
873
+ throw panelError('chat/unknown-effort',
874
+ `模型 ${currentModel.value} 不支持推理等级 ${wanted}。`);
875
+ }
876
+ const next = { ...botDefault, reasoningEffort: wanted || null };
877
+ await settings.write(channelId, botId, { model: next });
878
+ return {
879
+ field,
880
+ value: wanted,
881
+ message: wanted
882
+ ? `机器人默认推理等级已设为 ${wanted}(下一条消息新建的会话用它)。`
883
+ : '机器人默认推理等级已恢复模型默认(下一条消息新建的会话用它)。',
884
+ };
885
+ }
886
+ const { options } = await modelCatalog();
887
+ if (field === 'model') {
888
+ const target = options.find((item) => item.value === value);
889
+ if (!target) throw panelError('chat/unknown-model', `找不到模型 ${value}。`);
890
+ const selected = await sessions.invoke('session', 'selectModel', {
891
+ request: { sessionId, provider: target.provider, model: target.model },
892
+ });
893
+ const now = selected?.selected ?? {};
894
+ return {
895
+ field,
896
+ value: target.value,
897
+ message: `已切换模型为 ${now.provider ?? target.provider}/${now.model ?? target.model}。`,
898
+ };
899
+ }
900
+ // reasoning:必须已经显式选过模型,否则"推理等级"没有落点。
901
+ const selection = await currentSelection(sessionId).catch((error) => {
902
+ // 读不到 ≠ 没选过:混为一谈会把一次 RPC 失败说成"你从没选过模型"。
903
+ throw panelError('chat/model-selection-unavailable',
904
+ `读不到当前会话的模型选择:${error?.message ?? error}`);
905
+ });
906
+ if (!selection) {
907
+ throw panelError('chat/no-model', '当前会话还没有显式选择模型,先选一个模型再改推理等级。');
908
+ }
909
+ const currentModel = options.find((item) => item.provider === selection.provider
910
+ && item.model === selection.model);
911
+ if (!currentModel) throw panelError('chat/unknown-model', `当前模型 ${selection.provider}/${selection.model} 不在可用列表里。`);
912
+ const wanted = String(value ?? '');
913
+ if (wanted !== '' && !currentModel.efforts.some((effort) => effort.id === wanted)) {
914
+ throw panelError('chat/unknown-effort',
915
+ `模型 ${currentModel.value} 不支持推理等级 ${wanted}。`);
916
+ }
917
+ const selected = await sessions.invoke('session', 'selectModel', {
918
+ request: {
919
+ sessionId,
920
+ provider: selection.provider,
921
+ model: selection.model,
922
+ ...(wanted ? { reasoningEffort: wanted } : {}),
923
+ },
924
+ });
925
+ const now = selected?.selected ?? {};
926
+ return {
927
+ field,
928
+ value: wanted,
929
+ message: wanted
930
+ ? `推理等级已设为 ${now.reasoningEffort ?? wanted}。`
931
+ : '推理等级已恢复模型默认。',
932
+ };
933
+ }
934
+
935
+ /**
936
+ * 机器人级字段只限属主。
937
+ *
938
+ * 与设置页同一条口径(`bot.agent-preset.set` / 工作区那条都是属主专属),
939
+ * 否则在 open + 可执行命令的策略下,任何能聊天的成员都能把工作区改到
940
+ * 属主其它项目的目录里——改完 `/new` 再发一条消息,agent 就在那里起会话。
941
+ */
942
+ if ((field === 'preset' || field === 'workspace') && isOwner !== true) {
943
+ throw panelError('chat/owner-only', '工作区与 Agent 预设是机器人级设置,只有属主能改。');
944
+ }
945
+
946
+ if (field === 'preset') {
947
+ const target = typeof value === 'string' && value.trim() ? value.trim() : null;
948
+ if (target) {
949
+ const { options: presets, failed } = await presetOptions();
950
+ // 读失败 = 无法对账,不能当成"没有预设"放行(设置页那条路是 fail-closed)。
951
+ if (failed) throw panelError('chat/preset-unavailable', '读不到 Agent Preset 列表,请稍后再试。');
952
+ if (presets.length > 0 && !presets.some((item) => item.id === target)) {
953
+ throw panelError('chat/unknown-preset', `当前 Host 没有这个 Agent Preset:${target}`);
954
+ }
955
+ }
956
+ await settings.write(channelId, botId, { agentPreset: target });
957
+ return {
958
+ field,
959
+ value: target,
960
+ message: target
961
+ ? `Agent Preset 已设为 ${target}(只对新会话生效:先发 /new 再发消息)。`
962
+ : 'Agent Preset 已改为跟随 Host 默认(只对新会话生效)。',
963
+ };
964
+ }
965
+
966
+ if (field === 'workspace') {
967
+ const target = await validateWorkspacePath(value);
968
+ await settings.write(channelId, botId, { workspace: target });
969
+ return {
970
+ field,
971
+ value: target,
972
+ message: `工作区已设为 ${target}(只对新会话生效:先发 /new 再发消息)。`,
973
+ };
974
+ }
975
+
976
+ if (field === 'session') {
977
+ // 空串也当"新会话":下拉里的哨兵值翻译回来就是空串(面板的语义是"清掉绑定")。
978
+ if (value === 'new' || value === '' || value === null || value === undefined) {
979
+ await sessions.reset({ channelId, botId, key });
980
+ return { field, value: 'new', message: '已解除当前会话绑定,下一条消息将开启新会话。' };
981
+ }
982
+ const target = String(value);
983
+ // `sessionExists` 只把 not-found 折成 false,其余是真失败(DSH 侧不可用/超时):
984
+ // 压成 false 会让用户拿到"找不到会话",而真正的原因卡片和日志里都没有。
985
+ const exists = await sessions.sessionExists(target).catch((error) => {
986
+ throw panelError('chat/session-check-failed', `校验会话失败:${error?.message ?? error}`);
987
+ });
988
+ if (!exists) throw panelError('chat/unknown-session', `找不到会话 ${target}。`);
989
+ /**
990
+ * 会话不能被两个聊天共用:会话级增强提示词只有一个槽位(`systemPrompt` 段按会话求值),
991
+ * 共用会让两边的提示词互相覆盖。这里**再判一次**——下拉里已经不列别人的会话,
992
+ * 但别的地方(手打命令、旧按钮)也可能带着一个会话 id 过来。
993
+ */
994
+ const owner = Object.entries(sessionStore?.entries?.(channelId, botId) ?? {})
995
+ .find(([boundKey, entry]) => boundKey !== key && entry?.sessionId === target);
996
+ if (owner) {
997
+ const label = await sessionLabel(target).catch(() => null);
998
+ throw panelError(
999
+ 'chat/session-in-use',
1000
+ `会话${label ? `「${label}」` : ` ${String(target).slice(0, 12)}`}已经被另一个聊天`
1001
+ + `(${chatKeyLabel(owner[0])})绑定:会话不能共用(每个会话只装一份会话级提示词)。`
1002
+ + '在那个聊天里点「新会话」解绑,或换一个会话。',
1003
+ );
1004
+ }
1005
+ await sessions.bindings.bind(channelId, botId, key, { sessionId: target });
1006
+ return { field, value: target, message: `已切换到会话 ${target}。` };
1007
+ }
1008
+
1009
+ throw panelError('chat/unknown-field', `面板不支持这个操作:${field}`);
1010
+ },
1011
+ });
1012
+ }