@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,415 @@
1
+ /**
2
+ * dsh-chat 上下文增强引擎。
3
+ *
4
+ * 浏览器安全:host 侧捕获/拼装与 client 侧设置界面共用这一份实现,因此校验规则
5
+ * 不可能前后端漂移。渠道包通过 `dshChat.contextEnhancement` 使用它,不直接 import。
6
+ *
7
+ * 两级配置:
8
+ * - 全局:`group` / `direct` 各自一份 { enabled, fields, guidance };
9
+ * - 指定设置:`targets[]`,`kind:'user'` 只在私聊按 senderId 命中,
10
+ * `kind:'group'` 只在群聊按 chatId 命中;命中时用自己的来源字段与提示词,
11
+ * `merge:'append'` 再把该会话类型的全局提示词叠在其后。
12
+ *
13
+ * @module dsh-chat/shared/context-enhancement
14
+ */
15
+
16
+ /** 可用于来源块的字段(顺序即界面顺序)。 */
17
+ export const CONTEXT_FIELDS = Object.freeze([
18
+ 'channel',
19
+ 'conversationType',
20
+ 'senderId',
21
+ 'senderName',
22
+ 'conversationTitle',
23
+ 'chatId',
24
+ 'threadId',
25
+ 'botId',
26
+ ]);
27
+
28
+ /**
29
+ * 注入内容的标签语法。producer(渠道拼前缀)与 host 侧 splitter(把来源块
30
+ * 拆成独立会话上下文行)共用这组字面量,前缀不可能与解析器漂移。
31
+ */
32
+ export const CONTEXT_TAGS = Object.freeze({
33
+ sourceOpen: '<dsh_im_source>',
34
+ sourceClose: '</dsh_im_source>',
35
+ guidanceOpen: '<dsh_im_source_guidance>',
36
+ guidanceClose: '</dsh_im_source_guidance>',
37
+ });
38
+
39
+ /** 多块前缀之间的分隔符;splitter 按同一个值消费。 */
40
+ export const CONTEXT_BLOCK_SEPARATOR = '\n\n';
41
+
42
+ /** 单个作用域增强提示词上限。 */
43
+ export const GUIDANCE_MAX_LENGTH = 8_000;
44
+
45
+ /** 指定设置条数上限。 */
46
+ export const TARGET_LIMIT = 50;
47
+
48
+ /** 指定设置备注名上限。 */
49
+ export const TARGET_LABEL_MAX_LENGTH = 80;
50
+
51
+ /** 指定设置标识上限。 */
52
+ export const TARGET_ID_MAX_LENGTH = 256;
53
+
54
+ /** 指定设置的合法命中类型。 */
55
+ export const TARGET_KINDS = Object.freeze(['user', 'group']);
56
+
57
+ /** 提示词叠加方式:叠加全局提示词 / 只用自己的提示词。 */
58
+ export const TARGET_MERGES = Object.freeze(['append', 'replace']);
59
+
60
+ /** 群聊 / 私聊的提示词示例(界面的「填入示例」用)。 */
61
+ export const GROUP_GUIDANCE_EXAMPLE = `仅依据当前消息的 ${CONTEXT_TAGS.sourceOpen} 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
62
+ 当前消息来自群聊,请使用严肃、克制、简洁的表达方式。`;
63
+ export const DIRECT_GUIDANCE_EXAMPLE = `仅依据当前消息的 ${CONTEXT_TAGS.sourceOpen} 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
64
+ 当前消息来自私聊,可以使用更轻松、幽默、详细的表达方式。`;
65
+
66
+ /** 一个作用域的默认值。 */
67
+ export const DEFAULT_SCOPE = Object.freeze({
68
+ enabled: false,
69
+ fields: Object.freeze(['senderId']),
70
+ guidance: '',
71
+ });
72
+
73
+ /** 完整默认配置(含指定设置)。 */
74
+ export const DEFAULT_CONTEXT_CONFIG = Object.freeze({
75
+ group: DEFAULT_SCOPE,
76
+ direct: DEFAULT_SCOPE,
77
+ targets: Object.freeze([]),
78
+ });
79
+
80
+ const CONFIG_KEYS = Object.freeze(['group', 'direct', 'targets']);
81
+ const SCOPE_KEYS = Object.freeze(['enabled', 'fields', 'guidance']);
82
+ const TARGET_KEYS = Object.freeze([
83
+ 'kind', 'id', 'label', 'enabled', 'fields', 'guidance', 'merge',
84
+ ]);
85
+ const LEGACY_KEYS = Object.freeze(['groupEnabled', 'directEnabled', 'fields', 'guidance']);
86
+
87
+ const SOURCE_LIMITS = Object.freeze({
88
+ channel: 16,
89
+ conversationType: 6,
90
+ senderId: 256,
91
+ senderName: 256,
92
+ conversationTitle: 256,
93
+ chatId: 256,
94
+ threadId: 256,
95
+ botId: 128,
96
+ });
97
+
98
+ /** 清理用(带 g);测试是否存在用不带 g 的同一集合,避免 lastIndex 状态。 */
99
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
100
+ const CONTROL_CHARACTER_TEST = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
101
+
102
+ /** 渠道名白名单:来源块里的 channel 只能是这些值。 */
103
+ const KNOWN_CHANNELS = new Set(['feishu', 'weixin']);
104
+
105
+ /**
106
+ * 构造一个带稳定 code 的校验错误,便于 RPC 层区分"用户填错"与"服务端故障"。
107
+ *
108
+ * @param message - 面向用户的中文错误文案。
109
+ * @returns 带 code 的 TypeError。
110
+ */
111
+ function invalid(message) {
112
+ const error = new TypeError(message);
113
+ error.code = 'context-enhancement-invalid';
114
+ return error;
115
+ }
116
+
117
+ function isPlainObject(value) {
118
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
119
+ const prototype = Object.getPrototypeOf(value);
120
+ return prototype === Object.prototype || prototype === null;
121
+ }
122
+
123
+ function hasExactKeys(input, keys) {
124
+ return isPlainObject(input)
125
+ && Reflect.ownKeys(input).length === keys.length
126
+ && keys.every((key) => Object.hasOwn(input, key));
127
+ }
128
+
129
+ function offlineText(value, maxLength) {
130
+ return typeof value === 'string' ? value.replace(CONTROL_CHARACTERS, '').slice(0, maxLength) : '';
131
+ }
132
+
133
+ /**
134
+ * 校验一个作用域(群聊或私聊的全局设置)。
135
+ *
136
+ * @param input - { enabled, fields, guidance }。
137
+ * @param where - 报错文案里指明是哪个作用域。
138
+ * @returns 冻结后的作用域。
139
+ */
140
+ function validateScope(input, where) {
141
+ if (!hasExactKeys(input, SCOPE_KEYS)) throw invalid(`${where}设置不完整,请重新保存。`);
142
+ const { enabled, fields, guidance } = input;
143
+ if (typeof enabled !== 'boolean') throw invalid(`${where}的启用开关必须是布尔值。`);
144
+ if (!Array.isArray(fields) || !fields.every((field) => CONTEXT_FIELDS.includes(field))) {
145
+ throw invalid(`${where}的来源字段只能从已定义的八个字段中选择。`);
146
+ }
147
+ if (typeof guidance !== 'string' || guidance.length > GUIDANCE_MAX_LENGTH) {
148
+ throw invalid(`${where}的增强提示词不得超过 ${GUIDANCE_MAX_LENGTH} 个字符。`);
149
+ }
150
+ return Object.freeze({
151
+ enabled,
152
+ fields: Object.freeze(CONTEXT_FIELDS.filter((field) => fields.includes(field))),
153
+ guidance: guidance.trim() ? guidance : '',
154
+ });
155
+ }
156
+
157
+ /**
158
+ * 校验一条指定设置。
159
+ *
160
+ * @param input - 七个字段的完整对象。
161
+ * @returns 冻结后的指定设置。
162
+ */
163
+ function validateTarget(input) {
164
+ if (!hasExactKeys(input, TARGET_KEYS)) throw invalid('指定设置不完整,请重新保存。');
165
+ const {
166
+ kind, id, label, enabled, fields, guidance, merge,
167
+ } = input;
168
+ if (!TARGET_KINDS.includes(kind)) throw invalid('指定设置的类型只能是"指定用户"或"指定群"。');
169
+ const targetId = typeof id === 'string' ? id.trim() : '';
170
+ if (!targetId || targetId.length > TARGET_ID_MAX_LENGTH || CONTROL_CHARACTER_TEST.test(targetId)
171
+ || /\s/.test(targetId)) {
172
+ throw invalid('指定设置的标识不能为空、不能包含空白或控制字符,且不得超过 256 个字符。');
173
+ }
174
+ // 这里要的是**平台 id**(飞书 `ou_…` / `oc_…`),因为它是拿消息里的 senderId / chatId
175
+ // 去匹配的。`p2p_…` / `group_…` 是**投递目标**的 id(我们生成的),填进来不会报错、
176
+ // 只会永远匹配不上——那是最难查的形态,所以在保存时就挡下来。
177
+ if (/^(p2p|group)_/.test(targetId)) {
178
+ throw invalid('这里要填平台 id(如 ou_… / oc_…),不是投递目标的 id(p2p_… / group_…)。');
179
+ }
180
+ if (typeof label !== 'string' || label.length > TARGET_LABEL_MAX_LENGTH) {
181
+ throw invalid(`指定设置的备注名不得超过 ${TARGET_LABEL_MAX_LENGTH} 个字符。`);
182
+ }
183
+ if (typeof enabled !== 'boolean') throw invalid('指定设置的启用开关必须是布尔值。');
184
+ if (!Array.isArray(fields) || !fields.every((field) => CONTEXT_FIELDS.includes(field))) {
185
+ throw invalid('指定设置的来源字段只能从已定义的八个字段中选择。');
186
+ }
187
+ if (typeof guidance !== 'string' || guidance.length > GUIDANCE_MAX_LENGTH) {
188
+ throw invalid(`指定设置的增强提示词不得超过 ${GUIDANCE_MAX_LENGTH} 个字符。`);
189
+ }
190
+ if (!TARGET_MERGES.includes(merge)) throw invalid('指定设置的提示词叠加方式只支持"叠加"或"覆盖"。');
191
+ return Object.freeze({
192
+ kind,
193
+ id: targetId,
194
+ label: offlineText(label, TARGET_LABEL_MAX_LENGTH).trim(),
195
+ enabled,
196
+ fields: Object.freeze(CONTEXT_FIELDS.filter((field) => fields.includes(field))),
197
+ guidance: guidance.trim() ? guidance : '',
198
+ merge,
199
+ });
200
+ }
201
+
202
+ /**
203
+ * 严格校验一份完整配置(保存路径用;调用方提交什么就必须完整提交什么)。
204
+ *
205
+ * @param input - { group, direct, targets }。
206
+ * @returns 冻结后的完整配置。
207
+ */
208
+ export function validateContextConfig(input) {
209
+ if (!hasExactKeys(input, CONFIG_KEYS)) throw invalid('请提交完整的上下文增强设置。');
210
+ if (!Array.isArray(input.targets)) throw invalid('指定设置必须是列表。');
211
+ if (input.targets.length > TARGET_LIMIT) {
212
+ throw invalid(`指定设置最多 ${TARGET_LIMIT} 条。`);
213
+ }
214
+ const targets = input.targets.map(validateTarget);
215
+ const seen = new Set();
216
+ for (const target of targets) {
217
+ const key = `${target.kind}:${target.id}`;
218
+ if (seen.has(key)) throw invalid(`指定设置中「${target.id}」重复,请合并后再保存。`);
219
+ seen.add(key);
220
+ }
221
+ return Object.freeze({
222
+ group: validateScope(input.group, '群聊'),
223
+ direct: validateScope(input.direct, '私聊'),
224
+ targets: Object.freeze(targets),
225
+ });
226
+ }
227
+
228
+ /** 旧版(群聊/私聊共用一个开关与一份提示词)配置的迁移。 */
229
+ function migrateLegacyConfig(input) {
230
+ if (!hasExactKeys(input, LEGACY_KEYS)) throw invalid('请提交完整的上下文增强设置。');
231
+ return validateContextConfig({
232
+ group: { enabled: input.groupEnabled, fields: input.fields, guidance: input.guidance },
233
+ direct: { enabled: input.directEnabled, fields: input.fields, guidance: input.guidance },
234
+ targets: [],
235
+ });
236
+ }
237
+
238
+ /**
239
+ * 容错归一化:损坏或缺失的配置永远不能让机器人起不来。
240
+ *
241
+ * @param input - 任意历史数据。
242
+ * @returns 一份合法配置。
243
+ */
244
+ export function normalizeContextConfig(input) {
245
+ try {
246
+ return validateContextConfig(input);
247
+ } catch {
248
+ try {
249
+ return migrateLegacyConfig(input);
250
+ } catch {
251
+ // 已是 v2 形态但缺 targets(dsh-im 4.x 的数据)时补空列表。
252
+ try {
253
+ if (isPlainObject(input)) {
254
+ return validateContextConfig({ ...input, targets: input.targets ?? [] });
255
+ }
256
+ } catch { /* 继续回落默认值 */ }
257
+ return DEFAULT_CONTEXT_CONFIG;
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * 按会话类型与身份挑出本次生效的作用域。
264
+ *
265
+ * @param config - 原始配置(内部会归一化)。
266
+ * @param conversationType - 'direct' 或 'group'。
267
+ * @param identity - { senderId, chatId },缺字段即视为无法命中。
268
+ * @returns 生效作用域 { enabled, fields, guidance },未启用时为 null。
269
+ */
270
+ export function resolveContextScope(config, conversationType, identity = {}) {
271
+ if (conversationType !== 'direct' && conversationType !== 'group') return null;
272
+ const normalized = normalizeContextConfig(config);
273
+ const scope = normalized[conversationType];
274
+ const target = normalized.targets.find((candidate) => {
275
+ if (candidate.enabled !== true) return false;
276
+ if (conversationType === 'direct') {
277
+ return candidate.kind === 'user' && candidate.id === identity.senderId;
278
+ }
279
+ return candidate.kind === 'group' && candidate.id === identity.chatId;
280
+ });
281
+ if (!target) return scope.enabled === true ? scope : null;
282
+ const stacks = target.merge === 'append' && scope.enabled === true;
283
+ const guidance = stacks
284
+ ? [target.guidance.trim(), scope.guidance.trim()].filter(Boolean).join('\n\n')
285
+ : target.guidance;
286
+ return Object.freeze({ enabled: true, fields: target.fields, guidance });
287
+ }
288
+
289
+ /**
290
+ * 在消息入队前捕获生效配置。捕获而不是事后读取,保证排队中的消息用收到它时的设置。
291
+ *
292
+ * @param provider - { botId, channel, readConfig }。
293
+ * @param conversationType - 'direct' 或 'group'。
294
+ * @param identity - { senderId, chatId }。
295
+ * @returns 快照,或作用域未启用时的 null。
296
+ */
297
+ export function captureContextEnhancement(provider, conversationType, identity) {
298
+ try {
299
+ const scope = resolveContextScope(provider?.readConfig?.(), conversationType, identity);
300
+ if (!scope) return null;
301
+ return Object.freeze({
302
+ botId: typeof provider?.botId === 'string' ? provider.botId : '',
303
+ channel: typeof provider?.channel === 'string' ? provider.channel : '',
304
+ conversationType,
305
+ scope,
306
+ });
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * 捕获生效配置与其来源字段工厂。控制命令(不排队)在运行时捕获,普通消息在
314
+ * 接收时捕获,因此补充指令的来源字段属于发出补充指令的人。
315
+ *
316
+ * @param provider - 同 captureContextEnhancement。
317
+ * @param conversationType - 'direct' 或 'group'。
318
+ * @param identity - { senderId, chatId }。
319
+ * @param sourceFactory - 返回当前消息可用来源字段的函数。
320
+ * @returns { snapshot, source } 或 null。
321
+ */
322
+ export function captureContextEnhancementSource(provider, conversationType, identity, sourceFactory) {
323
+ const snapshot = captureContextEnhancement(provider, conversationType, identity);
324
+ return snapshot === null ? null : Object.freeze({ snapshot, source: sourceFactory });
325
+ }
326
+
327
+ function sourceValue(value, field) {
328
+ if (field === 'senderId' && (typeof value === 'bigint' || Number.isFinite(value))) {
329
+ value = String(value);
330
+ }
331
+ if (typeof value !== 'string') return undefined;
332
+ const normalized = value.replace(CONTROL_CHARACTERS, '').trim().slice(0, SOURCE_LIMITS[field]);
333
+ if (!normalized) return undefined;
334
+ if (field === 'channel' && !KNOWN_CHANNELS.has(normalized)) return undefined;
335
+ return normalized;
336
+ }
337
+
338
+ function jsonForTag(value) {
339
+ return JSON.stringify(value).replace(/[<>&]/g, (character) => ({
340
+ '<': '\\u003c', '>': '\\u003e', '&': '\\u0026',
341
+ })[character]);
342
+ }
343
+
344
+ function sourceBlock(snapshot, sourceFactory) {
345
+ const { fields } = snapshot.scope;
346
+ const needsSource = fields.some((field) => (
347
+ field !== 'botId' && field !== 'conversationType' && field !== 'channel'
348
+ ));
349
+ const source = needsSource && typeof sourceFactory === 'function' ? sourceFactory() : null;
350
+ const projected = {};
351
+ for (const field of fields) {
352
+ const raw = field === 'botId' || field === 'conversationType' || field === 'channel'
353
+ ? snapshot[field]
354
+ : source?.[field];
355
+ const value = sourceValue(raw, field);
356
+ if (value !== undefined) projected[field] = value;
357
+ }
358
+ if (Object.keys(projected).length === 0) return '';
359
+ return `${CONTEXT_TAGS.sourceOpen}${jsonForTag(projected)}${CONTEXT_TAGS.sourceClose}`;
360
+ }
361
+
362
+ function guidanceBlock(guidance) {
363
+ if (typeof guidance !== 'string' || !guidance.trim()) return '';
364
+ const body = guidance.replace(
365
+ /<\/?dsh_im_source_guidance\b[^>]*(?:>|$)/gi,
366
+ (tag) => tag.replace(/</g, '&lt;').replace(/>/g, '&gt;'),
367
+ );
368
+ return `${CONTEXT_TAGS.guidanceOpen}\n${body}\n${CONTEXT_TAGS.guidanceClose}`;
369
+ }
370
+
371
+ /**
372
+ * 给正文加前缀。关闭时原样返回,不做任何检查、格式化或复制。
373
+ *
374
+ * @param content - 字符串或内容块数组。
375
+ * @param snapshot - captureContextEnhancement 的结果。
376
+ * @param sourceFactory - 来源字段工厂。
377
+ * @param options - { includeGuidance }:是否把增强提示词也拼进来。
378
+ * **默认 true**,但 hub 在"提示词已经走系统提示词段"时会显式传 false:
379
+ * 来源块(这条消息从哪来)属于消息本身,提示词(对模型的长期指令)不该混在用户轮次里。
380
+ * @returns 加前缀后的正文。
381
+ */
382
+ export function enhanceContent(content, snapshot, sourceFactory, { includeGuidance = true } = {}) {
383
+ if (!snapshot) return content;
384
+ try {
385
+ const blocks = [
386
+ sourceBlock(snapshot, sourceFactory),
387
+ includeGuidance ? guidanceBlock(snapshot.scope.guidance) : '',
388
+ ].filter(Boolean);
389
+ if (blocks.length === 0) return content;
390
+ const prefix = blocks.join(CONTEXT_BLOCK_SEPARATOR);
391
+ if (typeof content === 'string') return `${prefix}${CONTEXT_BLOCK_SEPARATOR}${content}`;
392
+ if (Array.isArray(content)) return [{ type: 'text', text: prefix }, ...content];
393
+ return content;
394
+ } catch {
395
+ // 只有增强本身被隔离;调用方原有流程照常进行。
396
+ return content;
397
+ }
398
+ }
399
+
400
+ /**
401
+ * 设置页入口上显示的一句话状态。
402
+ *
403
+ * @param config - 原始配置。
404
+ * @returns 中文状态文案。
405
+ */
406
+ export function contextStatusLabel(config) {
407
+ const { group, direct, targets } = normalizeContextConfig(config);
408
+ const parts = [];
409
+ if (group.enabled) parts.push('群聊');
410
+ if (direct.enabled) parts.push('私聊');
411
+ const active = targets.filter((target) => target.enabled).length;
412
+ if (parts.length === 0 && active === 0) return '未开启';
413
+ const scopeText = parts.length === 0 ? '未开启全局' : `${parts.join('和')}全局`;
414
+ return active === 0 ? scopeText : `${scopeText} · ${active} 项指定`;
415
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * dsh-chat 渠道插件契约(v1)。
3
+ *
4
+ * 设计规则:**渠道包不 import hub 包**。共享能力一律经运行期服务传递——
5
+ * host 侧 `dshChat`,client 侧 `chatChannels` + `chatUi`。因此本文件只放
6
+ * 通道无关的常量与校验,且必须保持浏览器安全(会被 client bundle 打进浏览器)。
7
+ *
8
+ * @module dsh-chat/shared/contract
9
+ */
10
+
11
+ /** 契约版本。渠道包激活时校验,不匹配即大声失败。 */
12
+ export const CONTRACT_VERSION = 1;
13
+
14
+ /** hub 版本(与 package.json 的 version 保持一致,用于 /version 命令)。 */
15
+ export const HUB_VERSION = '0.0.1';
16
+
17
+ /** hub 包名与行 id。 */
18
+ export const HUB_PACKAGE = 'dsh-chat';
19
+
20
+ /** host 侧服务名:渠道注册表与共享内核。 */
21
+ export const HOST_SERVICE = 'dshChat';
22
+
23
+ /** client 侧服务名:渠道元数据注册表(驱动设置页左栏)。 */
24
+ export const CLIENT_CHANNEL_SERVICE = 'chatChannels';
25
+
26
+ /** client 侧服务名:hub 提供的共享 UI 组件与样式。 */
27
+ export const CLIENT_UI_SERVICE = 'chatUi';
28
+
29
+ /** 设置页 section 槽与 hub 占用的 id / 排序 / 标签。 */
30
+ export const SETTINGS_SECTION_SLOT = 'settings.section';
31
+ export const SETTINGS_SECTION_ID = 'dsh-chat';
32
+ export const SETTINGS_SECTION_ORDER = 21;
33
+ export const SETTINGS_LABEL_KEY = 'Chat机器人';
34
+
35
+ /** hub 在 section 内声明、由各渠道包注册页面的子槽(keyed)。 */
36
+ export const CHANNEL_PAGE_SLOT = 'chat.channel.page';
37
+
38
+ /** RPC 前缀:`/api/dsh-chat/<channelId>`;hub 自身的控制端点用 `<channelId> = control`。 */
39
+ export const RPC_PREFIX = 'dsh-chat';
40
+ export const CONTROL_CHANNEL_ID = 'control';
41
+
42
+ /** 渠道 id 语法(同时用作 URL 片段,因此限定为安全字符)。 */
43
+ export const CHANNEL_ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
44
+
45
+ const LEGACY_DIR_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
46
+
47
+ function isPlainObject(value) {
48
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
49
+ }
50
+
51
+ /**
52
+ * 校验渠道契约版本。渠道包在 apply() 里调用,不匹配就抛出可读错误。
53
+ *
54
+ * @param service - host 侧 `dshChat` 服务(或 client 侧 `chatChannels`)。
55
+ * @param owner - 报错里显示的调用方包名。
56
+ * @returns 服务本身的版本号。
57
+ */
58
+ export function requireContract(service, owner) {
59
+ const actual = service?.contractVersion;
60
+ if (actual === CONTRACT_VERSION) return actual;
61
+ throw new Error(
62
+ `${owner} 需要 dsh-chat 契约 v${CONTRACT_VERSION},当前 hub 提供 v${String(actual)};`
63
+ + '请升级 dsh-chat 或安装与该契约匹配的渠道插件版本。',
64
+ );
65
+ }
66
+
67
+ /**
68
+ * 校验并冻结一份渠道定义。任何字段不合法都抛出,而不是让半成品渠道默默上线。
69
+ *
70
+ * @param definition - 渠道包提交的定义。
71
+ * @returns 冻结后的定义(`label` 归一化为函数)。
72
+ */
73
+ export function validateChannelDefinition(definition) {
74
+ if (!isPlainObject(definition)) throw new TypeError('registerChannel 需要一份渠道定义对象。');
75
+ const { id, label, order, createChannel, legacy, version } = definition;
76
+ if (typeof id !== 'string' || !CHANNEL_ID_PATTERN.test(id)) {
77
+ throw new TypeError('渠道 id 必须是 2–32 位小写字母/数字/连字符,且以字母开头。');
78
+ }
79
+ if (typeof label !== 'string' && typeof label !== 'function') {
80
+ throw new TypeError('渠道 label 必须是字符串或返回字符串的函数。');
81
+ }
82
+ if (!Number.isFinite(order)) throw new TypeError('渠道 order 必须是有限数字。');
83
+ if (typeof createChannel !== 'function') {
84
+ throw new TypeError('渠道定义缺少 createChannel(deps) 函数。');
85
+ }
86
+ if (legacy !== undefined) {
87
+ if (!isPlainObject(legacy) || typeof legacy.dir !== 'string'
88
+ || !LEGACY_DIR_PATTERN.test(legacy.dir)) {
89
+ throw new TypeError('渠道 legacy 只接受 { dir: "dsh-<name>" } 形式的迁移来源。');
90
+ }
91
+ }
92
+ if (version !== undefined && (typeof version !== 'string' || !/^\d+\.\d+\.\d+/u.test(version))) {
93
+ throw new TypeError('渠道 version 必须是形如 1.2.3 的版本号。');
94
+ }
95
+ const resolveLabel = typeof label === 'function' ? label : () => label;
96
+ return Object.freeze({
97
+ id,
98
+ label: resolveLabel,
99
+ order,
100
+ createChannel,
101
+ // 渠道包的版本(设置页的"版本与更新"面板用它对照 package.json)。
102
+ version: version === undefined ? null : version,
103
+ legacy: legacy === undefined ? null : Object.freeze({ dir: legacy.dir }),
104
+ });
105
+ }
106
+
107
+ /**
108
+ * 读取一份渠道定义里可能动态变化的显示名。
109
+ *
110
+ * @param definition - 已冻结的渠道定义。
111
+ * @returns 当前显示名;取值失败时回落到 id。
112
+ */
113
+ export function channelLabel(definition) {
114
+ try {
115
+ const value = definition.label();
116
+ return typeof value === 'string' && value.trim() ? value.trim() : definition.id;
117
+ } catch {
118
+ return definition.id;
119
+ }
120
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 控制面板卡片的**显示项**配置。
3
+ *
4
+ * 为什么会需要:控制面板越长越难用——手机上一屏放不下,而每个人想看的项不一样
5
+ * (只想换模型的人不需要看到"访问策略/任务过程展示")。所以每一项都能关,
6
+ * 而且**私聊与群聊分开**(群里通常只留模型与命令,私聊才摆全套)。
7
+ *
8
+ * 浏览器安全:host 侧(`panel.read` 按这里的规则过滤)与设置页共用这一份实现,
9
+ * 归一化规则不可能前后端漂移。
10
+ *
11
+ * 归一化的方向:**缺项 = 显示**。显示项配置残缺时把卡片变得更空,是"设置页静默失效"
12
+ * 那一类最难查的问题(用户只会觉得"我明明开着");反过来多显示一项只是啰嗦。
13
+ *
14
+ * @module dsh-chat/shared/panel-sections
15
+ */
16
+
17
+ /** 可关闭的显示项(顺序即设置页顺序;标签由 client 侧按 key 翻译)。 */
18
+ export const PANEL_SECTIONS = Object.freeze([
19
+ 'model',
20
+ 'session',
21
+ 'preset',
22
+ 'context',
23
+ 'policy',
24
+ 'fields',
25
+ 'actions',
26
+ 'commands',
27
+ ]);
28
+
29
+ /** 两个作用域:私聊与群聊各一份。 */
30
+ export const PANEL_SCOPES = Object.freeze(['direct', 'group']);
31
+
32
+ function allOn() {
33
+ return Object.fromEntries(PANEL_SECTIONS.map((id) => [id, true]));
34
+ }
35
+
36
+ /**
37
+ * 默认值:全部显示(与加这个配置之前的行为一致)。
38
+ *
39
+ * @returns `{ direct, group }`。
40
+ */
41
+ export function defaultPanelSections() {
42
+ return Object.freeze({ direct: Object.freeze(allOn()), group: Object.freeze(allOn()) });
43
+ }
44
+
45
+ /**
46
+ * 容错归一化:只认已知的显示项,缺的与写错的都按"显示"补齐。
47
+ *
48
+ * @param input - 任意历史数据。
49
+ * @returns `{ direct, group }`(每项都是布尔)。
50
+ */
51
+ export function normalizePanelSections(input) {
52
+ const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
53
+ const scopeOf = (value) => {
54
+ const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
55
+ // 缺项与写错的都按"显示"补齐(只有明确 false 才关)。
56
+ return Object.fromEntries(
57
+ PANEL_SECTIONS.map((id) => [id, raw[id] !== false]),
58
+ );
59
+ };
60
+ return { direct: scopeOf(source.direct), group: scopeOf(source.group) };
61
+ }
62
+
63
+ /**
64
+ * 取某个会话类型该显示哪些项。
65
+ *
66
+ * 认不出会话类型(渠道没给、会话键也不合约定)时返回**全部显示**:宁可多显示,
67
+ * 也不要因为"不知道这是私聊还是群聊"把用户的设置项全藏起来。
68
+ *
69
+ * @param record - 机器人设置记录(`panelSections` 在其中)。
70
+ * @param conversationType - 'direct' | 'group' | null。
71
+ * @returns `{ [sectionId]: boolean }`。
72
+ */
73
+ export function sectionsFor(record, conversationType) {
74
+ if (conversationType !== 'direct' && conversationType !== 'group') return allOn();
75
+ return normalizePanelSections(record?.panelSections)[conversationType];
76
+ }