dsh-dock 0.19.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 +10 -14
- package/client.js +360 -614
- 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 +2 -1
- package/features/notify/view.jsx +200 -39
- 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 +2 -1
- package/src/shared.js +86 -0
- package/src/task-track.js +87 -0
- package/features/mobile/host.js +0 -315
- package/features/mobile/view.js +0 -530
|
@@ -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
|
@@ -125,7 +125,8 @@ export const DockConfig = z.object({
|
|
|
125
125
|
feishuEnabled: z.boolean().default(false),
|
|
126
126
|
feishuWebhook: z.string().default(''),
|
|
127
127
|
// ===== 语音播报(浏览器 speechSynthesis;与【语音朗读】同一套声音但相互独立开关) =====
|
|
128
|
-
//
|
|
128
|
+
// 服务端总开关(跨浏览器/跨设备):关闭时所有浏览器都不播报(默认关,避免升级后突然说话吓人)。
|
|
129
|
+
// 客户端另有按浏览器保存的「本机播报」开关(localStorage,不进本配置);两者都开才播报。
|
|
129
130
|
voiceNotify: z.boolean().default(false),
|
|
130
131
|
// 播报口径三挡:brief = 一句话;medium = 点名任务 + 原因/耗时/停滞时长;
|
|
131
132
|
// detailed = 带标题/耗时/回合统计/工具名/最后动作。
|
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 {
|
|
@@ -141,6 +164,21 @@ function createTracker(ctx) {
|
|
|
141
164
|
lastToolName: '',
|
|
142
165
|
// 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
|
|
143
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: '',
|
|
144
182
|
phase: 'think',
|
|
145
183
|
phaseAt: Date.now(),
|
|
146
184
|
lastActivityAt: Date.now(),
|
|
@@ -270,9 +308,49 @@ function createTracker(ctx) {
|
|
|
270
308
|
// 记下最后调用的工具名:卡住播报/卡住卡要回答「最后在干什么」
|
|
271
309
|
const called = event.data && typeof event.data.name === 'string' ? event.data.name : ''
|
|
272
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
|
+
}
|
|
273
321
|
// 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
|
|
274
322
|
s.motionTicks += s.phase === 'search' ? 7 : 9
|
|
275
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
|
|
276
354
|
} else if (event.type === 'user/message') {
|
|
277
355
|
if (!s.firstPrompt && event.data && event.data.content) {
|
|
278
356
|
const text = extractText(event.data.content)
|
|
@@ -370,11 +448,20 @@ function createTracker(ctx) {
|
|
|
370
448
|
lastActivityAt: s.lastActivityAt,
|
|
371
449
|
motionTicks: s.motionTicks,
|
|
372
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,
|
|
373
457
|
// 卡住播报/卡住卡专用:主/子归属(会话头判定)与最后调用的工具
|
|
374
458
|
isSubtask: !!s.isSubtask,
|
|
375
459
|
parentSessionId: s.parentSessionId || '',
|
|
376
460
|
lastToolName: s.lastToolName || '',
|
|
377
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 })),
|
|
378
465
|
})
|
|
379
466
|
}
|
|
380
467
|
return { now: at, active, recent: completedSessions.slice(0, 20) }
|