@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.
- package/README.md +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +106 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- package/src/client/panel.tsx +0 -1475
package/src/settings/contract.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
90
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
372
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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: '
|
|
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:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 +
|
|
650
|
+
accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。',
|
|
472
651
|
accessModeOpenWarning: '当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。',
|
|
473
|
-
accessAdminMissingWarning: '当前准入=guarded
|
|
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
|
+
}
|
package/src/settings/routes.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|