@modesto_ma/dsh-channels 0.2.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 +53 -29
- package/docs/access-control-design.md +1 -1
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +15 -13
- 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 +23 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +1110 -603
- 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/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +27 -8
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +119 -41
- 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 +18 -14
- package/src/settings/surface.ts +12 -4
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
package/src/session/manager.ts
CHANGED
|
@@ -25,12 +25,14 @@ import type {
|
|
|
25
25
|
} from '../dsh-types.ts'
|
|
26
26
|
import type { FeishuConfig } from '../config.ts'
|
|
27
27
|
import { mountChannelCommands } from '../commands.ts'
|
|
28
|
-
import { appendSessionHistory } from '../domain.ts'
|
|
28
|
+
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
32
|
import { installRequestPolicy } from './request-policy.ts'
|
|
33
33
|
import type { RequestPolicyController } from './request-policy.ts'
|
|
34
|
+
import { placementServices, restoreTopicDir } from './isolation.ts'
|
|
35
|
+
import type { PlacementServices, TopicPlacement } from './isolation.ts'
|
|
34
36
|
import { workspaceTitleFor } from '../workspaces.ts'
|
|
35
37
|
|
|
36
38
|
/** One live agent plus this plugin's ownership handle. */
|
|
@@ -105,14 +107,16 @@ export class SessionManager {
|
|
|
105
107
|
|
|
106
108
|
/**
|
|
107
109
|
* Create the root agent for one freshly routed session and register it live.
|
|
108
|
-
*
|
|
110
|
+
* `cwd` is the Session header cwd (the user workspace when the deployment can
|
|
111
|
+
* switch directories). The caller owns placement, the workspace attachment,
|
|
112
|
+
* the attachment guidance, and the failure card.
|
|
109
113
|
*/
|
|
110
114
|
async createSession(
|
|
111
115
|
sessionId: SessionId,
|
|
112
116
|
cwd: string,
|
|
113
117
|
agentOptions: Record<string, unknown> = this.agentOptions(),
|
|
114
118
|
): Promise<AgentHandle> {
|
|
115
|
-
const composition = await this.presetComposition(undefined, true
|
|
119
|
+
const composition = await this.presetComposition(undefined, true)
|
|
116
120
|
const handle = await this.deps.ctx.agents.create({
|
|
117
121
|
sessionId,
|
|
118
122
|
meta: {
|
|
@@ -127,6 +131,24 @@ export class SessionManager {
|
|
|
127
131
|
return handle
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Register the attachment-directory guidance for one Session. Called after
|
|
136
|
+
* the effective working directory is known (a worktree checkout path is
|
|
137
|
+
* only returned once the worktree exists), so the prompt section can name
|
|
138
|
+
* the absolute directory. Best-effort: a missing system-prompt service
|
|
139
|
+
* (headless) is a no-op and a late registration never blocks the turn.
|
|
140
|
+
*/
|
|
141
|
+
async mountGuidance(agent: Agent, directory: string): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
await mountChannelGuidance(agent.ctx, attachmentsDir(directory))
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.deps.warn(
|
|
146
|
+
'dsh-channels: mounting attachment guidance for session %s failed: %s',
|
|
147
|
+
agent.id, String(error),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
130
152
|
/**
|
|
131
153
|
* Live agent for a route: this plugin's own → a foreign live agent (borrow)
|
|
132
154
|
* → a serialized cold resume.
|
|
@@ -171,9 +193,7 @@ export class SessionManager {
|
|
|
171
193
|
// (config override or roster default). A session persisted before the
|
|
172
194
|
// preset fix has no preset recorded on its header; re-resolving here is
|
|
173
195
|
// what repairs it on the first resume after a restart.
|
|
174
|
-
const composition = await this.presetComposition(
|
|
175
|
-
undefined, true, attachmentsDir(record.workspacePath),
|
|
176
|
-
)
|
|
196
|
+
const composition = await this.presetComposition(undefined, true)
|
|
177
197
|
const handle = await this.deps.ctx.agents.resume({
|
|
178
198
|
resumeSessionId: sessionId,
|
|
179
199
|
agentOptions: this.agentOptions(),
|
|
@@ -187,6 +207,7 @@ export class SessionManager {
|
|
|
187
207
|
throw new Error('dsh-channels: session resume aborted by shutdown')
|
|
188
208
|
}
|
|
189
209
|
this.requestPolicy(handle.agent)
|
|
210
|
+
await this.mountGuidance(handle.agent, sessionDirOf(record))
|
|
190
211
|
const liveAgent: LiveAgent = { agent: handle.agent, handle }
|
|
191
212
|
this.live.set(sessionId, liveAgent)
|
|
192
213
|
return liveAgent
|
|
@@ -224,17 +245,23 @@ export class SessionManager {
|
|
|
224
245
|
return controller
|
|
225
246
|
}
|
|
226
247
|
|
|
227
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* Create, place, workspace-attach, and atomically publish one renewed route.
|
|
250
|
+
*
|
|
251
|
+
* A renewal keeps the topic's working directory: the new Session is created
|
|
252
|
+
* with the user workspace as its header cwd (so it stays grouped under the
|
|
253
|
+
* project) and then re-entered into the same topic directory or worktree.
|
|
254
|
+
*/
|
|
228
255
|
async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
|
|
229
256
|
const sessionId = randomUUID() as SessionId
|
|
230
257
|
const preset = this.effectivePreset(oldLive.agent.session)
|
|
231
|
-
const composition = await this.presetComposition(preset, false
|
|
258
|
+
const composition = await this.presetComposition(preset, false)
|
|
259
|
+
const services: PlacementServices = placementServices(this.deps.ctx)
|
|
260
|
+
const switchable = services.workingDirectory !== undefined
|
|
261
|
+
const workspacePath = record.workspacePath
|
|
232
262
|
const workspace = await this.deps.ctx.workspaceRegistry.create(
|
|
233
|
-
|
|
234
|
-
workspaceTitleFor(
|
|
235
|
-
record.chatType, record.chatId, record.senderOpenId,
|
|
236
|
-
record.kind === 'thread' ? record.key : record.sessionId,
|
|
237
|
-
),
|
|
263
|
+
workspacePath,
|
|
264
|
+
workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId),
|
|
238
265
|
)
|
|
239
266
|
|
|
240
267
|
let newHandle: AgentHandle | undefined
|
|
@@ -243,23 +270,38 @@ export class SessionManager {
|
|
|
243
270
|
newHandle = await this.deps.ctx.agents.create({
|
|
244
271
|
sessionId,
|
|
245
272
|
meta: {
|
|
246
|
-
|
|
273
|
+
// Without an effective-directory service the Session must be born in
|
|
274
|
+
// its topic directory; otherwise the workspace root keeps it grouped.
|
|
275
|
+
cwd: switchable ? workspacePath : sessionDirOf(record),
|
|
247
276
|
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
|
|
248
277
|
},
|
|
249
278
|
agentOptions: { ...oldLive.agent.options },
|
|
250
279
|
setup: composition.setup,
|
|
251
280
|
})
|
|
252
281
|
this.requestPolicy(newHandle.agent)
|
|
253
|
-
await
|
|
254
|
-
|
|
282
|
+
const placement: TopicPlacement = await restoreTopicDir(services, newHandle.agent, {
|
|
283
|
+
workspacePath,
|
|
284
|
+
sessionDir: sessionDirOf(record),
|
|
285
|
+
...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
|
|
286
|
+
}, this.deps.warn.bind(this.deps))
|
|
287
|
+
if (switchable) {
|
|
288
|
+
await workspace.attachSession(sessionId)
|
|
289
|
+
attached = true
|
|
290
|
+
}
|
|
291
|
+
await this.mountGuidance(newHandle.agent, placement.sessionDir)
|
|
255
292
|
const createdAt = newHandle.agent.session.header.createdAt
|
|
256
293
|
|
|
257
|
-
const
|
|
294
|
+
const base: RouteRecord = {
|
|
258
295
|
...record,
|
|
259
296
|
sessionId,
|
|
260
297
|
sessionHistory: appendSessionHistory(record, sessionId, createdAt),
|
|
261
298
|
lastActivityAt: Date.now(),
|
|
299
|
+
sessionDir: placement.sessionDir,
|
|
262
300
|
}
|
|
301
|
+
delete base.worktreeBranch
|
|
302
|
+
const current: RouteRecord = placement.worktreeBranch === undefined
|
|
303
|
+
? base
|
|
304
|
+
: { ...base, worktreeBranch: placement.worktreeBranch }
|
|
263
305
|
await this.deps.persistRoute(record.key, current)
|
|
264
306
|
this.live.set(sessionId, { agent: newHandle.agent, handle: newHandle })
|
|
265
307
|
return { previous: record, current, oldLive }
|
|
@@ -308,11 +350,14 @@ export class SessionManager {
|
|
|
308
350
|
* resumes. The id resolves BEFORE the session exists because the session
|
|
309
351
|
* boundary snapshots `meta` before asynchronous setup begins; mounting stays
|
|
310
352
|
* inside `setup`, where a failure rolls the whole creation back.
|
|
353
|
+
*
|
|
354
|
+
* The attachment guidance is mounted later through {@link mountGuidance},
|
|
355
|
+
* because a Git worktree's checkout path only exists after the Agent (and so
|
|
356
|
+
* the worktree) has been created.
|
|
311
357
|
*/
|
|
312
358
|
private async presetComposition(
|
|
313
359
|
requestedPreset: string | undefined = this.deps.config.agentPreset,
|
|
314
360
|
useRosterDefault = true,
|
|
315
|
-
attachmentsDir?: string,
|
|
316
361
|
): Promise<PresetComposition> {
|
|
317
362
|
// Read through ctx.get (safe lookup): only the web profile ships a roster,
|
|
318
363
|
// and direct property access on a cordis context throws for services the
|
|
@@ -324,7 +369,6 @@ export class SessionManager {
|
|
|
324
369
|
// cannot guarantee that the explicit channel value wins.
|
|
325
370
|
this.mountRequestPolicy(agentCtx)
|
|
326
371
|
await mountChannelCommands(agentCtx)
|
|
327
|
-
await mountChannelGuidance(agentCtx, attachmentsDir)
|
|
328
372
|
}
|
|
329
373
|
if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
|
|
330
374
|
return { setup: channelCommandsOnly }
|
|
@@ -336,7 +380,6 @@ export class SessionManager {
|
|
|
336
380
|
this.mountRequestPolicy(agentCtx)
|
|
337
381
|
await presets.mount(agentCtx, resolved.id)
|
|
338
382
|
await mountChannelCommands(agentCtx)
|
|
339
|
-
await mountChannelGuidance(agentCtx, attachmentsDir)
|
|
340
383
|
},
|
|
341
384
|
}
|
|
342
385
|
}
|
package/src/session/repair.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module dsh-channels/session/repair
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { buildInterruptionCard } from '../cards/streaming.ts'
|
|
14
|
+
import { buildInterruptionCard, segmentCardOptions } from '../cards/streaming.ts'
|
|
15
15
|
import type { InflightSnapshot } from '../domain.ts'
|
|
16
16
|
import type { FeishuChannel } from '../feishu-channel.ts'
|
|
17
17
|
import type { FeishuDomainHandle } from '../domain.ts'
|
|
@@ -146,8 +146,7 @@ export class InflightRepair {
|
|
|
146
146
|
if (card.mode !== 'card') continue
|
|
147
147
|
// Full replacement: the interrupted card's streamed content lived only
|
|
148
148
|
// in the previous process. Continuation cards keep their segment header.
|
|
149
|
-
|
|
150
|
-
await channel.updateCard(card.messageId, buildInterruptionCard(options))
|
|
149
|
+
await channel.updateCard(card.messageId, buildInterruptionCard(segmentCardOptions(index)))
|
|
151
150
|
}
|
|
152
151
|
if (snapshot.reactionId !== undefined) {
|
|
153
152
|
await channel.removeReaction(snapshot.triggerMessageId, snapshot.reactionId).catch(() => {})
|
package/src/session/routes.ts
CHANGED
|
@@ -148,6 +148,8 @@ export class RouteStore {
|
|
|
148
148
|
senderOpenId: record.senderOpenId,
|
|
149
149
|
expiredAt: now,
|
|
150
150
|
workspacePath: record.workspacePath,
|
|
151
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
152
|
+
...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
|
|
151
153
|
lastActivityAt: record.lastActivityAt,
|
|
152
154
|
}
|
|
153
155
|
await this.deps.domain()?.expiredRoutes.put(key, tombstone)
|
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'
|
|
@@ -49,7 +49,7 @@ export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
|
|
|
49
49
|
* 因此不会落进覆盖层文件。
|
|
50
50
|
*
|
|
51
51
|
* `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
|
|
52
|
-
* **绝对路径**(`~` 会展开);已有 Session /
|
|
52
|
+
* **绝对路径**(`~` 会展开);已有 Session / 目录不迁移,只影响之后新建的
|
|
53
53
|
* 会话。校验与保存时建目录见 `src/workspaces.ts`。
|
|
54
54
|
*/
|
|
55
55
|
export const EDITABLE_KEYS = [
|
|
@@ -361,9 +361,9 @@ export interface CleanupPreviewWire {
|
|
|
361
361
|
thresholdMs?: number
|
|
362
362
|
/** Periodic cleanup runs at startup and once per day. */
|
|
363
363
|
auto: boolean
|
|
364
|
-
/** Plugin-created workspaces under the current baseDir. */
|
|
364
|
+
/** Plugin-created workspaces under the current baseDir (user projects). */
|
|
365
365
|
workspaces: number
|
|
366
|
-
/**
|
|
366
|
+
/** Directories older than the threshold (legacy topic workspaces + per-topic dirs). */
|
|
367
367
|
candidates: number
|
|
368
368
|
/** Total bytes currently occupied by the candidates. */
|
|
369
369
|
candidateBytes: number
|
|
@@ -375,9 +375,11 @@ export interface CleanupPreviewWire {
|
|
|
375
375
|
export interface CleanupRunWire {
|
|
376
376
|
ok: boolean
|
|
377
377
|
deletedWorkspaces: number
|
|
378
|
+
/** Per-topic working directories removed (current layout). */
|
|
379
|
+
deletedSessionDirs: number
|
|
378
380
|
retiredRoutes: number
|
|
379
381
|
freedBytes: number
|
|
380
|
-
/** One `path: message` line per
|
|
382
|
+
/** One `path: message` line per directory that could not be deleted. */
|
|
381
383
|
failures: string[]
|
|
382
384
|
error?: string
|
|
383
385
|
}
|
|
@@ -570,7 +572,7 @@ export const PANEL_COPY = {
|
|
|
570
572
|
connectionTabHelp: '连接状态与飞书应用凭据。凭据不完整时不会连接飞书。',
|
|
571
573
|
sessionsTabHelp: '私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。',
|
|
572
574
|
modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
|
|
573
|
-
workspaceTabHelp: '
|
|
575
|
+
workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。',
|
|
574
576
|
accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
|
|
575
577
|
advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
|
|
576
578
|
save: '保存更改',
|
|
@@ -618,11 +620,12 @@ export const PANEL_COPY = {
|
|
|
618
620
|
readOnlyCard: '部署级参数',
|
|
619
621
|
// ---- model --------------------------------------------------------------
|
|
620
622
|
// ---- cleanup ------------------------------------------------------------
|
|
621
|
-
cleanupCard: '
|
|
622
|
-
cleanupCardHelp: '
|
|
623
|
+
cleanupCard: '目录清理',
|
|
624
|
+
cleanupCardHelp: '删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
|
|
623
625
|
cleanupRetention: '保留期',
|
|
624
626
|
cleanupAutoLabel: '自动清理',
|
|
625
|
-
cleanupCandidates: '
|
|
627
|
+
cleanupCandidates: '可清理目录',
|
|
628
|
+
cleanupWorkspaces: '插件工作区',
|
|
626
629
|
cleanupCandidateBytes: '预计释放空间',
|
|
627
630
|
cleanupRetentionOff: '关闭(不删除)',
|
|
628
631
|
cleanupAutoOn: '已开启(启动时 + 每天一次)',
|
|
@@ -630,17 +633,18 @@ export const PANEL_COPY = {
|
|
|
630
633
|
cleanupAutoInactive: '未执行(保留期为关闭)',
|
|
631
634
|
cleanupNow: '立即清理',
|
|
632
635
|
cleanupRunning: '清理中…',
|
|
633
|
-
cleanupConfirm: '
|
|
634
|
-
cleanupConfirmHint: '
|
|
635
|
-
cleanupDisabledOff: '
|
|
636
|
-
cleanupDisabledEmpty: '
|
|
636
|
+
cleanupConfirm: '确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。',
|
|
637
|
+
cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。',
|
|
638
|
+
cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。',
|
|
639
|
+
cleanupDisabledEmpty: '没有超过保留期的过期目录。',
|
|
637
640
|
cleanupPreviewFailed: '无法读取清理预览',
|
|
638
641
|
cleanupDone: '清理完成',
|
|
639
642
|
cleanupFailed: '清理失败',
|
|
640
643
|
cleanupDeleted: '已删除工作区',
|
|
644
|
+
cleanupDeletedDirs: '话题目录',
|
|
641
645
|
cleanupRetired: '已清理话题路由',
|
|
642
646
|
cleanupFreed: '释放空间',
|
|
643
|
-
cleanupFailures: '
|
|
647
|
+
cleanupFailures: '以下目录未能删除:',
|
|
644
648
|
// ---- access -------------------------------------------------------------
|
|
645
649
|
accessCard: '访问与授权',
|
|
646
650
|
accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。',
|
package/src/settings/surface.ts
CHANGED
|
@@ -160,7 +160,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
|
|
|
160
160
|
const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
|
|
161
161
|
baseDir: {
|
|
162
162
|
label: '工作区根目录(文件落点)',
|
|
163
|
-
help: '
|
|
163
|
+
help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
|
|
164
164
|
placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
|
|
165
165
|
wide: true,
|
|
166
166
|
},
|
|
@@ -186,8 +186,8 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
186
186
|
step: 1,
|
|
187
187
|
},
|
|
188
188
|
acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
|
|
189
|
-
workspaceRetention: { label: '
|
|
190
|
-
workspaceRetentionAuto: { label: '
|
|
189
|
+
workspaceRetention: { label: '过期目录保留期', help: '超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。' },
|
|
190
|
+
workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
|
|
191
191
|
provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
|
|
192
192
|
model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
|
|
193
193
|
reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
|
|
@@ -403,6 +403,14 @@ export function normalizeAccessName(
|
|
|
403
403
|
/** Build the read-only advanced card from the effective config. */
|
|
404
404
|
export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
|
|
405
405
|
return [
|
|
406
|
+
{
|
|
407
|
+
key: 'topicIsolation',
|
|
408
|
+
label: '话题目录隔离',
|
|
409
|
+
value: config.topicIsolation === 'auto'
|
|
410
|
+
? '自动(Git 仓库用 worktree,否则普通子目录)'
|
|
411
|
+
: config.topicIsolation === 'worktree' ? 'Git worktree(失败回退子目录)' : '普通子目录',
|
|
412
|
+
help: '每个话题的独立工作目录形式;在 profile 配置里用 topicIsolation 设置(auto / subdir / worktree)。',
|
|
413
|
+
},
|
|
406
414
|
{ key: 'routeTtlMs', label: '话题路由过期', value: formatDays(config.routeTtlMs), help: '超期话题的后续消息会被明确拒绝,而不是新建 Context-free Session。' },
|
|
407
415
|
{ key: 'approvalTimeoutMs', label: '审批卡片等待', value: formatSeconds(config.approvalTimeoutMs) },
|
|
408
416
|
{ key: 'questionTimeoutMs', label: '提问卡片等待', value: formatSeconds(config.questionTimeoutMs) },
|
|
@@ -569,7 +577,7 @@ export class SettingsSurface {
|
|
|
569
577
|
try {
|
|
570
578
|
cleanup = await this.target.cleanupPreview()
|
|
571
579
|
} catch (error) {
|
|
572
|
-
this.logger?.warn?.('dsh-channels:
|
|
580
|
+
this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
|
|
573
581
|
cleanup = {
|
|
574
582
|
retention: effective.workspaceRetention,
|
|
575
583
|
auto: effective.workspaceRetentionAuto,
|