@modesto_ma/dsh-channels 0.1.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 +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- 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 +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- 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/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +106 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- 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 +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- package/src/client/panel.tsx +0 -1475
package/src/settings/surface.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
AccessMutationResponse,
|
|
24
24
|
AccessWire,
|
|
25
25
|
CatalogWire,
|
|
26
|
+
ChannelPhaseWire,
|
|
26
27
|
ChannelStatusWire,
|
|
27
28
|
CleanupPreviewWire,
|
|
28
29
|
CleanupResponse,
|
|
@@ -51,15 +52,30 @@ import {
|
|
|
51
52
|
} from './overlay.ts'
|
|
52
53
|
import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
|
|
53
54
|
import type { SettingsCatalog } from './catalog.ts'
|
|
55
|
+
import type { SettingsDirectoryPicker } from './directory-picker.ts'
|
|
54
56
|
|
|
55
57
|
/** The slice of `FeishuService` the panel needs (structural, easy to fake). */
|
|
56
58
|
export interface SettingsTarget {
|
|
57
59
|
isRunning(): boolean
|
|
58
60
|
isRestarting(): boolean
|
|
61
|
+
/** True while an explicit connect action is in flight (panel button busy state). */
|
|
62
|
+
isConnecting(): boolean
|
|
59
63
|
health(): HealthSnapshot
|
|
60
64
|
startedAtMs(): number | undefined
|
|
61
65
|
uptimeMs(): number | undefined
|
|
62
66
|
startError(): string | undefined
|
|
67
|
+
/**
|
|
68
|
+
* Whether the Feishu application credentials resolve. A false value means the
|
|
69
|
+
* plugin is loaded but deliberately holds no connection: the panel guides the
|
|
70
|
+
* user through configuring credentials instead of reporting a failure.
|
|
71
|
+
*/
|
|
72
|
+
isConfigured(): boolean
|
|
73
|
+
/** Human-readable names of the missing configuration items (never values). */
|
|
74
|
+
missingConfig(): readonly string[]
|
|
75
|
+
/** Re-read credential presence (the credentials store is hot-reloaded on disk). */
|
|
76
|
+
refreshReadiness(): Promise<void>
|
|
77
|
+
/** Connect with the current config; throws when credentials are incomplete or the handshake fails. */
|
|
78
|
+
connect(): Promise<void>
|
|
63
79
|
configSnapshot(): FeishuConfig
|
|
64
80
|
baseConfigSnapshot(): FeishuConfig
|
|
65
81
|
runningConfigSnapshot(): FeishuConfig | undefined
|
|
@@ -144,7 +160,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
|
|
|
144
160
|
const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
|
|
145
161
|
baseDir: {
|
|
146
162
|
label: '工作区根目录(文件落点)',
|
|
147
|
-
help: '
|
|
163
|
+
help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
|
|
148
164
|
placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
|
|
149
165
|
wide: true,
|
|
150
166
|
},
|
|
@@ -164,14 +180,14 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
164
180
|
},
|
|
165
181
|
authRequestTtlDays: {
|
|
166
182
|
label: '授权申请有效期(天)',
|
|
167
|
-
help: '入群/用户授权申请卡的有效期,1–30
|
|
183
|
+
help: '入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可在本页下方「待处理申请」中处理)。',
|
|
168
184
|
min: 1,
|
|
169
185
|
max: 30,
|
|
170
186
|
step: 1,
|
|
171
187
|
},
|
|
172
188
|
acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
|
|
173
|
-
workspaceRetention: { label: '
|
|
174
|
-
workspaceRetentionAuto: { label: '
|
|
189
|
+
workspaceRetention: { label: '过期目录保留期', help: '超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。' },
|
|
190
|
+
workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
|
|
175
191
|
provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
|
|
176
192
|
model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
|
|
177
193
|
reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
|
|
@@ -190,9 +206,9 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
|
190
206
|
threadFallback: 'behavior',
|
|
191
207
|
welcomeOnboarding: 'behavior',
|
|
192
208
|
allowPermissionSwitch: 'behavior',
|
|
193
|
-
adminOpenId: '
|
|
194
|
-
accessMode: '
|
|
195
|
-
authRequestTtlDays: '
|
|
209
|
+
adminOpenId: 'access',
|
|
210
|
+
accessMode: 'access',
|
|
211
|
+
authRequestTtlDays: 'access',
|
|
196
212
|
acceptAttachments: 'content',
|
|
197
213
|
provider: 'model',
|
|
198
214
|
model: 'model',
|
|
@@ -296,6 +312,23 @@ function withCurrentOption(
|
|
|
296
312
|
return inherit === undefined ? [extra] : [inherit, extra, ...rest]
|
|
297
313
|
}
|
|
298
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Mark the workspace-root field as browsable when the host can open its native
|
|
317
|
+
* directory chooser. Every other host (no `directoryPicker`, or the `browse`
|
|
318
|
+
* backend of remote/headless sessions) keeps the plain text input.
|
|
319
|
+
*
|
|
320
|
+
* @param fields - Host field descriptors after the selector catalog is applied.
|
|
321
|
+
* @param available - whether a native directory chooser can be opened.
|
|
322
|
+
* @returns the descriptors with `browse: 'directory'` on `baseDir` when usable.
|
|
323
|
+
*/
|
|
324
|
+
export function withDirectoryBrowse(
|
|
325
|
+
fields: readonly EditableFieldWire[],
|
|
326
|
+
available: boolean,
|
|
327
|
+
): EditableFieldWire[] {
|
|
328
|
+
if (!available) return [...fields]
|
|
329
|
+
return fields.map(field => (field.key === 'baseDir' ? { ...field, browse: 'directory' } : field))
|
|
330
|
+
}
|
|
331
|
+
|
|
299
332
|
function messageOf(error: unknown): string {
|
|
300
333
|
return error instanceof Error ? error.message : String(error)
|
|
301
334
|
}
|
|
@@ -370,6 +403,14 @@ export function normalizeAccessName(
|
|
|
370
403
|
/** Build the read-only advanced card from the effective config. */
|
|
371
404
|
export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
|
|
372
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
|
+
},
|
|
373
414
|
{ key: 'routeTtlMs', label: '话题路由过期', value: formatDays(config.routeTtlMs), help: '超期话题的后续消息会被明确拒绝,而不是新建 Context-free Session。' },
|
|
374
415
|
{ key: 'approvalTimeoutMs', label: '审批卡片等待', value: formatSeconds(config.approvalTimeoutMs) },
|
|
375
416
|
{ key: 'questionTimeoutMs', label: '提问卡片等待', value: formatSeconds(config.questionTimeoutMs) },
|
|
@@ -377,12 +418,34 @@ export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
|
|
|
377
418
|
]
|
|
378
419
|
}
|
|
379
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Derive the panel phase from readiness + runtime facts. The order matters:
|
|
423
|
+
* an incomplete configuration always wins (no connection is attempted), and a
|
|
424
|
+
* failed attempt is only reported while the channel is stopped.
|
|
425
|
+
*/
|
|
426
|
+
function phaseOf(target: SettingsTarget): ChannelPhaseWire {
|
|
427
|
+
// A connection attempt in flight is the honest label even though `start()`
|
|
428
|
+
// flips `started` before the handshake finishes. A live connection outranks
|
|
429
|
+
// missing credentials: removing a stored value does not drop the open socket,
|
|
430
|
+
// so the panel keeps the connection actions available and flags the missing
|
|
431
|
+
// credential separately (`configured: false`).
|
|
432
|
+
if (target.isRestarting()) return 'connecting'
|
|
433
|
+
if (target.isConnecting()) return target.isConfigured() ? 'connecting' : 'unconfigured'
|
|
434
|
+
if (target.isRunning()) return 'connected'
|
|
435
|
+
if (!target.isConfigured()) return 'unconfigured'
|
|
436
|
+
if (target.startError() !== undefined) return 'error'
|
|
437
|
+
return 'idle'
|
|
438
|
+
}
|
|
439
|
+
|
|
380
440
|
/** Assemble the status half of the panel payload. */
|
|
381
441
|
function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
|
|
382
442
|
const health = target.health()
|
|
383
443
|
const connection = health.connection
|
|
384
444
|
return {
|
|
385
445
|
running: target.isRunning(),
|
|
446
|
+
configured: target.isConfigured(),
|
|
447
|
+
phase: phaseOf(target),
|
|
448
|
+
missingConfig: [...target.missingConfig()],
|
|
386
449
|
level: health.level,
|
|
387
450
|
startedAt: target.startedAtMs(),
|
|
388
451
|
uptimeMs: target.uptimeMs(),
|
|
@@ -402,7 +465,7 @@ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatus
|
|
|
402
465
|
reasons: health.reasons,
|
|
403
466
|
version: DSH_CHANNELS_VERSION,
|
|
404
467
|
overlayPath,
|
|
405
|
-
busy: target.isRestarting(),
|
|
468
|
+
busy: target.isRestarting() || target.isConnecting(),
|
|
406
469
|
}
|
|
407
470
|
}
|
|
408
471
|
|
|
@@ -419,10 +482,35 @@ export class SettingsSurface {
|
|
|
419
482
|
private readonly store: OverlayStore,
|
|
420
483
|
private readonly logger?: OverlayLogger,
|
|
421
484
|
private readonly catalog?: SettingsCatalog,
|
|
485
|
+
private readonly directoryPicker?: SettingsDirectoryPicker,
|
|
422
486
|
) {}
|
|
423
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Re-read credential presence. The credentials store is hot-reloaded from
|
|
490
|
+
* disk, so a value edited outside the panel becomes visible within one poll
|
|
491
|
+
* instead of only after a restart/connect.
|
|
492
|
+
*/
|
|
493
|
+
private async syncReadiness(): Promise<void> {
|
|
494
|
+
try {
|
|
495
|
+
await this.target.refreshReadiness()
|
|
496
|
+
} catch (error) {
|
|
497
|
+
this.logger?.warn?.('dsh-channels: 凭据状态刷新失败:%s', messageOf(error))
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Reject panel writes while the connection lifecycle is in flight: saving an
|
|
503
|
+
* overlay or credentials then would race `applyOverlay()`/`start()` and can
|
|
504
|
+
* record a running-config snapshot that never took effect.
|
|
505
|
+
*/
|
|
506
|
+
private writeBusy(): { ok: false; error: string } | undefined {
|
|
507
|
+
if (!this.target.isRestarting() && !this.target.isConnecting()) return undefined
|
|
508
|
+
return { ok: false, error: '飞书连接正在连接或重启,请稍候再试' }
|
|
509
|
+
}
|
|
510
|
+
|
|
424
511
|
/** Wire status only (used by the client's 5 s poll). */
|
|
425
512
|
async status(): Promise<ChannelStatusWire> {
|
|
513
|
+
await this.syncReadiness()
|
|
426
514
|
return buildStatus(this.target, this.store.file)
|
|
427
515
|
}
|
|
428
516
|
|
|
@@ -443,6 +531,7 @@ export class SettingsSurface {
|
|
|
443
531
|
|
|
444
532
|
/** Status + editable config with provenance + read-only facts. */
|
|
445
533
|
async panel(): Promise<PanelWire> {
|
|
534
|
+
await this.syncReadiness()
|
|
446
535
|
const base = this.target.baseConfigSnapshot()
|
|
447
536
|
const effective = this.target.configSnapshot()
|
|
448
537
|
const overlay = await this.store.load()
|
|
@@ -452,12 +541,15 @@ export class SettingsSurface {
|
|
|
452
541
|
? RESTART_KEYS.filter(key => effective[key] !== running[key])
|
|
453
542
|
: []
|
|
454
543
|
const catalog = await this.catalogFor(effective.provider, effective.model)
|
|
455
|
-
const fields =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
544
|
+
const fields = withDirectoryBrowse(
|
|
545
|
+
applyCatalog(FIELD_DESCRIPTORS, catalog, {
|
|
546
|
+
provider: effective.provider,
|
|
547
|
+
model: effective.model,
|
|
548
|
+
reasoningEffort: effective.reasoningEffort,
|
|
549
|
+
agentPreset: effective.agentPreset,
|
|
550
|
+
}),
|
|
551
|
+
this.directoryPicker?.available === true,
|
|
552
|
+
)
|
|
461
553
|
|
|
462
554
|
let credentialStates: { appId: CredentialInfo; appSecret: CredentialInfo } = {
|
|
463
555
|
appId: { configured: false, writable: false },
|
|
@@ -485,7 +577,7 @@ export class SettingsSurface {
|
|
|
485
577
|
try {
|
|
486
578
|
cleanup = await this.target.cleanupPreview()
|
|
487
579
|
} catch (error) {
|
|
488
|
-
this.logger?.warn?.('dsh-channels:
|
|
580
|
+
this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
|
|
489
581
|
cleanup = {
|
|
490
582
|
retention: effective.workspaceRetention,
|
|
491
583
|
auto: effective.workspaceRetentionAuto,
|
|
@@ -507,6 +599,7 @@ export class SettingsSurface {
|
|
|
507
599
|
access = {
|
|
508
600
|
mode: effective.accessMode,
|
|
509
601
|
admin: { configured: false },
|
|
602
|
+
loaded: false,
|
|
510
603
|
groups: [],
|
|
511
604
|
users: [],
|
|
512
605
|
requests: [],
|
|
@@ -532,6 +625,8 @@ export class SettingsSurface {
|
|
|
532
625
|
|
|
533
626
|
/** Validate + persist a partial edit; live fields take effect immediately. */
|
|
534
627
|
async save(values: EditableValues): Promise<SaveConfigResponse> {
|
|
628
|
+
const busy = this.writeBusy()
|
|
629
|
+
if (busy !== undefined) return busy
|
|
535
630
|
const unknown = Object.keys(values).filter(key => !(EDITABLE_KEYS as readonly string[]).includes(key))
|
|
536
631
|
if (unknown.length > 0) {
|
|
537
632
|
return { ok: false, error: `未知配置项:${unknown.join(', ')}` }
|
|
@@ -611,6 +706,8 @@ export class SettingsSurface {
|
|
|
611
706
|
|
|
612
707
|
/** Drop every Web override and fall back to the profile config. */
|
|
613
708
|
async clear(): Promise<ClearConfigResponse> {
|
|
709
|
+
const busy = this.writeBusy()
|
|
710
|
+
if (busy !== undefined) return busy
|
|
614
711
|
try {
|
|
615
712
|
await this.store.clear()
|
|
616
713
|
} catch (error) {
|
|
@@ -622,6 +719,8 @@ export class SettingsSurface {
|
|
|
622
719
|
|
|
623
720
|
/** Write provided credential values through the host seam (never echoed back). */
|
|
624
721
|
async saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
|
|
722
|
+
const busy = this.writeBusy()
|
|
723
|
+
if (busy !== undefined) return busy
|
|
625
724
|
const writes: Array<{ field: CredentialField; value: string }> = []
|
|
626
725
|
const issues: FieldIssueWire[] = []
|
|
627
726
|
for (const field of ['appId', 'appSecret'] as const) {
|
|
@@ -647,6 +746,8 @@ export class SettingsSurface {
|
|
|
647
746
|
|
|
648
747
|
/** Remove the stored value behind one credential reference. */
|
|
649
748
|
async clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
|
|
749
|
+
const busy = this.writeBusy()
|
|
750
|
+
if (busy !== undefined) return busy
|
|
650
751
|
if (field !== 'appId' && field !== 'appSecret') {
|
|
651
752
|
return { ok: false, error: '未知的凭据字段' }
|
|
652
753
|
}
|
|
@@ -659,36 +760,67 @@ export class SettingsSurface {
|
|
|
659
760
|
}
|
|
660
761
|
|
|
661
762
|
/**
|
|
662
|
-
* Credential values are resolved on connect.
|
|
663
|
-
*
|
|
664
|
-
*
|
|
763
|
+
* Credential values are resolved on connect. Opening a connection is gated on
|
|
764
|
+
* readiness: saving one half of the pair stores the value but keeps the
|
|
765
|
+
* plugin idle until the other half exists. When the channel is stopped and
|
|
766
|
+
* the pair is complete, connect immediately (nothing to interrupt); when it
|
|
767
|
+
* is running, the user reconnects to pick the value up.
|
|
665
768
|
*/
|
|
666
769
|
private async finishCredentialChange(kind: 'save' | 'clear'): Promise<SaveCredentialsResponse> {
|
|
667
770
|
if (this.target.isRunning()) {
|
|
668
771
|
return { ok: true, panel: await this.panel(), reconnect: 'pending' }
|
|
669
772
|
}
|
|
670
|
-
if (kind === 'clear') {
|
|
671
|
-
return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
|
|
773
|
+
if (kind === 'clear' || !this.target.isConfigured()) {
|
|
774
|
+
return { ok: true, panel: await this.panel(), reconnect: kind === 'clear' ? 'not-needed' : 'unconfigured' }
|
|
672
775
|
}
|
|
673
776
|
try {
|
|
674
|
-
await this.target.
|
|
777
|
+
await this.target.connect()
|
|
675
778
|
return { ok: true, panel: await this.panel(), reconnect: 'auto' }
|
|
676
779
|
} catch (error) {
|
|
780
|
+
// The value is stored, but the channel did not come up: report the
|
|
781
|
+
// failure state instead of claiming a successful connection.
|
|
677
782
|
return {
|
|
678
783
|
ok: true,
|
|
679
784
|
panel: await this.panel(),
|
|
680
|
-
reconnect: '
|
|
785
|
+
reconnect: 'failed',
|
|
681
786
|
autoReconnectError: messageOf(error),
|
|
682
787
|
}
|
|
683
788
|
}
|
|
684
789
|
}
|
|
685
790
|
|
|
686
|
-
/**
|
|
791
|
+
/**
|
|
792
|
+
* The panel's single connect/reconnect action: reopen a running connection,
|
|
793
|
+
* or open the first one when the channel is stopped. Credentials must be
|
|
794
|
+
* complete — otherwise the panel points the user back at the credentials form
|
|
795
|
+
* instead of surfacing a startup error.
|
|
796
|
+
*/
|
|
687
797
|
async reconnect(): Promise<ReconnectResponse> {
|
|
798
|
+
// Credentials may have been edited on disk since the last poll; re-check
|
|
799
|
+
// presence before refusing the action.
|
|
800
|
+
await this.syncReadiness()
|
|
801
|
+
if (!this.target.isConfigured()) {
|
|
802
|
+
return {
|
|
803
|
+
ok: false,
|
|
804
|
+
error: '飞书应用凭据尚未配置完整,请先填写 App ID 与 App Secret 并保存',
|
|
805
|
+
unconfigured: true,
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const wasRunning = this.target.isRunning()
|
|
688
809
|
try {
|
|
689
|
-
await this.target.restart()
|
|
810
|
+
if (wasRunning) await this.target.restart()
|
|
811
|
+
else await this.target.connect()
|
|
690
812
|
} catch (error) {
|
|
691
|
-
return { ok: false, error:
|
|
813
|
+
return { ok: false, error: `${wasRunning ? '重连' : '连接'}失败:${messageOf(error)}` }
|
|
814
|
+
}
|
|
815
|
+
// `restart()` intentionally does not throw when the credentials vanished;
|
|
816
|
+
// verify the connection actually exists before reporting success.
|
|
817
|
+
if (!this.target.isRunning()) {
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
unconfigured: !this.target.isConfigured(),
|
|
821
|
+
error: this.target.startError()
|
|
822
|
+
?? (this.target.isConfigured() ? '飞书连接未建立' : '飞书应用凭据尚未配置完整,请先填写并保存凭据'),
|
|
823
|
+
}
|
|
692
824
|
}
|
|
693
825
|
return { ok: true, panel: await this.panel() }
|
|
694
826
|
}
|
|
@@ -754,12 +886,26 @@ export class SettingsSurface {
|
|
|
754
886
|
}
|
|
755
887
|
}
|
|
756
888
|
|
|
889
|
+
/**
|
|
890
|
+
* Open the host's native directory chooser (the panel's「选择目录…」button).
|
|
891
|
+
*
|
|
892
|
+
* @param signal - caller lifetime; abort closes the OS dialog.
|
|
893
|
+
* @returns the chosen absolute path, or `null` when the operator cancels.
|
|
894
|
+
* @throws when the host provides no native directory chooser.
|
|
895
|
+
*/
|
|
896
|
+
async pickDirectory(signal: AbortSignal): Promise<string | null> {
|
|
897
|
+
if (this.directoryPicker === undefined) throw new Error(PANEL_COPY.directoryPickFailed)
|
|
898
|
+
return await this.directoryPicker.pick(signal)
|
|
899
|
+
}
|
|
900
|
+
|
|
757
901
|
/**
|
|
758
902
|
* Run the workspace-retention cleanup once. Irreversible deletions are the
|
|
759
903
|
* target's responsibility; the surface only reports the outcome and returns
|
|
760
904
|
* a refreshed panel (its cleanup preview then reflects the remaining workspaces).
|
|
761
905
|
*/
|
|
762
906
|
async runCleanup(): Promise<CleanupResponse> {
|
|
907
|
+
const busy = this.writeBusy()
|
|
908
|
+
if (busy !== undefined) return busy
|
|
763
909
|
let result: CleanupRunWire
|
|
764
910
|
try {
|
|
765
911
|
result = await this.target.runWorkspaceCleanup()
|
package/src/streamer.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* lines: the delegation status line (子代理 / 工作流 progress, settled
|
|
12
12
|
* workflow outcome, heartbeat) and the tool line (the parent's in-flight
|
|
13
13
|
* tool call). The lines are INDEPENDENT — a later tool call never
|
|
14
|
-
* supersedes the settled workflow status.
|
|
14
|
+
* supersedes the settled workflow status. The lines render only on the
|
|
15
|
+
* card that is currently streaming, so frozen segment cards never repeat
|
|
16
|
+
* a stale status. Subagent children live in their
|
|
15
17
|
* own sessions: their `subagent/descriptor` / `tool/call` / `turn/end`
|
|
16
18
|
* events are folded into the parent turn's status line through
|
|
17
19
|
* `session.header.parentSession` (status only — the child's text output
|
|
@@ -19,10 +21,11 @@
|
|
|
19
21
|
* 3. When the body outgrows one card's budget, the overflow is split at a
|
|
20
22
|
* clean breakpoint and sent as a continuation segment card (segment 2, 3,
|
|
21
23
|
* …) into the same topic; the stream then keeps flowing into the newest
|
|
22
|
-
* card. Reasoning is never segmented — it stays on card 1
|
|
23
|
-
*
|
|
24
|
-
* 4. `
|
|
25
|
-
*
|
|
24
|
+
* card. Reasoning is never segmented — it stays on card 1 inside the
|
|
25
|
+
* fold panel's own fixed window, independent of the body length.
|
|
26
|
+
* 4. `finalize(turn)` — flip every segment card to its terminal state
|
|
27
|
+
* (`回复结束` / `回复失败`), render the settled footer on the main card and
|
|
28
|
+
* request the Typing → DONE/No reaction swap on the triggering message.
|
|
26
29
|
*
|
|
27
30
|
* The first visible text delta flips the panel title 推理中 → 回复中; the
|
|
28
31
|
* final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
*/
|
|
39
42
|
|
|
40
43
|
import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
|
|
41
|
-
import { buildCard, segmentCardContent } from './cards.ts'
|
|
44
|
+
import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
|
|
42
45
|
import type { CardState, PanelPhase } from './cards.ts'
|
|
43
46
|
import type { FeishuChannel } from './feishu-channel.ts'
|
|
44
47
|
import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
|
|
@@ -49,7 +52,7 @@ import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
|
|
|
49
52
|
* console can confirm which build of the activity status line is loaded.
|
|
50
53
|
* Bump on every behavior change to this feature.
|
|
51
54
|
*/
|
|
52
|
-
export const ACTIVITY_BUILD_MARKER = 'activity-status-
|
|
55
|
+
export const ACTIVITY_BUILD_MARKER = 'activity-status-v7'
|
|
53
56
|
|
|
54
57
|
/** How a segment card reached Feishu. */
|
|
55
58
|
export interface SegmentDelivery {
|
|
@@ -149,8 +152,6 @@ interface TurnState {
|
|
|
149
152
|
header: HeaderSample | undefined
|
|
150
153
|
/** Provider usage produced during THIS turn; never inherited from history. */
|
|
151
154
|
lastUsage: UsageSample | undefined
|
|
152
|
-
/** Structured terminal failure for this turn, when `turn/end.kind=error`. */
|
|
153
|
-
failure: { message: string; code?: string } | undefined
|
|
154
155
|
/** Segment cards in order; `cards[0]` is the attached reply card. */
|
|
155
156
|
cards: SegmentCard[]
|
|
156
157
|
dirty: boolean
|
|
@@ -161,6 +162,12 @@ interface TurnState {
|
|
|
161
162
|
disposed: boolean
|
|
162
163
|
/** Unix ms when the turn was attached (heartbeat elapsed base). */
|
|
163
164
|
turnStartedAt: number
|
|
165
|
+
/** Wall-clock start of the currently open reasoning phase (undefined = not reasoning). */
|
|
166
|
+
reasoningSince: number | undefined
|
|
167
|
+
/** Accumulated wall-clock time the turn spent reasoning, in ms. A phase
|
|
168
|
+
* opens at the first reasoning delta and closes when the step's output ends
|
|
169
|
+
* (first answer text or step end), so tool execution is never counted. */
|
|
170
|
+
reasoningMs: number
|
|
164
171
|
/** The in-flight tool call, if any (cleared by its `tool/result`). */
|
|
165
172
|
toolActivity: { name: string; label: string; since: number } | undefined
|
|
166
173
|
/** Live workflow-run folds projected by the workflow tool into this session. */
|
|
@@ -202,6 +209,8 @@ interface AttemptMark {
|
|
|
202
209
|
cardCount: number
|
|
203
210
|
bodyLengths: number[]
|
|
204
211
|
reasoningLength: number
|
|
212
|
+
reasoningMs: number
|
|
213
|
+
reasoningSince: number | undefined
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
/** One live `tool-workflow/run-*` fold: run identity and member progress. */
|
|
@@ -321,7 +330,6 @@ export class TurnStreamer {
|
|
|
321
330
|
reasoning: '',
|
|
322
331
|
header: undefined,
|
|
323
332
|
lastUsage: undefined,
|
|
324
|
-
failure: undefined,
|
|
325
333
|
cards: [{
|
|
326
334
|
messageId: cardMessageId,
|
|
327
335
|
body: '',
|
|
@@ -339,6 +347,8 @@ export class TurnStreamer {
|
|
|
339
347
|
finalized: false,
|
|
340
348
|
disposed: false,
|
|
341
349
|
turnStartedAt: Date.now(),
|
|
350
|
+
reasoningSince: undefined,
|
|
351
|
+
reasoningMs: 0,
|
|
342
352
|
toolActivity: undefined,
|
|
343
353
|
workflowRuns: new Map(),
|
|
344
354
|
workflowEndStatus: undefined,
|
|
@@ -531,6 +541,9 @@ export class TurnStreamer {
|
|
|
531
541
|
if (state === undefined || state.disposed) return
|
|
532
542
|
switch (frame.type) {
|
|
533
543
|
case 'start': {
|
|
544
|
+
// A new step starts: close any reasoning phase the previous step left
|
|
545
|
+
// open (defensive — its `end` frame normally does this first).
|
|
546
|
+
this.closeReasoning(state)
|
|
534
547
|
state.attempts.set(frame.attemptId, this.attemptMark(state))
|
|
535
548
|
if (state.turn === undefined) state.turn = frame.turn
|
|
536
549
|
break
|
|
@@ -538,6 +551,7 @@ export class TurnStreamer {
|
|
|
538
551
|
case 'chunk': {
|
|
539
552
|
const chunk = frame.chunk
|
|
540
553
|
if (chunk.type === 'text-delta') {
|
|
554
|
+
if (chunk.text !== '') this.closeReasoning(state)
|
|
541
555
|
const last = state.cards[state.cards.length - 1]
|
|
542
556
|
if (last !== undefined && !last.frozen && chunk.text !== '') {
|
|
543
557
|
last.body += chunk.text
|
|
@@ -545,12 +559,17 @@ export class TurnStreamer {
|
|
|
545
559
|
this.markDirty(session.id, state)
|
|
546
560
|
}
|
|
547
561
|
} else if (chunk.type === 'reasoning-delta' && chunk.text !== '') {
|
|
562
|
+
if (state.reasoningSince === undefined) state.reasoningSince = Date.now()
|
|
548
563
|
state.reasoning += chunk.text
|
|
549
564
|
this.markDirty(session.id, state)
|
|
550
565
|
}
|
|
551
566
|
break
|
|
552
567
|
}
|
|
553
568
|
case 'end': {
|
|
569
|
+
// The step's output is complete: a reasoning phase that ended with a
|
|
570
|
+
// tool call (no answer text) stops counting here, so the tool run that
|
|
571
|
+
// follows is not miscounted as reasoning time.
|
|
572
|
+
this.closeReasoning(state)
|
|
554
573
|
const mark = state.attempts.get(frame.attemptId)
|
|
555
574
|
state.attempts.delete(frame.attemptId)
|
|
556
575
|
if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
|
|
@@ -568,9 +587,18 @@ export class TurnStreamer {
|
|
|
568
587
|
cardCount: state.cards.length,
|
|
569
588
|
bodyLengths: state.cards.map(card => card.body.length),
|
|
570
589
|
reasoningLength: state.reasoning.length,
|
|
590
|
+
reasoningMs: state.reasoningMs,
|
|
591
|
+
reasoningSince: state.reasoningSince,
|
|
571
592
|
}
|
|
572
593
|
}
|
|
573
594
|
|
|
595
|
+
/** Close an open reasoning phase, accumulating its wall-clock time. */
|
|
596
|
+
private closeReasoning(state: TurnState, now = Date.now()): void {
|
|
597
|
+
if (state.reasoningSince === undefined) return
|
|
598
|
+
state.reasoningMs += Math.max(0, now - state.reasoningSince)
|
|
599
|
+
state.reasoningSince = undefined
|
|
600
|
+
}
|
|
601
|
+
|
|
574
602
|
/**
|
|
575
603
|
* Drop one abandoned attempt's deltas. The attempt's text is a contiguous
|
|
576
604
|
* suffix of the logical body: segments created during the attempt are
|
|
@@ -594,6 +622,9 @@ export class TurnStreamer {
|
|
|
594
622
|
tail.dirty = true
|
|
595
623
|
}
|
|
596
624
|
state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
|
|
625
|
+
// The abandoned attempt's reasoning time is not part of the visible turn.
|
|
626
|
+
state.reasoningMs = mark.reasoningMs
|
|
627
|
+
state.reasoningSince = mark.reasoningSince
|
|
597
628
|
}
|
|
598
629
|
|
|
599
630
|
private markDirty(sessionId: string, state: TurnState): void {
|
|
@@ -641,6 +672,9 @@ export class TurnStreamer {
|
|
|
641
672
|
: context?.contextWindow ?? projected?.contextWindow,
|
|
642
673
|
model: header?.model,
|
|
643
674
|
effort: header?.effort,
|
|
675
|
+
// Only a turn that actually reasoned reports a duration; a non-reasoning
|
|
676
|
+
// model leaves the field undefined (rendered as `—`).
|
|
677
|
+
reasoningMs: state.reasoning === '' ? undefined : state.reasoningMs,
|
|
644
678
|
}
|
|
645
679
|
}
|
|
646
680
|
|
|
@@ -717,13 +751,10 @@ export class TurnStreamer {
|
|
|
717
751
|
* an elapsed counter so the card never sits silently.
|
|
718
752
|
* Once the turn is finalized, the line switches to the SETTLED form
|
|
719
753
|
* (workflow conclusion + finished-subagent summary) — the final card keeps
|
|
720
|
-
* the delegation status; only the tool line disappears.
|
|
754
|
+
* the delegation status; only the tool line disappears. A failed turn shows
|
|
755
|
+
* its diagnostic once, in the body: the status line never repeats it.
|
|
721
756
|
*/
|
|
722
757
|
private delegationLine(state: TurnState, now: number): string {
|
|
723
|
-
if (state.failure !== undefined) {
|
|
724
|
-
const suffix = state.failure.code === undefined ? '' : ` · ${state.failure.code}`
|
|
725
|
-
return clipActivity(`🔴 本轮运行失败${suffix}`)
|
|
726
|
-
}
|
|
727
758
|
if (state.finalized) return this.settledLine(state)
|
|
728
759
|
|
|
729
760
|
let latest: WorkflowRun | undefined
|
|
@@ -832,10 +863,14 @@ export class TurnStreamer {
|
|
|
832
863
|
}, this.heartbeatMs)
|
|
833
864
|
}
|
|
834
865
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
866
|
+
/**
|
|
867
|
+
* Snapshot one card. `active` is true for the card currently streaming:
|
|
868
|
+
* only that card renders the transient activity lines, so frozen segment
|
|
869
|
+
* cards never repeat a stale tool/delegation line. Both lines are still
|
|
870
|
+
* recomputed (and remembered) on every snapshot so heartbeat/transition
|
|
871
|
+
* logging stays accurate.
|
|
872
|
+
*/
|
|
873
|
+
private cardState(sessionId: string, state: TurnState, body: string, active = true): CardState {
|
|
839
874
|
const now = Date.now()
|
|
840
875
|
const status = this.delegationLine(state, now)
|
|
841
876
|
if (status !== state.status) {
|
|
@@ -853,8 +888,8 @@ export class TurnStreamer {
|
|
|
853
888
|
phase: state.phase,
|
|
854
889
|
reasoning: state.reasoning,
|
|
855
890
|
body,
|
|
856
|
-
status: state.status,
|
|
857
|
-
tool: state.tool,
|
|
891
|
+
status: active ? state.status : '',
|
|
892
|
+
tool: active ? state.tool : '',
|
|
858
893
|
stats: this.statsFor(sessionId, state),
|
|
859
894
|
}
|
|
860
895
|
}
|
|
@@ -882,7 +917,7 @@ export class TurnStreamer {
|
|
|
882
917
|
const card = state.cards[index]
|
|
883
918
|
if (card === undefined || card.frozen || card.mode === 'text') return
|
|
884
919
|
|
|
885
|
-
const options = index
|
|
920
|
+
const options = segmentCardOptions(index)
|
|
886
921
|
const split = segmentCardContent(this.cardState(sessionId, state, card.body), options)
|
|
887
922
|
if (split.head === '') {
|
|
888
923
|
// Degenerate budget (cannot happen with the real fixed overhead):
|
|
@@ -944,10 +979,10 @@ export class TurnStreamer {
|
|
|
944
979
|
let lastError: unknown
|
|
945
980
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
946
981
|
try {
|
|
947
|
-
const snapshot = buildCard(
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
982
|
+
const snapshot = buildCard(
|
|
983
|
+
this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
|
|
984
|
+
segmentCardOptions(index),
|
|
985
|
+
)
|
|
951
986
|
const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body)
|
|
952
987
|
card.sending = false
|
|
953
988
|
card.messageId = delivery.messageId
|
|
@@ -996,8 +1031,11 @@ export class TurnStreamer {
|
|
|
996
1031
|
if (!force && !card.dirty && !turnDirty) return
|
|
997
1032
|
if (!force && card.nextRetryAt > now) return
|
|
998
1033
|
card.dirty = false
|
|
999
|
-
const options = index
|
|
1000
|
-
const snapshot = buildCard(
|
|
1034
|
+
const options = segmentCardOptions(index)
|
|
1035
|
+
const snapshot = buildCard(
|
|
1036
|
+
this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
|
|
1037
|
+
options,
|
|
1038
|
+
)
|
|
1001
1039
|
this.channel.updateCard(card.messageId, snapshot).catch((error: unknown) => {
|
|
1002
1040
|
card.dirty = true
|
|
1003
1041
|
card.failures++
|
|
@@ -1027,8 +1065,10 @@ export class TurnStreamer {
|
|
|
1027
1065
|
): void {
|
|
1028
1066
|
if (state.finalized) return
|
|
1029
1067
|
state.finalized = true
|
|
1030
|
-
state.failure = failure
|
|
1031
1068
|
state.phase = failure === undefined ? 'done' : 'error'
|
|
1069
|
+
// A turn that ends inside a reasoning phase (e.g. no answer text) still
|
|
1070
|
+
// reports the time it spent reasoning.
|
|
1071
|
+
this.closeReasoning(state)
|
|
1032
1072
|
if (failure !== undefined) {
|
|
1033
1073
|
const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
|
|
1034
1074
|
const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
|
|
@@ -1040,7 +1080,7 @@ export class TurnStreamer {
|
|
|
1040
1080
|
// terminal diagnostic in its own continuation card instead.
|
|
1041
1081
|
state.cards.push(this.freshSegment(notice))
|
|
1042
1082
|
} else {
|
|
1043
|
-
tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n
|
|
1083
|
+
tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n${notice}`
|
|
1044
1084
|
tail.dirty = true
|
|
1045
1085
|
}
|
|
1046
1086
|
}
|