@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.
- package/README.md +115 -39
- package/docs/access-control-design.md +1 -1
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +123 -15
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +7 -6
- package/docs/workspace-isolation-design.md +81 -0
- package/lib/client.js +30 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +11008 -5132
- 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 +267 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +221 -13
- package/src/domain.ts +141 -3
- package/src/feishu/agent-context.ts +66 -16
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +730 -81
- package/src/service.ts +536 -50
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +120 -23
- 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 +43 -21
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +147 -14
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +74 -31
package/src/session-commands.ts
CHANGED
|
@@ -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
|
-
/**
|
|
65
|
-
|
|
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.
|
|
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
|
-
/**
|
|
152
|
-
export
|
|
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(
|
|
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
|
-
|
|
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:
|
|
197
|
+
return { kind: 'success', path: expected }
|
|
172
198
|
}
|
|
173
199
|
|
|
174
|
-
/** Render one verified absolute
|
|
175
|
-
export function buildWorkDirCard(sessionId: string,
|
|
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(
|
|
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
|
-
|
|
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
|
-
+
|
|
222
|
+
+ `**复用目录**:\`${escapeInlineCode(input.sessionDir)}\``,
|
|
196
223
|
},
|
|
197
224
|
{ tag: 'markdown', content: '旧 Session 及其日志仍由 Harness 保留。', text_size: 'notation' },
|
|
198
225
|
])
|
package/src/settings/contract.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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: '
|
|
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: '
|
|
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。',
|
package/src/settings/overlay.ts
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
140
|
+
else setOverlayValue(patch, key, checked.value)
|
|
56
141
|
continue
|
|
57
142
|
}
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
182
|
+
const value = overlayValueOf(patch, key)
|
|
90
183
|
if (value === undefined) continue
|
|
91
184
|
if (OPTIONAL_TEXT_KEYS.has(key) && value === '') continue
|
|
92
|
-
normalized
|
|
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
|
-
|
|
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
|
|
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.
|