dsh-dock 0.18.0 → 0.20.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 +11 -15
- package/client.js +578 -707
- package/features/animation/view.jsx +26 -12
- package/features/mobile-relay/host.js +3 -4
- package/features/mobile-relay/view.jsx +1 -2
- package/features/notify/host.js +18 -3
- package/features/notify/view.jsx +393 -68
- package/features/runstate/host.js +1 -11
- package/features/runstate/view.jsx +32 -15
- package/index.js +0 -3
- package/package.json +2 -2
- package/src/client.jsx +2 -3
- package/src/host-core.js +14 -2
- package/src/shared.js +86 -0
- package/src/task-track.js +135 -3
- package/features/mobile/host.js +0 -315
- package/features/mobile/view.js +0 -530
|
@@ -15,13 +15,11 @@ import { acquireTaskTracker } from '../../src/task-track.js'
|
|
|
15
15
|
export const feature = {
|
|
16
16
|
id: 'runstate',
|
|
17
17
|
name: '运行状态',
|
|
18
|
-
description: '任务运行状态一览:进行中任务(阶段 / 耗时 / 回合 / Token /
|
|
18
|
+
description: '任务运行状态一览:进行中任务(阶段 / 耗时 / 回合 / Token / 等人介入)与最近完成',
|
|
19
19
|
defaultEnabled: false,
|
|
20
20
|
setup(ctx) {
|
|
21
21
|
const disposers = []
|
|
22
22
|
const dispose = () => {
|
|
23
|
-
// 撤掉「本宿主注册了 runstate 路由」旗标(见下方注册处的说明)
|
|
24
|
-
try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ }
|
|
25
23
|
while (disposers.length > 0) {
|
|
26
24
|
const fn = disposers.pop()
|
|
27
25
|
try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
|
|
@@ -35,14 +33,6 @@ export const feature = {
|
|
|
35
33
|
|
|
36
34
|
// ===== RPC 路由 =====
|
|
37
35
|
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
38
|
-
// 路由注册成功后打旗标:【手机适配】的汉堡角标据此决定要不要轮询 status。
|
|
39
|
-
// 没有它的话,功能停用时角标会先打一次必然失败的请求 → 控制台一条 405/404,
|
|
40
|
-
// 与【用量记录】chip 当年同一个毛病(见 features/tokenlog/view.jsx 的 routeGone 闩锁)。
|
|
41
|
-
// 旗标与 __dshDockMobileDrawer / __dshDockWelcomeAck 同风格:宿主侧唯一事实,客户端只读。
|
|
42
|
-
wsCtx.effect(() => {
|
|
43
|
-
if (typeof window !== 'undefined') window.__dshDockRunstate = true
|
|
44
|
-
return () => { try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ } }
|
|
45
|
-
})
|
|
46
36
|
wsCtx.effect(() => wsCtx.webServer.register({
|
|
47
37
|
kind: 'prefix',
|
|
48
38
|
path: '/dsh-dock/runstate',
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// dsh-dock · 功能模块【运行状态】· 客户端视图
|
|
2
2
|
//
|
|
3
3
|
// 两部分:
|
|
4
|
-
// 1. View —— 功能坞面板页:进行中任务(阶段 / 耗时 / 回合 / 工具 / Token /
|
|
5
|
-
// 2. HomeStat —— 首页总揽概要(N 个任务进行中 /
|
|
4
|
+
// 1. View —— 功能坞面板页:进行中任务(阶段 / 耗时 / 回合 / 工具 / Token / 等人介入)与最近完成一览;
|
|
5
|
+
// 2. HomeStat —— 首页总揽概要(N 个任务进行中 / 等人介入 / 空闲 · 最近完成…)。
|
|
6
6
|
//
|
|
7
7
|
// 只读页面:数据全部来自宿主侧会话级任务追踪(src/task-track.js,与【任务动画】【任务通知】
|
|
8
8
|
// 共用同一份实例),本模块不写任何配置——动效在 features/animation/、结束与确认提醒在
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Host 通信:fetch('dsh-dock/runstate/status')(见 features/runstate/host.js)。
|
|
12
12
|
import { useState, useEffect } from "react";
|
|
13
|
+
import { waitingCountOf, taskWaiting, taskApprovals, taskQuestions } from "../../src/shared.js";
|
|
13
14
|
|
|
14
15
|
// ---------- Host RPC 桥接 ----------
|
|
15
16
|
function rpcCall(method, args) {
|
|
@@ -108,22 +109,33 @@ function useRunState() {
|
|
|
108
109
|
return snap;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
//
|
|
112
|
+
// 等人介入的项总数(审批项 + 待回答的提问;标题、横幅与首页概要共用)
|
|
113
|
+
// 口径统一走 src/shared.js 的 waitingCountOf——别在这里另写一份,
|
|
114
|
+
// 漏掉提问就会重演「明明在等用户答、界面却报卡住」那类误报。
|
|
112
115
|
function waitingOf(active) {
|
|
113
|
-
return (active
|
|
116
|
+
return waitingCountOf(active);
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
// ---------- 任务卡 ----------
|
|
117
120
|
function TaskCard(props) {
|
|
118
121
|
const t = props.t;
|
|
119
122
|
const info = endInfo(t.endReason);
|
|
120
|
-
const waiting = !props.done &&
|
|
123
|
+
const waiting = !props.done && taskWaiting(t);
|
|
124
|
+
// 等批准与等回答是不同的动作:标签与明细分开写,用户一眼知道该去点"批准"还是"选答案"
|
|
125
|
+
const approvals = taskApprovals(t);
|
|
126
|
+
const questions = taskQuestions(t);
|
|
127
|
+
const waitLabel = approvals.length > 0
|
|
128
|
+
? "等待确认"
|
|
129
|
+
: (questions.length > 0 ? "等待回答" : "");
|
|
130
|
+
const waitDetail = approvals.length > 0
|
|
131
|
+
? approvals.map((a) => (a && a.toolName ? a.toolName : "未知工具") + (a && a.reason ? ":" + truncate(a.reason, 80) : "")).join(";")
|
|
132
|
+
: questions.map((q) => (q && q.header ? q.header : "提问") + (q && q.question ? ":" + truncate(q.question, 80) : "")).join(";");
|
|
121
133
|
return (
|
|
122
134
|
<div className="dkrs-task">
|
|
123
135
|
<div className="dkrs-task-head">
|
|
124
136
|
<span className="dkrs-task-title" title={t.title}>{t.title || "(无标题)"}</span>
|
|
125
137
|
{props.done ? <span className={"dkrs-tag " + info.cls}>{info.label}</span>
|
|
126
|
-
: waiting ? <span className="dkrs-tag warn"
|
|
138
|
+
: waiting ? <span className="dkrs-tag warn">{waitLabel}</span>
|
|
127
139
|
: <span className="dkrs-tag on">{phaseLabel(t.phase)}</span>}
|
|
128
140
|
</div>
|
|
129
141
|
<div className="dkrs-task-meta">
|
|
@@ -133,10 +145,8 @@ function TaskCard(props) {
|
|
|
133
145
|
{t.totalTokens ? <span>{"↧" + fmtCompact(t.inputTokens) + " ↥" + fmtCompact(t.outputTokens)}</span> : null}
|
|
134
146
|
{props.done && t.endTime ? <span>{fmtTime(t.endTime)}</span> : null}
|
|
135
147
|
</div>
|
|
136
|
-
{waiting ? (
|
|
137
|
-
<div className="dkrs-task-wait">
|
|
138
|
-
{t.approvals.map((a, i) => (a && a.toolName ? a.toolName : "未知工具") + (a && a.reason ? ":" + truncate(a.reason, 80) : "")).join(";")}
|
|
139
|
-
</div>
|
|
148
|
+
{waiting && waitDetail ? (
|
|
149
|
+
<div className="dkrs-task-wait">{waitDetail}</div>
|
|
140
150
|
) : null}
|
|
141
151
|
{props.done && t.errorMessage ? <div className="dkrs-task-err">{truncate(t.errorMessage, 120)}</div> : null}
|
|
142
152
|
</div>
|
|
@@ -150,6 +160,13 @@ function RunStateView() {
|
|
|
150
160
|
const active = st && st.active ? st.active : [];
|
|
151
161
|
const recent = st && st.recent ? st.recent.slice(0, 10) : [];
|
|
152
162
|
const waitingCount = waitingOf(active);
|
|
163
|
+
// 等待的性质要分开说:等批准是"要不要执行",等回答是"选哪个答案",用户要做的动作不同。
|
|
164
|
+
// 两类同时存在时并列点名,别用一句含糊的「等待确认」把提问也吞进去。
|
|
165
|
+
const waitApprovals = (active || []).reduce((n, t) => n + taskApprovals(t).length, 0);
|
|
166
|
+
const waitQuestions = (active || []).reduce((n, t) => n + taskQuestions(t).length, 0);
|
|
167
|
+
const waitBannerHint = waitApprovals > 0 && waitQuestions > 0
|
|
168
|
+
? waitApprovals + " 项等批准、" + waitQuestions + " 项等你回答"
|
|
169
|
+
: waitApprovals > 0 ? "任务停在需要你批准的步骤上" : "任务在等你回答问题";
|
|
153
170
|
// 面板打开期间自动刷新:有任务 2s(阶段/耗时/待确认跟得上),空闲 8s,出错 15s。
|
|
154
171
|
// 本模块没有常驻浮层,所以关掉面板即停止轮询。
|
|
155
172
|
useEffect(() => {
|
|
@@ -167,7 +184,7 @@ function RunStateView() {
|
|
|
167
184
|
|
|
168
185
|
const sub = snap.error ? "状态拉取失败:" + snap.error
|
|
169
186
|
: !st ? (snap.loading ? "正在拉取任务状态…" : "等待任务状态")
|
|
170
|
-
: waitingCount > 0 ? waitingCount + "
|
|
187
|
+
: waitingCount > 0 ? waitingCount + " 项等你介入 · " + active.length + " 个任务进行中"
|
|
171
188
|
: active.length > 0 ? active.length + " 个任务进行中"
|
|
172
189
|
: recent.length > 0 ? "空闲 · 显示最近完成" : "空闲 · 暂无任务记录";
|
|
173
190
|
|
|
@@ -182,7 +199,7 @@ function RunStateView() {
|
|
|
182
199
|
</button>
|
|
183
200
|
</div>
|
|
184
201
|
{waitingCount > 0 ? (
|
|
185
|
-
<div className="dkrs-waitbanner">✋ {waitingCount}
|
|
202
|
+
<div className="dkrs-waitbanner">✋ {waitingCount} 项等你介入:{waitBannerHint},请在会话里处理后继续。</div>
|
|
186
203
|
) : null}
|
|
187
204
|
{active.length > 0 ? (
|
|
188
205
|
<div className="dkrs-tasks">{active.map((t) => <TaskCard key={t.sessionId} t={t} />)}</div>
|
|
@@ -217,7 +234,7 @@ function RunStateStat(props) {
|
|
|
217
234
|
if (!st) return <span>等待任务状态…</span>;
|
|
218
235
|
const active = st.active || [];
|
|
219
236
|
const waiting = waitingOf(active);
|
|
220
|
-
if (waiting > 0) return <span className="dkrs-warn">✋ {waiting}
|
|
237
|
+
if (waiting > 0) return <span className="dkrs-warn">✋ {waiting} 项等你介入 · {active.length} 个任务进行中</span>;
|
|
221
238
|
if (active.length > 0) {
|
|
222
239
|
return <span>{active.length + " 个任务进行中 · " + fmtDur(Math.max(...active.map((x) => x.elapsed || 0)))}</span>;
|
|
223
240
|
}
|
|
@@ -238,7 +255,7 @@ const css = [
|
|
|
238
255
|
".dkrs-sec-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:1;min-width:120px;}",
|
|
239
256
|
".dkrs-refresh{cursor:pointer;flex:none;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:2px 10px;font-family:inherit;font-size:12px;}",
|
|
240
257
|
".dkrs-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
241
|
-
//
|
|
258
|
+
// 等人介入横幅:琥珀描边,常驻在列表上方(任务卡各自也带「等待确认」/「等待回答」标签)
|
|
242
259
|
".dkrs-waitbanner{border:1px solid color-mix(in srgb,var(--dk-warn) 45%,transparent);background:color-mix(in srgb,var(--dk-warn) 10%,transparent);color:var(--dk-warn);border-radius:8px;padding:6px 10px;font-size:12px;line-height:1.6;}",
|
|
243
260
|
".dkrs-done-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);}",
|
|
244
261
|
// 任务列表
|
|
@@ -262,7 +279,7 @@ export const feature = {
|
|
|
262
279
|
name: "运行状态",
|
|
263
280
|
order: 150,
|
|
264
281
|
accent: "#38bdf8",
|
|
265
|
-
description: "任务运行状态一览:进行中任务的阶段 / 耗时 / Token /
|
|
282
|
+
description: "任务运行状态一览:进行中任务的阶段 / 耗时 / Token / 等人介入(等批准 / 等回答)与最近完成记录(只读)",
|
|
266
283
|
css,
|
|
267
284
|
View: RunStateView,
|
|
268
285
|
HomeStat: RunStateStat,
|
package/index.js
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
// - tts 语音朗读:把会话回复读出来(浏览器 TTS;宿主半部只管配置 + 共享任务追踪)
|
|
19
19
|
// - modelconfig 模型设置:模型目录的输入模态与思考强度(写回官方配置)
|
|
20
20
|
// - providerconfig 模型提供商:Provider 增删改 + API Key + 拉取上游模型列表
|
|
21
|
-
// - mobile 手机适配:窄屏排版/Tab 栏/抽屉/遮罩/键盘(宿主经 tapIndex 注入 index.html)
|
|
22
21
|
// - mobile-relay 远程访问:账号密码登录的远程入口(网关 + 账号 + 浏览模式目录选择器)
|
|
23
22
|
import { DockConfig, sendJson, readBody, rejectUntrustedRequest, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, migrateImportedSections, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
|
|
24
23
|
import { injectWelcomeAck } from './src/welcome-ack.js'
|
|
@@ -32,7 +31,6 @@ import { feature as fNotify } from './features/notify/host.js'
|
|
|
32
31
|
import { feature as fRunState } from './features/runstate/host.js'
|
|
33
32
|
import { feature as fTts } from './features/tts/host.js'
|
|
34
33
|
import { feature as fMobileRelay } from './features/mobile-relay/host.js'
|
|
35
|
-
import { feature as fMobile } from './features/mobile/host.js'
|
|
36
34
|
|
|
37
35
|
export const name = 'dsh-dock'
|
|
38
36
|
|
|
@@ -76,7 +74,6 @@ export function apply(ctx, config) {
|
|
|
76
74
|
fRunState,
|
|
77
75
|
fTts,
|
|
78
76
|
fMobileRelay,
|
|
79
|
-
fMobile,
|
|
80
77
|
]
|
|
81
78
|
|
|
82
79
|
// 绑定 Config 镜像(替代已移除的 settings.get);volatile 字段热更时重绑。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、语音播报可选音色、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览)、语音朗读(浏览器 TTS 读回复:回合下方喇叭按钮 + 自动朗读);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build:client": "node scripts/build-client.mjs",
|
|
45
45
|
"test:client": "node scripts/test-client-views.mjs",
|
|
46
|
-
"test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs && node scripts/test-balance-host.mjs && node scripts/test-route-trust-host.mjs && node scripts/test-tts-host.mjs && node scripts/test-legacy-import.mjs && node scripts/test-mobile-
|
|
46
|
+
"test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs && node scripts/test-balance-host.mjs && node scripts/test-route-trust-host.mjs && node scripts/test-tts-host.mjs && node scripts/test-legacy-import.mjs && node scripts/test-mobile-relay-host.mjs && node scripts/test-mobile-relay.mjs && node scripts/test-welcome-ack.mjs",
|
|
47
47
|
"dev:client": "node scripts/watch-client.mjs",
|
|
48
48
|
"dev:link": "node scripts/dev-link-deps.mjs"
|
|
49
49
|
},
|
package/src/client.jsx
CHANGED
|
@@ -30,13 +30,12 @@ import { feature as fRunState } from "../features/runstate/view.jsx";
|
|
|
30
30
|
import { feature as fTts } from "../features/tts/view.jsx";
|
|
31
31
|
import { feature as fGames } from "../features/games/view.jsx";
|
|
32
32
|
import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
|
|
33
|
-
import { feature as fMobile } from "../features/mobile/view.js";
|
|
34
33
|
|
|
35
34
|
const name = "dsh-dock";
|
|
36
|
-
const DOCK_VERSION = "0.
|
|
35
|
+
const DOCK_VERSION = "0.20.0";
|
|
37
36
|
|
|
38
37
|
// ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
|
|
39
|
-
const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fProviderConfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fTts, fGames, fMobileRelay
|
|
38
|
+
const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fProviderConfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fTts, fGames, fMobileRelay];
|
|
40
39
|
// 规划占位(路线图):接入后移除并建 features/<id>/ 模块
|
|
41
40
|
const PLANNED_FEATURES = [];
|
|
42
41
|
const PLANNED_NOTES = {};
|
package/src/host-core.js
CHANGED
|
@@ -101,6 +101,15 @@ export const DockConfig = z.object({
|
|
|
101
101
|
notifyOnError: z.boolean().default(true),
|
|
102
102
|
// 工具等待用户确认/批准时提醒(dsh 会话流 approval/asked → decided)
|
|
103
103
|
notifyOnConfirm: z.boolean().default(true),
|
|
104
|
+
// 卡住提醒卡片:编程阶段长时间无活动时弹一张(与 voiceOnStuck 同口径,但开关独立)
|
|
105
|
+
notifyOnStuck: z.boolean().default(true),
|
|
106
|
+
// 页内卡片总开关:关闭后右侧所有通知卡片一律不再弹出(含已挂着的)。
|
|
107
|
+
// 只管卡片——语音播报、提示音、系统通知、钉钉/飞书推送各有独立开关,不受它影响。
|
|
108
|
+
notifyCards: z.boolean().default(true),
|
|
109
|
+
// 卡片口径三挡(右侧卡片正文写多少):brief = 任务与耗时/谁卡住;medium = 加模型、
|
|
110
|
+
// 起止时间、停滞时长;detailed = 全部(回合步骤、Token、摘要或错误、最后动作)。
|
|
111
|
+
// 默认 detailed =历史内容——未设置时卡片不缩水。
|
|
112
|
+
cardMode: z.string().default('detailed'),
|
|
104
113
|
// 通知停留毫秒数(0 = 常驻直到手动关闭)
|
|
105
114
|
notifyStayMs: z.number().default(8000),
|
|
106
115
|
// 浏览器系统通知(页面后台时推送)
|
|
@@ -116,9 +125,11 @@ export const DockConfig = z.object({
|
|
|
116
125
|
feishuEnabled: z.boolean().default(false),
|
|
117
126
|
feishuWebhook: z.string().default(''),
|
|
118
127
|
// ===== 语音播报(浏览器 speechSynthesis;与【语音朗读】同一套声音但相互独立开关) =====
|
|
119
|
-
//
|
|
128
|
+
// 服务端总开关(跨浏览器/跨设备):关闭时所有浏览器都不播报(默认关,避免升级后突然说话吓人)。
|
|
129
|
+
// 客户端另有按浏览器保存的「本机播报」开关(localStorage,不进本配置);两者都开才播报。
|
|
120
130
|
voiceNotify: z.boolean().default(false),
|
|
121
|
-
//
|
|
131
|
+
// 播报口径三挡:brief = 一句话;medium = 点名任务 + 原因/耗时/停滞时长;
|
|
132
|
+
// detailed = 带标题/耗时/回合统计/工具名/最后动作。
|
|
122
133
|
voiceMode: z.string().default('brief'),
|
|
123
134
|
// 四个播报时刻,各自独立开关(开总开关后按这里筛选):
|
|
124
135
|
voiceOnComplete: z.boolean().default(true), // 任务正常完成
|
|
@@ -259,6 +270,7 @@ export async function mutateDockSection(ctx, path, value, expectedRevision) {
|
|
|
259
270
|
/** 通知配置字段清单(schema、迁移、客户端默认值三处共用同一份键名)。 */
|
|
260
271
|
export const NOTIFY_FIELDS = [
|
|
261
272
|
'notifyOnComplete', 'notifyOnError', 'notifyOnConfirm', 'notifyStayMs',
|
|
273
|
+
'notifyOnStuck', 'notifyCards', 'cardMode',
|
|
262
274
|
'systemNotify', 'soundNotify', 'soundEffect',
|
|
263
275
|
'dingtalkEnabled', 'dingtalkWebhook', 'feishuEnabled', 'feishuWebhook',
|
|
264
276
|
// 语音播报(新增段字段;旧 animation 段里不存在,迁移循环自然不会搬它们)
|
package/src/shared.js
CHANGED
|
@@ -193,3 +193,89 @@ export function subscribePanel(fn) {
|
|
|
193
193
|
navListeners.add(fn);
|
|
194
194
|
return () => { navListeners.delete(fn); };
|
|
195
195
|
}
|
|
196
|
+
|
|
197
|
+
// ---- 等人介入(等待确认 / 等待回答)的统一口径 ----
|
|
198
|
+
// 宿主快照里有两类「任务停在原地等人」:
|
|
199
|
+
// approvals —— 工具等待批准(approval/asked → approval/decided);
|
|
200
|
+
// questions —— 提问等待回答(tool/call ask_user_question → tool/result)。
|
|
201
|
+
// 两者语义相同(不动手就不会继续),但来源与文案不同。历史教训(2026-11-01 用户反馈):
|
|
202
|
+
// 只有 approvals 被当成「等人」,提问挂起期间会话零事件,18 秒后被卡住判定误报成
|
|
203
|
+
// 「主任务卡住了」——界面在报卡住,实际是等人选答案。凡「是否在等人」的判断都走
|
|
204
|
+
// 这里的统一口径,别各处各写一份(漏一处就又是一次误报)。
|
|
205
|
+
/** 任务等待批准的审批项(缺字段时给空数组,老宿主快照没有该字段)。 */
|
|
206
|
+
export function taskApprovals(task) {
|
|
207
|
+
return task && Array.isArray(task.approvals) ? task.approvals : [];
|
|
208
|
+
}
|
|
209
|
+
/** 任务等待回答的提问(缺字段时给空数组,老宿主快照没有该字段)。 */
|
|
210
|
+
export function taskQuestions(task) {
|
|
211
|
+
return task && Array.isArray(task.questions) ? task.questions : [];
|
|
212
|
+
}
|
|
213
|
+
/** 任务是否停在原地等人(等批准或等回答)。 */
|
|
214
|
+
export function taskWaiting(task) {
|
|
215
|
+
return taskApprovals(task).length > 0 || taskQuestions(task).length > 0;
|
|
216
|
+
}
|
|
217
|
+
/** 等人介入的项数(审批项 + 提问数),标题、横幅与角标共用。 */
|
|
218
|
+
export function waitingCountOf(active) {
|
|
219
|
+
return (active || []).reduce((n, t) => n + taskApprovals(t).length + taskQuestions(t).length, 0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- 「还在动」与「真的停了」的统一口径 ----
|
|
223
|
+
// 卡住判定误报过两次,毛病是同一个:只看「最后一个事件的时间」,把「会话在忙但事件
|
|
224
|
+
// 暂时没落地」当成停滞。
|
|
225
|
+
// · 2026-11-01:提问挂起等用户答(零事件)→ 误报「主任务卡住了」(由 taskWaiting 兜住)。
|
|
226
|
+
// · 2026-11-02:压缩上下文 / 等模型回包(零事件)→ 25 分钟里误报 18 次,最长 96.7 秒,
|
|
227
|
+
// 全部落在 compaction/start → compaction/end 之间(实测会话,工具调用最长只有 7.5 秒)。
|
|
228
|
+
// 所以「还在动」要看三类信号,并且只在这里定义一次(notify 的卡住判定与 animation 的
|
|
229
|
+
// 卡住姿态都走这里,别各处各写一份):
|
|
230
|
+
// 1) 最近活动时间 = max(lastActivityAt, lastMotionAt, phaseAt):流式输出(assistant/chunk)
|
|
231
|
+
// 只刷 lastMotionAt,漏掉它就会把正在刷字的会话当停滞;
|
|
232
|
+
// 2) 有在飞的工具调用(pendingTool):长命令(构建 / 全量测试)期间没有事件,但确实在跑;
|
|
233
|
+
// 3) 正在压缩上下文(compacting):compaction 全程同样零事件。
|
|
234
|
+
// 后两类给一个上限(TASK_BUSY_MS):真挂死(工具永不返回 / 压缩永不结束)仍要能报出来,
|
|
235
|
+
// 不能因为「有在飞的调用」就永远不报。
|
|
236
|
+
/** 卡住阈值:编程阶段多久没动静算停滞(宿主可用 stuckMs 覆盖)。 */
|
|
237
|
+
export const TASK_STALL_MS = 18000;
|
|
238
|
+
/** 「在飞」信号的有效上限:超过这个时长仍无任何事件,就按真停滞处理。 */
|
|
239
|
+
export const TASK_BUSY_MS = 180000;
|
|
240
|
+
|
|
241
|
+
function numOf(v) {
|
|
242
|
+
const n = Number(v);
|
|
243
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 任务最近一次「有动静」的时间:取活动时间、流式动作时间、阶段切换时间三者最大值。 */
|
|
247
|
+
export function taskActivityAt(task) {
|
|
248
|
+
if (!task) return 0;
|
|
249
|
+
return Math.max(numOf(task.lastActivityAt), numOf(task.lastMotionAt), numOf(task.phaseAt));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 任务是否「正在忙」(有在飞的工具调用 / 正在压缩上下文),与耗时无关的那部分信号。 */
|
|
253
|
+
export function taskBusy(task, now, capMs) {
|
|
254
|
+
if (!task) return false;
|
|
255
|
+
const at = typeof now === "number" ? now : Date.now();
|
|
256
|
+
const cap = typeof capMs === "number" && capMs > 0 ? capMs : TASK_BUSY_MS;
|
|
257
|
+
if (task.compacting === true) {
|
|
258
|
+
const since = numOf(task.compactStartedAt);
|
|
259
|
+
if (!since || at - since < cap) return true;
|
|
260
|
+
}
|
|
261
|
+
if (typeof task.pendingTool === "string" && task.pendingTool) {
|
|
262
|
+
const since = numOf(task.toolStartedAt);
|
|
263
|
+
if (!since || at - since < cap) return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 卡住判定:返回已停滞时长(毫秒),不算卡住时返回 0。
|
|
270
|
+
* 等人介入、有在飞调用、正在压缩上下文一律返回 0(那都不是卡住)。
|
|
271
|
+
*/
|
|
272
|
+
export function taskStalled(task, now, thresholdMs) {
|
|
273
|
+
if (!task || taskWaiting(task) || taskBusy(task, now)) return 0;
|
|
274
|
+
const at = typeof now === "number" ? now : Date.now();
|
|
275
|
+
const limit = typeof thresholdMs === "number" && thresholdMs > 0 ? thresholdMs : TASK_STALL_MS;
|
|
276
|
+
const lastAct = taskActivityAt(task);
|
|
277
|
+
if (!lastAct) return 0;
|
|
278
|
+
const stalled = at - lastAct;
|
|
279
|
+
return stalled >= limit ? stalled : 0;
|
|
280
|
+
}
|
|
281
|
+
|
package/src/task-track.js
CHANGED
|
@@ -72,6 +72,29 @@ function createTracker(ctx) {
|
|
|
72
72
|
return 'code'
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* ask_user_question 的调用参数 → 快照里够用的提问摘要。
|
|
77
|
+
* 解析失败不报错:参数拿不到就当「有一问在等人答」,标题留空由客户端兜底
|
|
78
|
+
* (宁可少说一句,也不要因为解析异常把整条任务追踪打断)。
|
|
79
|
+
* @param {unknown} rawArguments 工具调用的 arguments(JSON 字符串)
|
|
80
|
+
* @param {number} askedAt 提问时间(事件时间,不用本地钟)
|
|
81
|
+
* @returns {{header: string, question: string, count: number, askedAt: number}}
|
|
82
|
+
*/
|
|
83
|
+
function describeQuestion(rawArguments, askedAt) {
|
|
84
|
+
const summary = { header: '', question: '', count: 1, askedAt }
|
|
85
|
+
try {
|
|
86
|
+
const parsed = typeof rawArguments === 'string' ? JSON.parse(rawArguments) : rawArguments
|
|
87
|
+
const list = parsed && Array.isArray(parsed.questions) ? parsed.questions : []
|
|
88
|
+
if (list.length > 0) summary.count = list.length
|
|
89
|
+
const first = list[0]
|
|
90
|
+
if (first && typeof first === 'object') {
|
|
91
|
+
if (typeof first.header === 'string') summary.header = truncate(first.header, 60)
|
|
92
|
+
if (typeof first.question === 'string') summary.question = truncate(first.question, 200)
|
|
93
|
+
}
|
|
94
|
+
} catch { /* 参数不是合法 JSON:留空摘要,提问本身照样算「等人答」 */ }
|
|
95
|
+
return summary
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
// 会话标题:官方标题服务,取不到回退首条用户输入(firstPrompt)
|
|
76
99
|
async function getSessionTitle(sessionId, sessionQuery) {
|
|
77
100
|
try {
|
|
@@ -88,6 +111,32 @@ function createTracker(ctx) {
|
|
|
88
111
|
return ''
|
|
89
112
|
}
|
|
90
113
|
|
|
114
|
+
/**
|
|
115
|
+
* 会话标题 + 主/子任务归属:优先 readTitleSnapshot(一次观测同时拿到标题与会话头),
|
|
116
|
+
* 会话头里的 origin === 'subagent' / delegationDepth 是权威的子任务标记(见 dsh-session
|
|
117
|
+
* SessionHeader),fallback 路径只拿标题、按主任务处理。取不到头不报错——播报退化为
|
|
118
|
+
* 不带归属的旧口径,功能不受影响(测试桩只需实现 readTitle/readSession 也能跑)。
|
|
119
|
+
* @returns {Promise<{ title: string, header: object|null }>}
|
|
120
|
+
*/
|
|
121
|
+
async function readSessionFacts(sessionId, sessionQuery) {
|
|
122
|
+
const facts = { title: '', header: null }
|
|
123
|
+
if (!sessionQuery) return facts
|
|
124
|
+
try {
|
|
125
|
+
if (typeof sessionQuery.readTitleSnapshot === 'function') {
|
|
126
|
+
const obs = await sessionQuery.readTitleSnapshot(sessionId)
|
|
127
|
+
if (obs) {
|
|
128
|
+
const t = obs.title
|
|
129
|
+
if (typeof t === 'string') facts.title = t
|
|
130
|
+
else if (t && typeof t.title === 'string') facts.title = t.title
|
|
131
|
+
if (obs.session && typeof obs.session === 'object') facts.header = obs.session
|
|
132
|
+
if (facts.title || facts.header) return facts
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch { /* 换下一种读法 */ }
|
|
136
|
+
facts.title = await getSessionTitle(sessionId, sessionQuery)
|
|
137
|
+
return facts
|
|
138
|
+
}
|
|
139
|
+
|
|
91
140
|
function addActiveSession(sid, sessionQuery, extra = {}) {
|
|
92
141
|
if (activeSessions.has(sid)) return activeSessions.get(sid)
|
|
93
142
|
// 不在终端打「任务开始」日志:开始/结束状态界面(运行状态页)已实时可见
|
|
@@ -108,8 +157,28 @@ function createTracker(ctx) {
|
|
|
108
157
|
errorMessage: '',
|
|
109
158
|
lastText: '',
|
|
110
159
|
firstPrompt: '',
|
|
160
|
+
// 主/子任务归属 + 最近一次调用的工具:卡住播报与卡住卡要说清「哪里卡住、最后在干什么」
|
|
161
|
+
//(isSubtask 由会话头 origin/delegationDepth 异步判定,判定前按主任务处理)
|
|
162
|
+
isSubtask: false,
|
|
163
|
+
parentSessionId: '',
|
|
164
|
+
lastToolName: '',
|
|
111
165
|
// 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
|
|
112
166
|
approvals: new Map(), // approvalId -> { toolName, reason, askedAt }
|
|
167
|
+
// 用户提问等待回答(dsh 会话流 tool/call ask_user_question → tool/result):
|
|
168
|
+
// 与 approvals 同属「等人介入」。不记它的后果是实测过的(2026-11-01 用户反馈:
|
|
169
|
+
// 会话在等用户选答案,界面却报「主任务卡住了」)——提问期间会话天然零事件,
|
|
170
|
+
// 18 秒安静被卡住判定当成停滞。提问是**等回答**,不是卡住。
|
|
171
|
+
questions: new Map(), // callId -> { header, question, count, askedAt }
|
|
172
|
+
// 在飞的工具调用(tool/call → tool/result):长命令(构建/全量测试)期间会话零事件,
|
|
173
|
+
// 但任务确实在跑。客户端靠它区分「正在跑长命令」与「真卡住」。
|
|
174
|
+
pendingTool: '',
|
|
175
|
+
toolStartedAt: 0,
|
|
176
|
+
// 上下文压缩(compaction/start → compaction/end):压缩全程同样零事件,
|
|
177
|
+
// 实测 2026-11-02 的 25 分钟会话里 25 次长停顿有 18 次落在压缩窗口内
|
|
178
|
+
// (最长 96.7 秒),全被卡住判定误报。压缩是**正在干活**,不是卡住。
|
|
179
|
+
compacting: false,
|
|
180
|
+
compactStartedAt: 0,
|
|
181
|
+
compactId: '',
|
|
113
182
|
phase: 'think',
|
|
114
183
|
phaseAt: Date.now(),
|
|
115
184
|
lastActivityAt: Date.now(),
|
|
@@ -120,10 +189,17 @@ function createTracker(ctx) {
|
|
|
120
189
|
...extra,
|
|
121
190
|
}
|
|
122
191
|
activeSessions.set(sid, record)
|
|
123
|
-
|
|
192
|
+
readSessionFacts(sid, sessionQuery).then((facts) => {
|
|
124
193
|
const s = activeSessions.get(sid)
|
|
125
|
-
if (s)
|
|
126
|
-
|
|
194
|
+
if (!s) return
|
|
195
|
+
if (facts.title) s.title = facts.title
|
|
196
|
+
const h = facts.header
|
|
197
|
+
if (h) {
|
|
198
|
+
s.isSubtask = h.origin === 'subagent'
|
|
199
|
+
|| (typeof h.delegationDepth === 'number' && h.delegationDepth > 0)
|
|
200
|
+
s.parentSessionId = typeof h.parentSession === 'string' && h.parentSession ? h.parentSession : ''
|
|
201
|
+
}
|
|
202
|
+
}).catch(() => { /* 标题/归属读失败:title 走 firstPrompt 兜底,归属按主任务 */ })
|
|
127
203
|
return record
|
|
128
204
|
}
|
|
129
205
|
|
|
@@ -229,9 +305,52 @@ function createTracker(ctx) {
|
|
|
229
305
|
s.toolCalls++
|
|
230
306
|
s.phase = phaseOfToolName(event.data && event.data.name)
|
|
231
307
|
s.phaseAt = now
|
|
308
|
+
// 记下最后调用的工具名:卡住播报/卡住卡要回答「最后在干什么」
|
|
309
|
+
const called = event.data && typeof event.data.name === 'string' ? event.data.name : ''
|
|
310
|
+
if (called) s.lastToolName = called
|
|
311
|
+
// 记下在飞调用:长命令跑起来后到结果落地之间没有事件,靠它别误报卡住
|
|
312
|
+
s.pendingTool = called || 'tool'
|
|
313
|
+
s.toolStartedAt = now
|
|
314
|
+
// ask_user_question 是**阻塞式等人回答**:调用发出后到用户提交答案之间,会话
|
|
315
|
+
// 一条事件都不会有(实测 2026-11-01:提问挂起 66 秒,其间零事件)。把它记成
|
|
316
|
+
// 「等人介入」的一种,卡住判定才不会把这段安静误判成停滞。
|
|
317
|
+
if (called === 'ask_user_question') {
|
|
318
|
+
const callId = typeof event.data.callId === 'string' ? event.data.callId : ''
|
|
319
|
+
if (callId) s.questions.set(callId, describeQuestion(event.data.arguments, now))
|
|
320
|
+
}
|
|
232
321
|
// 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
|
|
233
322
|
s.motionTicks += s.phase === 'search' ? 7 : 9
|
|
234
323
|
s.lastMotionAt = now
|
|
324
|
+
} else if (event.type === 'tool/result') {
|
|
325
|
+
// 提问的回答(或超时/取消)落地 → 提问结束,恢复普通追踪。
|
|
326
|
+
// 时间字段用事件时间,保证「答完那一刻」就是新的活动起点。
|
|
327
|
+
const msg = event.data && event.data.message
|
|
328
|
+
const callId = msg && typeof msg.toolCallId === 'string' ? msg.toolCallId : ''
|
|
329
|
+
if (callId) s.questions.delete(callId)
|
|
330
|
+
// 调用落地 → 不再有在飞的调用(同一时刻只会有一个工具在跑)
|
|
331
|
+
s.pendingTool = ''
|
|
332
|
+
s.toolStartedAt = 0
|
|
333
|
+
} else if (event.type === 'compaction/start' && event.data) {
|
|
334
|
+
// 压缩上下文开始:期间会话零事件,但这是**正在干活**。记成在忙,别按停滞算。
|
|
335
|
+
s.compacting = true
|
|
336
|
+
s.compactStartedAt = now
|
|
337
|
+
s.compactId = typeof event.data.compactionId === 'string' ? event.data.compactionId : ''
|
|
338
|
+
s.lastActivityAt = now
|
|
339
|
+
} else if (event.type === 'compaction/summary' && event.data) {
|
|
340
|
+
// 压缩出摘要:仍在压缩中(llmStreamCall 表示还在等模型),只刷活动时间
|
|
341
|
+
s.lastActivityAt = now
|
|
342
|
+
} else if (event.type === 'compaction/end' && event.data) {
|
|
343
|
+
// 压缩收尾:解除在忙状态;compactionId 不一致说明是另一个窗口的迟到事件,不误清
|
|
344
|
+
const endId = typeof event.data.compactionId === 'string' ? event.data.compactionId : ''
|
|
345
|
+
if (!endId || !s.compactId || endId === s.compactId) {
|
|
346
|
+
s.compacting = false
|
|
347
|
+
s.compactStartedAt = 0
|
|
348
|
+
s.compactId = ''
|
|
349
|
+
}
|
|
350
|
+
s.lastActivityAt = now
|
|
351
|
+
} else if (event.type === 'compaction/prune' && event.data) {
|
|
352
|
+
// 修剪历史(不产摘要的轻量压缩):也算在忙,但很快结束,只刷活动时间
|
|
353
|
+
s.lastActivityAt = now
|
|
235
354
|
} else if (event.type === 'user/message') {
|
|
236
355
|
if (!s.firstPrompt && event.data && event.data.content) {
|
|
237
356
|
const text = extractText(event.data.content)
|
|
@@ -329,7 +448,20 @@ function createTracker(ctx) {
|
|
|
329
448
|
lastActivityAt: s.lastActivityAt,
|
|
330
449
|
motionTicks: s.motionTicks,
|
|
331
450
|
lastMotionAt: s.lastMotionAt,
|
|
451
|
+
// 「在忙」信号:有在飞的工具调用 / 正在压缩上下文。卡住判定据此豁免
|
|
452
|
+
//(客户端口径见 src/shared.js 的 taskBusy / taskStalled)
|
|
453
|
+
pendingTool: s.pendingTool || '',
|
|
454
|
+
toolStartedAt: s.toolStartedAt || 0,
|
|
455
|
+
compacting: !!s.compacting,
|
|
456
|
+
compactStartedAt: s.compactStartedAt || 0,
|
|
457
|
+
// 卡住播报/卡住卡专用:主/子归属(会话头判定)与最后调用的工具
|
|
458
|
+
isSubtask: !!s.isSubtask,
|
|
459
|
+
parentSessionId: s.parentSessionId || '',
|
|
460
|
+
lastToolName: s.lastToolName || '',
|
|
332
461
|
approvals: [...s.approvals.entries()].map(([id, v]) => ({ id, ...v })),
|
|
462
|
+
// 等人回答的提问(ask_user_question 挂起中):与 approvals 同属「等人介入」,
|
|
463
|
+
// 客户端据此显示「等待回答」而不是「卡住」。
|
|
464
|
+
questions: [...s.questions.entries()].map(([id, v]) => ({ id, ...v })),
|
|
333
465
|
})
|
|
334
466
|
}
|
|
335
467
|
return { now: at, active, recent: completedSessions.slice(0, 20) }
|