@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,873 @@
1
+ /**
2
+ * dsh-chat Hub:host 侧插件入口。
3
+ *
4
+ * 职责边界(见 CONTRACT.md):
5
+ * - hub 提供**渠道无关**的一切:渠道注册表、每机器人共享设置、上下文增强引擎、
6
+ * 提示词登记、会话桥、RPC 载体、控制端点;
7
+ * - 渠道包提供**平台相关**的一切:协议客户端、凭据、机器人/会话状态、
8
+ * 渠道专属设置(如飞书的任务过程展示)与自己的设置页。
9
+ *
10
+ * @module dsh-chat/host/plugin
11
+ */
12
+
13
+ import { stat } from 'node:fs/promises';
14
+ import { join, resolve } from 'node:path';
15
+
16
+ import {
17
+ CONTRACT_VERSION, CONTROL_CHANNEL_ID, HOST_SERVICE, HUB_VERSION,
18
+ } from '../shared/contract.mjs';
19
+ import * as accessPolicy from '../shared/access-policy.mjs';
20
+ import * as contextEnhancement from '../shared/context-enhancement.mjs';
21
+ import { enhanceReplyReference as enhanceReplyReferenceFn } from '../shared/reply-reference.mjs';
22
+ import { createBotSettingsStore } from './bot-settings.mjs';
23
+ import { createChannelRegistry } from './channel-registry.mjs';
24
+ import { createCommandRegistry, registerBuiltinCommands } from './commands.mjs';
25
+ import { createDeferredDelivery } from './deferred.mjs';
26
+ import { createDeliveryService } from './delivery.mjs';
27
+ import { channelLogPath, createLogFileSink, withFileSink } from './file-log.mjs';
28
+ import { readLogTail } from './log-tail.mjs';
29
+ import { normalizeBotModel } from './bot-model.mjs';
30
+ import { createPanelService, readModelCatalog, workspaceCandidates } from './panel.mjs';
31
+ import { createGuidanceRegistry } from './guidance.mjs';
32
+ import { installSourceGuidanceSection } from './prompt-context.mjs';
33
+ import { createInteractionService } from './interactions.mjs';
34
+ import { createJsonStore } from './json-store.mjs';
35
+ import { channelDataDir, hubDataDir, integrationRoot } from './paths.mjs';
36
+ import { createRpcCarrier, fail, failFrom, ok } from './rpc.mjs';
37
+ import { createSessionStore } from './session-store.mjs';
38
+ import { createSessionBridge } from './sessions.mjs';
39
+ import { registerChatTools } from './tools.mjs';
40
+
41
+ export const name = 'dsh-chat-host';
42
+
43
+ /** hub 需要 DSH 的连接载体、凭据服务与会话网关(modern 路径)。 */
44
+ export const inject = ['connection', 'credentials', 'typertGateway'];
45
+
46
+ const CHANNEL_ID = /^[a-z][a-z0-9-]{1,31}$/;
47
+ const BOT_ID = /^[A-Za-z0-9_@.:+-]{1,256}$/;
48
+
49
+ /**
50
+ * 显式的渠道数据目录覆盖(`config.channelDataDirs`)。
51
+ *
52
+ * @param config - 插件配置。
53
+ * @param channelId - 渠道 id。
54
+ * @returns 绝对路径,或 null。
55
+ */
56
+ function channelDataDirOverride(config, channelId) {
57
+ const value = config?.channelDataDirs?.[channelId];
58
+ return typeof value === 'string' && value.trim() ? resolve(value.trim()) : null;
59
+ }
60
+
61
+ function resolveLogger(ctx, scope) {
62
+ const logger = ctx?.logger;
63
+ if (typeof logger === 'function') {
64
+ try {
65
+ return logger(scope);
66
+ } catch {
67
+ // 回落 console。
68
+ }
69
+ }
70
+ return logger ?? console;
71
+ }
72
+
73
+ function provideService(ctx, serviceName, value) {
74
+ if (typeof ctx?.provide === 'function') return ctx.provide(serviceName, value);
75
+ if (typeof ctx?.reflect?.provide === 'function') return ctx.reflect.provide(serviceName, value);
76
+ throw new TypeError('dsh-chat 需要 Cordis 的 provide 能力来发布 dshChat 服务。');
77
+ }
78
+
79
+ function isPlainRecord(value) {
80
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
81
+ }
82
+
83
+ /**
84
+ * 校验"某机器人"类载荷:必须**恰好**是 channelId、botId 加上 extra 列出的键。
85
+ *
86
+ * 用"恰好"而不是"至少"是为了让多传/少传的客户端立刻收到 bad-request,而不是被默默忽略。
87
+ *
88
+ * @param payload - 待校验载荷。
89
+ * @param options - { withConfig } 或 { extra: [...] }。
90
+ * @returns 是否合法。
91
+ */
92
+ function validBotPayload(payload, options = {}) {
93
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
94
+ const allowed = options.withConfig
95
+ ? ['channelId', 'botId', 'config']
96
+ : ['channelId', 'botId', ...(options.extra ?? [])];
97
+ const keys = Object.keys(payload);
98
+ // 可选字段(如 sendFile 的 name)允许缺席,但绝不允许出现没声明的键。
99
+ if (keys.length < allowed.length - (options.optional?.length ?? 0) || keys.length > allowed.length) {
100
+ return false;
101
+ }
102
+ if (!keys.every((key) => allowed.includes(key))) return false;
103
+ if (!allowed.filter((key) => !options.optional?.includes(key)).every((key) => Object.hasOwn(payload, key))) {
104
+ return false;
105
+ }
106
+ if (typeof payload.channelId !== 'string' || !CHANNEL_ID.test(payload.channelId)) return false;
107
+ if (typeof payload.botId !== 'string' || !BOT_ID.test(payload.botId)) return false;
108
+ if (!options.withConfig) return true;
109
+ return payload.config !== null && typeof payload.config === 'object' && !Array.isArray(payload.config);
110
+ }
111
+
112
+ /**
113
+ * Cordis host 插件入口。
114
+ *
115
+ * @param ctx - host 上下文。
116
+ * @param config - 插件配置:{ dataDir, integrationRoot, deferred? }。
117
+ * `deferred` 可选地覆盖延迟交付的三个时间窗(`firstCheckMs / intervalMs / maxAgeMs`)——
118
+ * 默认 1 分钟 / 30 秒 / 30 分钟;编排演练或排查时可以用小值把整条链路跑完。
119
+ */
120
+ export function apply(ctx, config = {}) {
121
+ const baseLogger = resolveLogger(ctx, 'dsh-chat');
122
+ const integrations = integrationRoot(config.integrationRoot);
123
+ const logsDir = join(hubDataDir(config.dataDir), 'logs');
124
+ /** hub 与每个渠道各一份日志文件:出故障时不必再靠用户终端滚屏回忆。 */
125
+ const hubLog = createLogFileSink({ path: channelLogPath(logsDir, 'hub') });
126
+ const logger = withFileSink({ logger: baseLogger, sink: hubLog, scope: 'dsh-chat' });
127
+ const settings = createBotSettingsStore({ dataDir: hubDataDir(config.dataDir), logger });
128
+ /** 已注册渠道的旧数据目录,供 `maintenance.import-legacy` 重跑导入。 */
129
+ const legacyDirs = new Map();
130
+ const guidance = createGuidanceRegistry();
131
+ /**
132
+ * 增强提示词注入到哪里:`system`(默认,DSH 的系统提示词段)或 `prefix`(拼在消息前面)。
133
+ *
134
+ * 默认走系统提示词——提示词是"对模型的长期指令",不该混进用户轮次(每轮重复、还可能被
135
+ * 当成用户说的话)。Host 没有 `systemPrompt` 服务时自动退回 `prefix`,功能不丢。
136
+ */
137
+ const guidanceTarget = config.guidanceTarget === 'prefix' ? 'prefix' : 'system';
138
+ /** 提示词段是否已经装上(只在装上之后才从消息前缀里去掉提示词块)。 */
139
+ let guidanceInSystemPrompt = false;
140
+ let guidanceFallbackWarned = false;
141
+ /**
142
+ * 尽力把"增强提示词"装成系统提示词段。
143
+ *
144
+ * 服务是**可选依赖**:Cordis 的 `inject` 只能声明必选,声明了会让"没装
145
+ * dsh-system-prompt 的部署"整块加载不了;所以这里运行期探测 + 每次用之前重试一次
146
+ * (服务晚到也能装上)。装不上就退回前缀注入,**只告警一次**,功能不丢。
147
+ */
148
+ function ensureGuidanceSection() {
149
+ if (guidanceInSystemPrompt) return true;
150
+ if (guidanceTarget !== 'system') return false;
151
+ if (installSourceGuidanceSection(ctx, guidance, { logger })) {
152
+ guidanceInSystemPrompt = true;
153
+ return true;
154
+ }
155
+ if (!guidanceFallbackWarned) {
156
+ guidanceFallbackWarned = true;
157
+ logger.warn?.('[dsh-chat] 当前 Host 没有可用的 systemPrompt 服务:增强提示词退回'
158
+ + '"拼在消息前缀"的老路(功能不丢,但会跟着每条消息进会话)。');
159
+ }
160
+ return false;
161
+ }
162
+ ensureGuidanceSection();
163
+ /** 会话桥用的登记表:publish 之前再试一次装段(服务可能晚于本插件就绪)。 */
164
+ const guidanceForBridge = Object.freeze({
165
+ publish(sessionId, text) {
166
+ ensureGuidanceSection();
167
+ guidance.publish(sessionId, text);
168
+ },
169
+ get: (sessionId) => guidance.get(sessionId),
170
+ forget: (sessionId) => guidance.forget(sessionId),
171
+ });
172
+
173
+ /**
174
+ * 给渠道用的上下文增强引擎 = 引擎 + 一个**包装过的 `enhanceContent`**。
175
+ *
176
+ * 包装只做一件事:提示词已经在系统提示词段里时**不把它再拼进消息正文**
177
+ * (同一段提示词两处都出现 → 用户看到的还是"走的消息")。
178
+ * **渠道 deps 与 `dshChat.contextEnhancement` 必须是同一个对象**:真机上翻过一次车——
179
+ * 服务面给了包装版、渠道 deps 给的是原始模块,于是"渠道照旧拼提示词",
180
+ * 而测试与演练都在调服务面那份,谁都发现不了。
181
+ */
182
+ const contextEnhancementService = Object.freeze({
183
+ ...contextEnhancement,
184
+ enhanceContent: (content, snapshot, sourceFactory) => contextEnhancement.enhanceContent(
185
+ content,
186
+ snapshot,
187
+ sourceFactory,
188
+ { includeGuidance: !ensureGuidanceSection() },
189
+ ),
190
+ });
191
+ const sessionStore = createSessionStore({ dataDir: hubDataDir(config.dataDir), logger });
192
+ /** 人在环交互:agent 的提问/审批送到 IM 里问,答案从 IM 收回来。 */
193
+ const interactions = createInteractionService({ logger });
194
+ /**
195
+ * 延迟交付:`ask()` 判定超时后登记一条记录,之后有界复查会话终态、拿到结果补发。
196
+ *
197
+ * `probe` 由会话桥提供(只读叶子字段),`deliver` 由渠道在建桥时按 channel/bot 注册。
198
+ * 这里先建服务、再建会话桥:闭包是懒执行的,不构成循环依赖。
199
+ */
200
+ const deferred = createDeferredDelivery({
201
+ dataDir: hubDataDir(config.dataDir),
202
+ logger,
203
+ // 时间窗可按部署调整(默认 1 分钟 / 30 秒 / 30 分钟):演练脚本用小值把补发链路跑完。
204
+ ...(config.deferred && typeof config.deferred === 'object' ? config.deferred : {}),
205
+ probe: async ({ record }) => sessions.probeTurn({
206
+ channelId: record.channelId,
207
+ botId: record.botId,
208
+ key: record.key,
209
+ sessionId: record.sessionId,
210
+ }),
211
+ });
212
+ const sessions = createSessionBridge({
213
+ ctx, logger, store: sessionStore, settings, guidance: guidanceForBridge, interactions, deferred,
214
+ });
215
+ const rpc = createRpcCarrier(ctx, { logger });
216
+ /** 主动投递:hub 持有目标清单与调度,渠道提供"怎么发"与"能发给谁"。 */
217
+ const delivery = createDeliveryService({ settings, sessionStore, logger });
218
+ /**
219
+ * 控制面板:IM 卡片要的"当前值 + 可选项 + 应用某个选择"。
220
+ * agentPresets 是可选服务(某些部署没装),用 ctx.get 取、缺失时按"没有预设"处理。
221
+ */
222
+ const optionalAgentPresets = typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined;
223
+ const panel = createPanelService({
224
+ settings, sessions, sessionStore, agentPresets: optionalAgentPresets, logger,
225
+ /**
226
+ * 渠道自带的面板字段(飞书的「任务过程展示」)走这条:hub 不认识渠道语义,
227
+ * 只把 `panel.fields` / `panel.apply` 透传给渠道,渠道没实现就当没有这类设置。
228
+ */
229
+ channelRpc: (channelId, method, payload) => registry.handleRpc(channelId, method, payload),
230
+ });
231
+
232
+ function storageFor(channelId) {
233
+ return Object.freeze({
234
+ read: (botId) => settings.read(channelId, botId),
235
+ write: (botId, patch) => settings.write(channelId, botId, patch),
236
+ list: () => settings.list(channelId),
237
+ });
238
+ }
239
+
240
+ const registry = createChannelRegistry({
241
+ logger,
242
+ rpc,
243
+ onDelivery: (channelId, provider) => delivery.attach(channelId, provider),
244
+ /**
245
+ * 渠道注册后按 `legacy.dir` 做一次性旧设置导入(只读旧文件,绝不改写)。
246
+ * 旧数据目录沿用 dsh-im 的命名,因此用户现有绑定与设置零迁移。
247
+ */
248
+ onRegistered: (channelId, legacy) => {
249
+ const overridden = channelDataDirOverride(config, channelId);
250
+ if (overridden) {
251
+ // 隔离调试/双实例:既不做真实目录的旧设置导入,也不写导入标记,
252
+ // 否则会把真实来源记成"已导入",等真正迁移时反而跳过。
253
+ legacyDirs.set(channelId, overridden);
254
+ return;
255
+ }
256
+ if (!legacy?.dir) return;
257
+ const dir = channelDataDir(legacy.dir, integrations);
258
+ legacyDirs.set(channelId, dir);
259
+ void settings.importLegacy(channelId, dir).catch((error) => {
260
+ logger.warn?.(`[dsh-chat] 渠道 ${channelId} 旧设置导入失败:${error?.message ?? error}`);
261
+ });
262
+ },
263
+ createDeps: (channelId, definition) => Object.freeze({
264
+ channelId,
265
+ // 渠道的每一行日志同时进 <channelId>.log,排查时我能直接读文件。
266
+ logger: withFileSink({
267
+ logger: resolveLogger(ctx, `dsh-chat:${channelId}`),
268
+ sink: createLogFileSink({ path: channelLogPath(logsDir, channelId) }),
269
+ scope: `dsh-chat-${channelId}`,
270
+ }),
271
+ credentials: ctx.credentials,
272
+ /**
273
+ * 渠道历史数据目录(沿用 dsh-im 命名,保证零重绑)。
274
+ * `config.channelDataDirs[channelId]` 可显式覆盖——隔离调试或想同时跑两份时用。
275
+ */
276
+ dataDir: channelDataDirOverride(config, channelId)
277
+ ?? (definition.legacy?.dir
278
+ ? channelDataDir(definition.legacy.dir, integrations)
279
+ : hubDataDir(config.dataDir)),
280
+ resolveDataDir: (name) => channelDataDir(name, integrations),
281
+ storage: storageFor(channelId),
282
+ /**
283
+ * 渠道自建存储用的 JSON 文档工厂:原子写、首次覆盖备份、串行队列、变更订阅
284
+ * 由 hub 统一实现,渠道不必各写一遍。
285
+ */
286
+ createJsonStore,
287
+ /** 读取设置前先 await 它,避免启动竞态读到空文档。 */
288
+ ready: () => settings.ready(),
289
+ contextEnhancement: contextEnhancementService,
290
+ /**
291
+ * 延迟交付:渠道建桥时注册"怎么把补发内容发回这个会话"。
292
+ * `register({ channelId, botId, deliver })`,`deliver({ key, text, record })`。
293
+ */
294
+ deferred: Object.freeze({ register: (options) => deferred.register(options) }),
295
+ /**
296
+ * 引用回复:渠道只把平台字段映射成 `reply`(正文/类型/文件名/发送者/消息 id),
297
+ * 拼装(标签、限长、安全转义、读不到时的标记)由 hub 实现一次、所有渠道复用。
298
+ */
299
+ replyReference: Object.freeze({ enhanceReplyReference: enhanceReplyReferenceFn }),
300
+ /** 访问策略:渠道用它判定放行与命令权限(属主绕过由渠道传入 isOwner)。 */
301
+ accessPolicy: Object.freeze({ ...accessPolicy }),
302
+ /** 机器人命令:渠道把入站文本交进来即可,命令实现只在 hub 一份。 */
303
+ commands: Object.freeze({
304
+ handle: (options) => commands.handle(options),
305
+ list: () => commands.list(),
306
+ }),
307
+ guidance,
308
+ sessions,
309
+ /**
310
+ * 控制面板:渠道的可交互卡片用它读"当前值 + 可选项"、并应用用户的选择。
311
+ * `read({channelId, botId, key})` / `apply({channelId, botId, key, field, value})`。
312
+ */
313
+ panel,
314
+ /** 渠道接入 IM 回传(提问/审批):attach({ channelId, botId, send })。 */
315
+ interactions: Object.freeze({
316
+ attach: (options) => interactions.attach(options),
317
+ offer: (options) => interactions.offer(options),
318
+ has: (channelId) => interactions.has(channelId),
319
+ }),
320
+ }),
321
+ });
322
+
323
+ // 命令内核:命令操作的都是渠道无关的东西,因此 hub 实现一次、所有渠道复用。
324
+ const commands = createCommandRegistry({
325
+ logger,
326
+ services: {
327
+ sessions,
328
+ bots: {
329
+ read: (channelId, botId) => settings.read(channelId, botId),
330
+ write: (channelId, botId, patch) => settings.write(channelId, botId, patch),
331
+ },
332
+ channels: { list: () => registry.list() },
333
+ agentPresets: optionalAgentPresets,
334
+ // `/diag`:把设置页那份诊断现场用文字回出来(手机上排查不必开电脑)。
335
+ diagnostics: { read: () => collectDiagnostics() },
336
+ // `/menu` 用它取"当前值 + 可选项",卡片据此渲染下拉、渠道不必自己拼状态。
337
+ panel,
338
+ },
339
+ });
340
+ registerBuiltinCommands(commands, { hubVersion: HUB_VERSION, listCommands: () => commands.list() });
341
+
342
+ /**
343
+ * hub 控制端点:渠道无关、所有渠道共用,因此渠道包不必重复实现。
344
+ *
345
+ * @param method - 方法名。
346
+ * @param payload - 载荷。
347
+ * @returns RPC 结果。
348
+ */
349
+ /**
350
+ * 自助排查的"一屏现场":每台机器人的连接状态与最近错误 + 日志尾部。
351
+ *
352
+ * 抽成函数是因为**两条路要用同一份数据**:设置页的 `diagnostics.read` 与聊天里的 `/diag`
353
+ * (手机上排查时不想开电脑)。只取叶子字段,前端/命令都不碰 host 的活对象。
354
+ */
355
+ async function collectDiagnostics() {
356
+ const entries = registry.list();
357
+ const channels = await Promise.all(entries.map(async (entry) => {
358
+ const result = await registry.handleRpc(entry.id, 'connection.status', {});
359
+ return {
360
+ id: entry.id,
361
+ label: entry.label,
362
+ version: entry.version ?? null,
363
+ status: entry.status,
364
+ error: entry.error ?? null,
365
+ bots: result?.ok === true ? (result.value?.bots ?? []) : [],
366
+ statusError: result?.ok === true ? null : (result.error?.message ?? '状态读取失败'),
367
+ };
368
+ }));
369
+ const logs = await Promise.all(['hub', ...entries.map((entry) => entry.id)]
370
+ .map((logName) => readLogTail(channelLogPath(logsDir, logName))));
371
+ /**
372
+ * 待补发的延迟交付记录:超时之后那一轮还没跑完时,这里会有记录。
373
+ * 只取叶子字段(渠道/会话键/会话/登记时刻),别把内部的活对象透出去。
374
+ */
375
+ const deferredRecords = deferred.list().map((row) => ({
376
+ channelId: row.channelId,
377
+ botId: row.botId,
378
+ key: row.key,
379
+ sessionId: row.sessionId,
380
+ turn: row.turn,
381
+ timedOutAt: row.timedOutAt,
382
+ attempts: row.attempts,
383
+ lastError: row.lastError,
384
+ }));
385
+ return {
386
+ dataDir: hubDataDir(config.dataDir),
387
+ logDir: logsDir,
388
+ channels,
389
+ deferred: deferredRecords,
390
+ logs,
391
+ };
392
+ }
393
+
394
+ async function controlHandler(method, payload) {
395
+ if (method === 'channel.list') {
396
+ if (payload !== null && (typeof payload !== 'object' || Array.isArray(payload)
397
+ || Object.keys(payload).length > 0)) {
398
+ return fail('chat/bad-request', 'channel.list 不接受参数。');
399
+ }
400
+ return ok({
401
+ contractVersion: CONTRACT_VERSION,
402
+ // 「版本与更新」面板要的三个层次:hub 版本、渠道契约版本、各渠道包版本。
403
+ hubVersion: HUB_VERSION,
404
+ hubPackage: 'dsh-chat',
405
+ dataDir: hubDataDir(config.dataDir),
406
+ logDir: logsDir,
407
+ channels: registry.list(),
408
+ });
409
+ }
410
+ if (method === 'diagnostics.read') {
411
+ if (payload !== null && (typeof payload !== 'object' || Array.isArray(payload)
412
+ || Object.keys(payload).length > 0)) {
413
+ return fail('chat/bad-request', 'diagnostics.read 不接受参数。');
414
+ }
415
+ // 日志读不到不算失败(只返回 exists:false 的那一项)。
416
+ return ok(await collectDiagnostics());
417
+ }
418
+ if (method === 'bot.settings.get') {
419
+ if (!validBotPayload(payload)) return fail('chat/bad-request', 'bot.settings.get 需要 channelId 与 botId。');
420
+ await settings.ready();
421
+ return ok({ settings: settings.read(payload.channelId, payload.botId) });
422
+ }
423
+ if (method === 'bot.panel-sections.set') {
424
+ if (!validBotPayload(payload, { extra: ['sections'] })) {
425
+ return fail('chat/bad-request', 'bot.panel-sections.set 需要 channelId、botId 与 sections。');
426
+ }
427
+ try {
428
+ const saved = await settings.write(payload.channelId, payload.botId, {
429
+ panelSections: payload.sections,
430
+ });
431
+ return ok({ panelSections: saved.panelSections });
432
+ } catch (error) {
433
+ return failFrom(error, 'chat/panel-sections-failed');
434
+ }
435
+ }
436
+
437
+ if (method === 'bot.context-enhancement.set') {
438
+ if (!validBotPayload(payload, { withConfig: true })) {
439
+ return fail('chat/bad-request', 'bot.context-enhancement.set 需要 channelId、botId 与 config。');
440
+ }
441
+ try {
442
+ const config2 = contextEnhancement.validateContextConfig(payload.config);
443
+ const saved = await settings.write(payload.channelId, payload.botId, {
444
+ contextEnhancement: config2,
445
+ });
446
+ return ok({ contextEnhancement: saved.contextEnhancement });
447
+ } catch (error) {
448
+ return failFrom(error, 'chat/context-enhancement-failed');
449
+ }
450
+ }
451
+ /**
452
+ * 机器人设置页要用的"可选项":这台机器人用过的目录、当前 Host 可用的 Agent Preset。
453
+ * 渠道页据此渲染下拉,不必各自去查 DSH。
454
+ */
455
+ if (method === 'bot.settings.options') {
456
+ if (!validBotPayload(payload)) {
457
+ return fail('chat/bad-request', 'bot.settings.options 需要 channelId 与 botId。');
458
+ }
459
+ await settings.ready();
460
+ const record = settings.read(payload.channelId, payload.botId);
461
+ // 目录候选来自这台机器人**用过的**工作区(会话绑定表),而不是全机器的目录列表——
462
+ // 少而准,且不会把别的项目的路径泄漏到无关机器人的设置页。
463
+ // 与 IM 卡片上的工作区下拉共用同一个函数,避免两处逻辑漂移。
464
+ const workspacePaths = workspaceCandidates({
465
+ record, sessionStore, channelId: payload.channelId, botId: payload.botId,
466
+ });
467
+ let presets = [];
468
+ if (typeof optionalAgentPresets?.remoteExportList === 'function') {
469
+ try {
470
+ presets = (await optionalAgentPresets.remoteExportList())?.presets ?? [];
471
+ } catch (error) {
472
+ logger.warn?.(`[dsh-chat] 读取 Agent Preset 列表失败:${error?.message ?? error}`);
473
+ }
474
+ }
475
+ /**
476
+ * 模型目录:设置页的「默认模型」栏要用它渲染下拉。
477
+ * 与聊天里那块面板同一个来源(`readModelCatalog`),失败只记 warn —— 读不到时
478
+ * 那一栏退化成"读不到模型目录",不阻塞设置页其它部分。
479
+ */
480
+ let models = [];
481
+ let hostDefault = null;
482
+ let modelFailures = [];
483
+ try {
484
+ const catalog = await readModelCatalog(sessions, logger);
485
+ models = catalog.options;
486
+ hostDefault = catalog.hostDefault;
487
+ modelFailures = catalog.failures;
488
+ } catch (error) {
489
+ logger.warn?.(`[dsh-chat] 读取模型列表失败:${error?.message ?? error}`);
490
+ modelFailures = [{ id: '', name: '模型目录', message: String(error?.message ?? error) }];
491
+ }
492
+ return ok({
493
+ workspacePaths,
494
+ presets,
495
+ models,
496
+ hostDefault,
497
+ modelFailures,
498
+ current: {
499
+ workspace: record.workspace ?? null,
500
+ agentPreset: record.agentPreset ?? null,
501
+ accessPolicy: record.accessPolicy ?? null,
502
+ model: normalizeBotModel(record.model),
503
+ },
504
+ });
505
+ }
506
+ if (method === 'bot.workspace.set') {
507
+ if (!validBotPayload(payload, { extra: ['workspace'] })) {
508
+ return fail('chat/bad-request', 'bot.workspace.set 需要 channelId、botId 与 workspace。');
509
+ }
510
+ const raw = payload.workspace;
511
+ if (raw !== null && typeof raw !== 'string') {
512
+ return fail('chat/bad-request', 'workspace 只能是绝对路径或 null。');
513
+ }
514
+ if (raw === null || !raw.trim()) {
515
+ const saved = await settings.write(payload.channelId, payload.botId, { workspace: null });
516
+ return ok({ workspace: saved.workspace ?? null });
517
+ }
518
+ // 存绝对路径:相对路径会跟着 dsh 的启动目录变,排查时最难查。
519
+ const target = resolve(raw.trim());
520
+ let info;
521
+ try {
522
+ info = await stat(target);
523
+ } catch (error) {
524
+ return fail('chat/workspace-invalid', `目录不存在或读不到:${target}(${error?.code ?? error?.message})`);
525
+ }
526
+ if (!info.isDirectory()) return fail('chat/workspace-invalid', `不是目录:${target}`);
527
+ const saved = await settings.write(payload.channelId, payload.botId, { workspace: target });
528
+ return ok({ workspace: saved.workspace ?? null });
529
+ }
530
+ if (method === 'bot.agent-preset.set') {
531
+ if (!validBotPayload(payload, { extra: ['agentPreset'] })) {
532
+ return fail('chat/bad-request', 'bot.agent-preset.set 需要 channelId、botId 与 agentPreset。');
533
+ }
534
+ const raw = payload.agentPreset;
535
+ if (raw !== null && typeof raw !== 'string') {
536
+ return fail('chat/bad-request', 'agentPreset 只能是预设 id 或 null。');
537
+ }
538
+ const target = typeof raw === 'string' && raw.trim() ? raw.trim() : null;
539
+ if (target && typeof optionalAgentPresets?.remoteExportList === 'function') {
540
+ // 先跟当前 Host 的预设列表对账:存一个不存在的 id,只会在下次建会话时才炸。
541
+ let known = [];
542
+ try {
543
+ known = ((await optionalAgentPresets.remoteExportList())?.presets ?? []).map((row) => row.id);
544
+ } catch (error) {
545
+ return fail('chat/preset-unavailable', `读不到 Agent Preset 列表:${error?.message ?? error}`);
546
+ }
547
+ if (!known.includes(target)) {
548
+ return fail('chat/unknown-preset', `当前 Host 没有这个 Agent Preset:${target}`);
549
+ }
550
+ }
551
+ const saved = await settings.write(payload.channelId, payload.botId, { agentPreset: target });
552
+ return ok({ agentPreset: saved.agentPreset ?? null });
553
+ }
554
+ if (method === 'bot.model.set') {
555
+ if (!validBotPayload(payload, { extra: ['model'] })) {
556
+ return fail('chat/bad-request', 'bot.model.set 需要 channelId、botId 与 model。');
557
+ }
558
+ const raw = payload.model;
559
+ if (raw !== null && (typeof raw !== 'object' || Array.isArray(raw))) {
560
+ return fail('chat/bad-request', 'model 只能是 { provider, model, reasoningEffort? } 或 null。');
561
+ }
562
+ const target = raw === null ? null : normalizeBotModel(raw);
563
+ if (raw !== null && !target) {
564
+ return fail('chat/bad-request', 'model 需要非空的 provider 与 model。');
565
+ }
566
+ if (target) {
567
+ /**
568
+ * 先跟当前 Host 的模型目录对账(与 Agent Preset 同一条口径)。
569
+ *
570
+ * 读不到目录时**放行**:这条路的入口是设置页的下拉,值本来就来自目录;
571
+ * 而"读不到"不该把用户已经选好的东西判成非法(聊天里那条路是另一套语义)。
572
+ */
573
+ try {
574
+ const { options } = await readModelCatalog(sessions, logger);
575
+ if (options.length > 0) {
576
+ const found = options.find((item) => item.provider === target.provider && item.model === target.model);
577
+ if (!found) {
578
+ return fail('chat/unknown-model', `当前 Host 没有这个模型:${target.provider}/${target.model}`);
579
+ }
580
+ if (target.reasoningEffort && !found.efforts.some((effort) => effort.id === target.reasoningEffort)) {
581
+ return fail('chat/unknown-effort',
582
+ `模型 ${found.value} 不支持推理等级 ${target.reasoningEffort}。`);
583
+ }
584
+ }
585
+ } catch (error) {
586
+ logger.warn?.(`[dsh-chat] 校验机器人默认模型时读不到模型目录,按原值保存:${error?.message ?? error}`);
587
+ }
588
+ }
589
+ const saved = await settings.write(payload.channelId, payload.botId, { model: target });
590
+ return ok({ model: normalizeBotModel(saved.model) });
591
+ }
592
+ if (method === 'bot.access-policy.set') {
593
+ if (!validBotPayload(payload, { extra: ['policy'] })) {
594
+ return fail('chat/bad-request', 'bot.access-policy.set 需要 channelId、botId 与 policy。');
595
+ }
596
+ try {
597
+ // 用与 host 拦消息时**同一份**校验,避免"设置页存得进、运行时判非法"。
598
+ const policy = payload.policy === null ? null : accessPolicy.validateAccessPolicy(payload.policy);
599
+ const saved = await settings.write(payload.channelId, payload.botId, { accessPolicy: policy });
600
+ return ok({ accessPolicy: saved.accessPolicy ?? null });
601
+ } catch (error) {
602
+ return failFrom(error, 'chat/access-policy-failed');
603
+ }
604
+ }
605
+ /**
606
+ * 把某个会话类型放宽到「任何人可用」(只改那一份,另一份与名单照旧)。
607
+ *
608
+ * 用途只有一个:「新建机器人接入」刚加进来的机器人**还没有属主**(属主要从"聊过的会话"
609
+ * 里选,而新机器人一个人都没聊过),默认的 allowlist + 空名单 = 谁都进不来,属主自己
610
+ * 也没法跟它说上第一句话。于是接入流程把私聊放宽,让属主先聊一句、再把自己设为属主。
611
+ *
612
+ * 策略的形状与默认值都在 hub(`defaultAccessPolicy`),所以这一步也必须由 hub 做:
613
+ * 渠道包不许 import hub 的模块,让渠道去拼一个完整 policy 等于把形状知识复制出去。
614
+ */
615
+ if (method === 'bot.access-policy.open-scope') {
616
+ if (!validBotPayload(payload, { extra: ['conversationType'] })) {
617
+ return fail('chat/bad-request',
618
+ 'bot.access-policy.open-scope 需要 channelId、botId 与 conversationType。');
619
+ }
620
+ if (!accessPolicy.ACCESS_CONVERSATION_TYPES.includes(payload.conversationType)) {
621
+ return fail('chat/bad-request',
622
+ `conversationType 只能是 ${accessPolicy.ACCESS_CONVERSATION_TYPES.join(' / ')}。`);
623
+ }
624
+ try {
625
+ await settings.ready();
626
+ const current = settings.read(payload.channelId, payload.botId)?.accessPolicy ?? null;
627
+ const base = current ?? accessPolicy.defaultAccessPolicy();
628
+ const next = {
629
+ ...base,
630
+ [payload.conversationType]: { ...base[payload.conversationType], mode: 'open' },
631
+ };
632
+ const saved = await settings.write(payload.channelId, payload.botId, {
633
+ accessPolicy: accessPolicy.validateAccessPolicy(next),
634
+ });
635
+ return ok({ accessPolicy: saved.accessPolicy ?? null });
636
+ } catch (error) {
637
+ return failFrom(error, 'chat/access-policy-failed');
638
+ }
639
+ }
640
+ /**
641
+ * 该机器人聊过的会话(带人能认出的名字),给"指定用户/指定群"这类选择器用。
642
+ *
643
+ * 复用投递那套:hub 的持久会话绑定表 + 渠道的发现与 `decorateTargets`,
644
+ * 因此名字与投递列表一致,也不必让渠道页各自去查平台。
645
+ */
646
+ if (method === 'bot.conversations') {
647
+ if (!validBotPayload(payload)) {
648
+ return fail('chat/bad-request', 'bot.conversations 需要 channelId 与 botId。');
649
+ }
650
+ try {
651
+ const listed = await delivery.list({ channelId: payload.channelId, botId: payload.botId });
652
+ return ok({
653
+ conversations: listed.targets.map((target) => ({
654
+ id: target.id,
655
+ name: target.name ?? target.id,
656
+ kind: target.kind,
657
+ route: target.route,
658
+ saved: target.discovered !== true,
659
+ })),
660
+ });
661
+ } catch (error) {
662
+ return failFrom(error, 'chat/conversations-failed');
663
+ }
664
+ }
665
+ if (method === 'maintenance.import-legacy') {
666
+ const valid = payload !== null && typeof payload === 'object' && !Array.isArray(payload)
667
+ && Object.keys(payload).length === 2
668
+ && typeof payload.channelId === 'string' && CHANNEL_ID.test(payload.channelId)
669
+ && typeof payload.force === 'boolean';
670
+ if (!valid) {
671
+ return fail('chat/bad-request', 'maintenance.import-legacy 需要 { channelId, force }。');
672
+ }
673
+ const dir = legacyDirs.get(payload.channelId);
674
+ if (!dir) return fail('chat/no-legacy', `渠道 ${payload.channelId} 没有声明旧数据目录。`);
675
+ try {
676
+ return ok(await settings.importLegacy(payload.channelId, dir, { force: payload.force }));
677
+ } catch (error) {
678
+ return failFrom(error, 'chat/import-failed');
679
+ }
680
+ }
681
+ if (method === 'delivery.list') {
682
+ if (!validBotPayload(payload)) return fail('chat/bad-request', 'delivery.list 需要 channelId 与 botId。');
683
+ return ok(await delivery.list({ channelId: payload.channelId, botId: payload.botId }));
684
+ }
685
+ if (method === 'delivery.save') {
686
+ if (!isPlainRecord(payload) || typeof payload.channelId !== 'string'
687
+ || typeof payload.botId !== 'string' || !isPlainRecord(payload.target)) {
688
+ return fail('chat/bad-request', 'delivery.save 需要 { channelId, botId, target }。');
689
+ }
690
+ try {
691
+ const saved = await delivery.save({
692
+ channelId: payload.channelId, botId: payload.botId, target: payload.target,
693
+ });
694
+ return ok({ target: saved });
695
+ } catch (error) {
696
+ return failFrom(error, 'chat/delivery-save-failed');
697
+ }
698
+ }
699
+ if (method === 'delivery.target.rename') {
700
+ if (!validBotPayload(payload, { extra: ['targetId', 'name'] }) || typeof payload.targetId !== 'string'
701
+ || typeof payload.name !== 'string') {
702
+ return fail('chat/bad-request', 'delivery.target.rename 需要 { channelId, botId, targetId, name }。');
703
+ }
704
+ try {
705
+ const renamed = await delivery.rename({
706
+ channelId: payload.channelId, botId: payload.botId,
707
+ targetId: payload.targetId, name: payload.name,
708
+ });
709
+ return ok({ target: renamed });
710
+ } catch (error) {
711
+ return failFrom(error, 'chat/delivery-rename-failed');
712
+ }
713
+ }
714
+ if (method === 'delivery.remove') {
715
+ if (!validBotPayload(payload, { extra: ['targetId'] }) || typeof payload.targetId !== 'string') {
716
+ return fail('chat/bad-request', 'delivery.remove 需要 { channelId, botId, targetId }。');
717
+ }
718
+ return ok({ removed: await delivery.remove({
719
+ channelId: payload.channelId, botId: payload.botId, targetId: payload.targetId,
720
+ }) });
721
+ }
722
+ if (method === 'delivery.sendFile') {
723
+ if (!validBotPayload(payload, { extra: ['targetId', 'path', 'name'], optional: ['name'] })
724
+ || typeof payload.targetId !== 'string'
725
+ || typeof payload.path !== 'string'
726
+ || (payload.name !== undefined && typeof payload.name !== 'string')) {
727
+ return fail('chat/bad-request', 'delivery.sendFile 需要 { channelId, botId, targetId, path, name? }。');
728
+ }
729
+ try {
730
+ return ok(await delivery.sendFile({
731
+ channelId: payload.channelId,
732
+ botId: payload.botId,
733
+ targetId: payload.targetId,
734
+ path: payload.path,
735
+ name: payload.name,
736
+ }));
737
+ } catch (error) {
738
+ return failFrom(error, 'chat/delivery-failed');
739
+ }
740
+ }
741
+ if (method === 'delivery.send') {
742
+ if (!validBotPayload(payload, { extra: ['targetId', 'text'] })
743
+ || typeof payload.targetId !== 'string'
744
+ || typeof payload.text !== 'string') {
745
+ return fail('chat/bad-request', 'delivery.send 需要 { channelId, botId, targetId, text }。');
746
+ }
747
+ try {
748
+ return ok(await delivery.send({
749
+ channelId: payload.channelId,
750
+ botId: payload.botId,
751
+ targetId: payload.targetId,
752
+ text: payload.text,
753
+ }));
754
+ } catch (error) {
755
+ return failFrom(error, 'chat/delivery-failed');
756
+ }
757
+ }
758
+ return fail('chat/unknown-method', `控制端点不支持 ${method}。`);
759
+ }
760
+
761
+ void settings.ready().catch((error) => {
762
+ logger.warn?.(`[dsh-chat] 初始化每机器人设置失败:${error?.message ?? error}`);
763
+ });
764
+ void sessionStore.ready().catch((error) => {
765
+ logger.warn?.(`[dsh-chat] 初始化会话绑定表失败:${error?.message ?? error}`);
766
+ });
767
+
768
+ const service = Object.freeze({
769
+ contractVersion: CONTRACT_VERSION,
770
+
771
+ /**
772
+ * 渠道包注册自己的实现。
773
+ *
774
+ * @param definition - { id, label, order, createChannel, legacy? }。
775
+ * @returns 同步注销函数。
776
+ */
777
+ registerChannel: (definition) => registry.register(definition),
778
+
779
+ /** 渠道注册表:只读视图 + 进程内分派(诊断/CLI 用,省掉走浏览器 RPC)。 */
780
+ channels: Object.freeze({
781
+ list: () => registry.list(),
782
+ subscribe: (listener) => registry.subscribe(listener),
783
+ call: (channelId, method, payload, signal) => registry.handleRpc(
784
+ channelId, method, payload, signal,
785
+ ),
786
+ }),
787
+
788
+ /** 每机器人设置的磁盘文档就绪信号;渠道读取设置前应 await 它。 */
789
+ ready: () => settings.ready(),
790
+
791
+ bots: Object.freeze({
792
+ read: (channelId, botId) => settings.read(channelId, botId),
793
+ write: (channelId, botId, patch) => settings.write(channelId, botId, patch),
794
+ list: (channelId) => settings.list(channelId),
795
+ subscribe: (listener) => settings.subscribe(listener),
796
+ storageFor,
797
+ }),
798
+
799
+ /** 机器人命令:渠道把入站文本交进来,拿回要回复的文本。 */
800
+ commands: Object.freeze({
801
+ handle: (options) => commands.handle(options),
802
+ list: () => commands.list(),
803
+ }),
804
+
805
+ /** 主动投递:定时任务/脚本用 `send` 把结果推到指定会话。 */
806
+ delivery: Object.freeze({
807
+ send: (options) => delivery.send(options),
808
+ sendFile: (options) => delivery.sendFile(options),
809
+ list: (options) => delivery.list(options),
810
+ save: (options) => delivery.save(options),
811
+ remove: (options) => delivery.remove(options),
812
+ supports: (channelId) => delivery.supports(channelId),
813
+ supportsFile: (channelId) => delivery.supportsFile(channelId),
814
+ }),
815
+
816
+ contextEnhancement: contextEnhancementService,
817
+ /** 延迟交付:渠道注册发送器;`list()` 供诊断查看待交付记录。 */
818
+ deferred: Object.freeze({
819
+ register: (options) => deferred.register(options),
820
+ list: () => deferred.list(),
821
+ }),
822
+ /** 引用回复的拼装函数(服务面同样暴露一份,渠道按需取用)。 */
823
+ replyReference: Object.freeze({ enhanceReplyReference: enhanceReplyReferenceFn }),
824
+ guidance: Object.freeze({
825
+ publish: (sessionId, text) => guidanceForBridge.publish(sessionId, text),
826
+ forget: (sessionId) => guidanceForBridge.forget(sessionId),
827
+ }),
828
+ sessions,
829
+ /**
830
+ * 控制面板(服务面同样暴露一份):渠道的可交互卡片用它读"当前值 + 可选项"、
831
+ * 并应用用户的选择。`read({channelId,botId,key})` / `apply({channelId,botId,key,field,value})`。
832
+ */
833
+ panel,
834
+ });
835
+
836
+ ctx.effect(() => {
837
+ const disposeProvide = provideService(ctx, HOST_SERVICE, service);
838
+ return () => {
839
+ registry.disposeAll();
840
+ rpc.disposeAll();
841
+ if (typeof disposeProvide === 'function') disposeProvide();
842
+ };
843
+ }, 'dsh-chat: host service');
844
+
845
+ ctx.effect(() => rpc.register(CONTROL_CHANNEL_ID, controlHandler), 'dsh-chat: control rpc');
846
+
847
+ // 模型可调用工具:让 agent 会话自己把结果发到 IM。`tools` 是可选服务(没有 agent
848
+ // 的部署可能没有它),因此按"依赖出现即注册、消失即注销"的方式挂载;若上下文根本
849
+ // 不支持 inject,要留下可见的日志,不能让能力悄悄缺席。
850
+ if (typeof ctx.inject === 'function') {
851
+ ctx.inject(['tools'], (toolCtx) => {
852
+ toolCtx.effect(
853
+ () => registerChatTools(toolCtx, {
854
+ delivery,
855
+ // agent 需要先"发现"渠道与机器人,才能拿到投递目标,因此把只读视图一并给它。
856
+ channels: { list: () => registry.list() },
857
+ bots: { list: (channelId) => settings.list(channelId) },
858
+ logger,
859
+ }),
860
+ 'dsh-chat: agent tools',
861
+ );
862
+ });
863
+ } else {
864
+ logger.warn?.('[dsh-chat] 当前上下文不支持 ctx.inject,'
865
+ + 'chat_targets/chat_send/chat_save_target 未注册(agent 无法主动发消息)。');
866
+ }
867
+
868
+ // 审批与提问是 agent 作用域的 waterfall 事件,hub 在 root 上参与并把它们交给
869
+ // 对应渠道(按会话绑定定位);不属于本插件的会话一律 next() 让给浏览器 UI。
870
+ ctx.effect(() => sessions.installInteractionRelays(), 'dsh-chat: 审批与提问回传');
871
+
872
+ logger.info?.(`[dsh-chat] hub 已就绪(契约 v${CONTRACT_VERSION}),等待渠道插件注册。`);
873
+ }