@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0

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 (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Web 设置面板的 Host 视图模型:把 `FeishuService` 的运行时事实、覆盖层与
3
+ * 字段描述组装成 wire 数据,并处理保存/清除/重连/清理四个动作。
4
+ *
5
+ * 这一层不碰 HTTP(见 `routes.ts`),也不碰 fs 细节(见 `overlay.ts`);
6
+ * 唯一的文件系统动作是保存 `baseDir` 时通过 `workspaces.ts` 创建并检查
7
+ * 工作区根目录,这样不可用的路径在保存时就暴露,而不是等到飞书里提示
8
+ * 「会话创建失败」。因此可以只用假 target 做单测。
9
+ *
10
+ * @module dsh-channels/settings/surface
11
+ */
12
+
13
+ import { DSH_CHANNELS_VERSION } from '../session-export.ts'
14
+ import type { FeishuConfig } from '../config.ts'
15
+ import type { HealthSnapshot } from '../health.ts'
16
+ import { prepareWorkspaceBaseDir } from '../workspaces.ts'
17
+ import {
18
+ EDITABLE_KEYS,
19
+ PANEL_COPY,
20
+ RESTART_KEYS,
21
+ } from './contract.ts'
22
+ import type {
23
+ AccessMutationResponse,
24
+ AccessWire,
25
+ CatalogWire,
26
+ ChannelStatusWire,
27
+ CleanupPreviewWire,
28
+ CleanupResponse,
29
+ CleanupRunWire,
30
+ ClearConfigResponse,
31
+ ClearCredentialResponse,
32
+ ConfigValue,
33
+ CredentialField,
34
+ CredentialRefWire,
35
+ EditableFieldWire,
36
+ EditableKey,
37
+ EditableValues,
38
+ FieldIssueWire,
39
+ FieldOptionWire,
40
+ PanelConfigWire,
41
+ PanelWire,
42
+ ReadOnlyFieldWire,
43
+ ReconnectResponse,
44
+ SaveConfigResponse,
45
+ SaveCredentialsRequest,
46
+ SaveCredentialsResponse,
47
+ } from './contract.ts'
48
+ import {
49
+ parseOverlay,
50
+ pickEditable,
51
+ } from './overlay.ts'
52
+ import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
53
+ import type { SettingsCatalog } from './catalog.ts'
54
+
55
+ /** The slice of `FeishuService` the panel needs (structural, easy to fake). */
56
+ export interface SettingsTarget {
57
+ isRunning(): boolean
58
+ isRestarting(): boolean
59
+ health(): HealthSnapshot
60
+ startedAtMs(): number | undefined
61
+ uptimeMs(): number | undefined
62
+ startError(): string | undefined
63
+ configSnapshot(): FeishuConfig
64
+ baseConfigSnapshot(): FeishuConfig
65
+ runningConfigSnapshot(): FeishuConfig | undefined
66
+ applyOverlay(overlay: Partial<FeishuConfig>): void
67
+ restart(): Promise<void>
68
+ credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }>
69
+ setCredential(field: CredentialField, value: string): Promise<void>
70
+ clearCredential(field: CredentialField): Promise<void>
71
+ /** What the workspace-retention cleanup would delete right now (read-only scan). */
72
+ cleanupPreview(): Promise<CleanupPreviewWire>
73
+ /** Run the workspace cleanup once; partial failures are reported per workspace. */
74
+ runWorkspaceCleanup(): Promise<CleanupRunWire>
75
+ /** Current admission snapshot: mode, administrator, whitelists, pending requests. */
76
+ accessSnapshot(): AccessWire
77
+ /** Authorize one group (panel entry); resolves with the refreshed snapshot. */
78
+ addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse>
79
+ /** Revoke one group authorization (takes effect on the next message). */
80
+ removeAccessGroup(chatId: string): Promise<AccessMutationResponse>
81
+ /** Authorize one user `open_id` (global across authorized groups). */
82
+ addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse>
83
+ /** Revoke one user authorization (takes effect on the next message). */
84
+ removeAccessUser(openId: string): Promise<AccessMutationResponse>
85
+ /** Settle one pending request (panel shares this path with the Feishu DM card). */
86
+ resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse>
87
+ }
88
+
89
+ /** Presence/source/writability facts returned by the credentials seam. */
90
+ export interface CredentialInfo {
91
+ configured: boolean
92
+ writable?: boolean
93
+ source?: string
94
+ }
95
+
96
+ const RESTART_SET = new Set<string>(RESTART_KEYS)
97
+
98
+ /** Optional text fields: an empty string in the panel clears the override. */
99
+ const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
100
+ 'provider',
101
+ 'model',
102
+ 'reasoningEffort',
103
+ 'agentPreset',
104
+ 'adminOpenId',
105
+ ])
106
+
107
+ const BOOLEAN_KEYS = new Set<EditableKey>([
108
+ 'requireMention',
109
+ 'welcomeOnboarding',
110
+ 'allowPermissionSwitch',
111
+ 'acceptAttachments',
112
+ 'workspaceRetentionAuto',
113
+ ])
114
+
115
+ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
116
+ dmMode: [
117
+ { value: 'open', label: '开放私聊' },
118
+ { value: 'disabled', label: '禁用私聊' },
119
+ ],
120
+ threadFallback: [
121
+ { value: 'degrade', label: '话题被拒时降级为会话级' },
122
+ { value: 'strict', label: '话题被拒时直接失败' },
123
+ ],
124
+ workspaceRetention: [
125
+ { value: 'off', label: '关闭(不删除)' },
126
+ { value: '7d', label: '7 天' },
127
+ { value: '30d', label: '30 天' },
128
+ { value: '90d', label: '90 天' },
129
+ { value: '180d', label: '180 天' },
130
+ ],
131
+ loggerLevel: [
132
+ { value: 'debug', label: 'debug' },
133
+ { value: 'info', label: 'info' },
134
+ { value: 'warn', label: 'warn' },
135
+ { value: 'error', label: 'error' },
136
+ ],
137
+ accessMode: [
138
+ { value: 'guarded', label: '守卫模式(仅管理员与白名单)' },
139
+ { value: 'open', label: '开放(不安全,仅调试)' },
140
+ ],
141
+ }
142
+
143
+ /** Panel copy per editable key: label, help, and (for numbers) bounds. */
144
+ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
145
+ baseDir: {
146
+ label: '工作区根目录(文件落点)',
147
+ help: '每个飞书话题拥有独立工作区:私聊 <baseDir>/topics/p2p/<open_id>/<topic>/,群聊 <baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/(topic 来自话题 id)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的工作区 —— 已有话题即使 /renew 也沿用原工作区,旧文件不会迁移。',
148
+ placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
149
+ wide: true,
150
+ },
151
+ dmMode: { label: '私聊模式', help: '禁用后私聊消息不再触发会话;群聊与已绑定话题不受影响。' },
152
+ requireMention: { label: '普通群需 @ 机器人', help: '已绑定的主题内始终不需要 @;关闭后普通群的每条消息都会触发。' },
153
+ threadFallback: { label: '话题被拒时的兜底', help: 'degrade 降级为会话级路由;strict 直接让回复失败。' },
154
+ welcomeOnboarding: { label: '首次私聊引导卡', help: '每个私聊会话第一次触达时发送一张命令与用法引导卡,只发一次。' },
155
+ allowPermissionSwitch: { label: '允许飞书侧切换权限预设', help: '安全敏感:开启后话题所有者可用 /permissions 切换沙箱与审批预设。' },
156
+ adminOpenId: {
157
+ label: '管理员 open_id',
158
+ help: '唯一管理员。私聊机器人发送 /userid 获取(ou_ 开头,不是 user_id/邮箱);留空 = 清除 Web 覆盖。guarded 模式下未配置会拒绝所有对话。',
159
+ placeholder: 'ou_xxx',
160
+ },
161
+ accessMode: {
162
+ label: '准入模式',
163
+ help: 'guarded(默认):私聊仅管理员,群聊需群授权且发送者是管理员或已授权用户;open:应用可用范围内的任何人都能对话,不安全,仅调试。',
164
+ },
165
+ authRequestTtlDays: {
166
+ label: '授权申请有效期(天)',
167
+ help: '入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可直接在「访问与授权」面板处理)。',
168
+ min: 1,
169
+ max: 30,
170
+ step: 1,
171
+ },
172
+ acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
173
+ workspaceRetention: { label: '过期工作区保留期', help: '超过所选天数没有活动的飞书工作区会进入下方「工作区清理」列表;「关闭」表示永不删除。DSH 会话日志不受影响。' },
174
+ workspaceRetentionAuto: { label: '自动执行工作区清理', help: '开启后插件在启动时及每天执行一次过期工作区清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
175
+ provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
176
+ model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
177
+ reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
178
+ agentPreset: { label: 'Agent 预设', help: '新会话使用的预设 id(如 standard);留空 = roster 默认。', placeholder: '留空 = roster 默认' },
179
+ refreshMs: { label: '流式刷新间隔(ms)', help: '流式卡片的 patch 节流间隔,200–10000;需重连生效。', min: 200, max: 10_000, step: 100 },
180
+ statusRefreshMs: { label: '状态行保活间隔(ms)', help: '无文本输出时活动状态行的计时刷新间隔,1000–60000;需重连生效。', min: 1_000, max: 60_000, step: 500 },
181
+ loggerLevel: { label: 'SDK 日志级别', help: '官方 SDK 的日志级别;需重连生效。' },
182
+ }
183
+
184
+ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
185
+ baseDir: 'workspace',
186
+ workspaceRetention: 'workspace',
187
+ workspaceRetentionAuto: 'workspace',
188
+ dmMode: 'behavior',
189
+ requireMention: 'behavior',
190
+ threadFallback: 'behavior',
191
+ welcomeOnboarding: 'behavior',
192
+ allowPermissionSwitch: 'behavior',
193
+ adminOpenId: 'behavior',
194
+ accessMode: 'behavior',
195
+ authRequestTtlDays: 'behavior',
196
+ acceptAttachments: 'content',
197
+ provider: 'model',
198
+ model: 'model',
199
+ reasoningEffort: 'model',
200
+ agentPreset: 'model',
201
+ refreshMs: 'streaming',
202
+ statusRefreshMs: 'streaming',
203
+ loggerLevel: 'streaming',
204
+ }
205
+
206
+ /** One descriptor per editable key, in `EDITABLE_KEYS` order (Host-driven form). */
207
+ export const FIELD_DESCRIPTORS: readonly EditableFieldWire[] = EDITABLE_KEYS.map(key => {
208
+ const meta = FIELD_META[key]
209
+ const options = SELECT_OPTIONS[key]
210
+ return {
211
+ key,
212
+ label: meta.label,
213
+ kind: BOOLEAN_KEYS.has(key) ? 'boolean' : options !== undefined ? 'select' : meta.min !== undefined ? 'number' : 'text',
214
+ group: GROUP_OF[key],
215
+ help: meta.help,
216
+ ...(options === undefined ? {} : { options }),
217
+ ...(meta.min === undefined ? {} : { min: meta.min }),
218
+ ...(meta.max === undefined ? {} : { max: meta.max }),
219
+ ...(meta.step === undefined ? {} : { step: meta.step }),
220
+ ...(meta.placeholder === undefined ? {} : { placeholder: meta.placeholder }),
221
+ ...(meta.wide === undefined ? {} : { wide: meta.wide }),
222
+ restartRequired: RESTART_SET.has(key),
223
+ }
224
+ })
225
+
226
+ /** Keys whose options are resolved from the live host catalog at panel time. */
227
+ type CatalogKey = 'provider' | 'model' | 'reasoningEffort' | 'agentPreset'
228
+
229
+ const CATALOG_KEYS = new Set<EditableKey>(['provider', 'model', 'reasoningEffort', 'agentPreset'])
230
+
231
+ function isCatalogKey(key: EditableKey): key is CatalogKey {
232
+ return CATALOG_KEYS.has(key)
233
+ }
234
+
235
+ /** Every group switched off: the form keeps its free-text controls. */
236
+ export function emptyCatalog(): CatalogWire {
237
+ const none = { selectable: false, options: [] as FieldOptionWire[] }
238
+ return {
239
+ provider: { ...none },
240
+ model: { ...none },
241
+ reasoningEffort: { ...none },
242
+ agentPreset: { ...none },
243
+ }
244
+ }
245
+
246
+ /** Longest accepted catalog query value; anything longer is treated as absent. */
247
+ const MAX_CATALOG_QUERY_LENGTH = 200
248
+
249
+ /** Trim one provider/model query value, dropping empty, hostile, or oversized input. */
250
+ function cleanCatalogQuery(value: string | undefined): string | undefined {
251
+ if (value === undefined) return undefined
252
+ const trimmed = value.trim()
253
+ if (trimmed === '' || trimmed.length > MAX_CATALOG_QUERY_LENGTH) return undefined
254
+ // eslint-disable-next-line no-control-regex
255
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) return undefined
256
+ return trimmed
257
+ }
258
+
259
+ /**
260
+ * Attach the live catalog to the four selector fields. A configured value the
261
+ * host no longer advertises stays visible as an extra option so opening the
262
+ * panel never silently rewrites the effective config.
263
+ */
264
+ export function applyCatalog(
265
+ fields: readonly EditableFieldWire[],
266
+ catalog: CatalogWire,
267
+ current: Partial<Record<CatalogKey, ConfigValue | undefined>>,
268
+ ): EditableFieldWire[] {
269
+ return fields.map((field) => {
270
+ if (!isCatalogKey(field.key)) return field
271
+ const selector = catalog[field.key]
272
+ if (!selector.selectable) return field
273
+ const value = current[field.key]
274
+ const options = typeof value === 'string' && value !== ''
275
+ && !selector.options.some(option => option.value === value)
276
+ ? withCurrentOption(selector.options, value)
277
+ : [...selector.options]
278
+ const next: EditableFieldWire = {
279
+ ...field,
280
+ kind: 'select',
281
+ options,
282
+ }
283
+ if (selector.disabled === true) next.disabled = true
284
+ if (selector.help !== undefined) next.help = selector.help
285
+ return next
286
+ })
287
+ }
288
+
289
+ /** Keep the leading inherit option first, then surface the unlisted current value. */
290
+ function withCurrentOption(
291
+ options: readonly FieldOptionWire[],
292
+ value: string,
293
+ ): FieldOptionWire[] {
294
+ const [inherit, ...rest] = options
295
+ const extra: FieldOptionWire = { value, label: `${value}(当前配置,不在宿主机列表中)` }
296
+ return inherit === undefined ? [extra] : [inherit, extra, ...rest]
297
+ }
298
+
299
+ function messageOf(error: unknown): string {
300
+ return error instanceof Error ? error.message : String(error)
301
+ }
302
+
303
+ function formatDays(ms: number): string {
304
+ return ms === 0 ? '关闭(不过期)' : `${Math.round(ms / 86_400_000)} 天`
305
+ }
306
+
307
+ function formatSeconds(ms: number): string {
308
+ return `${Math.round(ms / 1_000)} 秒`
309
+ }
310
+
311
+ function formatOptional(value: string | number | undefined, suffix = ''): string {
312
+ return value === undefined ? '未设置' : `${value}${suffix}`
313
+ }
314
+
315
+ /** Credential values are opaque strings; trim, cap length, reject control chars. */
316
+ const MAX_CREDENTIAL_LENGTH = 512
317
+
318
+ export function normalizeCredentialValue(raw: unknown): { ok: true; value: string } | { ok: false; message: string } {
319
+ if (typeof raw !== 'string') return { ok: false, message: '必须是字符串' }
320
+ const value = raw.trim()
321
+ if (value === '') return { ok: false, message: '不能为空' }
322
+ if (value.length > MAX_CREDENTIAL_LENGTH) return { ok: false, message: `长度不得超过 ${MAX_CREDENTIAL_LENGTH}` }
323
+ // eslint-disable-next-line no-control-regex
324
+ if (/[\u0000-\u001f\u007f]/.test(value)) return { ok: false, message: '包含非法控制字符' }
325
+ return { ok: true, value }
326
+ }
327
+
328
+ /** Longest accepted whitelist identifier / display name. */
329
+ const MAX_ACCESS_VALUE_LENGTH = 128
330
+
331
+ /** Feishu user open_ids (`ou_` + URL-safe id). Identity is open_id only. */
332
+ const OPEN_ID_PATTERN = /^ou_[A-Za-z0-9_-]+$/
333
+
334
+ /** Trim one whitelist identifier, rejecting empty, oversized, or hostile input. */
335
+ export function normalizeAccessId(
336
+ raw: unknown,
337
+ label: string,
338
+ invalidFormat?: string,
339
+ ): { ok: true; value: string } | { ok: false; message: string } {
340
+ if (typeof raw !== 'string') return { ok: false, message: `${label}必须是字符串` }
341
+ const value = raw.trim()
342
+ if (value === '') return { ok: false, message: `${label}不能为空` }
343
+ if (value.length > MAX_ACCESS_VALUE_LENGTH) {
344
+ return { ok: false, message: `${label}长度不得超过 ${MAX_ACCESS_VALUE_LENGTH}` }
345
+ }
346
+ // eslint-disable-next-line no-control-regex
347
+ if (/[\u0000-\u001f\u007f]/.test(value)) return { ok: false, message: `${label}包含非法控制字符` }
348
+ if (invalidFormat !== undefined && !OPEN_ID_PATTERN.test(value)) {
349
+ return { ok: false, message: invalidFormat }
350
+ }
351
+ return { ok: true, value }
352
+ }
353
+
354
+ /** Optional display name: trimmed; empty means "unknown" and is dropped. */
355
+ export function normalizeAccessName(
356
+ raw: unknown,
357
+ ): { ok: true; value: string | undefined } | { ok: false; message: string } {
358
+ if (raw === undefined || raw === null) return { ok: true, value: undefined }
359
+ if (typeof raw !== 'string') return { ok: false, message: '名称必须是字符串' }
360
+ const value = raw.trim()
361
+ if (value === '') return { ok: true, value: undefined }
362
+ if (value.length > MAX_ACCESS_VALUE_LENGTH) {
363
+ return { ok: false, message: `名称长度不得超过 ${MAX_ACCESS_VALUE_LENGTH}` }
364
+ }
365
+ // eslint-disable-next-line no-control-regex
366
+ if (/[\u0000-\u001f\u007f]/.test(value)) return { ok: false, message: '名称包含非法控制字符' }
367
+ return { ok: true, value }
368
+ }
369
+
370
+ /** Build the read-only advanced card from the effective config. */
371
+ export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
372
+ return [
373
+ { key: 'routeTtlMs', label: '话题路由过期', value: formatDays(config.routeTtlMs), help: '超期话题的后续消息会被明确拒绝,而不是新建 Context-free Session。' },
374
+ { key: 'approvalTimeoutMs', label: '审批卡片等待', value: formatSeconds(config.approvalTimeoutMs) },
375
+ { key: 'questionTimeoutMs', label: '提问卡片等待', value: formatSeconds(config.questionTimeoutMs) },
376
+ { key: 'maxTokens', label: '单次输出上限', value: formatOptional(config.maxTokens, ' tokens'), help: '留空/未设置时使用模型适配器默认值。' },
377
+ ]
378
+ }
379
+
380
+ /** Assemble the status half of the panel payload. */
381
+ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
382
+ const health = target.health()
383
+ const connection = health.connection
384
+ return {
385
+ running: target.isRunning(),
386
+ level: health.level,
387
+ startedAt: target.startedAtMs(),
388
+ uptimeMs: target.uptimeMs(),
389
+ ...(connection === undefined ? {} : {
390
+ connection: {
391
+ state: connection.state,
392
+ lastConnectTime: connection.lastConnectTime,
393
+ nextConnectTime: connection.nextConnectTime,
394
+ reconnectAttempts: connection.reconnectAttempts,
395
+ },
396
+ }),
397
+ ...(health.botOpenIdTail === undefined ? {} : { botOpenIdTail: health.botOpenIdTail }),
398
+ routeCount: health.routeCount,
399
+ liveAgentCount: health.liveAgentCount,
400
+ inflightTurnCount: health.inflightTurnCount,
401
+ ...(target.startError() === undefined ? {} : { lastStartError: target.startError() }),
402
+ reasons: health.reasons,
403
+ version: DSH_CHANNELS_VERSION,
404
+ overlayPath,
405
+ busy: target.isRestarting(),
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Stateful panel facade over one service + one overlay file. All mutations are
411
+ * validated here; the HTTP layer only parses the transport. The optional
412
+ * [`SettingsCatalog`] turns the four model selectors into host-driven choice
413
+ * lists; without it (headless tests, host without LLM services) they stay
414
+ * free-text controls.
415
+ */
416
+ export class SettingsSurface {
417
+ constructor(
418
+ private readonly target: SettingsTarget,
419
+ private readonly store: OverlayStore,
420
+ private readonly logger?: OverlayLogger,
421
+ private readonly catalog?: SettingsCatalog,
422
+ ) {}
423
+
424
+ /** Wire status only (used by the client's 5 s poll). */
425
+ async status(): Promise<ChannelStatusWire> {
426
+ return buildStatus(this.target, this.store.file)
427
+ }
428
+
429
+ /**
430
+ * Resolve the selector catalog for one route (the client re-queries it when
431
+ * the draft provider/model changes). A broken lookup degrades to the
432
+ * free-text form instead of failing the panel.
433
+ */
434
+ async catalogFor(provider?: string, model?: string): Promise<CatalogWire> {
435
+ if (this.catalog === undefined) return emptyCatalog()
436
+ try {
437
+ return await this.catalog.catalog(cleanCatalogQuery(provider), cleanCatalogQuery(model))
438
+ } catch (error) {
439
+ this.logger?.warn?.('dsh-channels: 选择目录加载失败:%s', messageOf(error))
440
+ return emptyCatalog()
441
+ }
442
+ }
443
+
444
+ /** Status + editable config with provenance + read-only facts. */
445
+ async panel(): Promise<PanelWire> {
446
+ const base = this.target.baseConfigSnapshot()
447
+ const effective = this.target.configSnapshot()
448
+ const overlay = await this.store.load()
449
+ const running = this.target.runningConfigSnapshot()
450
+ const overriddenKeys = EDITABLE_KEYS.filter(key => overlay[key] !== undefined)
451
+ const restartPendingKeys = this.target.isRunning() && running !== undefined
452
+ ? RESTART_KEYS.filter(key => effective[key] !== running[key])
453
+ : []
454
+ const catalog = await this.catalogFor(effective.provider, effective.model)
455
+ const fields = applyCatalog(FIELD_DESCRIPTORS, catalog, {
456
+ provider: effective.provider,
457
+ model: effective.model,
458
+ reasoningEffort: effective.reasoningEffort,
459
+ agentPreset: effective.agentPreset,
460
+ })
461
+
462
+ let credentialStates: { appId: CredentialInfo; appSecret: CredentialInfo } = {
463
+ appId: { configured: false, writable: false },
464
+ appSecret: { configured: false, writable: false },
465
+ }
466
+ try {
467
+ credentialStates = await this.target.credentialStates()
468
+ } catch (error) {
469
+ this.logger?.warn?.('dsh-channels: 凭据状态读取失败:%s', messageOf(error))
470
+ }
471
+ const toWire = (ref: string, state: CredentialInfo): CredentialRefWire => ({
472
+ ref,
473
+ configured: state.configured,
474
+ writable: state.writable === true,
475
+ ...(state.source === undefined ? {} : { source: state.source }),
476
+ })
477
+ const credentials: PanelConfigWire['credentials'] = {
478
+ appId: toWire(effective.appId, credentialStates.appId),
479
+ appSecret: toWire(effective.appSecret, credentialStates.appSecret),
480
+ }
481
+
482
+ // The cleanup preview is derived from live workspace/route state; a broken
483
+ // seam must not break the whole panel, so it degrades to zero counts + error.
484
+ let cleanup: CleanupPreviewWire
485
+ try {
486
+ cleanup = await this.target.cleanupPreview()
487
+ } catch (error) {
488
+ this.logger?.warn?.('dsh-channels: 工作区清理预览失败:%s', messageOf(error))
489
+ cleanup = {
490
+ retention: effective.workspaceRetention,
491
+ auto: effective.workspaceRetentionAuto,
492
+ workspaces: 0,
493
+ candidates: 0,
494
+ candidateBytes: 0,
495
+ error: messageOf(error),
496
+ }
497
+ }
498
+
499
+ // The access snapshot comes from in-memory maps, but a broken seam must
500
+ // still not take the whole panel down: degrade to an empty, fail-closed
501
+ // view (the whitelist mutations will report their own errors).
502
+ let access: AccessWire
503
+ try {
504
+ access = this.target.accessSnapshot()
505
+ } catch (error) {
506
+ this.logger?.warn?.('dsh-channels: 访问授权快照读取失败:%s', messageOf(error))
507
+ access = {
508
+ mode: effective.accessMode,
509
+ admin: { configured: false },
510
+ groups: [],
511
+ users: [],
512
+ requests: [],
513
+ }
514
+ }
515
+
516
+ return {
517
+ status: buildStatus(this.target, this.store.file),
518
+ config: {
519
+ base: pickEditable(base) as Record<string, ConfigValue | undefined>,
520
+ effective: pickEditable(effective) as Record<string, ConfigValue | undefined>,
521
+ overlay: overlay as EditableValues,
522
+ overriddenKeys,
523
+ restartPendingKeys,
524
+ fields,
525
+ readOnly: readOnlyFields(effective),
526
+ credentials,
527
+ },
528
+ cleanup,
529
+ access,
530
+ }
531
+ }
532
+
533
+ /** Validate + persist a partial edit; live fields take effect immediately. */
534
+ async save(values: EditableValues): Promise<SaveConfigResponse> {
535
+ const unknown = Object.keys(values).filter(key => !(EDITABLE_KEYS as readonly string[]).includes(key))
536
+ if (unknown.length > 0) {
537
+ return { ok: false, error: `未知配置项:${unknown.join(', ')}` }
538
+ }
539
+
540
+ const raw: Record<string, unknown> = {}
541
+ const clearedKeys: EditableKey[] = []
542
+ for (const key of EDITABLE_KEYS) {
543
+ const value = values[key]
544
+ if (value === undefined) continue
545
+ if (typeof value === 'string' && value === '' && OPTIONAL_TEXT_KEYS.has(key)) {
546
+ clearedKeys.push(key)
547
+ continue
548
+ }
549
+ raw[key] = value
550
+ }
551
+
552
+ const parsed = parseOverlay(raw)
553
+ if (!parsed.ok) {
554
+ const issues: FieldIssueWire[] = parsed.issues.map(issue => ({
555
+ key: issue.path,
556
+ message: issue.message,
557
+ }))
558
+ return { ok: false, error: '配置校验失败', issues }
559
+ }
560
+
561
+ // A workspace root typed into the panel must be usable right now: create
562
+ // and probe it before anything is persisted, so a typo (or a path that is
563
+ // an existing file / unwritable) fails here rather than as a session
564
+ // creation failure card inside Feishu.
565
+ if (parsed.patch.baseDir !== undefined) {
566
+ const problem = await prepareWorkspaceBaseDir(parsed.patch.baseDir)
567
+ if (problem !== undefined) {
568
+ return {
569
+ ok: false,
570
+ error: '工作区目录不可用',
571
+ issues: [{ key: 'baseDir', message: problem }],
572
+ }
573
+ }
574
+ }
575
+
576
+ let merged: OverlayPatch
577
+ try {
578
+ merged = { ...await this.store.load() }
579
+ for (const key of clearedKeys) delete merged[key]
580
+ Object.assign(merged, parsed.patch)
581
+ } catch (error) {
582
+ return { ok: false, error: `写入覆盖配置失败:${messageOf(error)}` }
583
+ }
584
+
585
+ // The host resolves a request route as a provider/model PAIR: a lone
586
+ // provider (or lone model) leaves the session without a complete route,
587
+ // so an edit to either side must end with both set or both cleared.
588
+ if (values.provider !== undefined || values.model !== undefined) {
589
+ const prospective = { ...this.target.baseConfigSnapshot(), ...merged }
590
+ const hasProvider = (prospective.provider ?? '') !== ''
591
+ const hasModel = (prospective.model ?? '') !== ''
592
+ if (hasProvider !== hasModel) {
593
+ return {
594
+ ok: false,
595
+ error: '模型服务商与模型 id 必须同时配置,或都留空沿用部署默认',
596
+ issues: [hasProvider
597
+ ? { key: 'model', message: '已选择模型服务商,还需要选择模型 id(或把两者都留空)' }
598
+ : { key: 'provider', message: '已选择模型 id,还需要选择模型服务商(或把两者都留空)' }],
599
+ }
600
+ }
601
+ }
602
+
603
+ try {
604
+ await this.store.save(merged)
605
+ } catch (error) {
606
+ return { ok: false, error: `写入覆盖配置失败:${messageOf(error)}` }
607
+ }
608
+ this.target.applyOverlay(merged)
609
+ return { ok: true, panel: await this.panel(), clearedKeys }
610
+ }
611
+
612
+ /** Drop every Web override and fall back to the profile config. */
613
+ async clear(): Promise<ClearConfigResponse> {
614
+ try {
615
+ await this.store.clear()
616
+ } catch (error) {
617
+ return { ok: false, error: `清除覆盖配置失败:${messageOf(error)}` }
618
+ }
619
+ this.target.applyOverlay({})
620
+ return { ok: true, panel: await this.panel() }
621
+ }
622
+
623
+ /** Write provided credential values through the host seam (never echoed back). */
624
+ async saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
625
+ const writes: Array<{ field: CredentialField; value: string }> = []
626
+ const issues: FieldIssueWire[] = []
627
+ for (const field of ['appId', 'appSecret'] as const) {
628
+ const raw = values[field]
629
+ if (raw === undefined) continue
630
+ const normalized = normalizeCredentialValue(raw)
631
+ if (!normalized.ok) {
632
+ issues.push({ key: field, message: normalized.message })
633
+ continue
634
+ }
635
+ writes.push({ field, value: normalized.value })
636
+ }
637
+ if (issues.length > 0) return { ok: false, error: '凭据校验失败', issues }
638
+ if (writes.length === 0) return { ok: false, error: '请至少填写一项凭据(App ID 或 App Secret)' }
639
+
640
+ try {
641
+ for (const write of writes) await this.target.setCredential(write.field, write.value)
642
+ } catch (error) {
643
+ return { ok: false, error: messageOf(error) }
644
+ }
645
+ return this.finishCredentialChange('save')
646
+ }
647
+
648
+ /** Remove the stored value behind one credential reference. */
649
+ async clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
650
+ if (field !== 'appId' && field !== 'appSecret') {
651
+ return { ok: false, error: '未知的凭据字段' }
652
+ }
653
+ try {
654
+ await this.target.clearCredential(field)
655
+ } catch (error) {
656
+ return { ok: false, error: messageOf(error) }
657
+ }
658
+ return this.finishCredentialChange('clear')
659
+ }
660
+
661
+ /**
662
+ * Credential values are resolved on connect. When the channel is stopped,
663
+ * retry immediately (nothing to interrupt); when it is running, ask the user
664
+ * to reconnect. A failed retry still reports the save as successful.
665
+ */
666
+ private async finishCredentialChange(kind: 'save' | 'clear'): Promise<SaveCredentialsResponse> {
667
+ if (this.target.isRunning()) {
668
+ return { ok: true, panel: await this.panel(), reconnect: 'pending' }
669
+ }
670
+ if (kind === 'clear') {
671
+ return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
672
+ }
673
+ try {
674
+ await this.target.restart()
675
+ return { ok: true, panel: await this.panel(), reconnect: 'auto' }
676
+ } catch (error) {
677
+ return {
678
+ ok: true,
679
+ panel: await this.panel(),
680
+ reconnect: 'auto',
681
+ autoReconnectError: messageOf(error),
682
+ }
683
+ }
684
+ }
685
+
686
+ /** Close and reopen the Feishu long connection with the latest config. */
687
+ async reconnect(): Promise<ReconnectResponse> {
688
+ try {
689
+ await this.target.restart()
690
+ } catch (error) {
691
+ return { ok: false, error: `重连失败:${messageOf(error)}` }
692
+ }
693
+ return { ok: true, panel: await this.panel() }
694
+ }
695
+
696
+ /** Read the current admission snapshot (mode, administrator, whitelists, requests). */
697
+ async access(): Promise<AccessWire> {
698
+ return this.target.accessSnapshot()
699
+ }
700
+
701
+ /** Authorize one group from the panel. Validation happens before the write. */
702
+ async saveAccessGroup(chatId: unknown, name?: unknown): Promise<AccessMutationResponse> {
703
+ const id = normalizeAccessId(chatId, '群 chat_id')
704
+ if (!id.ok) return { ok: false, error: id.message }
705
+ const label = normalizeAccessName(name)
706
+ if (!label.ok) return { ok: false, error: label.message }
707
+ return this.mutateAccess(() => this.target.addAccessGroup(id.value, label.value))
708
+ }
709
+
710
+ /** Revoke one group authorization (immediate: the next message re-evaluates). */
711
+ async removeAccessGroup(chatId: unknown): Promise<AccessMutationResponse> {
712
+ const id = normalizeAccessId(chatId, '群 chat_id')
713
+ if (!id.ok) return { ok: false, error: id.message }
714
+ return this.mutateAccess(() => this.target.removeAccessGroup(id.value))
715
+ }
716
+
717
+ /** Authorize one user `open_id`; `ou_` prefix is required (not user_id/email). */
718
+ async saveAccessUser(openId: unknown, name?: unknown): Promise<AccessMutationResponse> {
719
+ const id = normalizeAccessId(openId, '用户 open_id', '用户 open_id 必须形如 ou_xxx(不是 user_id/邮箱)')
720
+ if (!id.ok) return { ok: false, error: id.message }
721
+ const label = normalizeAccessName(name)
722
+ if (!label.ok) return { ok: false, error: label.message }
723
+ return this.mutateAccess(() => this.target.addAccessUser(id.value, label.value))
724
+ }
725
+
726
+ /** Revoke one user authorization (immediate: the next message re-evaluates). */
727
+ async removeAccessUser(openId: unknown): Promise<AccessMutationResponse> {
728
+ const id = normalizeAccessId(openId, '用户 open_id', '用户 open_id 必须形如 ou_xxx(不是 user_id/邮箱)')
729
+ if (!id.ok) return { ok: false, error: id.message }
730
+ return this.mutateAccess(() => this.target.removeAccessUser(id.value))
731
+ }
732
+
733
+ /**
734
+ * Settle one pending request. Shares the target-side resolver with the DM
735
+ * card, so the panel and the card can never disagree on the outcome.
736
+ */
737
+ async resolveAccessRequest(token: unknown, decision: unknown): Promise<AccessMutationResponse> {
738
+ const normalized = normalizeAccessId(token, '申请 token')
739
+ if (!normalized.ok) return { ok: false, error: normalized.message }
740
+ if (decision !== 'allow' && decision !== 'deny') {
741
+ return { ok: false, error: 'decision 必须是 allow 或 deny' }
742
+ }
743
+ return this.mutateAccess(() => this.target.resolveAccessRequest(normalized.value, decision))
744
+ }
745
+
746
+ /** Run one whitelist mutation, folding thrown seam errors into the wire type. */
747
+ private async mutateAccess(
748
+ mutation: () => Promise<AccessMutationResponse>,
749
+ ): Promise<AccessMutationResponse> {
750
+ try {
751
+ return await mutation()
752
+ } catch (error) {
753
+ return { ok: false, error: messageOf(error) }
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Run the workspace-retention cleanup once. Irreversible deletions are the
759
+ * target's responsibility; the surface only reports the outcome and returns
760
+ * a refreshed panel (its cleanup preview then reflects the remaining workspaces).
761
+ */
762
+ async runCleanup(): Promise<CleanupResponse> {
763
+ let result: CleanupRunWire
764
+ try {
765
+ result = await this.target.runWorkspaceCleanup()
766
+ } catch (error) {
767
+ return { ok: false, error: `清理失败:${messageOf(error)}` }
768
+ }
769
+ return { ok: true, result, panel: await this.panel() }
770
+ }
771
+ }
772
+
773
+ export { PANEL_COPY }