@modesto_ma/dsh-channels 0.3.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 +71 -19
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +108 -2
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +9 -6
- package/lib/client.js +10 -5
- package/lib/client.js.map +1 -1
- package/lib/index.js +10193 -4824
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cleanup.ts +13 -2
- package/src/config.ts +194 -5
- package/src/domain.ts +107 -0
- package/src/feishu/agent-context.ts +65 -19
- 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 +1 -1
- package/src/routing/inbound.ts +652 -48
- package/src/service.ts +417 -9
- package/src/session/manager.ts +66 -12
- package/src/settings/contract.ts +28 -10
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +137 -12
- package/src/workspaces.ts +25 -4
package/src/session/manager.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { appendSessionHistory, sessionDirOf } from '../domain.ts'
|
|
|
29
29
|
import type { RouteRecord } from '../domain.ts'
|
|
30
30
|
import { mountChannelGuidance } from '../feishu/agent-context.ts'
|
|
31
31
|
import { attachmentsDir } from '../feishu/inbound-attachments.ts'
|
|
32
|
+
import { effectiveAttachmentMode, resolveAttachmentService } from '../feishu/native-attachments.ts'
|
|
32
33
|
import { installRequestPolicy } from './request-policy.ts'
|
|
33
34
|
import type { RequestPolicyController } from './request-policy.ts'
|
|
34
35
|
import { placementServices, restoreTopicDir } from './isolation.ts'
|
|
@@ -65,6 +66,14 @@ export interface SessionManagerDeps {
|
|
|
65
66
|
started(): boolean
|
|
66
67
|
/** Drop streamer state for a session whose owner handle is being released. */
|
|
67
68
|
detachStream(sessionId: string): void
|
|
69
|
+
/**
|
|
70
|
+
* Register this channel's model tools inside one Agent scope. Called from
|
|
71
|
+
* the agent-preset `setup` after the preset is mounted (tools live inside
|
|
72
|
+
* presets in web-profile deployments), and once more for a BORROWED live
|
|
73
|
+
* agent as a best-effort race guard (D7). Optional: unit tests that do not
|
|
74
|
+
* compose model tools omit it.
|
|
75
|
+
*/
|
|
76
|
+
mountTools?(agentCtx: AgentContext, sessionId: string): Promise<void> | void
|
|
68
77
|
warn(message: string, ...args: unknown[]): void
|
|
69
78
|
}
|
|
70
79
|
|
|
@@ -82,6 +91,8 @@ export class SessionManager {
|
|
|
82
91
|
private readonly live = new Map<string, LiveAgent>()
|
|
83
92
|
private readonly pendingResume = new Map<string, Promise<LiveAgent>>()
|
|
84
93
|
private readonly policies = new WeakMap<AgentContext, RequestPolicyController>()
|
|
94
|
+
/** Agent scopes this plugin already attempted to mount model tools into. */
|
|
95
|
+
private readonly toolMounts = new WeakSet<AgentContext>()
|
|
85
96
|
|
|
86
97
|
constructor(private readonly deps: SessionManagerDeps) {}
|
|
87
98
|
|
|
@@ -116,7 +127,7 @@ export class SessionManager {
|
|
|
116
127
|
cwd: string,
|
|
117
128
|
agentOptions: Record<string, unknown> = this.agentOptions(),
|
|
118
129
|
): Promise<AgentHandle> {
|
|
119
|
-
const composition = await this.presetComposition(undefined, true)
|
|
130
|
+
const composition = await this.presetComposition(undefined, true, sessionId)
|
|
120
131
|
const handle = await this.deps.ctx.agents.create({
|
|
121
132
|
sessionId,
|
|
122
133
|
meta: {
|
|
@@ -132,15 +143,31 @@ export class SessionManager {
|
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
/**
|
|
135
|
-
* Register the attachment
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
146
|
+
* Register the attachment guidance for one Session. Called after the
|
|
147
|
+
* effective working directory is known (a worktree checkout path is only
|
|
148
|
+
* returned once the worktree exists), so the archive guidance can name the
|
|
149
|
+
* absolute directory. The guidance flavor follows the *effective* attachment
|
|
150
|
+
* backend: an explicitly configured archive deployment, or a native
|
|
151
|
+
* deployment whose host does not mount `ctx.attachments`, keeps the legacy
|
|
152
|
+
* workspace-directory instructions. Best-effort: a missing system-prompt
|
|
153
|
+
* service (headless) is a no-op and a late registration never blocks the turn.
|
|
140
154
|
*/
|
|
141
155
|
async mountGuidance(agent: Agent, directory: string): Promise<void> {
|
|
156
|
+
// With inbound attachments disabled the Agent has no attachment workflow to
|
|
157
|
+
// discover (and the legacy guidance would wrongly claim files are archived).
|
|
158
|
+
if (!this.deps.config.acceptAttachments) return
|
|
159
|
+
const mode = effectiveAttachmentMode(
|
|
160
|
+
this.deps.config.attachmentStore,
|
|
161
|
+
resolveAttachmentService(this.deps.ctx),
|
|
162
|
+
this.deps.config.feishuTools.attachmentFetch,
|
|
163
|
+
this.deps.config.acceptAttachments,
|
|
164
|
+
)
|
|
142
165
|
try {
|
|
143
|
-
await mountChannelGuidance(
|
|
166
|
+
await mountChannelGuidance(
|
|
167
|
+
agent.ctx,
|
|
168
|
+
mode,
|
|
169
|
+
mode === 'archive' ? attachmentsDir(directory) : undefined,
|
|
170
|
+
)
|
|
144
171
|
} catch (error) {
|
|
145
172
|
this.deps.warn(
|
|
146
173
|
'dsh-channels: mounting attachment guidance for session %s failed: %s',
|
|
@@ -166,7 +193,7 @@ export class SessionManager {
|
|
|
166
193
|
if (own !== undefined) return own
|
|
167
194
|
|
|
168
195
|
const foreign = this.deps.ctx.agents.get(sessionId as SessionId)
|
|
169
|
-
if (foreign !== undefined) return this.borrow(sessionId, foreign)
|
|
196
|
+
if (foreign !== undefined) return await this.borrow(sessionId, foreign)
|
|
170
197
|
|
|
171
198
|
let pending = this.pendingResume.get(sessionId)
|
|
172
199
|
if (pending === undefined) {
|
|
@@ -179,9 +206,14 @@ export class SessionManager {
|
|
|
179
206
|
}
|
|
180
207
|
|
|
181
208
|
/** Borrow one foreign live agent and mount this channel's scoped policy. */
|
|
182
|
-
private borrow(sessionId: string, agent: Agent): LiveAgent {
|
|
209
|
+
private async borrow(sessionId: string, agent: Agent): Promise<LiveAgent> {
|
|
183
210
|
const borrowed = borrowLiveAgent(agent, this.mountRequestPolicy(agent.ctx, agent.session))
|
|
184
211
|
this.live.set(sessionId, borrowed)
|
|
212
|
+
// Best-effort tool backfill (D7): the foreign owner may have composed the
|
|
213
|
+
// session without this plugin's setup (web UI resolver, previous plugin
|
|
214
|
+
// instance). mountToolsFor swallows failures, so the borrow itself never
|
|
215
|
+
// fails; awaiting it makes the tool available before the first turn.
|
|
216
|
+
await this.mountToolsFor(agent.ctx, sessionId)
|
|
185
217
|
return borrowed
|
|
186
218
|
}
|
|
187
219
|
|
|
@@ -193,7 +225,7 @@ export class SessionManager {
|
|
|
193
225
|
// (config override or roster default). A session persisted before the
|
|
194
226
|
// preset fix has no preset recorded on its header; re-resolving here is
|
|
195
227
|
// what repairs it on the first resume after a restart.
|
|
196
|
-
const composition = await this.presetComposition(undefined, true)
|
|
228
|
+
const composition = await this.presetComposition(undefined, true, sessionId)
|
|
197
229
|
const handle = await this.deps.ctx.agents.resume({
|
|
198
230
|
resumeSessionId: sessionId,
|
|
199
231
|
agentOptions: this.agentOptions(),
|
|
@@ -215,7 +247,7 @@ export class SessionManager {
|
|
|
215
247
|
// Lost a cold-resume race: another owner published the session while our
|
|
216
248
|
// resume was in flight. Borrow the winner instead of failing the turn.
|
|
217
249
|
const winner = this.deps.ctx.agents.get(sessionId)
|
|
218
|
-
if (winner !== undefined) return this.borrow(sessionId, winner)
|
|
250
|
+
if (winner !== undefined) return await this.borrow(sessionId, winner)
|
|
219
251
|
throw error
|
|
220
252
|
}
|
|
221
253
|
}
|
|
@@ -255,7 +287,7 @@ export class SessionManager {
|
|
|
255
287
|
async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
|
|
256
288
|
const sessionId = randomUUID() as SessionId
|
|
257
289
|
const preset = this.effectivePreset(oldLive.agent.session)
|
|
258
|
-
const composition = await this.presetComposition(preset, false)
|
|
290
|
+
const composition = await this.presetComposition(preset, false, sessionId)
|
|
259
291
|
const services: PlacementServices = placementServices(this.deps.ctx)
|
|
260
292
|
const switchable = services.workingDirectory !== undefined
|
|
261
293
|
const workspacePath = record.workspacePath
|
|
@@ -358,6 +390,7 @@ export class SessionManager {
|
|
|
358
390
|
private async presetComposition(
|
|
359
391
|
requestedPreset: string | undefined = this.deps.config.agentPreset,
|
|
360
392
|
useRosterDefault = true,
|
|
393
|
+
sessionId?: string,
|
|
361
394
|
): Promise<PresetComposition> {
|
|
362
395
|
// Read through ctx.get (safe lookup): only the web profile ships a roster,
|
|
363
396
|
// and direct property access on a cordis context throws for services the
|
|
@@ -369,6 +402,7 @@ export class SessionManager {
|
|
|
369
402
|
// cannot guarantee that the explicit channel value wins.
|
|
370
403
|
this.mountRequestPolicy(agentCtx)
|
|
371
404
|
await mountChannelCommands(agentCtx)
|
|
405
|
+
if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
|
|
372
406
|
}
|
|
373
407
|
if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
|
|
374
408
|
return { setup: channelCommandsOnly }
|
|
@@ -380,10 +414,30 @@ export class SessionManager {
|
|
|
380
414
|
this.mountRequestPolicy(agentCtx)
|
|
381
415
|
await presets.mount(agentCtx, resolved.id)
|
|
382
416
|
await mountChannelCommands(agentCtx)
|
|
417
|
+
if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
|
|
383
418
|
},
|
|
384
419
|
}
|
|
385
420
|
}
|
|
386
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Mount the channel model tools into one Agent scope, at most once per
|
|
424
|
+
* scope. A failure is logged and swallowed: missing tools never fail a
|
|
425
|
+
* session (attachment fetch degrades to its structured failures).
|
|
426
|
+
*/
|
|
427
|
+
private async mountToolsFor(agentCtx: AgentContext, sessionId: string): Promise<void> {
|
|
428
|
+
if (this.deps.mountTools === undefined) return
|
|
429
|
+
if (this.toolMounts.has(agentCtx)) return
|
|
430
|
+
try {
|
|
431
|
+
await this.deps.mountTools(agentCtx, sessionId)
|
|
432
|
+
this.toolMounts.add(agentCtx)
|
|
433
|
+
} catch (error) {
|
|
434
|
+
this.deps.warn(
|
|
435
|
+
'dsh-channels: mounting channel tools for session %s failed: %s',
|
|
436
|
+
sessionId, String(error),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
387
441
|
/** Effective preset after folding any durable runtime preset switch. */
|
|
388
442
|
private effectivePreset(session: Session): string | undefined {
|
|
389
443
|
let selected = session.header.agentPreset
|
package/src/settings/contract.ts
CHANGED
|
@@ -40,9 +40,10 @@ 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 凭据服务写入),
|
|
@@ -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 {
|
|
@@ -486,7 +503,7 @@ export type PanelTabId = (typeof PANEL_TABS)[number]['id']
|
|
|
486
503
|
/** Which editable field groups each tab renders (the tabs own one or two). */
|
|
487
504
|
export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]>> = {
|
|
488
505
|
connection: [],
|
|
489
|
-
sessions: ['behavior', 'content'],
|
|
506
|
+
sessions: ['behavior', 'content', 'tools'],
|
|
490
507
|
model: ['model'],
|
|
491
508
|
workspace: ['workspace'],
|
|
492
509
|
access: ['access'],
|
|
@@ -498,6 +515,7 @@ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
|
|
|
498
515
|
workspace: '工作区与文件',
|
|
499
516
|
behavior: '行为',
|
|
500
517
|
content: '内容与附件',
|
|
518
|
+
tools: '模型工具',
|
|
501
519
|
model: '模型与推理',
|
|
502
520
|
streaming: '流式卡片与日志',
|
|
503
521
|
access: '准入与授权',
|
|
@@ -570,9 +588,9 @@ export const PANEL_COPY = {
|
|
|
570
588
|
credentialsPendingHint: '填写后点「保存凭据」;两项齐全时插件会自动连接飞书。',
|
|
571
589
|
// ---- tabs / forms -------------------------------------------------------
|
|
572
590
|
connectionTabHelp: '连接状态与飞书应用凭据。凭据不完整时不会连接飞书。',
|
|
573
|
-
sessionsTabHelp: '
|
|
591
|
+
sessionsTabHelp: '私聊、群聊触发方式与入站内容:附件存储/预取、静默登记保留、模型工具开关与读取预算;「需重连」项在下次连接时读取。',
|
|
574
592
|
modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
|
|
575
|
-
workspaceTabHelp: '
|
|
593
|
+
workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录,也不会被这里的目录清理删除。',
|
|
576
594
|
accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
|
|
577
595
|
advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
|
|
578
596
|
save: '保存更改',
|
|
@@ -621,7 +639,7 @@ export const PANEL_COPY = {
|
|
|
621
639
|
// ---- model --------------------------------------------------------------
|
|
622
640
|
// ---- cleanup ------------------------------------------------------------
|
|
623
641
|
cleanupCard: '目录清理',
|
|
624
|
-
cleanupCardHelp: '
|
|
642
|
+
cleanupCardHelp: '只清理工作区与 Agent 产物:删除超过保留期且不再活跃的飞书话题目录、旧版本按话题建立的工作区目录,以及这些目录内 Agent 产生的文件;不影响 Session 附件——入站图片/文件默认存于 DSH 原生附件库,不随目录清理删除。用户工作区本身与 DSH 会话日志也不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
|
|
625
643
|
cleanupRetention: '保留期',
|
|
626
644
|
cleanupAutoLabel: '自动清理',
|
|
627
645
|
cleanupCandidates: '可清理目录',
|
|
@@ -633,7 +651,7 @@ export const PANEL_COPY = {
|
|
|
633
651
|
cleanupAutoInactive: '未执行(保留期为关闭)',
|
|
634
652
|
cleanupNow: '立即清理',
|
|
635
653
|
cleanupRunning: '清理中…',
|
|
636
|
-
cleanupConfirm: '
|
|
654
|
+
cleanupConfirm: '确定清理过期目录吗?只清理工作区与 Agent 产物,不影响 Session 附件;但这些目录内未提交的文件会被删除,且操作不可恢复。',
|
|
637
655
|
cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。',
|
|
638
656
|
cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。',
|
|
639
657
|
cleanupDisabledEmpty: '没有超过保留期的过期目录。',
|
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.
|