@modesto_ma/dsh-channels 0.2.0 → 0.4.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 (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -61,8 +61,8 @@ export interface SessionStatusCardInput {
61
61
  export interface SessionStatusExtras {
62
62
  /** Effective permission preset of this Session (`ctx.permissionPresets`). */
63
63
  permissionPreset?: string
64
- /** Workspace directory bound to this route. */
65
- workspacePath?: string
64
+ /** Effective working directory of this Session (its topic directory). */
65
+ sessionDir?: string
66
66
  /** Plugin uptime in milliseconds. */
67
67
  uptimeMs?: number
68
68
  /** Live agent entries this channel owns/borrows. */
@@ -123,7 +123,7 @@ function statusExtrasElements(extras: SessionStatusExtras | undefined): object[]
123
123
  if (extras === undefined) return []
124
124
  const lines: string[] = []
125
125
  if (extras.permissionPreset !== undefined) lines.push(`**权限预设**:\`${escapeInlineCode(extras.permissionPreset)}\``)
126
- if (extras.workspacePath !== undefined) lines.push(`**工作区**:\`${escapeInlineCode(extras.workspacePath)}\``)
126
+ if (extras.sessionDir !== undefined) lines.push(`**会话目录**:\`${escapeInlineCode(extras.sessionDir)}\``)
127
127
  if (extras.uptimeMs !== undefined) lines.push(`**插件运行时长**:${fmtUptime(extras.uptimeMs)}`)
128
128
  if (extras.liveAgents !== undefined || extras.routeCount !== undefined) {
129
129
  const parts: string[] = []
@@ -148,33 +148,59 @@ export type WorkDirResolution =
148
148
  | { kind: 'missing'; text: string }
149
149
  | { kind: 'mismatch'; text: string }
150
150
 
151
- /** Validate the Session header cwd against the plugin's durable route. */
152
- export function resolveWorkDir(headerCwd: string | undefined, routeWorkspacePath: string): WorkDirResolution {
151
+ /** The durable route facts needed to verify a Session's working directory. */
152
+ export interface WorkDirRoute {
153
+ /** Workspace root owning the Session (its header cwd when grouped). */
154
+ readonly workspacePath: string
155
+ /** Effective working directory (topic directory / worktree); absent on v1 routes. */
156
+ readonly sessionDir?: string
157
+ }
158
+
159
+ /**
160
+ * Validate the Session header cwd against the plugin's durable route and
161
+ * return the Session's effective working directory.
162
+ *
163
+ * The header cwd is normally the workspace root (the Session stays grouped
164
+ * under the user's project while working in a per-topic directory). In the
165
+ * degraded placement used when the host exposes no `workingDirectory` service,
166
+ * the header cwd is the topic directory itself.
167
+ */
168
+ export function resolveWorkDir(
169
+ headerCwd: string | undefined,
170
+ route: WorkDirRoute,
171
+ ): WorkDirResolution {
153
172
  if (headerCwd === undefined || headerCwd === '' || !path.isAbsolute(headerCwd)) {
154
173
  return {
155
174
  kind: 'missing',
156
175
  text: '当前 Session header 未记录可用的绝对工作目录;请检查 Session 创建或恢复配置。',
157
176
  }
158
177
  }
159
- if (!path.isAbsolute(routeWorkspacePath)) {
178
+ if (!path.isAbsolute(route.workspacePath)) {
179
+ return {
180
+ kind: 'missing',
181
+ text: '当前飞书路由未记录可用的工作区目录;请检查路由持久化状态。',
182
+ }
183
+ }
184
+ if (route.sessionDir !== undefined && !path.isAbsolute(route.sessionDir)) {
160
185
  return {
161
186
  kind: 'missing',
162
- text: '当前飞书路由未记录可用的绝对工作目录;请检查路由持久化状态。',
187
+ text: '当前飞书路由未记录可用的会话目录;请检查路由持久化状态。',
163
188
  }
164
189
  }
165
- if (headerCwd !== routeWorkspacePath) {
190
+ const expected = route.sessionDir ?? route.workspacePath
191
+ if (headerCwd !== route.workspacePath && headerCwd !== expected) {
166
192
  return {
167
193
  kind: 'mismatch',
168
194
  text: 'Session header 与飞书路由记录的工作目录不一致;为避免返回错误目录,已拒绝显示。',
169
195
  }
170
196
  }
171
- return { kind: 'success', path: headerCwd }
197
+ return { kind: 'success', path: expected }
172
198
  }
173
199
 
174
- /** Render one verified absolute workspace path. */
175
- export function buildWorkDirCard(sessionId: string, workspacePath: string): object {
200
+ /** Render one verified absolute working directory. */
201
+ export function buildWorkDirCard(sessionId: string, sessionDir: string): object {
176
202
  return standaloneCard('**📁 当前工作目录**', [
177
- { tag: 'markdown', content: `\`${escapeInlineCode(workspacePath)}\`` },
203
+ { tag: 'markdown', content: `\`${escapeInlineCode(sessionDir)}\`` },
178
204
  { tag: 'markdown', content: sessionId, text_size: 'notation' },
179
205
  ])
180
206
  }
@@ -182,7 +208,8 @@ export function buildWorkDirCard(sessionId: string, workspacePath: string): obje
182
208
  export interface RenewCardInput {
183
209
  previousSessionId: string
184
210
  currentSessionId: string
185
- workspacePath: string
211
+ /** Topic directory that the renewed Session reuses. */
212
+ sessionDir: string
186
213
  }
187
214
 
188
215
  /** Render the committed route switch without implying that the old log was deleted. */
@@ -192,7 +219,7 @@ export function buildRenewCard(input: RenewCardInput): object {
192
219
  tag: 'markdown',
193
220
  content: `**旧 Session**:${input.previousSessionId}\n`
194
221
  + `**新 Session**:${input.currentSessionId}\n`
195
- + `**复用工作区**:\`${escapeInlineCode(input.workspacePath)}\``,
222
+ + `**复用目录**:\`${escapeInlineCode(input.sessionDir)}\``,
196
223
  },
197
224
  { tag: 'markdown', content: '旧 Session 及其日志仍由 Harness 保留。', text_size: 'notation' },
198
225
  ])
@@ -20,7 +20,7 @@ export const CREDENTIALS_ROUTE = '/dsh-channels/credentials'
20
20
  export const RECONNECT_ROUTE = '/dsh-channels/reconnect'
21
21
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
22
22
  export const CATALOG_ROUTE = '/dsh-channels/catalog'
23
- /** 手动执行一次过期工作区清理(POST,无请求体)。 */
23
+ /** 手动执行一次过期目录清理(POST,无请求体)。 */
24
24
  export const CLEANUP_ROUTE = '/dsh-channels/cleanup'
25
25
  /** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
26
26
  export const ACCESS_ROUTE = '/dsh-channels/access'
@@ -40,16 +40,17 @@ export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
40
40
  export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
41
41
 
42
42
  /**
43
- * 可由 Web 面板覆盖的配置项。全部是 `src/config.ts` 里已存在的字段;
44
- * 覆盖值写进 `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile
45
- * 的 `cordis.patch.yml`(见 README「Web 设置面板」)。
43
+ * 可由 Web 面板覆盖的配置项。顶层键对应 `src/config.ts` 的字段;`feishuTools.*`
44
+ * 是嵌套字段(覆盖层里仍写成 `{ "feishuTools": { ... } }` 对象)。覆盖值写进
45
+ * `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile 的
46
+ * `cordis.patch.yml`(见 README「Web 设置面板」)。
46
47
  *
47
48
  * 刻意不包含 `appId` / `appSecret`:它们是**凭据引用名**,面板改为直接
48
49
  * 编辑引用背后的值(`PUT /dsh-channels/credentials`,经 DSH 凭据服务写入),
49
50
  * 因此不会落进覆盖层文件。
50
51
  *
51
52
  * `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
52
- * **绝对路径**(`~` 会展开);已有 Session / 工作区不迁移,只影响之后新建的
53
+ * **绝对路径**(`~` 会展开);已有 Session / 目录不迁移,只影响之后新建的
53
54
  * 会话。校验与保存时建目录见 `src/workspaces.ts`。
54
55
  */
55
56
  export const EDITABLE_KEYS = [
@@ -67,8 +68,24 @@ export const EDITABLE_KEYS = [
67
68
  'adminOpenId',
68
69
  'accessMode',
69
70
  'authRequestTtlDays',
70
- // 内容
71
+ // 内容与附件(v2 管线;`feishuTools.*` 是嵌套配置,覆盖层仍写成嵌套对象)
71
72
  'acceptAttachments',
73
+ 'attachmentStore',
74
+ 'prefetchImages',
75
+ 'prefetchTimeoutMs',
76
+ 'prefetchImageMaxBytes',
77
+ 'prefetchTotalMaxBytes',
78
+ 'historyBootstrapLimit',
79
+ 'pendingAttachmentTtlMs',
80
+ 'pendingAttachmentLimit',
81
+ // 模型工具(feishuTools 子项)
82
+ 'feishuTools.attachmentFetch',
83
+ 'feishuTools.attachmentFetchMaxResources',
84
+ 'feishuTools.attachmentFetchMaxBytes',
85
+ 'feishuTools.attachmentFetchTimeoutMs',
86
+ 'feishuTools.topicMessages',
87
+ 'feishuTools.topicMessagesTimeoutMs',
88
+ 'promptContextTimeoutMs',
72
89
  // 模型
73
90
  'provider',
74
91
  'model',
@@ -104,7 +121,7 @@ export type FieldKind = 'boolean' | 'number' | 'text' | 'select'
104
121
  * panel tab (see {@link TAB_FIELD_GROUPS}); the Host is the source of truth for
105
122
  * which key belongs to which group.
106
123
  */
107
- export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'model' | 'streaming' | 'access'
124
+ export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'tools' | 'model' | 'streaming' | 'access'
108
125
 
109
126
  /** One selectable value rendered by the generic form. */
110
127
  export interface FieldOptionWire {
@@ -361,9 +378,9 @@ export interface CleanupPreviewWire {
361
378
  thresholdMs?: number
362
379
  /** Periodic cleanup runs at startup and once per day. */
363
380
  auto: boolean
364
- /** Plugin-created workspaces under the current baseDir. */
381
+ /** Plugin-created workspaces under the current baseDir (user projects). */
365
382
  workspaces: number
366
- /** Workspaces older than the threshold (the deletable set). */
383
+ /** Directories older than the threshold (legacy topic workspaces + per-topic dirs). */
367
384
  candidates: number
368
385
  /** Total bytes currently occupied by the candidates. */
369
386
  candidateBytes: number
@@ -375,9 +392,11 @@ export interface CleanupPreviewWire {
375
392
  export interface CleanupRunWire {
376
393
  ok: boolean
377
394
  deletedWorkspaces: number
395
+ /** Per-topic working directories removed (current layout). */
396
+ deletedSessionDirs: number
378
397
  retiredRoutes: number
379
398
  freedBytes: number
380
- /** One `path: message` line per workspace that could not be deleted. */
399
+ /** One `path: message` line per directory that could not be deleted. */
381
400
  failures: string[]
382
401
  error?: string
383
402
  }
@@ -484,7 +503,7 @@ export type PanelTabId = (typeof PANEL_TABS)[number]['id']
484
503
  /** Which editable field groups each tab renders (the tabs own one or two). */
485
504
  export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]>> = {
486
505
  connection: [],
487
- sessions: ['behavior', 'content'],
506
+ sessions: ['behavior', 'content', 'tools'],
488
507
  model: ['model'],
489
508
  workspace: ['workspace'],
490
509
  access: ['access'],
@@ -496,6 +515,7 @@ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
496
515
  workspace: '工作区与文件',
497
516
  behavior: '行为',
498
517
  content: '内容与附件',
518
+ tools: '模型工具',
499
519
  model: '模型与推理',
500
520
  streaming: '流式卡片与日志',
501
521
  access: '准入与授权',
@@ -568,9 +588,9 @@ export const PANEL_COPY = {
568
588
  credentialsPendingHint: '填写后点「保存凭据」;两项齐全时插件会自动连接飞书。',
569
589
  // ---- tabs / forms -------------------------------------------------------
570
590
  connectionTabHelp: '连接状态与飞书应用凭据。凭据不完整时不会连接飞书。',
571
- sessionsTabHelp: '私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。',
591
+ sessionsTabHelp: '私聊、群聊触发方式与入站内容:附件存储/预取、静默登记保留、模型工具开关与读取预算;「需重连」项在下次连接时读取。',
572
592
  modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
573
- workspaceTabHelp: '每个飞书话题一个独立工作区(模型/工具产生文件的落点);只影响之后新建的话题。',
593
+ workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录,也不会被这里的目录清理删除。',
574
594
  accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
575
595
  advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
576
596
  save: '保存更改',
@@ -618,11 +638,12 @@ export const PANEL_COPY = {
618
638
  readOnlyCard: '部署级参数',
619
639
  // ---- model --------------------------------------------------------------
620
640
  // ---- cleanup ------------------------------------------------------------
621
- cleanupCard: '工作区清理',
622
- cleanupCardHelp: '删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。',
641
+ cleanupCard: '目录清理',
642
+ cleanupCardHelp: '只清理工作区与 Agent 产物:删除超过保留期且不再活跃的飞书话题目录、旧版本按话题建立的工作区目录,以及这些目录内 Agent 产生的文件;不影响 Session 附件——入站图片/文件默认存于 DSH 原生附件库,不随目录清理删除。用户工作区本身与 DSH 会话日志也不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
623
643
  cleanupRetention: '保留期',
624
644
  cleanupAutoLabel: '自动清理',
625
- cleanupCandidates: '可清理工作区',
645
+ cleanupCandidates: '可清理目录',
646
+ cleanupWorkspaces: '插件工作区',
626
647
  cleanupCandidateBytes: '预计释放空间',
627
648
  cleanupRetentionOff: '关闭(不删除)',
628
649
  cleanupAutoOn: '已开启(启动时 + 每天一次)',
@@ -630,17 +651,18 @@ export const PANEL_COPY = {
630
651
  cleanupAutoInactive: '未执行(保留期为关闭)',
631
652
  cleanupNow: '立即清理',
632
653
  cleanupRunning: '清理中…',
633
- cleanupConfirm: '确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。',
634
- cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。',
635
- cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期工作区保留期」中选择保留天数。',
636
- cleanupDisabledEmpty: '没有超过保留期的过期工作区。',
654
+ cleanupConfirm: '确定清理过期目录吗?只清理工作区与 Agent 产物,不影响 Session 附件;但这些目录内未提交的文件会被删除,且操作不可恢复。',
655
+ cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。',
656
+ cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。',
657
+ cleanupDisabledEmpty: '没有超过保留期的过期目录。',
637
658
  cleanupPreviewFailed: '无法读取清理预览',
638
659
  cleanupDone: '清理完成',
639
660
  cleanupFailed: '清理失败',
640
661
  cleanupDeleted: '已删除工作区',
662
+ cleanupDeletedDirs: '话题目录',
641
663
  cleanupRetired: '已清理话题路由',
642
664
  cleanupFreed: '释放空间',
643
- cleanupFailures: '以下工作区未能删除:',
665
+ cleanupFailures: '以下目录未能删除:',
644
666
  // ---- access -------------------------------------------------------------
645
667
  accessCard: '访问与授权',
646
668
  accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。',
@@ -20,10 +20,89 @@ import { configSchema } from '../config.ts'
20
20
  import type { FeishuConfig } from '../config.ts'
21
21
  import { normalizeWorkspaceBaseDir } from '../workspaces.ts'
22
22
  import { EDITABLE_KEYS } from './contract.ts'
23
- import type { ConfigValue, EditableKey } from './contract.ts'
23
+ import type { ConfigValue, EditableKey, EditableValues } from './contract.ts'
24
24
 
25
- /** The subset of `FeishuConfig` the Web panel may override. */
26
- export type OverlayPatch = Partial<Pick<FeishuConfig, EditableKey>>
25
+ /** Prefix of a nested panel key that addresses one `feishuTools` field. */
26
+ const FEISHU_TOOLS_PREFIX = 'feishuTools.'
27
+
28
+ /** Panel keys that address a nested `feishuTools` field (e.g. `feishuTools.topicMessages`). */
29
+ export type NestedEditableKey = Extract<EditableKey, `feishuTools.${string}`>
30
+
31
+ /** Panel keys that address a top-level config field. */
32
+ export type TopLevelEditableKey = Exclude<EditableKey, NestedEditableKey>
33
+
34
+ /** One partial `feishuTools` object inside the overlay layer. */
35
+ export type FeishuToolsOverlay = Partial<FeishuConfig['feishuTools']>
36
+
37
+ /**
38
+ * The subset of `FeishuConfig` the Web panel may override. `feishuTools` stays
39
+ * partial: the overlay only records the sub-fields the operator actually
40
+ * changed, and the merge keeps every other value from the profile layer.
41
+ */
42
+ export type OverlayPatch = Partial<Pick<FeishuConfig, TopLevelEditableKey>> & {
43
+ feishuTools?: FeishuToolsOverlay
44
+ }
45
+
46
+ /** Whether one editable key addresses a nested `feishuTools` field. */
47
+ export function isNestedEditableKey(key: EditableKey): key is NestedEditableKey {
48
+ return key.startsWith(FEISHU_TOOLS_PREFIX)
49
+ }
50
+
51
+ /** `attachmentFetch` for the panel key `feishuTools.attachmentFetch`. */
52
+ function nestedFieldOf(key: NestedEditableKey): keyof FeishuConfig['feishuTools'] {
53
+ return key.slice(FEISHU_TOOLS_PREFIX.length) as keyof FeishuConfig['feishuTools']
54
+ }
55
+
56
+ /** The `feishuTools` object of an untyped overlay/config record, when usable. */
57
+ function toolsRecordOf(container: Record<string, unknown>): Record<string, unknown> | undefined {
58
+ const tools = container.feishuTools
59
+ if (typeof tools !== 'object' || tools === null || Array.isArray(tools)) return undefined
60
+ return tools as Record<string, unknown>
61
+ }
62
+
63
+ /** Read one editable value out of an untyped overlay record (nested-aware). */
64
+ export function overlayValueOf(source: Record<string, unknown>, key: EditableKey): unknown {
65
+ if (!isNestedEditableKey(key)) return source[key]
66
+ return toolsRecordOf(source)?.[nestedFieldOf(key)]
67
+ }
68
+
69
+ /** Write one editable value into an untyped overlay record (nested-aware). */
70
+ export function setOverlayValue(target: Record<string, unknown>, key: EditableKey, value: unknown): void {
71
+ if (!isNestedEditableKey(key)) {
72
+ target[key] = value
73
+ return
74
+ }
75
+ const tools = toolsRecordOf(target) ?? {}
76
+ tools[nestedFieldOf(key)] = value
77
+ target.feishuTools = tools
78
+ }
79
+
80
+ /** Read one editable value out of a resolved config (nested-aware). */
81
+ export function configValueOf(config: FeishuConfig, key: EditableKey): ConfigValue | undefined {
82
+ if (!isNestedEditableKey(key)) return config[key] as ConfigValue | undefined
83
+ return config.feishuTools[nestedFieldOf(key)] as ConfigValue | undefined
84
+ }
85
+
86
+ /** Deep-merge one overlay patch over another (only `feishuTools` is nested). */
87
+ export function mergeOverlayPatch(base: OverlayPatch, addition: OverlayPatch): OverlayPatch {
88
+ const merged: OverlayPatch = { ...base, ...addition }
89
+ if (base.feishuTools !== undefined || addition.feishuTools !== undefined) {
90
+ merged.feishuTools = { ...base.feishuTools, ...addition.feishuTools }
91
+ }
92
+ return merged
93
+ }
94
+
95
+ /** Remove one editable key from an overlay patch (nested-aware). */
96
+ export function deleteOverlayKey(patch: OverlayPatch, key: EditableKey): void {
97
+ if (!isNestedEditableKey(key)) {
98
+ delete patch[key as TopLevelEditableKey]
99
+ return
100
+ }
101
+ const tools = patch.feishuTools
102
+ if (tools === undefined) return
103
+ delete tools[nestedFieldOf(key)]
104
+ if (Object.keys(tools).length === 0) delete patch.feishuTools
105
+ }
27
106
 
28
107
  /** Fields whose empty string means "clear the override" (optional text config). */
29
108
  const OPTIONAL_TEXT_KEYS = new Set<EditableKey>(['provider', 'model', 'reasoningEffort', 'agentPreset'])
@@ -43,24 +122,38 @@ export function parseOverlay(raw: unknown): { ok: true; patch: OverlayPatch } |
43
122
  const issues: Array<{ path: string; message: string }> = []
44
123
  const patch: Record<string, unknown> = {}
45
124
  const shape = configSchema.shape as unknown as Record<string, z.ZodType>
125
+ const toolsShape = configSchema.shape.feishuTools
126
+ if (Object.hasOwn(source, 'feishuTools') && source.feishuTools !== undefined
127
+ && toolsRecordOf(source) === undefined) {
128
+ issues.push({ path: 'feishuTools', message: '必须是 JSON 对象' })
129
+ }
46
130
  for (const key of EDITABLE_KEYS) {
47
- if (!Object.hasOwn(source, key)) continue
48
- const value = source[key]
131
+ // Nested keys have no own property on the root object, so read through the
132
+ // shared accessor instead of `Object.hasOwn`.
133
+ const value = overlayValueOf(source, key)
49
134
  if (value === undefined) continue
50
135
  // `baseDir` needs location semantics the shared string schema cannot
51
136
  // express (absolute only, `~` expansion, no filesystem root).
52
137
  if (key === 'baseDir') {
53
138
  const checked = normalizeWorkspaceBaseDir(value)
54
139
  if (!checked.ok) issues.push({ path: key, message: checked.message })
55
- else patch[key] = checked.value
140
+ else setOverlayValue(patch, key, checked.value)
56
141
  continue
57
142
  }
58
- const parsed = shape[key]!.safeParse(value)
143
+ // Nested `feishuTools` values validate against the canonical sub-object
144
+ // schema; only the edited sub-field is copied out of the parsed result so
145
+ // schema defaults never leak into the overlay.
146
+ const parsed = isNestedEditableKey(key)
147
+ ? toolsShape.safeParse({ [nestedFieldOf(key)]: value })
148
+ : shape[key]!.safeParse(value)
59
149
  if (!parsed.success) {
60
150
  issues.push({ path: key, message: parsed.error.issues[0]?.message ?? '非法值' })
61
151
  continue
62
152
  }
63
- patch[key] = parsed.data
153
+ const normalizedValue = isNestedEditableKey(key)
154
+ ? (parsed.data as FeishuConfig['feishuTools'])[nestedFieldOf(key)]
155
+ : parsed.data
156
+ setOverlayValue(patch, key, normalizedValue)
64
157
  }
65
158
  if (issues.length > 0) return { ok: false, issues }
66
159
  return { ok: true, patch: normalizeOverlay(patch) }
@@ -86,26 +179,44 @@ export interface OverlayLogger {
86
179
  export function normalizeOverlay(patch: Record<string, unknown>): OverlayPatch {
87
180
  const normalized: Record<string, unknown> = {}
88
181
  for (const key of EDITABLE_KEYS) {
89
- const value = patch[key]
182
+ const value = overlayValueOf(patch, key)
90
183
  if (value === undefined) continue
91
184
  if (OPTIONAL_TEXT_KEYS.has(key) && value === '') continue
92
- normalized[key] = value
185
+ setOverlayValue(normalized, key, value)
93
186
  }
94
187
  return normalized as OverlayPatch
95
188
  }
96
189
 
97
190
  /** Merge the Web overlay over the profile-provided base config. */
98
191
  export function mergeOverlay(base: FeishuConfig, overlay: OverlayPatch): FeishuConfig {
99
- return { ...base, ...overlay }
192
+ const { feishuTools, ...topLevel } = overlay
193
+ const merged: FeishuConfig = { ...base, ...topLevel }
194
+ if (feishuTools !== undefined) merged.feishuTools = { ...base.feishuTools, ...feishuTools }
195
+ return merged
100
196
  }
101
197
 
102
- /** Project one config onto the editable-key subset (wire shape). */
198
+ /** Project one config onto the editable-key subset (wire shape, nested keys dotted). */
103
199
  export function pickEditable(config: FeishuConfig): Record<EditableKey, ConfigValue | undefined> {
104
200
  const picked: Record<string, ConfigValue | undefined> = {}
105
- for (const key of EDITABLE_KEYS) picked[key] = config[key] as ConfigValue | undefined
201
+ for (const key of EDITABLE_KEYS) picked[key] = configValueOf(config, key)
106
202
  return picked as Record<EditableKey, ConfigValue | undefined>
107
203
  }
108
204
 
205
+ /**
206
+ * Project an overlay patch onto flat editable keys (nested `feishuTools.*`
207
+ * values become dotted keys). The panel wire uses the same key space as
208
+ * `fields` / `overriddenKeys`, so the client never has to know the nested
209
+ * storage layout.
210
+ */
211
+ export function overlayValues(patch: OverlayPatch): EditableValues {
212
+ const values: Record<string, ConfigValue> = {}
213
+ for (const key of EDITABLE_KEYS) {
214
+ const value = overlayValueOf(patch as Record<string, unknown>, key)
215
+ if (value !== undefined) values[key] = value as ConfigValue
216
+ }
217
+ return values as EditableValues
218
+ }
219
+
109
220
  /**
110
221
  * One JSON overlay file. Reads degrade to `{}` with a warning; writes are
111
222
  * atomic and owner-readable only.