@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.1
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 +73 -55
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +33 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +173 -287
- package/lib/client.js.map +1 -1
- package/lib/index.js +3001 -2627
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +25 -56
- package/src/domain.ts +28 -125
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +19 -22
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- package/src/feishu-channel.ts +270 -9
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +73 -106
- package/src/service.ts +463 -438
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +68 -85
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +37 -88
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- package/src/feishu/pending-attachments.ts +0 -562
package/src/settings/surface.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { DSH_CHANNELS_VERSION } from '../session-export.ts'
|
|
14
14
|
import type { FeishuConfig } from '../config.ts'
|
|
15
|
+
import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
|
|
15
16
|
import type { HealthSnapshot } from '../health.ts'
|
|
16
17
|
import { prepareWorkspaceBaseDir } from '../workspaces.ts'
|
|
17
18
|
import {
|
|
@@ -25,9 +26,6 @@ import type {
|
|
|
25
26
|
CatalogWire,
|
|
26
27
|
ChannelPhaseWire,
|
|
27
28
|
ChannelStatusWire,
|
|
28
|
-
CleanupPreviewWire,
|
|
29
|
-
CleanupResponse,
|
|
30
|
-
CleanupRunWire,
|
|
31
29
|
ClearConfigResponse,
|
|
32
30
|
ClearCredentialResponse,
|
|
33
31
|
ConfigValue,
|
|
@@ -45,6 +43,8 @@ import type {
|
|
|
45
43
|
SaveConfigResponse,
|
|
46
44
|
SaveCredentialsRequest,
|
|
47
45
|
SaveCredentialsResponse,
|
|
46
|
+
ScopeCheckWire,
|
|
47
|
+
SlashCommandSyncWire,
|
|
48
48
|
} from './contract.ts'
|
|
49
49
|
import {
|
|
50
50
|
configValueOf,
|
|
@@ -71,6 +71,10 @@ export interface SettingsTarget {
|
|
|
71
71
|
startedAtMs(): number | undefined
|
|
72
72
|
uptimeMs(): number | undefined
|
|
73
73
|
startError(): string | undefined
|
|
74
|
+
/** Post-connect scope self-check for the open connection, once it finished. */
|
|
75
|
+
scopeCheck(): ScopeCheckWire | undefined
|
|
76
|
+
/** Post-connect native slash-command panel sync for the open connection, once it finished. */
|
|
77
|
+
slashCommandSync(): SlashCommandSyncWire | undefined
|
|
74
78
|
/**
|
|
75
79
|
* Whether the Feishu application credentials resolve. A false value means the
|
|
76
80
|
* plugin is loaded but deliberately holds no connection: the panel guides the
|
|
@@ -91,20 +95,16 @@ export interface SettingsTarget {
|
|
|
91
95
|
credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }>
|
|
92
96
|
setCredential(field: CredentialField, value: string): Promise<void>
|
|
93
97
|
clearCredential(field: CredentialField): Promise<void>
|
|
94
|
-
/** What the workspace-retention cleanup would delete right now (read-only scan). */
|
|
95
|
-
cleanupPreview(): Promise<CleanupPreviewWire>
|
|
96
|
-
/** Run the workspace cleanup once; partial failures are reported per workspace. */
|
|
97
|
-
runWorkspaceCleanup(): Promise<CleanupRunWire>
|
|
98
98
|
/** Current admission snapshot: mode, administrator, whitelists, pending requests. */
|
|
99
99
|
accessSnapshot(): AccessWire
|
|
100
100
|
/** Authorize one group (panel entry); resolves with the refreshed snapshot. */
|
|
101
101
|
addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse>
|
|
102
102
|
/** Revoke one group authorization (takes effect on the next message). */
|
|
103
103
|
removeAccessGroup(chatId: string): Promise<AccessMutationResponse>
|
|
104
|
-
/** Authorize one user `
|
|
105
|
-
addAccessUser(
|
|
104
|
+
/** Authorize one user `user_id` (global across authorized groups). */
|
|
105
|
+
addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse>
|
|
106
106
|
/** Revoke one user authorization (takes effect on the next message). */
|
|
107
|
-
removeAccessUser(
|
|
107
|
+
removeAccessUser(userId: string): Promise<AccessMutationResponse>
|
|
108
108
|
/** Settle one pending request (panel shares this path with the Feishu DM card). */
|
|
109
109
|
resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse>
|
|
110
110
|
}
|
|
@@ -124,18 +124,19 @@ const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
|
|
|
124
124
|
'model',
|
|
125
125
|
'reasoningEffort',
|
|
126
126
|
'agentPreset',
|
|
127
|
-
'
|
|
127
|
+
'adminUserId',
|
|
128
128
|
])
|
|
129
129
|
|
|
130
130
|
const BOOLEAN_KEYS = new Set<EditableKey>([
|
|
131
131
|
'requireMention',
|
|
132
132
|
'welcomeOnboarding',
|
|
133
|
+
'slashCommandPanel',
|
|
133
134
|
'allowPermissionSwitch',
|
|
134
135
|
'acceptAttachments',
|
|
135
136
|
'prefetchImages',
|
|
136
|
-
'workspaceRetentionAuto',
|
|
137
137
|
'feishuTools.attachmentFetch',
|
|
138
138
|
'feishuTools.topicMessages',
|
|
139
|
+
'feishuTools.sendFile',
|
|
139
140
|
])
|
|
140
141
|
|
|
141
142
|
const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
|
|
@@ -151,13 +152,6 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
|
|
|
151
152
|
{ value: 'degrade', label: '话题被拒时降级为会话级' },
|
|
152
153
|
{ value: 'strict', label: '话题被拒时直接失败' },
|
|
153
154
|
],
|
|
154
|
-
workspaceRetention: [
|
|
155
|
-
{ value: 'off', label: '关闭(不删除)' },
|
|
156
|
-
{ value: '7d', label: '7 天' },
|
|
157
|
-
{ value: '30d', label: '30 天' },
|
|
158
|
-
{ value: '90d', label: '90 天' },
|
|
159
|
-
{ value: '180d', label: '180 天' },
|
|
160
|
-
],
|
|
161
155
|
loggerLevel: [
|
|
162
156
|
{ value: 'debug', label: 'debug' },
|
|
163
157
|
{ value: 'info', label: 'info' },
|
|
@@ -174,7 +168,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
|
|
|
174
168
|
const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
|
|
175
169
|
baseDir: {
|
|
176
170
|
label: '工作区根目录(文件落点)',
|
|
177
|
-
help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<
|
|
171
|
+
help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<user_id>/,群聊 <baseDir>/groups/<chat_id>/<user_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
|
|
178
172
|
placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
|
|
179
173
|
wide: true,
|
|
180
174
|
},
|
|
@@ -182,11 +176,12 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
182
176
|
requireMention: { label: '普通群需 @ 机器人', help: '已绑定的主题内始终不需要 @;关闭后普通群的每条消息都会触发。' },
|
|
183
177
|
threadFallback: { label: '话题被拒时的兜底', help: 'degrade 降级为会话级路由;strict 直接让回复失败。' },
|
|
184
178
|
welcomeOnboarding: { label: '首次私聊引导卡', help: '每个私聊会话第一次触达时发送一张命令与用法引导卡,只发一次。' },
|
|
179
|
+
slashCommandPanel: { label: '同步飞书原生斜杠指令面板', help: '连接时把插件命令同步到飞书输入 / 时弹出的面板;需开通 application:app_slash_command:read 与 write 权限,部分企业不允许应用添加斜杠指令。关闭时不读写面板,直接输入命令不受影响。' },
|
|
185
180
|
allowPermissionSwitch: { label: '允许飞书侧切换权限预设', help: '安全敏感:开启后话题所有者可用 /permissions 切换沙箱与审批预设。' },
|
|
186
|
-
|
|
187
|
-
label: '管理员
|
|
188
|
-
help: '唯一管理员。私聊机器人发送 /userid
|
|
189
|
-
placeholder: '
|
|
181
|
+
adminUserId: {
|
|
182
|
+
label: '管理员 user_id',
|
|
183
|
+
help: '唯一管理员。私聊机器人发送 /userid 获取(不是 ou_ 开头的 open_id,也不是邮箱);留空 = 清除 Web 覆盖。未配置时自动使用飞书应用所有者(需可选的「获取应用信息」权限,开不了就在这里手动填写);两者都没有时 guarded 模式会拒绝所有对话。',
|
|
184
|
+
placeholder: 'user_id',
|
|
190
185
|
},
|
|
191
186
|
accessMode: {
|
|
192
187
|
label: '准入模式',
|
|
@@ -201,7 +196,7 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
201
196
|
},
|
|
202
197
|
acceptAttachments: {
|
|
203
198
|
label: '接收入站附件',
|
|
204
|
-
help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH
|
|
199
|
+
help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH 原生附件库:触发消息的图片预取进首轮、文件保持懒取,纯附件消息不回复、不落地,需要时请引用该消息(多个文件可合并转发后引用)再提问;archive 下按旧版行为下载到工作区 attachments/。关闭后附件消息不保存、不触发模型、也不列入清单。',
|
|
205
200
|
},
|
|
206
201
|
attachmentStore: {
|
|
207
202
|
label: '入站附件存储后端',
|
|
@@ -239,19 +234,6 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
239
234
|
max: 20,
|
|
240
235
|
step: 1,
|
|
241
236
|
},
|
|
242
|
-
pendingAttachmentTtlMs: {
|
|
243
|
-
label: '静默附件登记有效期(ms)',
|
|
244
|
-
help: '纯附件/未触发消息只登记元数据(不下载)。超过该时长的登记由周期 GC 清除;0 = 不按时间清理(每个 scope 仍有容量上限)。默认 604800000 = 7 天。',
|
|
245
|
-
min: 0,
|
|
246
|
-
step: 1,
|
|
247
|
-
},
|
|
248
|
-
pendingAttachmentLimit: {
|
|
249
|
-
label: '静默附件登记容量(每个 scope)',
|
|
250
|
-
help: '每个 (会话, 话题, 发送者) scope 最多保留的登记条数(1–100);超出时淘汰该 scope 最旧的一条。',
|
|
251
|
-
min: 1,
|
|
252
|
-
max: 100,
|
|
253
|
-
step: 1,
|
|
254
|
-
},
|
|
255
237
|
promptContextTimeoutMs: {
|
|
256
238
|
label: '被动读取预算(ms)',
|
|
257
239
|
help: '引用摘录与首轮话题历史这类被动 prompt 增强的整段预算(默认 3000 ms,100–10000);超时即省略对应块、回合照跑。模型主动调用工具的读取超时另由 feishu_tools 超时控制。',
|
|
@@ -295,8 +277,10 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
295
277
|
max: 120_000,
|
|
296
278
|
step: 1_000,
|
|
297
279
|
},
|
|
298
|
-
|
|
299
|
-
|
|
280
|
+
'feishuTools.sendFile': {
|
|
281
|
+
label: '启用 feishu_send_file 工具',
|
|
282
|
+
help: '让模型把当前话题工作目录里生成的图片/文件发回当前会话(图片 ≤10 MB,文件 ≤30 MB)。需开通 im:resource 权限。对新会话生效。',
|
|
283
|
+
},
|
|
300
284
|
provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
|
|
301
285
|
model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
|
|
302
286
|
reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
|
|
@@ -308,14 +292,13 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
308
292
|
|
|
309
293
|
const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
310
294
|
baseDir: 'workspace',
|
|
311
|
-
workspaceRetention: 'workspace',
|
|
312
|
-
workspaceRetentionAuto: 'workspace',
|
|
313
295
|
dmMode: 'behavior',
|
|
314
296
|
requireMention: 'behavior',
|
|
315
297
|
threadFallback: 'behavior',
|
|
316
298
|
welcomeOnboarding: 'behavior',
|
|
299
|
+
slashCommandPanel: 'behavior',
|
|
317
300
|
allowPermissionSwitch: 'behavior',
|
|
318
|
-
|
|
301
|
+
adminUserId: 'access',
|
|
319
302
|
accessMode: 'access',
|
|
320
303
|
authRequestTtlDays: 'access',
|
|
321
304
|
acceptAttachments: 'content',
|
|
@@ -325,8 +308,6 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
|
325
308
|
prefetchImageMaxBytes: 'content',
|
|
326
309
|
prefetchTotalMaxBytes: 'content',
|
|
327
310
|
historyBootstrapLimit: 'content',
|
|
328
|
-
pendingAttachmentTtlMs: 'content',
|
|
329
|
-
pendingAttachmentLimit: 'content',
|
|
330
311
|
promptContextTimeoutMs: 'tools',
|
|
331
312
|
'feishuTools.attachmentFetch': 'tools',
|
|
332
313
|
'feishuTools.attachmentFetchMaxResources': 'tools',
|
|
@@ -334,6 +315,7 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
|
334
315
|
'feishuTools.attachmentFetchTimeoutMs': 'tools',
|
|
335
316
|
'feishuTools.topicMessages': 'tools',
|
|
336
317
|
'feishuTools.topicMessagesTimeoutMs': 'tools',
|
|
318
|
+
'feishuTools.sendFile': 'tools',
|
|
337
319
|
provider: 'model',
|
|
338
320
|
model: 'model',
|
|
339
321
|
reasoningEffort: 'model',
|
|
@@ -485,8 +467,6 @@ export function normalizeCredentialValue(raw: unknown): { ok: true; value: strin
|
|
|
485
467
|
/** Longest accepted whitelist identifier / display name. */
|
|
486
468
|
const MAX_ACCESS_VALUE_LENGTH = 128
|
|
487
469
|
|
|
488
|
-
/** Feishu user open_ids (`ou_` + URL-safe id). Identity is open_id only. */
|
|
489
|
-
const OPEN_ID_PATTERN = /^ou_[A-Za-z0-9_-]+$/
|
|
490
470
|
|
|
491
471
|
/** Trim one whitelist identifier, rejecting empty, oversized, or hostile input. */
|
|
492
472
|
export function normalizeAccessId(
|
|
@@ -502,7 +482,7 @@ export function normalizeAccessId(
|
|
|
502
482
|
}
|
|
503
483
|
// eslint-disable-next-line no-control-regex
|
|
504
484
|
if (/[\u0000-\u001f\u007f]/.test(value)) return { ok: false, message: `${label}包含非法控制字符` }
|
|
505
|
-
if (invalidFormat !== undefined && !
|
|
485
|
+
if (invalidFormat !== undefined && !FEISHU_USER_ID_PATTERN.test(value)) {
|
|
506
486
|
return { ok: false, message: invalidFormat }
|
|
507
487
|
}
|
|
508
488
|
return { ok: true, value }
|
|
@@ -565,6 +545,8 @@ function phaseOf(target: SettingsTarget): ChannelPhaseWire {
|
|
|
565
545
|
function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
|
|
566
546
|
const health = target.health()
|
|
567
547
|
const connection = health.connection
|
|
548
|
+
const scopeCheck = target.isRunning() ? target.scopeCheck() : undefined
|
|
549
|
+
const slashCommandSync = target.isRunning() ? target.slashCommandSync() : undefined
|
|
568
550
|
return {
|
|
569
551
|
running: target.isRunning(),
|
|
570
552
|
configured: target.isConfigured(),
|
|
@@ -590,6 +572,8 @@ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatus
|
|
|
590
572
|
version: DSH_CHANNELS_VERSION,
|
|
591
573
|
overlayPath,
|
|
592
574
|
busy: target.isRestarting() || target.isConnecting(),
|
|
575
|
+
...(scopeCheck === undefined ? {} : { scopeCheck }),
|
|
576
|
+
...(slashCommandSync === undefined ? {} : { slashCommandSync }),
|
|
593
577
|
}
|
|
594
578
|
}
|
|
595
579
|
|
|
@@ -697,23 +681,6 @@ export class SettingsSurface {
|
|
|
697
681
|
appSecret: toWire(effective.appSecret, credentialStates.appSecret),
|
|
698
682
|
}
|
|
699
683
|
|
|
700
|
-
// The cleanup preview is derived from live workspace/route state; a broken
|
|
701
|
-
// seam must not break the whole panel, so it degrades to zero counts + error.
|
|
702
|
-
let cleanup: CleanupPreviewWire
|
|
703
|
-
try {
|
|
704
|
-
cleanup = await this.target.cleanupPreview()
|
|
705
|
-
} catch (error) {
|
|
706
|
-
this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
|
|
707
|
-
cleanup = {
|
|
708
|
-
retention: effective.workspaceRetention,
|
|
709
|
-
auto: effective.workspaceRetentionAuto,
|
|
710
|
-
workspaces: 0,
|
|
711
|
-
candidates: 0,
|
|
712
|
-
candidateBytes: 0,
|
|
713
|
-
error: messageOf(error),
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
684
|
// The access snapshot comes from in-memory maps, but a broken seam must
|
|
718
685
|
// still not take the whole panel down: degrade to an empty, fail-closed
|
|
719
686
|
// view (the whitelist mutations will report their own errors).
|
|
@@ -744,7 +711,6 @@ export class SettingsSurface {
|
|
|
744
711
|
readOnly: readOnlyFields(effective),
|
|
745
712
|
credentials,
|
|
746
713
|
},
|
|
747
|
-
cleanup,
|
|
748
714
|
access,
|
|
749
715
|
}
|
|
750
716
|
}
|
|
@@ -971,9 +937,9 @@ export class SettingsSurface {
|
|
|
971
937
|
return this.mutateAccess(() => this.target.removeAccessGroup(id.value))
|
|
972
938
|
}
|
|
973
939
|
|
|
974
|
-
/** Authorize one user `
|
|
975
|
-
async saveAccessUser(
|
|
976
|
-
const id = normalizeAccessId(
|
|
940
|
+
/** Authorize one user `user_id` (not an open_id or email). */
|
|
941
|
+
async saveAccessUser(userId: unknown, name?: unknown): Promise<AccessMutationResponse> {
|
|
942
|
+
const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
|
|
977
943
|
if (!id.ok) return { ok: false, error: id.message }
|
|
978
944
|
const label = normalizeAccessName(name)
|
|
979
945
|
if (!label.ok) return { ok: false, error: label.message }
|
|
@@ -981,8 +947,8 @@ export class SettingsSurface {
|
|
|
981
947
|
}
|
|
982
948
|
|
|
983
949
|
/** Revoke one user authorization (immediate: the next message re-evaluates). */
|
|
984
|
-
async removeAccessUser(
|
|
985
|
-
const id = normalizeAccessId(
|
|
950
|
+
async removeAccessUser(userId: unknown): Promise<AccessMutationResponse> {
|
|
951
|
+
const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
|
|
986
952
|
if (!id.ok) return { ok: false, error: id.message }
|
|
987
953
|
return this.mutateAccess(() => this.target.removeAccessUser(id.value))
|
|
988
954
|
}
|
|
@@ -1022,23 +988,6 @@ export class SettingsSurface {
|
|
|
1022
988
|
if (this.directoryPicker === undefined) throw new Error(PANEL_COPY.directoryPickFailed)
|
|
1023
989
|
return await this.directoryPicker.pick(signal)
|
|
1024
990
|
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Run the workspace-retention cleanup once. Irreversible deletions are the
|
|
1028
|
-
* target's responsibility; the surface only reports the outcome and returns
|
|
1029
|
-
* a refreshed panel (its cleanup preview then reflects the remaining workspaces).
|
|
1030
|
-
*/
|
|
1031
|
-
async runCleanup(): Promise<CleanupResponse> {
|
|
1032
|
-
const busy = this.writeBusy()
|
|
1033
|
-
if (busy !== undefined) return busy
|
|
1034
|
-
let result: CleanupRunWire
|
|
1035
|
-
try {
|
|
1036
|
-
result = await this.target.runWorkspaceCleanup()
|
|
1037
|
-
} catch (error) {
|
|
1038
|
-
return { ok: false, error: `清理失败:${messageOf(error)}` }
|
|
1039
|
-
}
|
|
1040
|
-
return { ok: true, result, panel: await this.panel() }
|
|
1041
|
-
}
|
|
1042
991
|
}
|
|
1043
992
|
|
|
1044
993
|
export { PANEL_COPY }
|
package/src/streamer.ts
CHANGED
|
@@ -24,8 +24,16 @@
|
|
|
24
24
|
* card. Reasoning is never segmented — it stays on card 1 inside the
|
|
25
25
|
* fold panel's own fixed window, independent of the body length.
|
|
26
26
|
* 4. `finalize(turn)` — flip every segment card to its terminal state
|
|
27
|
-
* (`回复结束` / `回复失败`), render the settled footer on the main
|
|
28
|
-
* request the Typing → DONE/No reaction swap on the triggering
|
|
27
|
+
* (`回复结束` / `已停止` / `回复失败`), render the settled footer on the main
|
|
28
|
+
* card and request the Typing → DONE/No reaction swap on the triggering
|
|
29
|
+
* message.
|
|
30
|
+
*
|
|
31
|
+
* Stop button: the card that is currently streaming in the session's active
|
|
32
|
+
* (front) turn carries a stop button until the turn settles; queued turns
|
|
33
|
+
* gain it when they become the front. `stopTargetOf()` resolves a clicked card to its
|
|
34
|
+
* session; `markStopping()` / `markStopDenied()` record the click's outcome
|
|
35
|
+
* on the card. Ownership checks and the cancellation itself belong to the
|
|
36
|
+
* caller.
|
|
29
37
|
*
|
|
30
38
|
* The first visible text delta flips the panel title 推理中 → 回复中; the
|
|
31
39
|
* final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
|
|
@@ -42,7 +50,7 @@
|
|
|
42
50
|
|
|
43
51
|
import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
|
|
44
52
|
import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
|
|
45
|
-
import type { CardState, PanelPhase } from './cards.ts'
|
|
53
|
+
import type { CardState, PanelPhase, StopControl } from './cards.ts'
|
|
46
54
|
import type { FeishuChannel } from './feishu-channel.ts'
|
|
47
55
|
import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
|
|
48
56
|
import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
|
|
@@ -170,6 +178,8 @@ interface TurnState {
|
|
|
170
178
|
reasoningMs: number
|
|
171
179
|
/** The in-flight tool call, if any (cleared by its `tool/result`). */
|
|
172
180
|
toolActivity: { name: string; label: string; since: number } | undefined
|
|
181
|
+
/** Tool names whose approval card is still undecided (shown over the tool line). */
|
|
182
|
+
awaitingApproval: readonly string[]
|
|
173
183
|
/** Live workflow-run folds projected by the workflow tool into this session. */
|
|
174
184
|
workflowRuns: Map<string, WorkflowRun>
|
|
175
185
|
/** The last settled workflow run (kept visible until the next activity). */
|
|
@@ -202,6 +212,10 @@ interface TurnState {
|
|
|
202
212
|
* durable model history, so the card must drop them again.
|
|
203
213
|
*/
|
|
204
214
|
attempts: Map<string, AttemptMark>
|
|
215
|
+
/** The owner clicked stop; the button renders disabled until the turn settles. */
|
|
216
|
+
stopRequested: boolean
|
|
217
|
+
/** A non-owner clicked stop; the owner-only notice renders under the button. */
|
|
218
|
+
stopDenied: boolean
|
|
205
219
|
}
|
|
206
220
|
|
|
207
221
|
/** Card/body lengths captured at one stream attempt's `start` frame. */
|
|
@@ -350,6 +364,7 @@ export class TurnStreamer {
|
|
|
350
364
|
reasoningSince: undefined,
|
|
351
365
|
reasoningMs: 0,
|
|
352
366
|
toolActivity: undefined,
|
|
367
|
+
awaitingApproval: [],
|
|
353
368
|
workflowRuns: new Map(),
|
|
354
369
|
workflowEndStatus: undefined,
|
|
355
370
|
children: new Map(),
|
|
@@ -360,10 +375,15 @@ export class TurnStreamer {
|
|
|
360
375
|
toolKind: '',
|
|
361
376
|
heartbeatTimer: undefined,
|
|
362
377
|
attempts: new Map(),
|
|
378
|
+
stopRequested: false,
|
|
379
|
+
stopDenied: false,
|
|
363
380
|
})
|
|
364
381
|
this.bySession.set(session.id, queue)
|
|
365
382
|
const state = queue[queue.length - 1]!
|
|
366
383
|
this.scheduleHeartbeat(session.id, state)
|
|
384
|
+
// The first frame is sent without a stop button; the front turn gains it
|
|
385
|
+
// on its first patch.
|
|
386
|
+
if (this.front(session.id) === state) this.markDirty(session.id, state)
|
|
367
387
|
}
|
|
368
388
|
|
|
369
389
|
/** Drop all state for a session (agent disposed / route removed). */
|
|
@@ -381,10 +401,61 @@ export class TurnStreamer {
|
|
|
381
401
|
this.segmentChains.delete(sessionId)
|
|
382
402
|
}
|
|
383
403
|
|
|
404
|
+
/** Drop every session's state and timers (service shutdown). */
|
|
405
|
+
detachAll(): void {
|
|
406
|
+
for (const sessionId of [...this.bySession.keys()]) this.detach(sessionId)
|
|
407
|
+
}
|
|
408
|
+
|
|
384
409
|
hasSession(sessionId: string): boolean {
|
|
385
410
|
return this.bySession.has(sessionId)
|
|
386
411
|
}
|
|
387
412
|
|
|
413
|
+
/**
|
|
414
|
+
* The session whose active (front) turn streams into `messageId`, or
|
|
415
|
+
* undefined when the card belongs to no such turn (queued, settled,
|
|
416
|
+
* detached, or unknown). Only the front turn is the one `cancel` stops.
|
|
417
|
+
* @param messageId Feishu message id of a clicked card.
|
|
418
|
+
* @returns The owning session id.
|
|
419
|
+
*/
|
|
420
|
+
stopTargetOf(messageId: string): string | undefined {
|
|
421
|
+
for (const sessionId of this.bySession.keys()) {
|
|
422
|
+
if (this.front(sessionId)?.cards.some(card => card.messageId === messageId) === true) return sessionId
|
|
423
|
+
}
|
|
424
|
+
return undefined
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Show (or clear) the approval wait on the session's live turn: the tool
|
|
429
|
+
* line reads `⏳ 等待你审批:bash` while any approval card is undecided.
|
|
430
|
+
* @param sessionId - the session waiting on approval.
|
|
431
|
+
* @param toolNames - the tools still waiting; empty clears the line.
|
|
432
|
+
*/
|
|
433
|
+
setAwaitingApproval(sessionId: string, toolNames: readonly string[]): void {
|
|
434
|
+
const state = this.front(sessionId)
|
|
435
|
+
if (state === undefined) return
|
|
436
|
+
if (state.awaitingApproval.join('\u0000') === toolNames.join('\u0000')) return
|
|
437
|
+
state.awaitingApproval = [...toolNames]
|
|
438
|
+
this.markDirty(sessionId, state)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Render the stop button disabled on the session's live turn. */
|
|
442
|
+
markStopping(sessionId: string): void {
|
|
443
|
+
this.markStop(sessionId, state => { state.stopRequested = true })
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Add the owner-only notice under the session's stop button (once). */
|
|
447
|
+
markStopDenied(sessionId: string): void {
|
|
448
|
+
this.markStop(sessionId, state => { state.stopDenied = true })
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private markStop(sessionId: string, apply: (state: TurnState) => void): void {
|
|
452
|
+
const state = this.front(sessionId)
|
|
453
|
+
if (state === undefined) return
|
|
454
|
+
const before = `${state.stopRequested}/${state.stopDenied}`
|
|
455
|
+
apply(state)
|
|
456
|
+
if (`${state.stopRequested}/${state.stopDenied}` !== before) this.markDirty(sessionId, state)
|
|
457
|
+
}
|
|
458
|
+
|
|
388
459
|
/** The front (oldest unfinished) turn state of a session. */
|
|
389
460
|
private front(sessionId: string): TurnState | undefined {
|
|
390
461
|
const queue = this.bySession.get(sessionId)
|
|
@@ -518,7 +589,7 @@ export class TurnStreamer {
|
|
|
518
589
|
// The ended turn belongs to the front state: assign it if a turn was
|
|
519
590
|
// rejected/emptied before any step started.
|
|
520
591
|
if (state.turn === undefined) state.turn = event.data.turn
|
|
521
|
-
this.finalize(session.id, state, turnFailure(event.data.reason))
|
|
592
|
+
this.finalize(session.id, state, turnFailure(event.data.reason), turnAborted(event.data.reason))
|
|
522
593
|
break
|
|
523
594
|
}
|
|
524
595
|
default:
|
|
@@ -834,6 +905,9 @@ export class TurnStreamer {
|
|
|
834
905
|
*/
|
|
835
906
|
private toolLine(state: TurnState, now: number): string {
|
|
836
907
|
if (state.finalized) return ''
|
|
908
|
+
if (state.awaitingApproval.length > 0) {
|
|
909
|
+
return clipActivity(`⏳ 等待你审批:${[...new Set(state.awaitingApproval)].join('、')}`)
|
|
910
|
+
}
|
|
837
911
|
const tool = state.toolActivity
|
|
838
912
|
if (tool === undefined) return ''
|
|
839
913
|
const prefix = tool.name === 'subagent'
|
|
@@ -884,12 +958,16 @@ export class TurnStreamer {
|
|
|
884
958
|
const kind = this.noteKind('tool', tool, state.toolKind)
|
|
885
959
|
if (kind !== undefined) state.toolKind = kind
|
|
886
960
|
}
|
|
961
|
+
const stop: StopControl | undefined = !active || state.finalized || this.front(sessionId) !== state
|
|
962
|
+
? undefined
|
|
963
|
+
: state.stopRequested ? 'stopping' : 'ready'
|
|
887
964
|
return {
|
|
888
965
|
phase: state.phase,
|
|
889
966
|
reasoning: state.reasoning,
|
|
890
967
|
body,
|
|
891
968
|
status: active ? state.status : '',
|
|
892
969
|
tool: active ? state.tool : '',
|
|
970
|
+
...(stop === undefined ? {} : { stop, stopDenied: state.stopDenied }),
|
|
893
971
|
stats: this.statsFor(sessionId, state),
|
|
894
972
|
}
|
|
895
973
|
}
|
|
@@ -1062,10 +1140,13 @@ export class TurnStreamer {
|
|
|
1062
1140
|
sessionId: string,
|
|
1063
1141
|
state: TurnState,
|
|
1064
1142
|
failure: { message: string; code?: string } | undefined,
|
|
1143
|
+
aborted = false,
|
|
1065
1144
|
): void {
|
|
1066
1145
|
if (state.finalized) return
|
|
1067
1146
|
state.finalized = true
|
|
1068
|
-
|
|
1147
|
+
// Only a stop this channel requested reads as 已停止; other cancellation
|
|
1148
|
+
// causes (parent, hook, disposal) settle like a normal end.
|
|
1149
|
+
state.phase = failure !== undefined ? 'error' : aborted && state.stopRequested ? 'stopped' : 'done'
|
|
1069
1150
|
// A turn that ends inside a reasoning phase (e.g. no answer text) still
|
|
1070
1151
|
// reports the time it spent reasoning.
|
|
1071
1152
|
this.closeReasoning(state)
|
|
@@ -1118,6 +1199,9 @@ export class TurnStreamer {
|
|
|
1118
1199
|
if (queue === undefined) return
|
|
1119
1200
|
const index = queue.indexOf(state)
|
|
1120
1201
|
if (index >= 0) queue.splice(index, 1)
|
|
1202
|
+
// The next queued turn becomes the active one and gains the stop button.
|
|
1203
|
+
const next = this.front(sessionId)
|
|
1204
|
+
if (next !== undefined) this.markDirty(sessionId, next)
|
|
1121
1205
|
})
|
|
1122
1206
|
|
|
1123
1207
|
this.hooks.onTurnFinalized(sessionId, state.turn ?? 0, state.cards[0]?.messageId ?? '')
|
|
@@ -1136,6 +1220,11 @@ export class TurnStreamer {
|
|
|
1136
1220
|
}
|
|
1137
1221
|
}
|
|
1138
1222
|
|
|
1223
|
+
/** Whether a turn ended through a cancellation request (`/stop`, the stop button). */
|
|
1224
|
+
function turnAborted(reason: TurnEndReason): boolean {
|
|
1225
|
+
return typeof reason === 'object' && reason !== null && reason.kind === 'aborted'
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1139
1228
|
/** Extract the user-visible failure from the current Harness turn-end union. */
|
|
1140
1229
|
function turnFailure(reason: TurnEndReason): { message: string; code?: string } | undefined {
|
|
1141
1230
|
if (typeof reason !== 'object' || reason === null || reason.kind !== 'error') return undefined
|
package/src/workspaces.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Workspace directory resolution: one DSH workspace per user, one working
|
|
3
3
|
* directory per Feishu topic.
|
|
4
4
|
*
|
|
5
|
-
* - p2p: workspace `<baseDir>/p2p/<
|
|
6
|
-
* - group: workspace `<baseDir>/groups/<chat_id>/<
|
|
5
|
+
* - p2p: workspace `<baseDir>/p2p/<user_id>/`
|
|
6
|
+
* - group: workspace `<baseDir>/groups/<chat_id>/<user_id>/`
|
|
7
7
|
* - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
|
|
8
8
|
*
|
|
9
9
|
* The workspace root is the Session header cwd: it keeps the Session grouped
|
|
@@ -11,18 +11,19 @@
|
|
|
11
11
|
* The per-topic directory is where the tools actually work (see
|
|
12
12
|
* `./session/isolation.ts`), so files produced by different topics never mix.
|
|
13
13
|
* Older versions registered one workspace per topic under `<baseDir>/topics/`;
|
|
14
|
-
* those directories are kept verbatim for legacy routes and
|
|
15
|
-
*
|
|
14
|
+
* those directories are kept verbatim for legacy routes and never migrated.
|
|
15
|
+
* The plugin never deletes workspace or topic directories; operators remove
|
|
16
|
+
* stale ones by hand (a missing topic directory is recreated on the next turn).
|
|
16
17
|
*
|
|
17
18
|
* Attachments received before a topic exists were staged by older plugin
|
|
18
|
-
* versions below `<baseDir>/_staging/<chatType>/<chat_id>/<
|
|
19
|
+
* versions below `<baseDir>/_staging/<chatType>/<chat_id>/<user_id>/` and
|
|
19
20
|
* adopted into the topic directory once its first Session is created. The v2
|
|
20
21
|
* pipeline stores inbound attachments in the DSH native attachment library and
|
|
21
22
|
* never writes staging, so this is a one-release compatibility path only: it is
|
|
22
23
|
* scheduled for removal in the release cycle after attachment_pipeline_v2 ships
|
|
23
24
|
* (see Roadmap decisions D12 and docs/attachment-pipeline-v2.md).
|
|
24
25
|
*
|
|
25
|
-
* Feishu ids (`
|
|
26
|
+
* Feishu ids (`user_id`, `oc_…`) are safe path segments, but they are validated
|
|
26
27
|
* anyway so a hostile id can never escape `baseDir`.
|
|
27
28
|
*
|
|
28
29
|
* @module dsh-channels/workspaces
|
|
@@ -145,8 +146,8 @@ export function worktreeBranchFor(topicToken: string): string {
|
|
|
145
146
|
* Deterministic workspace directory for one user in one chat context: the
|
|
146
147
|
* durable project the user's Sessions are grouped under.
|
|
147
148
|
*
|
|
148
|
-
* - p2p: `<baseDir>/p2p/<
|
|
149
|
-
* - group: `<baseDir>/groups/<chat_id>/<
|
|
149
|
+
* - p2p: `<baseDir>/p2p/<user_id>/`
|
|
150
|
+
* - group: `<baseDir>/groups/<chat_id>/<user_id>/`
|
|
150
151
|
*
|
|
151
152
|
* These are the same paths older versions used as their shared (user × chat)
|
|
152
153
|
* workspaces, so an upgraded deployment reuses the existing registry record
|
|
@@ -156,9 +157,9 @@ export function workspacePathFor(
|
|
|
156
157
|
baseDir: string,
|
|
157
158
|
chatType: 'p2p' | 'group',
|
|
158
159
|
chatId: string,
|
|
159
|
-
|
|
160
|
+
senderUserId: string,
|
|
160
161
|
): string {
|
|
161
|
-
const user = safeSegment(
|
|
162
|
+
const user = safeSegment(senderUserId, 'senderUserId')
|
|
162
163
|
if (chatType === 'p2p') return path.join(baseDir, 'p2p', user)
|
|
163
164
|
return path.join(baseDir, 'groups', safeSegment(chatId, 'chatId'), user)
|
|
164
165
|
}
|
|
@@ -168,20 +169,11 @@ export function topicDirFor(workspacePath: string, topicToken: string): string {
|
|
|
168
169
|
return path.join(workspacePath, 'topics', safeSegment(topicToken, 'topic token'))
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
/**
|
|
172
|
-
* Root of the legacy (one workspace per topic) directories. Legacy workspaces
|
|
173
|
-
* live strictly below this path, which is how the retention cleanup tells them
|
|
174
|
-
* apart from user workspaces that must never be deleted.
|
|
175
|
-
*/
|
|
176
|
-
export function legacyTopicWorkspaceRoot(baseDir: string): string {
|
|
177
|
-
return path.join(baseDir, 'topics')
|
|
178
|
-
}
|
|
179
|
-
|
|
180
172
|
/**
|
|
181
173
|
* Legacy pre-topic attachment staging root.
|
|
182
174
|
*
|
|
183
|
-
* Only older plugin versions wrote here; the v2 native pipeline never does
|
|
184
|
-
*
|
|
175
|
+
* Only older plugin versions wrote here; the v2 native pipeline never does.
|
|
176
|
+
* Scheduled for removal together with
|
|
185
177
|
* the one-time adoption in the release cycle after attachment_pipeline_v2
|
|
186
178
|
* (decisions D12).
|
|
187
179
|
*
|
|
@@ -203,10 +195,10 @@ export function stagingPathFor(
|
|
|
203
195
|
baseDir: string,
|
|
204
196
|
chatType: 'p2p' | 'group',
|
|
205
197
|
chatId: string,
|
|
206
|
-
|
|
198
|
+
senderUserId: string,
|
|
207
199
|
): string {
|
|
208
200
|
const chat = safeSegment(chatId, 'chatId')
|
|
209
|
-
const user = safeSegment(
|
|
201
|
+
const user = safeSegment(senderUserId, 'senderUserId')
|
|
210
202
|
return path.join(stagingRoot(baseDir), chatType, chat, user)
|
|
211
203
|
}
|
|
212
204
|
|
|
@@ -214,11 +206,11 @@ export function stagingPathFor(
|
|
|
214
206
|
export function workspaceTitleFor(
|
|
215
207
|
chatType: 'p2p' | 'group',
|
|
216
208
|
chatId: string,
|
|
217
|
-
|
|
209
|
+
senderUserId: string,
|
|
218
210
|
): string {
|
|
219
211
|
return chatType === 'p2p'
|
|
220
|
-
? `飞书私聊 · ${
|
|
221
|
-
: `飞书群聊 · ${chatId} · ${
|
|
212
|
+
? `飞书私聊 · ${senderUserId}`
|
|
213
|
+
: `飞书群聊 · ${chatId} · ${senderUserId}`
|
|
222
214
|
}
|
|
223
215
|
|
|
224
216
|
/**
|
|
@@ -231,12 +223,12 @@ export async function ensureWorkspace(
|
|
|
231
223
|
baseDir: string,
|
|
232
224
|
chatType: 'p2p' | 'group',
|
|
233
225
|
chatId: string,
|
|
234
|
-
|
|
226
|
+
senderUserId: string,
|
|
235
227
|
): Promise<Workspace> {
|
|
236
|
-
const dir = workspacePathFor(baseDir, chatType, chatId,
|
|
228
|
+
const dir = workspacePathFor(baseDir, chatType, chatId, senderUserId)
|
|
237
229
|
await mkdir(dir, { recursive: true })
|
|
238
230
|
return ctx.workspaceRegistry.create(
|
|
239
231
|
dir,
|
|
240
|
-
workspaceTitleFor(chatType, chatId,
|
|
232
|
+
workspaceTitleFor(chatType, chatId, senderUserId),
|
|
241
233
|
)
|
|
242
234
|
}
|