dsh-dock 0.19.0 → 0.21.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 +21 -20
- package/client.js +1426 -931
- package/features/animation/view.jsx +26 -12
- package/features/balance/view.js +8 -6
- package/features/mobile/host.js +18 -311
- package/features/mobile/view.js +295 -480
- package/features/mobile-relay/host.js +3 -4
- package/features/mobile-relay/view.jsx +1 -2
- package/features/notify/host.js +52 -16
- package/features/notify/view.jsx +884 -377
- package/features/runstate/host.js +1 -11
- package/features/runstate/view.jsx +32 -15
- package/features/tokenlog/view.jsx +8 -3
- package/index.js +1 -1
- package/package.json +2 -2
- package/screenshots/dock-notify.png +0 -0
- package/src/client.jsx +36 -6
- package/src/host-core.js +8 -1
- package/src/shared.js +86 -0
- package/src/task-track.js +87 -0
|
@@ -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,
|
|
@@ -1413,11 +1413,16 @@ export function TokenLogChip(props) {
|
|
|
1413
1413
|
const gone = isTokenlogRouteGone();
|
|
1414
1414
|
// chip 文案用紧凑人民币金额(2 位小数),避免撑爆输入卡工具行;
|
|
1415
1415
|
// 完整金额在 title 悬浮提示里。
|
|
1416
|
-
|
|
1416
|
+
// 手机上输入区工具行很窄:主文案(图标 + token 数)单独一段;费用放进 .dockchip-x,
|
|
1417
|
+
// 窄屏由 CSS 隐藏,避免整条被截成「⛁ 2…」。完整信息仍在 title 悬浮提示里。
|
|
1418
|
+
const hasData = !!t && !gone && !(snap.err && !t);
|
|
1419
|
+
const labelIcon = hasData ? "⛁ " : "";
|
|
1420
|
+
const labelMain = gone
|
|
1417
1421
|
? "用量·未启用"
|
|
1418
1422
|
: snap.err && !t
|
|
1419
1423
|
? (snap.err.includes("重启") || snap.err.includes("启用") ? "用量·未启用" : "用量·失败")
|
|
1420
|
-
: (t ?
|
|
1424
|
+
: (t ? fmtCompact(t.totalTokens) : "…");
|
|
1425
|
+
const labelExtra = hasData && t.cost > 0 ? " · " + fmtCostCny(t.cost, snap.rate) : "";
|
|
1421
1426
|
const title = gone
|
|
1422
1427
|
? routeGoneMessage() + "\n(可在功能坞里启用「用量记录」)"
|
|
1423
1428
|
: t
|
|
@@ -1428,7 +1433,7 @@ export function TokenLogChip(props) {
|
|
|
1428
1433
|
<button type="button" className={"dockchip" + (snap.err && !t && !gone ? " err" : "")} title={title} aria-label="会话用量"
|
|
1429
1434
|
onClick={() => openPanel("tokenlog", { sessionId: sid, navAt: Date.now() })}>
|
|
1430
1435
|
<span className="dockchip-dot" style={{ background: gone ? "var(--dsw-alias-label-tertiary,#8b95ab)" : "var(--dk-warn)" }} />
|
|
1431
|
-
<span>{
|
|
1436
|
+
{labelIcon ? <span className="dockchip-ic">{labelIcon}</span> : null}<span>{labelMain}</span>{labelExtra ? <span className="dockchip-x">{labelExtra}</span> : null}
|
|
1432
1437
|
</button>
|
|
1433
1438
|
);
|
|
1434
1439
|
}
|
package/index.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
// - tts 语音朗读:把会话回复读出来(浏览器 TTS;宿主半部只管配置 + 共享任务追踪)
|
|
19
19
|
// - modelconfig 模型设置:模型目录的输入模态与思考强度(写回官方配置)
|
|
20
20
|
// - providerconfig 模型提供商:Provider 增删改 + API Key + 拉取上游模型列表
|
|
21
|
-
// - mobile 手机适配:窄屏排版/Tab 栏/抽屉/遮罩/键盘(宿主经 tapIndex 注入 index.html)
|
|
22
21
|
// - mobile-relay 远程访问:账号密码登录的远程入口(网关 + 账号 + 浏览模式目录选择器)
|
|
22
|
+
// - mobile 手机适配:手机/窄屏体验优化(客户端运行时注入,关闭后零残留;宿主半部只留开关占位)
|
|
23
23
|
import { DockConfig, sendJson, readBody, rejectUntrustedRequest, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, migrateImportedSections, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
|
|
24
24
|
import { injectWelcomeAck } from './src/welcome-ack.js'
|
|
25
25
|
import { feature as fModels } from './features/modelconfig/host.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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-host.mjs && node scripts/test-mobile-relay
|
|
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-mobile-host.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
|
},
|
|
Binary file
|
package/src/client.jsx
CHANGED
|
@@ -33,7 +33,7 @@ import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
|
|
|
33
33
|
import { feature as fMobile } from "../features/mobile/view.js";
|
|
34
34
|
|
|
35
35
|
const name = "dsh-dock";
|
|
36
|
-
const DOCK_VERSION = "0.
|
|
36
|
+
const DOCK_VERSION = "0.21.0";
|
|
37
37
|
|
|
38
38
|
// ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
|
|
39
39
|
const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fProviderConfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fTts, fGames, fMobileRelay, fMobile];
|
|
@@ -164,6 +164,12 @@ const SHELL_CSS = [
|
|
|
164
164
|
".dockm-name{display:flex;align-items:center;gap:8px;font-weight:600;font-size:15px;}",
|
|
165
165
|
".dockm-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;}",
|
|
166
166
|
".dockm-view{display:flex;flex-direction:column;gap:8px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;}",
|
|
167
|
+
".dockm-page{box-sizing:border-box;height:100%;overflow:auto;padding:20px 24px 32px;}",
|
|
168
|
+
".dockm-page-head{display:flex;align-items:flex-start;gap:10px;margin-bottom:16px;}",
|
|
169
|
+
".dockm-page-icon{flex:none;display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}",
|
|
170
|
+
".dockm-page-titles{min-width:0;}",
|
|
171
|
+
".dockm-page-title{font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary);line-height:1.3;}",
|
|
172
|
+
".dockm-page-sub{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-top:2px;}",
|
|
167
173
|
".dockm-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;}",
|
|
168
174
|
".dockm-err{color:var(--dsw-alias-state-error-primary);}",
|
|
169
175
|
".dockm-foot{margin-top:auto;display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-tertiary);font-size:11px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;flex-wrap:wrap;}",
|
|
@@ -195,7 +201,11 @@ const SHELL_CSS = [
|
|
|
195
201
|
".dockm-foot-swlabel.on{color:var(--dsw-alias-state-success-primary);}",
|
|
196
202
|
// 窄屏:功能坞是一级工作台而不是被压扁的桌面弹窗。全屏承接安全区,导航改横向,
|
|
197
203
|
// 禁用桌面拖拽/缩放,所有导航和关闭动作保持至少 44px 的可点击面积。
|
|
198
|
-
"@media (max-width:680px){.dockm-backdrop{align-items:stretch;justify-content:stretch;background:var(--dsw-alias-bg-layer-2);backdrop-filter:none}.dockm-dialog,.dockm-dialog.dockm-max{position:fixed!important;left:0!important;top:0!important;right:auto!important;bottom:auto!important;width:100dvw!important;height:100dvh!important;min-height:100dvh;border:0;border-radius:0;box-shadow:none}.dockm-dialog.dockm-min .dockm-body{display:flex}.dockm-head{min-height:60px;box-sizing:border-box;padding:calc(env(safe-area-inset-top) + 8px) 12px 8px;cursor:default;touch-action:manipulation}.dockm-sub,.dockm-win,.dockm-resize{display:none}.dockm-title{font-size:16px}.dockm-close{width:44px;height:44px;font-size:17px}.dockm-body{flex-direction:column;min-height:0}.dockm-nav{box-sizing:border-box;width:auto;max-width:100%;min-height:56px;flex-direction:row;gap:6px;padding:6px 10px;overflow-x:auto;overflow-y:hidden;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l1);scrollbar-width:none;overscroll-behavior-x:contain}.dockm-nav::-webkit-scrollbar{display:none}.dockm-nav-item{flex:none;min-height:44px;padding:0 12px;font-size:13px;touch-action:manipulation}.dockm-nav-item .dockm-badge{display:none}.dockm-content{padding:16px max(16px,env(safe-area-inset-right)) calc(20px + env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));gap:14px;overscroll-behavior:contain}.dockm-content-head{gap:5px}.dockm-name{font-size:16px}.dockm-desc{font-size:13px;line-height:1.55}.dockm-foot{padding-top:12px;font-size:11px}.dockm-foot-sw{width:100%;margin-left:0;justify-content:space-between}.dock-sw{width:44px;height:26px}.dock-sw::after{top:3px;left:3px;width:18px;height:18px}.dock-sw.on::after{transform:translateX(18px)}.dockh-grid{grid-template-columns:1fr;gap:10px}.dockh-card{padding:12px}.dockh-desc{font-size:13px}.dockh-go{font-size:12px}}",
|
|
204
|
+
"@media (max-width:680px){.dockm-backdrop{align-items:stretch;justify-content:stretch;background:var(--dsw-alias-bg-layer-2);backdrop-filter:none}.dockm-dialog,.dockm-dialog.dockm-max{position:fixed!important;left:0!important;top:0!important;right:auto!important;bottom:auto!important;width:100dvw!important;height:100dvh!important;min-height:100dvh;border:0;border-radius:0;box-shadow:none}.dockm-dialog.dockm-min .dockm-body{display:flex}.dockm-head{min-height:60px;box-sizing:border-box;padding:calc(env(safe-area-inset-top) + 8px) 12px 8px;cursor:default;touch-action:manipulation}.dockm-sub,.dockm-win,.dockm-resize{display:none}.dockm-title{font-size:16px}.dockm-close{width:44px;height:44px;font-size:17px}.dockm-body{flex-direction:column;min-height:0}.dockm-nav{box-sizing:border-box;width:auto;max-width:100%;min-height:56px;flex-direction:row;gap:6px;padding:6px 10px;overflow-x:auto;overflow-y:hidden;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l1);scrollbar-width:none;overscroll-behavior-x:contain}.dockm-nav::-webkit-scrollbar{display:none}.dockm-nav-item{flex:none;min-height:44px;padding:0 12px;font-size:13px;touch-action:manipulation}.dockm-nav-item .dockm-badge{display:none}.dockm-content{padding:16px max(16px,env(safe-area-inset-right)) calc(20px + env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));gap:14px;overscroll-behavior:contain}.dockm-content-head{gap:5px}.dockm-name{font-size:16px}.dockm-desc{font-size:13px;line-height:1.55}.dockm-foot{padding-top:12px;font-size:11px}.dockm-foot-sw{width:100%;margin-left:0;justify-content:space-between}.dock-sw{width:44px;height:26px}.dock-sw::after{top:3px;left:3px;width:18px;height:18px}.dock-sw.on::after{transform:translateX(18px)}.dockh-grid{grid-template-columns:minmax(0,1fr);gap:10px;min-width:0}.dockh-card{padding:12px;box-sizing:border-box;min-width:0}.dockh-desc{font-size:13px}.dockh-go{font-size:12px}}",
|
|
205
|
+
/* 手机端:功能坞弹层挂在 .overlayLayer(z20) 内,会被 z71 的侧栏抽屉盖住(只剩右侧一条)。\n 把 overlayLayer 在窄屏抬到抽屉之上,面板才能全屏覆盖——且不依赖【手机适配】模块是否启用。 */
|
|
206
|
+
"@media (max-width:767px){[class*=\"overlayLayer\"]{z-index:100 !important}}",
|
|
207
|
+
/* 手机:输入卡里的「轮/步/token/缓存」状态条移到输入框上方,压成一行不溢出;空会话时把输入卡贴底 */
|
|
208
|
+
"@media (max-width:767px){[class*=\"scrollBody\"]>[class*=\"composerSeat\"]{margin-top:auto!important;padding-bottom:0!important}[class*=\"composerSeat\"] [class*=\"_dock\"]{order:-1!important;width:100%!important;max-width:100%!important;box-sizing:border-box!important;padding:0 2px 6px!important;gap:6px!important;font-size:10px!important;overflow:hidden!important}[class*=\"composerSeat\"] [class*=\"_dock\"]>*{min-width:0}}",
|
|
199
209
|
"@media (prefers-reduced-motion:reduce){.dockm-backdrop,.dockm-dialog{animation:none}.dockm-nav-item,.dockm-close,.dockm-win,.dock-sw{transition:none}}",
|
|
200
210
|
".dockh-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;}",
|
|
201
211
|
".dockh-stat{color:var(--dsw-alias-label-tertiary);font-size:12px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
@@ -221,7 +231,9 @@ const SHELL_CSS = [
|
|
|
221
231
|
".dockchip > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}",
|
|
222
232
|
".dockchip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
|
|
223
233
|
".dockchip .dockchip-dot{width:6px;height:6px;border-radius:50%;flex:none;}",
|
|
224
|
-
".dockchip.err{color:var(--dsw-alias-state-error-primary);}"
|
|
234
|
+
".dockchip.err{color:var(--dsw-alias-state-error-primary);}",
|
|
235
|
+
/* 手机:输入区工具行很窄,给 chips 稍多宽度,并隐藏费用等次要后缀(.dockchip-x),避免被截成「⛁ 2…/余…」 */
|
|
236
|
+
"@media (max-width:767px){.dockchip-row{max-width:min(216px,40cqw)}.dockchip-x{display:none}.dockchip{font-size:10px!important;padding:1px 2px!important;gap:3px!important}.dockchip .dockchip-dot{width:4px;height:4px}.dockchip-ic{display:none}.dockchip-pre{display:none}}"
|
|
225
237
|
].join("\n");
|
|
226
238
|
|
|
227
239
|
let dockCssTag = null;
|
|
@@ -617,6 +629,19 @@ function DockPanel(props) {
|
|
|
617
629
|
}));
|
|
618
630
|
}
|
|
619
631
|
|
|
632
|
+
// ---- 功能坞主面板页(sidebar.panellist 一级入口 → main 面板) ----
|
|
633
|
+
// 作为侧栏面板入口注册后,DSH 自带的入口自定义(Pin/更多)即可让用户把它固定在侧栏顶部
|
|
634
|
+
// (“放到上面”),或取消固定收进侧栏「更多」菜单(“放到更多里”)。
|
|
635
|
+
function DockPage() {
|
|
636
|
+
return react.createElement("div", { className: "dockm-page" },
|
|
637
|
+
react.createElement("div", { className: "dockm-page-head" },
|
|
638
|
+
react.createElement("span", { className: "dockm-page-icon" }, react.createElement(DockIcon, { size: 20 })),
|
|
639
|
+
react.createElement("div", { className: "dockm-page-titles" },
|
|
640
|
+
react.createElement("div", { className: "dockm-page-title" }, "功能坞"),
|
|
641
|
+
react.createElement("div", { className: "dockm-page-sub" }, "所有小功能集中在这里管理。侧栏图标可在「更多」菜单里 Pin 到顶部,或取消固定收进更多。"))),
|
|
642
|
+
react.createElement("div", { className: "dockm-page-body" }, react.createElement(DockPanel, { params: null })));
|
|
643
|
+
}
|
|
644
|
+
|
|
620
645
|
// ---- 会话输入区工具行 chips:已启用功能的随身小控件(模型选择器左侧) ----
|
|
621
646
|
// 功能模块在 feature 描述符上挂可选 Chip 组件(props: { ctx, session, sessionId, input, feature }),
|
|
622
647
|
// 启用即在 conversation.input.left 渲染;点击通常经 openPanel(...) 打开功能坞定位到对应功能页。
|
|
@@ -684,9 +709,14 @@ export function apply(ctx) {
|
|
|
684
709
|
const slots = ctx.get("slots");
|
|
685
710
|
if (slots === undefined) return;
|
|
686
711
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
712
|
+
// 侧栏一级入口(sidebar.panellist)+ 对应主面板(main):注册成面板入口后,DSH 自带的
|
|
713
|
+
// 入口自定义可以「固定到左侧栏」= 显示在顶部图标区;取消固定 = 收进侧栏「更多」菜单。
|
|
714
|
+
slots.inject("sidebar.panellist", () => slots.register(
|
|
715
|
+
{ name: "sidebar.panellist", id: "dsh-dock", order: 30, label: "功能坞" },
|
|
716
|
+
(props) => react.createElement(DockIcon, { size: (props && props.size) || 18 })));
|
|
717
|
+
slots.inject("main", () => slots.register(
|
|
718
|
+
{ name: "main", key: "dsh-dock", label: "功能坞" },
|
|
719
|
+
() => react.createElement(DockPage, null)));
|
|
690
720
|
slots.inject("shell.overlay", () => slots.register(
|
|
691
721
|
{ name: "shell.overlay", id: "dsh-dock-panel", order: 21, label: "功能坞面板" },
|
|
692
722
|
() => react.createElement(DockModal, null)));
|
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 = 带标题/耗时/回合统计/工具名/最后动作。
|
|
@@ -141,6 +142,12 @@ export const DockConfig = z.object({
|
|
|
141
142
|
voiceVolume: z.number().default(1),
|
|
142
143
|
voiceLang: z.string().default(''),
|
|
143
144
|
voiceURI: z.string().default(''),
|
|
145
|
+
// 事件 × 渠道开关矩阵:扁平键形如 card:complete / sound:error / system:confirm /
|
|
146
|
+
// voice:stuck / dingtalk:complete / feishu:error。未设置的键由客户端回退到旧的
|
|
147
|
+
// notifyOn* / voiceOn* 字段(升级不改变既有行为)。
|
|
148
|
+
eventMatrix: z.dict(z.boolean()).default({}),
|
|
149
|
+
// 群机器人消息口径三挡:brief / medium / detailed(宿主发消息时按此裁剪正文)。
|
|
150
|
+
pushMode: z.string().default('detailed'),
|
|
144
151
|
// 一次性迁移标记:animation 段的旧通知配置已搬到本段(搬完置 true,避免每次启动重复写)
|
|
145
152
|
migratedFromAnimation: z.boolean().default(false),
|
|
146
153
|
}).default({}).volatile(),
|
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) }
|