@modesto_ma/dsh-channels 0.1.0 → 0.3.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 (50) hide show
  1. package/README.md +89 -55
  2. package/docs/access-control-design.md +5 -5
  3. package/docs/feasibility-and-design.md +54 -26
  4. package/docs/feishu-onboarding.md +26 -20
  5. package/docs/persistence.md +4 -4
  6. package/docs/refactor-plan.md +6 -5
  7. package/docs/workspace-isolation-design.md +78 -0
  8. package/lib/client.js +1715 -882
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +1556 -680
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/cards/index.ts +1 -0
  14. package/src/cards/onboarding.ts +23 -0
  15. package/src/cards/primitives.ts +20 -0
  16. package/src/cards/streaming.ts +310 -291
  17. package/src/cleanup.ts +256 -119
  18. package/src/client/api.ts +20 -0
  19. package/src/client/catalog.ts +1 -1
  20. package/src/client/index.ts +1 -1
  21. package/src/client/panel/access-card.tsx +294 -0
  22. package/src/client/panel/access-tab.tsx +46 -0
  23. package/src/client/panel/advanced-tab.tsx +80 -0
  24. package/src/client/panel/cleanup-card.tsx +106 -0
  25. package/src/client/panel/connection-tab.tsx +214 -0
  26. package/src/client/panel/credentials-card.tsx +104 -0
  27. package/src/client/panel/index.tsx +862 -0
  28. package/src/client/panel/model-tab.tsx +23 -0
  29. package/src/client/panel/sessions-tab.tsx +21 -0
  30. package/src/client/panel/shared.tsx +488 -0
  31. package/src/client/panel/workspace-tab.tsx +40 -0
  32. package/src/client/styles.ts +24 -6
  33. package/src/config.ts +44 -12
  34. package/src/domain.ts +34 -3
  35. package/src/feishu/agent-context.ts +9 -5
  36. package/src/index.ts +32 -14
  37. package/src/routing/inbound.ts +88 -43
  38. package/src/service.ts +259 -65
  39. package/src/session/isolation.ts +277 -0
  40. package/src/session/manager.ts +63 -20
  41. package/src/session/repair.ts +2 -3
  42. package/src/session/routes.ts +2 -0
  43. package/src/session-commands.ts +41 -14
  44. package/src/settings/contract.ts +223 -47
  45. package/src/settings/directory-picker.ts +121 -0
  46. package/src/settings/routes.ts +49 -2
  47. package/src/settings/surface.ts +171 -25
  48. package/src/streamer.ts +70 -30
  49. package/src/workspaces.ts +50 -28
  50. package/src/client/panel.tsx +0 -1475
@@ -12,10 +12,15 @@ export const STATUS_ROUTE = '/dsh-channels/status'
12
12
  export const PANEL_ROUTE = '/dsh-channels/panel'
13
13
  export const CONFIG_ROUTE = '/dsh-channels/config'
14
14
  export const CREDENTIALS_ROUTE = '/dsh-channels/credentials'
15
+ /**
16
+ * Connection lifecycle action (POST). While the channel runs it restarts the
17
+ * long connection; while it is stopped it opens one — so the panel has exactly
18
+ * one "connect / reconnect" button. Credentials are resolved again either way.
19
+ */
15
20
  export const RECONNECT_ROUTE = '/dsh-channels/reconnect'
16
21
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
17
22
  export const CATALOG_ROUTE = '/dsh-channels/catalog'
18
- /** 手动执行一次过期工作区清理(POST,无请求体)。 */
23
+ /** 手动执行一次过期目录清理(POST,无请求体)。 */
19
24
  export const CLEANUP_ROUTE = '/dsh-channels/cleanup'
20
25
  /** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
21
26
  export const ACCESS_ROUTE = '/dsh-channels/access'
@@ -25,6 +30,14 @@ export const ACCESS_GROUPS_ROUTE = '/dsh-channels/access/groups'
25
30
  export const ACCESS_USERS_ROUTE = '/dsh-channels/access/users'
26
31
  /** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
27
32
  export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
33
+ /**
34
+ * 打开宿主机的系统目录选择框(POST,无请求体),返回绝对路径。
35
+ *
36
+ * 只有宿主挂载了 `directoryPicker` 的 native 后端(桌面/本地 GUI 会话)才
37
+ * 会注册实际选择逻辑;远程/无头会话的 browse 后端不可用,面板不下发
38
+ * [`EditableFieldWire.browse`],保留手写输入。
39
+ */
40
+ export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
28
41
 
29
42
  /**
30
43
  * 可由 Web 面板覆盖的配置项。全部是 `src/config.ts` 里已存在的字段;
@@ -36,7 +49,7 @@ export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
36
49
  * 因此不会落进覆盖层文件。
37
50
  *
38
51
  * `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
39
- * **绝对路径**(`~` 会展开);已有 Session / 工作区不迁移,只影响之后新建的
52
+ * **绝对路径**(`~` 会展开);已有 Session / 目录不迁移,只影响之后新建的
40
53
  * 会话。校验与保存时建目录见 `src/workspaces.ts`。
41
54
  */
42
55
  export const EDITABLE_KEYS = [
@@ -86,8 +99,12 @@ export type RestartKey = (typeof RESTART_KEYS)[number]
86
99
  /** Render kinds the generic panel form understands. */
87
100
  export type FieldKind = 'boolean' | 'number' | 'text' | 'select'
88
101
 
89
- /** Field groups map one-to-one onto panel cards. */
90
- export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'model' | 'streaming'
102
+ /**
103
+ * Field groups partition the editable form. Each group is rendered inside one
104
+ * panel tab (see {@link TAB_FIELD_GROUPS}); the Host is the source of truth for
105
+ * which key belongs to which group.
106
+ */
107
+ export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'model' | 'streaming' | 'access'
91
108
 
92
109
  /** One selectable value rendered by the generic form. */
93
110
  export interface FieldOptionWire {
@@ -111,6 +128,12 @@ export interface EditableFieldWire {
111
128
  placeholder?: string
112
129
  /** Render across the whole form width (long paths). */
113
130
  wide?: boolean
131
+ /**
132
+ * `directory`:给这个 text 字段加一个「选择目录…」按钮,点击后由 Host
133
+ * 打开宿主机系统目录选择框并把绝对路径写回草稿。仅当宿主提供 native
134
+ * 目录选择器时下发;缺省(含 browse 后端)保持纯手写输入。
135
+ */
136
+ browse?: 'directory'
114
137
  /** 保存后需重连飞书通道才生效。 */
115
138
  restartRequired: boolean
116
139
  }
@@ -178,9 +201,36 @@ export interface ConnectionWire {
178
201
  reconnectAttempts: number
179
202
  }
180
203
 
204
+ /**
205
+ * The panel-facing lifecycle phase, derived by the Host from configuration
206
+ * readiness and runtime state. Enabling the plugin starts in `unconfigured`
207
+ * (or `idle` when credentials already resolve) and never forces a connection
208
+ * before the Feishu application credentials are complete.
209
+ */
210
+ export type ChannelPhaseWire =
211
+ /** Credentials are incomplete: the plugin is loaded but deliberately not connected. */
212
+ | 'unconfigured'
213
+ /** Credentials are complete, no connection is open yet. */
214
+ | 'idle'
215
+ /** A connection attempt / reconnect is in flight. */
216
+ | 'connecting'
217
+ /** The Feishu long connection is open. */
218
+ | 'connected'
219
+ /** The last connection attempt failed; the reason is in `lastStartError`. */
220
+ | 'error'
221
+
181
222
  /** Runtime connection/liveness facts. */
182
223
  export interface ChannelStatusWire {
183
224
  running: boolean
225
+ /**
226
+ * Whether the Feishu application credentials are complete. When false the
227
+ * plugin stays loaded (settings panel usable) but opens no long connection.
228
+ */
229
+ configured: boolean
230
+ /** Display phase derived from `configured` / `running` / `lastStartError`. */
231
+ phase: ChannelPhaseWire
232
+ /** Human-readable config items that are still missing (names only, never values). */
233
+ missingConfig: string[]
184
234
  level: 'healthy' | 'degraded' | 'unhealthy'
185
235
  startedAt?: number
186
236
  uptimeMs?: number
@@ -189,7 +239,7 @@ export interface ChannelStatusWire {
189
239
  routeCount: number
190
240
  liveAgentCount: number
191
241
  inflightTurnCount: number
192
- /** 最近一次启动失败的原因(成功后清空)。 */
242
+ /** 最近一次连接失败的原因(成功后清空)。 */
193
243
  lastStartError?: string
194
244
  /** 健康判定的原因说明(空数组 = 健康);由 Host 的 health 折叠给出。 */
195
245
  reasons: string[]
@@ -197,7 +247,7 @@ export interface ChannelStatusWire {
197
247
  version: string
198
248
  /** Web 覆盖文件绝对路径(用户可直接编辑)。 */
199
249
  overlayPath: string
200
- /** 正在重连时为 true,此时面板按钮禁用。 */
250
+ /** 正在连接或重连时为 true,此时面板的写操作按钮禁用。 */
201
251
  busy: boolean
202
252
  }
203
253
 
@@ -274,6 +324,12 @@ export interface AccessWire {
274
324
  /** false while guarded mode would refuse every conversation (fail closed). */
275
325
  configured: boolean
276
326
  }
327
+ /**
328
+ * Whether the persisted whitelists were loaded. While the channel holds no
329
+ * connection the plugin storage domain is closed, so the lists are unknown
330
+ * (not empty) and the panel must not offer mutations.
331
+ */
332
+ loaded: boolean
277
333
  groups: AccessGroupWire[]
278
334
  users: AccessUserWire[]
279
335
  requests: AccessRequestWire[]
@@ -305,9 +361,9 @@ export interface CleanupPreviewWire {
305
361
  thresholdMs?: number
306
362
  /** Periodic cleanup runs at startup and once per day. */
307
363
  auto: boolean
308
- /** Plugin-created workspaces under the current baseDir. */
364
+ /** Plugin-created workspaces under the current baseDir (user projects). */
309
365
  workspaces: number
310
- /** Workspaces older than the threshold (the deletable set). */
366
+ /** Directories older than the threshold (legacy topic workspaces + per-topic dirs). */
311
367
  candidates: number
312
368
  /** Total bytes currently occupied by the candidates. */
313
369
  candidateBytes: number
@@ -319,9 +375,11 @@ export interface CleanupPreviewWire {
319
375
  export interface CleanupRunWire {
320
376
  ok: boolean
321
377
  deletedWorkspaces: number
378
+ /** Per-topic working directories removed (current layout). */
379
+ deletedSessionDirs: number
322
380
  retiredRoutes: number
323
381
  freedBytes: number
324
- /** One `path: message` line per workspace that could not be deleted. */
382
+ /** One `path: message` line per directory that could not be deleted. */
325
383
  failures: string[]
326
384
  error?: string
327
385
  }
@@ -331,6 +389,16 @@ export type CleanupResponse =
331
389
  | { ok: true; panel: PanelWire; result: CleanupRunWire }
332
390
  | { ok: false; error: string }
333
391
 
392
+ /**
393
+ * `POST /dsh-channels/pick-directory` response. `canceled` is a normal
394
+ * outcome (the operator dismissed the OS dialog), not an error; `path` is an
395
+ * absolute directory the Host's native chooser returned.
396
+ */
397
+ export type PickDirectoryResponse =
398
+ | { ok: true; path: string }
399
+ | { ok: true; canceled: true }
400
+ | { ok: false; error: string }
401
+
334
402
  /** `PUT /dsh-channels/config` body. */
335
403
  export interface SaveConfigRequest {
336
404
  values: EditableValues
@@ -350,7 +418,8 @@ export type SaveConfigResponse =
350
418
  /** `POST /dsh-channels/reconnect` response. */
351
419
  export type ReconnectResponse =
352
420
  | { ok: true; panel: PanelWire }
353
- | { ok: false; error: string }
421
+ /** `unconfigured` tells the panel to route the user to the credentials form. */
422
+ | { ok: false; error: string; unconfigured?: boolean }
354
423
 
355
424
  /** `DELETE /dsh-channels/config` response. */
356
425
  export type ClearConfigResponse =
@@ -368,8 +437,19 @@ export interface ClearCredentialRequest {
368
437
  field: CredentialField
369
438
  }
370
439
 
371
- /** How a saved credential reached the running channel. */
372
- export type CredentialReconnectState = 'auto' | 'pending' | 'not-needed'
440
+ /**
441
+ * How a saved credential reached the running channel.
442
+ *
443
+ * - `auto`: the channel was stopped and connected immediately after the save.
444
+ * - `pending`: the channel is running; reconnect for the value to take effect.
445
+ * - `not-needed`: removed while stopped, nothing to reconnect.
446
+ * - `unconfigured`: saved, but the other credential of the pair is still
447
+ * missing — the plugin keeps waiting (no connection attempt) until it is
448
+ * configured.
449
+ * - `failed`: the pair is complete and a connection was attempted, but it
450
+ * failed; the value is stored and `autoReconnectError` explains why.
451
+ */
452
+ export type CredentialReconnectState = 'auto' | 'pending' | 'not-needed' | 'unconfigured' | 'failed'
373
453
 
374
454
  export interface CredentialsSavedPayload {
375
455
  panel: PanelWire
@@ -387,25 +467,97 @@ export type ClearCredentialResponse =
387
467
  | ({ ok: true } & CredentialsSavedPayload)
388
468
  | { ok: false; error: string; issues?: FieldIssueWire[] }
389
469
 
470
+ /**
471
+ * Top-level panel tabs, in render order. The panel shows the connection state
472
+ * header above every tab, so switching tabs never hides whether the bridge is
473
+ * connected.
474
+ */
475
+ export const PANEL_TABS = [
476
+ { id: 'connection', label: '连接' },
477
+ { id: 'sessions', label: '会话与行为' },
478
+ { id: 'model', label: '模型' },
479
+ { id: 'workspace', label: '工作区' },
480
+ { id: 'access', label: '访问授权' },
481
+ { id: 'advanced', label: '高级' },
482
+ ] as const
483
+
484
+ export type PanelTabId = (typeof PANEL_TABS)[number]['id']
485
+
486
+ /** Which editable field groups each tab renders (the tabs own one or two). */
487
+ export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]>> = {
488
+ connection: [],
489
+ sessions: ['behavior', 'content'],
490
+ model: ['model'],
491
+ workspace: ['workspace'],
492
+ access: ['access'],
493
+ advanced: ['streaming'],
494
+ }
495
+
496
+ /** Section heading for one field group inside its tab. */
497
+ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
498
+ workspace: '工作区与文件',
499
+ behavior: '行为',
500
+ content: '内容与附件',
501
+ model: '模型与推理',
502
+ streaming: '流式卡片与日志',
503
+ access: '准入与授权',
504
+ }
505
+
390
506
  /** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
391
507
  export const PANEL_COPY = {
392
508
  title: '飞书渠道',
393
- description: '飞书长连接状态、应用凭据与常用配置。凭据写入 DSH 凭据库且不回显;标注「需重连」的项需重启飞书连接生效。',
509
+ description: '飞书长连接、应用凭据与渠道配置。凭据只写入 DSH 凭据库且不回显;标注「需重连」的项在连接建立时读取。',
510
+ // ---- lifecycle / status -------------------------------------------------
394
511
  statusCard: '连接状态',
512
+ phaseUnconfigured: '待配置',
513
+ phaseIdle: '未连接',
514
+ phaseConnecting: '连接中',
515
+ phaseConnected: '已连接',
516
+ phaseError: '连接失败',
517
+ configuredBadge: '凭据已配置',
518
+ unconfiguredBadge: '凭据未配置',
519
+ statusSummaryConnected: '飞书长连接已建立,机器人可以收发消息。',
520
+ statusSummaryUnconfigured: '尚未配置飞书应用凭据:插件已加载,但不会连接飞书。填写凭据并保存后会自动连接。',
521
+ statusSummaryIdle: '飞书应用凭据已就绪,但当前没有建立长连接。',
522
+ statusSummaryConnecting: '正在连接飞书长连接…',
523
+ statusSummaryError: '最近一次连接飞书失败,请检查凭据与网络后重试。',
524
+ lastError: '最近一次连接失败',
525
+ missingConfig: '缺少配置',
526
+ statusHintUnconfigured: '配置完成后插件会自动连接;也可以稍后在本页手动连接。',
527
+ connectionInfo: '连接信息',
528
+ connectionStateLabel: '飞书长连接',
529
+ botOpenId: 'Bot open_id',
530
+ uptime: '运行时长',
531
+ routeCount: '话题路由',
532
+ liveAgentCount: '活动会话',
533
+ inflightTurnCount: '在途回复',
534
+ healthReasons: '健康判定',
535
+ unknownValue: '—',
536
+ connect: '连接飞书',
537
+ connecting: '连接中…',
538
+ // ---- onboarding ---------------------------------------------------------
539
+ onboardingTitle: '开始使用飞书渠道',
540
+ onboardingIntro: '只需三步:在飞书开放平台创建自建应用,把 App ID / App Secret 填入本页,保存后自动连接。',
541
+ onboardingStep1Title: '创建飞书自建应用',
542
+ onboardingStep1Text: '在飞书开放平台创建企业自建应用,开启机器人能力;事件订阅选择「长连接」,订阅 im.message.receive_v1(接收消息)。',
543
+ onboardingStep2Title: '填写应用凭据',
544
+ onboardingStep2Text: '把应用的 App ID(cli_ 开头)与 App Secret 填入下方「飞书应用凭据」,值会写入 DSH 凭据库。',
545
+ onboardingStep3Title: '保存并等待连接',
546
+ onboardingStep3Text: '两项凭据都保存后插件会自动连接;状态变为「已连接」即可在飞书里私聊机器人。',
547
+ onboardingDocsHint: '开放平台地址:open.feishu.cn/app;应用需要开通 im:message、im:message:send_as_bot 等消息权限。',
548
+ // ---- credentials --------------------------------------------------------
395
549
  credentialsCard: '飞书应用凭据',
396
- modelCard: '模型与推理',
397
- modelCardHelp: '模型服务商、模型 id、推理档位与新会话的 Agent 预设。下拉选项来自宿主机实时目录;保存后对新会话生效。',
398
- configCard: '常用配置',
399
- advancedCard: '只读与高级信息',
550
+ credentialsCardHelp: '凭据值经 DSH 凭据服务写入本机凭据库;面板只显示「已配置 / 未配置」,永不回显已存值。两项都配置后才会连接飞书。',
400
551
  appIdLabel: '飞书 App ID',
401
552
  appSecretLabel: '飞书 App Secret',
402
- credentialsHelp: '值通过 DSH 凭据服务写入本机凭据库;面板只显示是否已配置,永不回显已存值。留空表示保持当前值。',
403
553
  credentialsRef: '凭据引用',
404
554
  saveCredentials: '保存凭据',
405
555
  savingCredentials: '保存中…',
406
556
  credentialSaved: '凭据已保存',
407
- credentialSavedReconnected: '凭据已保存,已自动重连飞书通道',
557
+ credentialSavedReconnected: '凭据已保存,飞书已连接',
408
558
  credentialSavedPending: '凭据已保存,重启飞书连接后生效',
559
+ credentialSavedUnconfigured: '凭据已保存;还缺少另一项凭据,补齐后会自动连接',
560
+ credentialAutoConnectFailed: '凭据已保存,但自动连接失败',
409
561
  credentialRemoved: '已移除存储的凭据',
410
562
  credentialRemovedPending: '已移除存储的凭据,重启飞书连接后生效',
411
563
  removeCredential: '移除已存值',
@@ -415,40 +567,65 @@ export const PANEL_COPY = {
415
567
  credentialReadOnly: '当前凭据存储不可写',
416
568
  appIdPlaceholder: '输入 App ID(cli_…)',
417
569
  appSecretPlaceholder: '输入 App Secret',
418
- save: '保存配置',
419
- saveAll: '保存全部配置',
420
- saveModel: '保存模型配置',
421
- modelActionsHint: '只保存模型与推理字段',
570
+ credentialsPendingHint: '填写后点「保存凭据」;两项齐全时插件会自动连接飞书。',
571
+ // ---- tabs / forms -------------------------------------------------------
572
+ connectionTabHelp: '连接状态与飞书应用凭据。凭据不完整时不会连接飞书。',
573
+ sessionsTabHelp: '私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。',
574
+ modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
575
+ workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。',
576
+ accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
577
+ advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
578
+ save: '保存更改',
422
579
  saving: '保存中…',
580
+ saveHintDirty: '只保存改动过的字段;标注「需重连」的项在下次连接时生效。',
581
+ discardTab: '放弃本页更改',
582
+ saveHintClean: '没有未保存的更改。',
583
+ dirtyCount: '未保存项',
584
+ browseDirectory: '选择目录…',
585
+ browsingDirectory: '选择中…',
586
+ directoryPicked: '已选择目录',
587
+ directoryPickFailed: '无法打开系统目录选择器',
588
+ saved: '已保存',
589
+ cleared: '已清除 Web 覆盖,恢复为 profile 配置',
423
590
  clear: '清除 Web 覆盖',
424
591
  clearing: '清除中…',
592
+ noOverrides: '当前没有 Web 覆盖',
593
+ overridden: 'Web 覆盖',
594
+ inherited: '继承 profile',
595
+ restartTag: '需重连',
596
+ restartBanner: '以下配置需重启飞书连接后生效:',
597
+ restartWarning: '重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。',
425
598
  reconnect: '重启飞书连接',
426
599
  reconnecting: '重连中…',
427
600
  refresh: '刷新状态',
428
601
  retry: '重试',
602
+ pollFailed: '状态刷新失败',
603
+ connectDone: '已连接飞书',
604
+ reconnectDone: '已重启飞书连接',
605
+ saveInvalid: '请先修正标红的配置项',
606
+ enabled: '已启用',
607
+ disabled: '已关闭',
608
+ credentialRemoveConfirm: '确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。',
609
+ accessNotLoaded: '飞书连接建立后加载授权列表',
429
610
  loading: '加载中…',
430
611
  loadFailed: '设置面板加载失败',
431
- saved: '已保存',
432
- savedModel: '模型配置已保存',
433
- cleared: '已清除 Web 覆盖,恢复为 profile 配置',
434
612
  catalogFailed: '模型目录加载失败,已退回手填。',
435
613
  catalogLoading: '正在读取宿主机选项…',
436
614
  catalogUnavailable: '宿主机未提供可选项,请手填。',
437
- noOverrides: '当前没有 Web 覆盖',
438
- overridden: 'Web 覆盖',
439
- inherited: '继承 profile',
440
- restartTag: '需重连',
441
- restartBanner: '以下配置需重启飞书连接后生效:',
442
- restartWarning: '重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。',
443
- lastError: '最近一次启动失败',
444
- notRunning: '未运行',
445
615
  version: '插件版本',
446
616
  overlayPath: '覆盖文件',
447
- cleanupCard: '工作区清理',
448
- cleanupCardHelp: '删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。',
617
+ overlayPathHelp: '可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它并回落到 profile 配置。',
618
+ advancedCard: '只读与高级信息',
619
+ advancedHelp: '这些值来自 profile 配置,不能在面板里编辑。',
620
+ readOnlyCard: '部署级参数',
621
+ // ---- model --------------------------------------------------------------
622
+ // ---- cleanup ------------------------------------------------------------
623
+ cleanupCard: '目录清理',
624
+ cleanupCardHelp: '删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
449
625
  cleanupRetention: '保留期',
450
626
  cleanupAutoLabel: '自动清理',
451
- cleanupCandidates: '可清理工作区',
627
+ cleanupCandidates: '可清理目录',
628
+ cleanupWorkspaces: '插件工作区',
452
629
  cleanupCandidateBytes: '预计释放空间',
453
630
  cleanupRetentionOff: '关闭(不删除)',
454
631
  cleanupAutoOn: '已开启(启动时 + 每天一次)',
@@ -456,24 +633,23 @@ export const PANEL_COPY = {
456
633
  cleanupAutoInactive: '未执行(保留期为关闭)',
457
634
  cleanupNow: '立即清理',
458
635
  cleanupRunning: '清理中…',
459
- cleanupConfirm: '确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。',
460
- cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。',
461
- cleanupDisabledOff: '保留策略为「关闭」:请先在「常用配置 → 工作区与文件」中选择保留天数。',
462
- cleanupDisabledEmpty: '没有超过保留期的过期工作区。',
636
+ cleanupConfirm: '确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。',
637
+ cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。',
638
+ cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。',
639
+ cleanupDisabledEmpty: '没有超过保留期的过期目录。',
463
640
  cleanupPreviewFailed: '无法读取清理预览',
464
641
  cleanupDone: '清理完成',
465
642
  cleanupFailed: '清理失败',
466
643
  cleanupDeleted: '已删除工作区',
644
+ cleanupDeletedDirs: '话题目录',
467
645
  cleanupRetired: '已清理话题路由',
468
646
  cleanupFreed: '释放空间',
469
- cleanupFailures: '以下工作区未能删除:',
647
+ cleanupFailures: '以下目录未能删除:',
648
+ // ---- access -------------------------------------------------------------
470
649
  accessCard: '访问与授权',
471
- accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员 open_id 在「常用配置 → 行为」中填写,私聊机器人发送 /userid 获取。',
650
+ accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。',
472
651
  accessModeOpenWarning: '当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。',
473
- accessAdminMissingWarning: '当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请先在「常用配置 → 行为 → 管理员 open_id」填写。',
474
- accessAdminLabel: '管理员',
475
- accessAdminMissing: '未配置',
476
- accessAdminHint: '修改管理员请到「常用配置 → 行为 → 管理员 open_id」;私聊机器人发送 /userid 可查看自己的 open_id(ou_ 开头,不是 user_id/邮箱)。',
652
+ accessAdminMissingWarning: '当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 open_id。',
477
653
  accessGroupsTitle: '已授权群',
478
654
  accessGroupsEmpty: '尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。',
479
655
  accessUsersTitle: '已授权用户',
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 系统目录选择器的 Host 适配层:把宿主可选的 `directoryPicker` 服务折叠成
3
+ * 设置面板的最小 picker。
4
+ *
5
+ * 宿主的目录选择 seam 有两种后端(`native` / `browse`,见
6
+ * `@deepseek-ai/dsh-host-directory-picker-auto` 的启动判定):
7
+ *
8
+ * - `native`:桌面/本地 GUI 会话,`pick(signal)` 打开操作系统的目录选择框;
9
+ * 面板据此给「工作区根目录」加「选择目录…」按钮。
10
+ * - `browse`:远程/无头/SSH 会话,没有可服务的显示会话,只能由前端自绘
11
+ * 目录浏览器;本插件不实现那套 UI,`browse` 后端按“不可用”处理,字段
12
+ * 保持手写输入。
13
+ *
14
+ * 与 `src/host/` 的其余桥接一致:这里只声明消费到的结构类型,不 import
15
+ * 任何 DSH 内部包,因此宿主缺该服务(旧版本/无 GUI)时插件照常加载。
16
+ *
17
+ * @module dsh-channels/settings/directory-picker
18
+ */
19
+
20
+ import type { HostContext } from '../dsh-types.ts'
21
+
22
+ /** 宿主 native 能力的最小结构副本(`kind` + `pick`,其余字段不关心)。 */
23
+ export interface NativeDirectoryPickerCapability {
24
+ kind: 'native'
25
+ /**
26
+ * 打开系统目录选择框。
27
+ * @param signal - 调用方生命周期;中止会关闭选择框。
28
+ * @returns 绝对路径;操作者取消时为 `null`。
29
+ */
30
+ pick(signal: AbortSignal): Promise<string | null>
31
+ }
32
+
33
+ /** 宿主能力联合的保守副本:只识别 `native`,其余后端一律视为不可用。 */
34
+ export type HostDirectoryPickerCapability =
35
+ | NativeDirectoryPickerCapability
36
+ | { kind: string; [key: string]: unknown }
37
+
38
+ /** `ctx.directoryPicker` 的结构副本(抽象服务,只有一个 `capability()`)。 */
39
+ export interface HostDirectoryPickerService {
40
+ capability(): HostDirectoryPickerCapability
41
+ }
42
+
43
+ /** 设置面板消费的最小 picker(surface / routes 只依赖这个接口,便于单测)。 */
44
+ export interface SettingsDirectoryPicker {
45
+ /** 宿主是否提供可用的系统目录选择框。 */
46
+ readonly available: boolean
47
+ /**
48
+ * 打开系统目录选择框;同一 picker 同时只允许一个选择框。
49
+ * @param signal - 调用方生命周期;中止会关闭选择框。
50
+ * @returns 绝对路径;操作者取消时为 `null`。
51
+ */
52
+ pick(signal: AbortSignal): Promise<string | null>
53
+ }
54
+
55
+ /** 取出 native 能力;服务缺失、后端为 browse 或 capability 抛错都返回 undefined。 */
56
+ function nativeCapability(
57
+ service: HostDirectoryPickerService | undefined,
58
+ ): NativeDirectoryPickerCapability | undefined {
59
+ if (service === undefined || typeof service.capability !== 'function') return undefined
60
+ let capability: unknown
61
+ try {
62
+ capability = service.capability()
63
+ } catch {
64
+ // A broken picker seam must not break the settings panel: fall back to text.
65
+ return undefined
66
+ }
67
+ if (typeof capability !== 'object' || capability === null) return undefined
68
+ const record = capability as { kind?: unknown; pick?: unknown }
69
+ if (record.kind !== 'native' || typeof record.pick !== 'function') return undefined
70
+ return capability as NativeDirectoryPickerCapability
71
+ }
72
+
73
+ /**
74
+ * 从宿主上下文取出可用的系统目录选择器。
75
+ *
76
+ * 解析是**惰性**的:插件可能先于 `dsh-web-app` 的 directory-picker 行完成
77
+ * apply,而设置面板只会在宿主全部加载完成后被请求。因此 `available` 与
78
+ * `pick()` 每次都按需解析(成功结果缓存;失败不缓存,晚挂载的服务仍能被
79
+ * 发现),避免把加载时序固化成「永远没有按钮」。
80
+ *
81
+ * @param ctx - 宿主上下文;`ctx.get('directoryPicker')` 是可选服务。
82
+ * @returns picker;`available` 为 false 时面板保留手写输入。
83
+ */
84
+ export function createSettingsDirectoryPicker(ctx: HostContext): SettingsDirectoryPicker {
85
+ let resolved: NativeDirectoryPickerCapability | undefined
86
+ const resolve = (): NativeDirectoryPickerCapability | undefined => {
87
+ if (resolved !== undefined) return resolved
88
+ try {
89
+ const service = ctx.get?.('directoryPicker') as HostDirectoryPickerService | undefined
90
+ resolved = nativeCapability(service)
91
+ } catch {
92
+ // A throwing service lookup must not take the settings panel down.
93
+ resolved = undefined
94
+ }
95
+ return resolved
96
+ }
97
+ /** 互斥:一个原生模态框同时只应存在一个(双击/多标签页不得叠窗)。 */
98
+ let picking = false
99
+ return {
100
+ get available(): boolean {
101
+ return resolve() !== undefined
102
+ },
103
+ async pick(signal: AbortSignal): Promise<string | null> {
104
+ const capability = resolve()
105
+ if (capability === undefined) throw new Error('当前宿主机不提供系统目录选择器')
106
+ if (picking) throw new Error('已有目录选择窗口正在打开')
107
+ if (signal.aborted) return null
108
+ picking = true
109
+ try {
110
+ const picked = await capability.pick.call(capability, signal)
111
+ if (picked === null || picked === undefined) return null
112
+ if (typeof picked !== 'string' || picked.trim() === '') {
113
+ throw new Error('目录选择器返回了无效路径')
114
+ }
115
+ return picked
116
+ } finally {
117
+ picking = false
118
+ }
119
+ },
120
+ }
121
+ }
@@ -24,10 +24,16 @@ import {
24
24
  CONFIG_ROUTE,
25
25
  CREDENTIALS_ROUTE,
26
26
  PANEL_ROUTE,
27
+ PICK_DIRECTORY_ROUTE,
27
28
  RECONNECT_ROUTE,
28
29
  STATUS_ROUTE,
29
30
  } from './contract.ts'
30
- import type { CredentialField, EditableValues, SaveCredentialsRequest } from './contract.ts'
31
+ import type {
32
+ CredentialField,
33
+ EditableValues,
34
+ PickDirectoryResponse,
35
+ SaveCredentialsRequest,
36
+ } from './contract.ts'
31
37
  import type { SettingsSurface } from './surface.ts'
32
38
 
33
39
  /** The narrow `ctx.webServer` surface this module registers into. */
@@ -299,7 +305,9 @@ export function registerSettingsRoutes(server: HostWebServer, surface: SettingsS
299
305
  return
300
306
  }
301
307
  const result = await surface.reconnect()
302
- sendJson(res, result.ok ? 200 : 500, result)
308
+ // An incomplete configuration is a client-side state (the panel routes
309
+ // the user to the credentials form), not a server failure.
310
+ sendJson(res, result.ok ? 200 : (result.unconfigured === true ? 400 : 500), result)
303
311
  }),
304
312
  }),
305
313
  server.register({
@@ -315,6 +323,45 @@ export function registerSettingsRoutes(server: HostWebServer, surface: SettingsS
315
323
  sendJson(res, result.ok ? 200 : 500, result)
316
324
  }),
317
325
  }),
326
+ server.register({
327
+ kind: 'exact',
328
+ path: PICK_DIRECTORY_ROUTE,
329
+ handler: guarded(async (req, res) => {
330
+ if (!allowOnly(req, res, ['POST'])) return
331
+ if (!isSameOriginRequest(req)) {
332
+ forbiddenWrite(res)
333
+ return
334
+ }
335
+ // The chooser is a local desktop modal: it can stay open far longer
336
+ // than a normal request, and the operator may close the page instead
337
+ // of the dialog. Abort the host picker when the response socket goes
338
+ // away so no orphaned modal outlives its panel.
339
+ const controller = new AbortController()
340
+ let answered = false
341
+ if (typeof res.on === 'function') {
342
+ res.on('close', () => {
343
+ if (!answered) controller.abort()
344
+ })
345
+ }
346
+ let path: string | null
347
+ try {
348
+ path = await surface.pickDirectory(controller.signal)
349
+ } catch (error) {
350
+ answered = true
351
+ // A request the browser already dropped has nobody left to answer.
352
+ if (controller.signal.aborted) {
353
+ res.end()
354
+ return
355
+ }
356
+ throw error
357
+ }
358
+ answered = true
359
+ const payload: PickDirectoryResponse = path === null
360
+ ? { ok: true, canceled: true }
361
+ : { ok: true, path }
362
+ sendJson(res, 200, payload)
363
+ }),
364
+ }),
318
365
  server.register({
319
366
  kind: 'exact',
320
367
  path: ACCESS_ROUTE,