@modesto_ma/dsh-channels 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -39
- package/docs/access-control-design.md +1 -1
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +123 -15
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +7 -6
- package/docs/workspace-isolation-design.md +81 -0
- package/lib/client.js +30 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +11008 -5132
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +267 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +221 -13
- package/src/domain.ts +141 -3
- package/src/feishu/agent-context.ts +66 -16
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +730 -81
- package/src/service.ts +536 -50
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +120 -23
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +43 -21
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +147 -14
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +74 -31
package/src/settings/surface.ts
CHANGED
|
@@ -47,8 +47,15 @@ import type {
|
|
|
47
47
|
SaveCredentialsResponse,
|
|
48
48
|
} from './contract.ts'
|
|
49
49
|
import {
|
|
50
|
+
configValueOf,
|
|
51
|
+
deleteOverlayKey,
|
|
52
|
+
mergeOverlay,
|
|
53
|
+
mergeOverlayPatch,
|
|
54
|
+
overlayValueOf,
|
|
55
|
+
overlayValues,
|
|
50
56
|
parseOverlay,
|
|
51
57
|
pickEditable,
|
|
58
|
+
setOverlayValue,
|
|
52
59
|
} from './overlay.ts'
|
|
53
60
|
import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
|
|
54
61
|
import type { SettingsCatalog } from './catalog.ts'
|
|
@@ -79,7 +86,7 @@ export interface SettingsTarget {
|
|
|
79
86
|
configSnapshot(): FeishuConfig
|
|
80
87
|
baseConfigSnapshot(): FeishuConfig
|
|
81
88
|
runningConfigSnapshot(): FeishuConfig | undefined
|
|
82
|
-
applyOverlay(overlay:
|
|
89
|
+
applyOverlay(overlay: OverlayPatch): void
|
|
83
90
|
restart(): Promise<void>
|
|
84
91
|
credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }>
|
|
85
92
|
setCredential(field: CredentialField, value: string): Promise<void>
|
|
@@ -125,10 +132,17 @@ const BOOLEAN_KEYS = new Set<EditableKey>([
|
|
|
125
132
|
'welcomeOnboarding',
|
|
126
133
|
'allowPermissionSwitch',
|
|
127
134
|
'acceptAttachments',
|
|
135
|
+
'prefetchImages',
|
|
128
136
|
'workspaceRetentionAuto',
|
|
137
|
+
'feishuTools.attachmentFetch',
|
|
138
|
+
'feishuTools.topicMessages',
|
|
129
139
|
])
|
|
130
140
|
|
|
131
141
|
const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
|
|
142
|
+
attachmentStore: [
|
|
143
|
+
{ value: 'native', label: 'DSH 原生附件库(默认)' },
|
|
144
|
+
{ value: 'archive', label: '工作区 attachments/(旧版)' },
|
|
145
|
+
],
|
|
132
146
|
dmMode: [
|
|
133
147
|
{ value: 'open', label: '开放私聊' },
|
|
134
148
|
{ value: 'disabled', label: '禁用私聊' },
|
|
@@ -160,7 +174,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
|
|
|
160
174
|
const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
|
|
161
175
|
baseDir: {
|
|
162
176
|
label: '工作区根目录(文件落点)',
|
|
163
|
-
help: '
|
|
177
|
+
help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
|
|
164
178
|
placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
|
|
165
179
|
wide: true,
|
|
166
180
|
},
|
|
@@ -185,9 +199,104 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
185
199
|
max: 30,
|
|
186
200
|
step: 1,
|
|
187
201
|
},
|
|
188
|
-
acceptAttachments: {
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
acceptAttachments: {
|
|
203
|
+
label: '接收入站附件',
|
|
204
|
+
help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH 原生附件库:触发消息的图片预取进首轮、文件保持懒取,纯附件消息静默登记元数据;archive 下按旧版行为下载到工作区 attachments/。关闭后附件消息不保存、不触发模型、也不登记。',
|
|
205
|
+
},
|
|
206
|
+
attachmentStore: {
|
|
207
|
+
label: '入站附件存储后端',
|
|
208
|
+
help: 'native(默认):图片与文件存入 DSH 原生附件库(不可变、内容寻址去重、读取校验、随 Session 回放;不自动清理)。archive:旧版下载到话题工作区 attachments/(可清理但会阻塞会话)。宿主未挂载附件服务时 native 自动降级 archive。',
|
|
209
|
+
},
|
|
210
|
+
prefetchImages: {
|
|
211
|
+
label: '预取触发消息的图片',
|
|
212
|
+
help: 'native 下把触发消息的图片预取为首轮 ImageBlock,会话/话题/卡片不等下载;失败、超限或超时的图片降级为清单中的懒取项。关闭后图片也需用 feishu_attachment_fetch 获取。',
|
|
213
|
+
},
|
|
214
|
+
prefetchTimeoutMs: {
|
|
215
|
+
label: '图片预取整段超时(ms)',
|
|
216
|
+
help: '一次触发消息图片预取的总超时(默认 10000 ms,100–120000);到期后已取回的图片保留,其余降级为清单,不阻塞回合。',
|
|
217
|
+
min: 100,
|
|
218
|
+
max: 120_000,
|
|
219
|
+
step: 100,
|
|
220
|
+
},
|
|
221
|
+
prefetchImageMaxBytes: {
|
|
222
|
+
label: '单张预取图片上限(字节)',
|
|
223
|
+
help: '单张图片超过该字节数即不入首轮图片、留在清单懒取(默认 20971520 = 20 MiB)。',
|
|
224
|
+
min: 1,
|
|
225
|
+
max: 200 * 1024 * 1024,
|
|
226
|
+
step: 1,
|
|
227
|
+
},
|
|
228
|
+
prefetchTotalMaxBytes: {
|
|
229
|
+
label: '单次预取总量上限(字节)',
|
|
230
|
+
help: '一次触发消息图片预取的总字节预算(默认 52428800 = 50 MiB);用尽后剩余图片降级为清单懒取。',
|
|
231
|
+
min: 1,
|
|
232
|
+
max: 1024 * 1024 * 1024,
|
|
233
|
+
step: 1,
|
|
234
|
+
},
|
|
235
|
+
historyBootstrapLimit: {
|
|
236
|
+
label: '首轮话题历史条数',
|
|
237
|
+
help: '新 Session 首轮自动附带本话题最近 N 条消息摘要(0 = 关闭,上限 20);被动读取使用短预算,缺权限/失败时静默省略。更早的消息用 feishu_topic_messages 工具翻页。',
|
|
238
|
+
min: 0,
|
|
239
|
+
max: 20,
|
|
240
|
+
step: 1,
|
|
241
|
+
},
|
|
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
|
+
promptContextTimeoutMs: {
|
|
256
|
+
label: '被动读取预算(ms)',
|
|
257
|
+
help: '引用摘录与首轮话题历史这类被动 prompt 增强的整段预算(默认 3000 ms,100–10000);超时即省略对应块、回合照跑。模型主动调用工具的读取超时另由 feishu_tools 超时控制。',
|
|
258
|
+
min: 100,
|
|
259
|
+
max: 10_000,
|
|
260
|
+
step: 100,
|
|
261
|
+
},
|
|
262
|
+
'feishuTools.attachmentFetch': {
|
|
263
|
+
label: '启用 feishu_attachment_fetch 工具',
|
|
264
|
+
help: '让模型按 message_id/file_key 懒取已授权消息的图片/文件。关闭后文件无法懒取,插件整体回退 archive 后端(入站文件直接下载到工作区)。对新会话生效。',
|
|
265
|
+
},
|
|
266
|
+
'feishuTools.attachmentFetchMaxResources': {
|
|
267
|
+
label: '单次取回资源数上限',
|
|
268
|
+
help: '一次工具调用最多取回的附件数量(默认 10,1–50);超出会返回可读原因,模型可按 file_key 拆分重试。',
|
|
269
|
+
min: 1,
|
|
270
|
+
max: 50,
|
|
271
|
+
step: 1,
|
|
272
|
+
},
|
|
273
|
+
'feishuTools.attachmentFetchMaxBytes': {
|
|
274
|
+
label: '单次取回字节预算(字节)',
|
|
275
|
+
help: '一次工具调用的总下载字节预算(默认 104857600 = 100 MiB);文件流式写入原生库,不整文件缓冲。',
|
|
276
|
+
min: 1,
|
|
277
|
+
max: 1024 * 1024 * 1024,
|
|
278
|
+
step: 1,
|
|
279
|
+
},
|
|
280
|
+
'feishuTools.attachmentFetchTimeoutMs': {
|
|
281
|
+
label: '单次取回超时(ms)',
|
|
282
|
+
help: '一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,1000–600000);到期取消下载并逐项报告可读原因。',
|
|
283
|
+
min: 1_000,
|
|
284
|
+
max: 600_000,
|
|
285
|
+
step: 1_000,
|
|
286
|
+
},
|
|
287
|
+
'feishuTools.topicMessages': {
|
|
288
|
+
label: '启用 feishu_topic_messages 工具',
|
|
289
|
+
help: '让模型按需读取引用消息与话题内更早的消息/附件。关闭只影响模型工具;被动的引用摘录与首轮历史仍由 historyBootstrapLimit 控制。',
|
|
290
|
+
},
|
|
291
|
+
'feishuTools.topicMessagesTimeoutMs': {
|
|
292
|
+
label: '单次话题读取超时(ms)',
|
|
293
|
+
help: '一次 feishu_topic_messages 的整段超时(默认 20000 ms,1000–120000);被动摘录使用独立的短预算。',
|
|
294
|
+
min: 1_000,
|
|
295
|
+
max: 120_000,
|
|
296
|
+
step: 1_000,
|
|
297
|
+
},
|
|
298
|
+
workspaceRetention: { label: '过期目录保留期', help: '只清理工作区与 Agent 产物目录,不影响 Session 附件:默认 native 存储下附件在 DSH 原生附件库,不参与目录清理;仅当显式使用 archive 后端时,附件副本位于话题目录、会随目录清理。用户工作区本身永不删除,DSH 会话日志不受影响。' },
|
|
299
|
+
workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置;清理只清理工作区与 Agent 产物目录,不影响 Session 附件。' },
|
|
191
300
|
provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
|
|
192
301
|
model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
|
|
193
302
|
reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
|
|
@@ -210,6 +319,21 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
|
210
319
|
accessMode: 'access',
|
|
211
320
|
authRequestTtlDays: 'access',
|
|
212
321
|
acceptAttachments: 'content',
|
|
322
|
+
attachmentStore: 'content',
|
|
323
|
+
prefetchImages: 'content',
|
|
324
|
+
prefetchTimeoutMs: 'content',
|
|
325
|
+
prefetchImageMaxBytes: 'content',
|
|
326
|
+
prefetchTotalMaxBytes: 'content',
|
|
327
|
+
historyBootstrapLimit: 'content',
|
|
328
|
+
pendingAttachmentTtlMs: 'content',
|
|
329
|
+
pendingAttachmentLimit: 'content',
|
|
330
|
+
promptContextTimeoutMs: 'tools',
|
|
331
|
+
'feishuTools.attachmentFetch': 'tools',
|
|
332
|
+
'feishuTools.attachmentFetchMaxResources': 'tools',
|
|
333
|
+
'feishuTools.attachmentFetchMaxBytes': 'tools',
|
|
334
|
+
'feishuTools.attachmentFetchTimeoutMs': 'tools',
|
|
335
|
+
'feishuTools.topicMessages': 'tools',
|
|
336
|
+
'feishuTools.topicMessagesTimeoutMs': 'tools',
|
|
213
337
|
provider: 'model',
|
|
214
338
|
model: 'model',
|
|
215
339
|
reasoningEffort: 'model',
|
|
@@ -403,6 +527,14 @@ export function normalizeAccessName(
|
|
|
403
527
|
/** Build the read-only advanced card from the effective config. */
|
|
404
528
|
export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
|
|
405
529
|
return [
|
|
530
|
+
{
|
|
531
|
+
key: 'topicIsolation',
|
|
532
|
+
label: '话题目录隔离',
|
|
533
|
+
value: config.topicIsolation === 'auto'
|
|
534
|
+
? '自动(Git 仓库用 worktree,否则普通子目录)'
|
|
535
|
+
: config.topicIsolation === 'worktree' ? 'Git worktree(失败回退子目录)' : '普通子目录',
|
|
536
|
+
help: '每个话题的独立工作目录形式;在 profile 配置里用 topicIsolation 设置(auto / subdir / worktree)。',
|
|
537
|
+
},
|
|
406
538
|
{ key: 'routeTtlMs', label: '话题路由过期', value: formatDays(config.routeTtlMs), help: '超期话题的后续消息会被明确拒绝,而不是新建 Context-free Session。' },
|
|
407
539
|
{ key: 'approvalTimeoutMs', label: '审批卡片等待', value: formatSeconds(config.approvalTimeoutMs) },
|
|
408
540
|
{ key: 'questionTimeoutMs', label: '提问卡片等待', value: formatSeconds(config.questionTimeoutMs) },
|
|
@@ -528,9 +660,11 @@ export class SettingsSurface {
|
|
|
528
660
|
const effective = this.target.configSnapshot()
|
|
529
661
|
const overlay = await this.store.load()
|
|
530
662
|
const running = this.target.runningConfigSnapshot()
|
|
531
|
-
const overriddenKeys = EDITABLE_KEYS.filter(
|
|
663
|
+
const overriddenKeys = EDITABLE_KEYS.filter(
|
|
664
|
+
key => overlayValueOf(overlay as Record<string, unknown>, key) !== undefined,
|
|
665
|
+
)
|
|
532
666
|
const restartPendingKeys = this.target.isRunning() && running !== undefined
|
|
533
|
-
? RESTART_KEYS.filter(key => effective
|
|
667
|
+
? RESTART_KEYS.filter(key => configValueOf(effective, key) !== configValueOf(running, key))
|
|
534
668
|
: []
|
|
535
669
|
const catalog = await this.catalogFor(effective.provider, effective.model)
|
|
536
670
|
const fields = withDirectoryBrowse(
|
|
@@ -569,7 +703,7 @@ export class SettingsSurface {
|
|
|
569
703
|
try {
|
|
570
704
|
cleanup = await this.target.cleanupPreview()
|
|
571
705
|
} catch (error) {
|
|
572
|
-
this.logger?.warn?.('dsh-channels:
|
|
706
|
+
this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
|
|
573
707
|
cleanup = {
|
|
574
708
|
retention: effective.workspaceRetention,
|
|
575
709
|
auto: effective.workspaceRetentionAuto,
|
|
@@ -603,7 +737,7 @@ export class SettingsSurface {
|
|
|
603
737
|
config: {
|
|
604
738
|
base: pickEditable(base) as Record<string, ConfigValue | undefined>,
|
|
605
739
|
effective: pickEditable(effective) as Record<string, ConfigValue | undefined>,
|
|
606
|
-
overlay: overlay
|
|
740
|
+
overlay: overlayValues(overlay),
|
|
607
741
|
overriddenKeys,
|
|
608
742
|
restartPendingKeys,
|
|
609
743
|
fields,
|
|
@@ -633,7 +767,7 @@ export class SettingsSurface {
|
|
|
633
767
|
clearedKeys.push(key)
|
|
634
768
|
continue
|
|
635
769
|
}
|
|
636
|
-
raw
|
|
770
|
+
setOverlayValue(raw, key, value)
|
|
637
771
|
}
|
|
638
772
|
|
|
639
773
|
const parsed = parseOverlay(raw)
|
|
@@ -662,9 +796,8 @@ export class SettingsSurface {
|
|
|
662
796
|
|
|
663
797
|
let merged: OverlayPatch
|
|
664
798
|
try {
|
|
665
|
-
merged =
|
|
666
|
-
for (const key of clearedKeys)
|
|
667
|
-
Object.assign(merged, parsed.patch)
|
|
799
|
+
merged = mergeOverlayPatch(await this.store.load(), parsed.patch)
|
|
800
|
+
for (const key of clearedKeys) deleteOverlayKey(merged, key)
|
|
668
801
|
} catch (error) {
|
|
669
802
|
return { ok: false, error: `写入覆盖配置失败:${messageOf(error)}` }
|
|
670
803
|
}
|
|
@@ -673,7 +806,7 @@ export class SettingsSurface {
|
|
|
673
806
|
// provider (or lone model) leaves the session without a complete route,
|
|
674
807
|
// so an edit to either side must end with both set or both cleared.
|
|
675
808
|
if (values.provider !== undefined || values.model !== undefined) {
|
|
676
|
-
const prospective =
|
|
809
|
+
const prospective = mergeOverlay(this.target.baseConfigSnapshot(), merged)
|
|
677
810
|
const hasProvider = (prospective.provider ?? '') !== ''
|
|
678
811
|
const hasModel = (prospective.model ?? '') !== ''
|
|
679
812
|
if (hasProvider !== hasModel) {
|
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
|
}
|