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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Host 通信:fetch('dsh-dock/animation/<method>')(见 features/animation/host.js)。
|
|
16
16
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
17
|
-
import { openPanel, panelNav, subscribePanel } from "../../src/shared.js";
|
|
17
|
+
import { openPanel, panelNav, subscribePanel, taskWaiting, taskApprovals, taskStalled } from "../../src/shared.js";
|
|
18
18
|
|
|
19
19
|
// ---------- Host RPC 桥接 ----------
|
|
20
20
|
function rpcCall(method, args) {
|
|
@@ -721,7 +721,11 @@ function MissionDeparture(props) {
|
|
|
721
721
|
}
|
|
722
722
|
function ProgrammingStuck(props) {
|
|
723
723
|
const waiting = !!props.waiting;
|
|
724
|
-
|
|
724
|
+
// 等人的性质要说清:等批准(要不要执行)与等回答(选哪个答案)是两件事
|
|
725
|
+
const task = props.task;
|
|
726
|
+
const label = !waiting ? "编程等待中"
|
|
727
|
+
: (taskApprovals(task).length > 0 ? "等待确认" : "等待回答");
|
|
728
|
+
return <div className={"dkan-space-stuck" + (waiting ? " waiting" : "")} aria-hidden="true"><FreighterHull units={3} empty /><span className="dkan-stuck-code"><i />{`{ }`}</span><strong>{label}</strong><small>{truncate((props.task && props.task.title) || "工具暂无新响应", 28)}</small></div>;
|
|
725
729
|
}
|
|
726
730
|
|
|
727
731
|
// ---------- 全局浮层:轮询 + 动效(功能启用即常驻;通知在 features/notify/) ----------
|
|
@@ -889,9 +893,13 @@ export function AnimationOverlay(props) {
|
|
|
889
893
|
const phase = active.length > 0
|
|
890
894
|
? active.reduce((best, x) => (!best || (x.phaseAt || 0) > (best.phaseAt || 0) ? x : best), null).phase
|
|
891
895
|
: "think";
|
|
892
|
-
//
|
|
896
|
+
// 有会话在等用户介入时,状态文字让位给"等待确认 / 等待回答"(优先级最高:不动手就停住)。
|
|
893
897
|
// 会话级卡片(等待确认/编程等待中)只对"当前正在看的会话"出现,别的会话等待不打扰。
|
|
894
|
-
|
|
898
|
+
// 等人介入的口径统一走 src/shared.js 的 taskWaiting——只认 approvals 会把「提问等回答」
|
|
899
|
+
// 漏成"编程等待中",甚至被卡住判定误报(2026-11-01 用户实测)。
|
|
900
|
+
const waitingTask = active.find((x) => taskWaiting(x)) || null;
|
|
901
|
+
// 等的是批准还是回答:徽标文字跟着变,别一律说「等待确认」把提问吞进去
|
|
902
|
+
const waitWord = waitingTask && taskApprovals(waitingTask).length === 0 ? "等待回答" : "等待确认";
|
|
895
903
|
const sessionTitleRef = useRef({ at: 0, title: "" });
|
|
896
904
|
const currentTitle = useMemo(() => {
|
|
897
905
|
// 标题查询要 getBoundingClientRect(强制布局),限频 1s;面板页/任务行为不受此限制
|
|
@@ -903,7 +911,7 @@ export function AnimationOverlay(props) {
|
|
|
903
911
|
return c.title;
|
|
904
912
|
}, [snap.status]);
|
|
905
913
|
const onScreenTasks = useMemo(() => active.filter((x) => taskOnScreen(x, currentTitle)), [active, currentTitle]);
|
|
906
|
-
const waitingTaskOnScreen = onScreenTasks.find((x) =>
|
|
914
|
+
const waitingTaskOnScreen = onScreenTasks.find((x) => taskWaiting(x)) || null;
|
|
907
915
|
useTicker(ctx, animOn);
|
|
908
916
|
// 徽标计时:以最早开始的任务为基准
|
|
909
917
|
const now = Date.now();
|
|
@@ -936,7 +944,13 @@ export function AnimationOverlay(props) {
|
|
|
936
944
|
const spaceGalaxies = spaceModeOn && spaceDocks ? spaceGalaxyLayout() : null;
|
|
937
945
|
// Host 在编程阶段持续 18 秒没有 chunk/tool 活动,才把它视为卡住;正常长任务不会误触发。
|
|
938
946
|
// 卡住卡片同样只对当前会话出现。
|
|
939
|
-
|
|
947
|
+
// 停在原地等人的会话不算卡住(等批准、等回答都是合法停点,会话零事件是必然的)——
|
|
948
|
+
// 与【任务通知】的卡住判定同一口径:两处都直接调 src/shared.js 的 taskStalled,
|
|
949
|
+
// 它已经把「有在飞的工具调用」「正在压缩上下文」这两种正在干活的安静排除在外
|
|
950
|
+
//(2026-11-02 实测:25 分钟里 18 次误报全落在 compaction 窗口内,最长 96.7 秒)。
|
|
951
|
+
// 别在这里另写一份表达式,否则动画报卡住而通知不报,自相矛盾。
|
|
952
|
+
const stuckTask = onScreenTasks.find((task) => task.phase === "code"
|
|
953
|
+
&& taskStalled(task, now, 18000)) || null;
|
|
940
954
|
const robotScaleFromConfig = Math.max(.85, Math.min(2.2, Number(cfg && cfg.robotScale) || 1.35));
|
|
941
955
|
const speedStyle = { "--dkan-speed": Number(speed).toFixed(2) };
|
|
942
956
|
const saveRobotScale = useCallback((robotScale) => {
|
|
@@ -1050,7 +1064,7 @@ export function AnimationOverlay(props) {
|
|
|
1050
1064
|
? <AmbientLayer mode={mode} phase={phase} speed={Number(speed).toFixed(2)} tasks={active} galaxies={spaceGalaxies} /> : null}
|
|
1051
1065
|
{/* 星际模式:空闲时在输入框上方待命;任务卡住时在中央呈现编程等待状态。 */}
|
|
1052
1066
|
{spaceModeOn && spaceDocks && active.length === 0 && deliveries.length === 0 ? <ReadyFreighter dock={spaceDocks.ready} /> : null}
|
|
1053
|
-
{spaceModeOn && stuckTask ? <ProgrammingStuck task={stuckTask} waiting={
|
|
1067
|
+
{spaceModeOn && stuckTask ? <ProgrammingStuck task={stuckTask} waiting={taskWaiting(stuckTask)} /> : null}
|
|
1054
1068
|
{!panelOpen ? departures.map((departure) => <MissionDeparture key={departure.id} departure={departure} />) : null}
|
|
1055
1069
|
{/* 环屏巡航:一颗光点沿屏幕边缘转圈(orbit 模式) */}
|
|
1056
1070
|
{ambientOn && mode === "orbit" ? <div className="dkan-orbit" style={speedStyle} aria-hidden="true" /> : null}
|
|
@@ -1059,7 +1073,7 @@ export function AnimationOverlay(props) {
|
|
|
1059
1073
|
{/* 桌面伙伴:背侧视角紧凑双工位人物,阶段与任务同步;整卡可拖到任意位置(robot 模式) */}
|
|
1060
1074
|
{ambientOn && mode === "robot" ? (
|
|
1061
1075
|
<div className="dkan-botcard" role="button" tabIndex={0} style={Object.assign({}, botCardStyle, waitingTask ? { "--dkan-phase": "#b45309" } : null)}
|
|
1062
|
-
title={active.length + " 个任务 · " + (waitingTask ?
|
|
1076
|
+
title={active.length + " 个任务 · " + (waitingTask ? waitWord : phaseLabel(phase)) + " · 点击查看任务动画页 · 可拖动到任意位置"}
|
|
1063
1077
|
onPointerDown={onBotPointerDown}
|
|
1064
1078
|
onPointerMove={onBotPointerMove}
|
|
1065
1079
|
onPointerUp={onBotPointerUp}
|
|
@@ -1068,7 +1082,7 @@ export function AnimationOverlay(props) {
|
|
|
1068
1082
|
onKeyDown={(e) => { if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); onBotClick(); } }}>
|
|
1069
1083
|
<RobotScene phase={phase} tasks={active} />
|
|
1070
1084
|
<span className="dkan-bot-cap">
|
|
1071
|
-
<span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ?
|
|
1085
|
+
<span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ? waitWord : phaseLabel(phase)}
|
|
1072
1086
|
</span>
|
|
1073
1087
|
<button type="button" className="dkan-bot-resize" aria-label="调整桌面伙伴大小"
|
|
1074
1088
|
title="拖动调整桌面伙伴大小"
|
|
@@ -1089,14 +1103,14 @@ export function AnimationOverlay(props) {
|
|
|
1089
1103
|
{ambientOn && mode !== "robot" ? (
|
|
1090
1104
|
<button type="button" className={"dkan-badge" + (mode === "breathe" ? " dkan-badge-breathe" : "")}
|
|
1091
1105
|
style={Object.assign({}, speedStyle, { "--dkan-phase": phaseColor(waitingTask ? "code" : phase) })}
|
|
1092
|
-
title={active.length + " 个任务进行中 · " + (waitingTask ?
|
|
1106
|
+
title={active.length + " 个任务进行中 · " + (waitingTask ? waitWord : phaseLabel(phase)) + " · 点击查看任务动画页"}
|
|
1093
1107
|
onClick={() => openPanel("animation")}>
|
|
1094
1108
|
<span className="dkan-dotwrap">
|
|
1095
1109
|
<span className="dkan-dot" />
|
|
1096
1110
|
{mode === "breathe" ? <span className="dkan-halo" /> : null}
|
|
1097
1111
|
{mode === "ring" ? <span className="dkan-ring" /> : null}
|
|
1098
1112
|
</span>
|
|
1099
|
-
<span className="dkan-badge-txt"><span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ?
|
|
1113
|
+
<span className="dkan-badge-txt"><span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ? waitWord : phaseLabel(phase)}</span>
|
|
1100
1114
|
</button>
|
|
1101
1115
|
) : null}
|
|
1102
1116
|
{/* 完成瞬间的一次性流光(成功绿 / 异常红),动画结束自动清场 */}
|
|
@@ -1261,7 +1275,7 @@ function AnimationView(props) {
|
|
|
1261
1275
|
// 运行状态(进行中任务 / 最近完成 / 等待确认)已独立成左侧菜单项,这里只留一句指路
|
|
1262
1276
|
rows.push(
|
|
1263
1277
|
<div key="hint" className="dkan-note">
|
|
1264
|
-
任务运行状态(进行中任务、阶段、Token
|
|
1278
|
+
任务运行状态(进行中任务、阶段、Token、等人介入(等批准 / 等回答)与最近完成)已独立成左侧「运行状态」菜单;本页只负责动效。
|
|
1265
1279
|
</div>
|
|
1266
1280
|
);
|
|
1267
1281
|
return <div className="dkan-root">{rows}</div>;
|
|
@@ -125,10 +125,9 @@ function removeRemotePatches(list) {
|
|
|
125
125
|
// 回环/安全上下文下 randomUUID 原生存在,脚本自我短路零副作用。
|
|
126
126
|
const LAN_COMPAT_JS = "(function(){var g=typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof self!=='undefined'?self:undefined));if(!g)return;if(!g.crypto){try{g.crypto={}}catch(e){return}}var c=g.crypto;var nativeRng=typeof c.getRandomValues==='function'?c.getRandomValues.bind(c):null;function rng(bytes){if(nativeRng){nativeRng(bytes);return bytes}for(var i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256)&255;return bytes}function uuid(){var b=rng(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=[];for(var i=0;i<16;i++)h.push((b[i]+256).toString(16).slice(1));return h.slice(0,4).join('')+'-'+h.slice(4,6).join('')+'-'+h.slice(6,8).join('')+'-'+h.slice(8,10).join('')+'-'+h.slice(10).join('')}function fill(array){rng(array);return array}function define(obj,name,value){if(!obj||typeof obj[name]==='function')return;try{Object.defineProperty(obj,name,{value:value,configurable:true})}catch(e){try{obj[name]=value}catch(e2){}}}define(c,'randomUUID',uuid);if(!nativeRng)define(c,'getRandomValues',fill);define(g.Crypto&&g.Crypto.prototype||null,'randomUUID',uuid);if(!nativeRng)define(g.Crypto&&g.Crypto.prototype||null,'getRandomValues',fill);if(typeof c.randomUUID!=='function'){var fresh={getRandomValues:fill,randomUUID:uuid};if(c.subtle)fresh.subtle=c.subtle;var keyOrigin=Object.create(null);for(var k in c){try{keyOrigin[k]=c[k]}catch(e3){}}for(var k2 in keyOrigin){if(typeof fresh[k2]==='undefined')fresh[k2]=keyOrigin[k2]}try{Object.defineProperty(g,'crypto',{value:fresh,configurable:true})}catch(e4){try{g.crypto=fresh}catch(e5){}}}})()"
|
|
127
127
|
const LAN_COMPAT_MARKER = 'data-dsh-lan-compat'
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// 访问时缺该 API,DSH 客户端引导会整片失败)。
|
|
128
|
+
// 远程访问只注入 LAN 非安全上下文的 crypto.randomUUID 兜底(远程设备经
|
|
129
|
+
// http://<局域网IP> 访问时缺该 API,DSH 客户端引导会整片失败)。
|
|
130
|
+
// 手机端排版/行为(旧【手机适配】)已整体移除,本模块不再注入任何窄屏 chrome。
|
|
132
131
|
/** 把 LAN 兼容脚本内联注入 index.html 的 <head> 之后(幂等)。 */
|
|
133
132
|
function injectLanCompat(html) {
|
|
134
133
|
let out = html
|
|
@@ -159,8 +159,7 @@ export function MobileRelayView() {
|
|
|
159
159
|
* 【远程访问】不再挂 Overlay:「预览版说明」自动确认已迁到插件级
|
|
160
160
|
* (src/welcome-ack.js 经 index.js 注册 tapIndex,不随任何功能开关启停)——
|
|
161
161
|
* 关掉远程访问、主实例直接绑 0.0.0.0 的直连形态同样每次重弹,归属不能在本模块。
|
|
162
|
-
*
|
|
163
|
-
* (features/mobile/view.js + features/mobile/host.js)。远程访问只负责「远程入口」本身。
|
|
162
|
+
* 手机端排版/行为(旧【手机适配】)已整体移除,远程访问只负责「远程入口」本身。
|
|
164
163
|
*/
|
|
165
164
|
|
|
166
165
|
export function MobileRelayHomeStat() {
|
package/features/notify/host.js
CHANGED
|
@@ -37,7 +37,8 @@ function defaultConfig() {
|
|
|
37
37
|
dingtalkWebhook: '',
|
|
38
38
|
feishuEnabled: false,
|
|
39
39
|
feishuWebhook: '',
|
|
40
|
-
// 语音播报(浏览器 speechSynthesis
|
|
40
|
+
// 语音播报(浏览器 speechSynthesis 短句播报):服务端总开关,默认关闭。
|
|
41
|
+
// 客户端另有按浏览器保存的「本机播报」开关;服务端开 + 本机开才出声。
|
|
41
42
|
voiceNotify: false,
|
|
42
43
|
voiceMode: 'brief',
|
|
43
44
|
voiceOnComplete: true,
|
package/features/notify/view.jsx
CHANGED
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
// 1. View —— 功能坞面板页:通知事件 / 呈现方式 / 钉钉推送 / 飞书推送 四组设置;
|
|
5
5
|
// 2. HomeStat —— 首页总揽概要(当前通知事件与推送通道);
|
|
6
6
|
// 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
|
|
7
|
-
//
|
|
7
|
+
// 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等批准 / 提问等回答时弹常驻提醒卡,
|
|
8
8
|
// 编程阶段长时间无活动弹「卡住」提醒卡(写明主/子任务、停滞时长、最后动作)。
|
|
9
|
+
// 卡住判定的口径统一在 src/shared.js 的 taskStalled:等人介入、命令还在跑、
|
|
10
|
+
// 正在压缩上下文都不算卡住(那都是「在干活」),只有真的安静下来才报。
|
|
9
11
|
// 所有卡片受「页内卡片」总开关与各类型开关控制(见 View 的通知事件区)。
|
|
10
12
|
// 结束卡常驻到「看过」(点卡直达会话 / 点进会话 / 手动 ✕),≥3 张叠放收纳。
|
|
11
13
|
//
|
|
12
14
|
// 与【任务动画】【运行状态】完全独立:动效在 features/animation/、任务状态一览在
|
|
13
15
|
// features/runstate/,本模块只管通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书群机器人推送)。
|
|
14
16
|
// 模块启停 = 功能坞里的开关(独立菜单项),本页不再有第二个总开关。
|
|
17
|
+
// 语音播报是双层开关:服务端 voiceNotify(settings,跨浏览器)+ 本机开关(localStorage,
|
|
18
|
+
// 当前浏览器),两者都开才播报——团队共用一个 dsh 实例时可按浏览器各自静音。
|
|
15
19
|
//
|
|
16
20
|
// Host 通信:fetch('dsh-dock/notify/<method>')(见 features/notify/host.js)。
|
|
17
21
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
22
|
+
import { taskWaiting, taskApprovals, taskQuestions, taskStalled, taskActivityAt } from "../../src/shared.js";
|
|
18
23
|
|
|
19
24
|
// ---------- Host RPC 桥接 ----------
|
|
20
25
|
function rpcCall(method, args) {
|
|
@@ -160,6 +165,42 @@ function utterCtor() {
|
|
|
160
165
|
export function voiceSupported() {
|
|
161
166
|
return synthOf() !== null && utterCtor() !== null;
|
|
162
167
|
}
|
|
168
|
+
// ---------- 本机播报开关(每浏览器独立,存 localStorage) ----------
|
|
169
|
+
// 服务端 voiceNotify 是总开关(跨浏览器/跨设备,宿主 settings 持久化);本机开关是
|
|
170
|
+
// 「这台浏览器要不要出声」的第二层开关——团队共用一个 dsh 实例时,有人想听、有人嫌吵。
|
|
171
|
+
// 两个都开才播报;默认开(保持服务端总开关打开后的既有行为),用户可在本机关闭本机开关。
|
|
172
|
+
const VOICE_CLIENT_KEY = "dsh-dock/notify/voice-client/v1";
|
|
173
|
+
export function voiceClientEnabled() {
|
|
174
|
+
try {
|
|
175
|
+
if (typeof localStorage === "undefined") return true;
|
|
176
|
+
return localStorage.getItem(VOICE_CLIENT_KEY) !== "0";
|
|
177
|
+
} catch { return true; }
|
|
178
|
+
}
|
|
179
|
+
function setVoiceClientEnabled(on) {
|
|
180
|
+
try { if (typeof localStorage !== "undefined") localStorage.setItem(VOICE_CLIENT_KEY, on ? "1" : "0"); } catch { /* 持久化失败仅在本次会话生效 */ }
|
|
181
|
+
}
|
|
182
|
+
/** 真正允许自动播报:服务端总开关 && 本机开关。所有自动播报统一走这里判断。 */
|
|
183
|
+
export function voiceAllowed(cfg) {
|
|
184
|
+
return !!cfg && cfg.voiceNotify === true && voiceClientEnabled();
|
|
185
|
+
}
|
|
186
|
+
/** 本机开关的 React 状态:读写 localStorage,并跟随其它标签页的 storage 事件。 */
|
|
187
|
+
function useVoiceClientEnabled() {
|
|
188
|
+
const [on, setOn] = useState(() => voiceClientEnabled());
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
const sync = () => setOn(voiceClientEnabled());
|
|
191
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return undefined;
|
|
192
|
+
window.addEventListener("storage", sync);
|
|
193
|
+
return () => { if (typeof window.removeEventListener === "function") window.removeEventListener("storage", sync); };
|
|
194
|
+
}, []);
|
|
195
|
+
const set = useCallback((next) => {
|
|
196
|
+
const v = next !== false;
|
|
197
|
+
setVoiceClientEnabled(v);
|
|
198
|
+
setOn(v);
|
|
199
|
+
// 关掉本机播报时立刻收声:正在念的那一句不该再继续打扰。
|
|
200
|
+
if (!v) { const s = synthOf(); if (s) { try { s.cancel(); } catch { /* ignore */ } } }
|
|
201
|
+
}, []);
|
|
202
|
+
return [on, set];
|
|
203
|
+
}
|
|
163
204
|
/** 本机音色表(可能为空:Chrome 首次调用是异步加载,靠 voiceschanged 补)。 */
|
|
164
205
|
export function localVoices() {
|
|
165
206
|
const s = synthOf();
|
|
@@ -212,7 +253,7 @@ function useLocalVoices() {
|
|
|
212
253
|
}
|
|
213
254
|
let voiceEpoch = 0;
|
|
214
255
|
/**
|
|
215
|
-
*
|
|
256
|
+
* 播一句话(幂等键防重)。调用方已用 voiceAllowed 判过服务端总开关 + 本机开关;这里读
|
|
216
257
|
* voiceRate/voiceVolume/voiceLang/voiceURI,voiceURI 在本机不存在时按语言回落,
|
|
217
258
|
* lang 空时中文文本优先挑中文音色。
|
|
218
259
|
* @returns 是否真的开始播(不支持/文本为空返回 false)
|
|
@@ -334,6 +375,31 @@ export function confirmBroadcastText(task, approval, cfg) {
|
|
|
334
375
|
parts.push("正在等待你批准" + (scene ? "执行" + scene : "") + ",工具是" + tool + ",请回到会话处理。");
|
|
335
376
|
return parts.join("");
|
|
336
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* 需回答播报文案(ask_user_question 挂起,等人选答案)。
|
|
380
|
+
* 与「需授权」是两件事:批准是"要不要执行",提问是"要选哪个答案"——播报不能混说。
|
|
381
|
+
* 简洁口径只说「任务需要回答」;适中点名任务与问题;详细再补"共几问"与回到会话的叮嘱。
|
|
382
|
+
*/
|
|
383
|
+
export function questionBroadcastText(task, question, cfg) {
|
|
384
|
+
const mode = voiceTone(cfg);
|
|
385
|
+
if (mode === "brief") return "任务需要你回答";
|
|
386
|
+
const who = task && task.title ? "任务「" + truncate(task.title, 30) + "」" : "任务";
|
|
387
|
+
const head = (question && question.header) || "";
|
|
388
|
+
const body = (question && question.question) || "";
|
|
389
|
+
const what = body ? truncate(body.replace(/[#*_`>\[\]()!]/g, ""), 40) : "";
|
|
390
|
+
if (mode === "medium") {
|
|
391
|
+
// 适中口径:点名任务 + 问的是什么(问题正文优先,没有正文退回标题);句号统一补
|
|
392
|
+
const tail = what ? ":" + what : (head ? ":" + head : "");
|
|
393
|
+
return who + "在等你回答" + tail + "。";
|
|
394
|
+
}
|
|
395
|
+
const parts = [];
|
|
396
|
+
if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
|
|
397
|
+
parts.push(what ? "正在等你回答问题:" + what + "。" : "正在等你回答问题。");
|
|
398
|
+
const count = (question && question.count) || 0;
|
|
399
|
+
if (count > 1) parts.push("共 " + count + " 个问题。");
|
|
400
|
+
parts.push("请回到会话选择答案。");
|
|
401
|
+
return parts.join("");
|
|
402
|
+
}
|
|
337
403
|
/**
|
|
338
404
|
* 卡住播报文案:必须回答「哪里卡住了」——主任务还是子任务、哪个会话、停了多久、
|
|
339
405
|
* 最后在干什么。子任务静默会把「主任务明明在跑却突然播报」放大成困惑(2026-10-30
|
|
@@ -358,8 +424,14 @@ export function stuckBroadcastText(task, cfg) {
|
|
|
358
424
|
return parts.join("");
|
|
359
425
|
}
|
|
360
426
|
|
|
361
|
-
/** 卡住判定阈值:与【任务动画】同款——phase=code
|
|
427
|
+
/** 卡住判定阈值:与【任务动画】同款——phase=code 且这么久没动静才视为卡住(正常长任务不误报)。
|
|
428
|
+
* 「没动静」由 src/shared.js 的 taskStalled 定义:等人介入、命令在跑、正在压缩上下文都算在干活。 */
|
|
362
429
|
const STUCK_MS = 18000;
|
|
430
|
+
/** 读取卡住阈值(宿主可下发 stuckMs 覆盖,缺字段回退 STUCK_MS;播报与判定共用同一口径)。 */
|
|
431
|
+
function stuckMsOf(cfg) {
|
|
432
|
+
const v = cfg && Number(cfg.stuckMs);
|
|
433
|
+
return Number.isFinite(v) && v > 0 ? v : STUCK_MS;
|
|
434
|
+
}
|
|
363
435
|
|
|
364
436
|
// ---------- 共享快照:浮层(轮询) / 面板页 / 首页概要共用一份数据 ----------
|
|
365
437
|
const notifyStore = {
|
|
@@ -533,6 +605,7 @@ export function NotifyOverlay(props) {
|
|
|
533
605
|
const [stackHover, setStackHover] = useState(false); // 卡片栈被悬停(叠放时整体展开)
|
|
534
606
|
const prevActiveRef = useRef(null);
|
|
535
607
|
const remindedApprovalsRef = useRef(new Map()); // sessionId -> 提醒过的 approvalId 集合
|
|
608
|
+
const remindedQuestionsRef = useRef(new Map()); // sessionId -> 提醒过的提问 callId 集合
|
|
536
609
|
const toastSeqRef = useRef(0);
|
|
537
610
|
|
|
538
611
|
const pushToast = useCallback((toast) => {
|
|
@@ -598,15 +671,16 @@ export function NotifyOverlay(props) {
|
|
|
598
671
|
if (!activeIds.has(sid)) remindedApprovalsRef.current.delete(sid);
|
|
599
672
|
}
|
|
600
673
|
for (const task of st.active) {
|
|
601
|
-
const approvals =
|
|
674
|
+
const approvals = taskApprovals(task);
|
|
602
675
|
if (approvals.length === 0) continue;
|
|
603
676
|
let seen = remindedApprovalsRef.current.get(task.sessionId);
|
|
604
677
|
if (!seen) { seen = new Set(); remindedApprovalsRef.current.set(task.sessionId, seen); }
|
|
605
678
|
const fresh = approvals.filter((a) => a && !seen.has(a.id));
|
|
606
679
|
if (fresh.length === 0) continue;
|
|
607
680
|
for (const a of fresh) seen.add(a.id);
|
|
608
|
-
//
|
|
609
|
-
|
|
681
|
+
// 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)。
|
|
682
|
+
// 服务端总开关 + 本机开关都开才出声(团队共用一个实例时各自可静音)。
|
|
683
|
+
if (voiceAllowed(cfg) && cfg.voiceOnConfirm !== false) {
|
|
610
684
|
speakVoice(confirmBroadcastText(task, fresh[0], cfg), cfg);
|
|
611
685
|
}
|
|
612
686
|
// 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
|
|
@@ -650,22 +724,87 @@ export function NotifyOverlay(props) {
|
|
|
650
724
|
}
|
|
651
725
|
}, [snap.status]);
|
|
652
726
|
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
727
|
+
// ===== 需回答检测:会话调 ask_user_question 后挂起等人选答案时通知 =====
|
|
728
|
+
// 与「需确认」分开成一段:来源不同(tool/call → tool/result,而非 approval/asked →
|
|
729
|
+
// decided),文案也不同("要你选答案"不是"要你批准执行")。但语义同属「任务停在原地等人」,
|
|
730
|
+
// 因此卡住判定把它们一并排除(见下方 stuck 检测的 taskWaiting 判断)。
|
|
731
|
+
// 真实事故(2026-11-01 用户反馈):提问挂起期间会话零事件,18 秒后被判成「主任务卡住了」,
|
|
732
|
+
// 界面一边显示提问表单、一边报卡住——错的不是检测阈值,而是"等人"没被算作一种合法停点。
|
|
733
|
+
useEffect(() => {
|
|
734
|
+
const st = snap.status;
|
|
735
|
+
if (!st || !st.active) return;
|
|
736
|
+
const cfg = st.config || {};
|
|
737
|
+
const activeIds = new Set(st.active.map((x) => x.sessionId));
|
|
738
|
+
for (const [sid, seen] of remindedQuestionsRef.current) {
|
|
739
|
+
if (!activeIds.has(sid)) remindedQuestionsRef.current.delete(sid);
|
|
740
|
+
}
|
|
741
|
+
for (const task of st.active) {
|
|
742
|
+
const questions = taskQuestions(task);
|
|
743
|
+
if (questions.length === 0) continue;
|
|
744
|
+
let seen = remindedQuestionsRef.current.get(task.sessionId);
|
|
745
|
+
if (!seen) { seen = new Set(); remindedQuestionsRef.current.set(task.sessionId, seen); }
|
|
746
|
+
const fresh = questions.filter((q) => q && q.id && !seen.has(q.id));
|
|
747
|
+
if (fresh.length === 0) continue;
|
|
748
|
+
for (const q of fresh) seen.add(q.id);
|
|
749
|
+
// 语音播报走自己的开关(与卡片开关正交:关卡片不等于关掉耳朵)。
|
|
750
|
+
// 服务端总开关 + 本机开关都开才出声。
|
|
751
|
+
if (voiceAllowed(cfg) && cfg.voiceOnConfirm !== false) {
|
|
752
|
+
speakVoice(questionBroadcastText(task, fresh[0], cfg), cfg);
|
|
753
|
+
}
|
|
754
|
+
// 提醒开关在"收到新提问"时检查,保证开关中途打开对下一问生效
|
|
755
|
+
if (cfg.notifyOnConfirm === false) continue;
|
|
756
|
+
const tone = cardTone(cfg);
|
|
757
|
+
const head = fresh[0].header || "";
|
|
758
|
+
const body = fresh[0].question || "";
|
|
759
|
+
const lines = ["任务:" + (task.title || "(无标题)")];
|
|
760
|
+
if (body) lines.push((head ? head + ":" : "问题:") + truncate(body, 140));
|
|
761
|
+
else if (head) lines.push("问题:" + head);
|
|
762
|
+
const count = (fresh[0].count || 0);
|
|
763
|
+
if (count > 1) lines.push(tone === "brief" ? "共 " + count + " 个问题" : "共 " + count + " 个问题等你选择");
|
|
764
|
+
if (tone === "detailed") lines.push("请在会话里选好答案后继续");
|
|
765
|
+
pushToast({
|
|
766
|
+
id: ++toastSeqRef.current,
|
|
767
|
+
kind: "confirm",
|
|
768
|
+
title: "任务需要你回答",
|
|
769
|
+
body: lines.join("\n"),
|
|
770
|
+
// 点卡片直达该会话去选答案(与需确认卡同哲学:问题没答完卡片就该在)
|
|
771
|
+
sessionId: task.sessionId || "",
|
|
772
|
+
sessionTitle: task.title || "",
|
|
773
|
+
// 与这批提问绑定:回答落地后宿主把它们移出 questions,下面的对账 effect 据此收起
|
|
774
|
+
questionKeys: fresh.map((q) => task.sessionId + "|" + q.id),
|
|
775
|
+
});
|
|
776
|
+
if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
|
|
777
|
+
if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
|
|
778
|
+
&& typeof Notification !== "undefined" && Notification.permission === "granted") {
|
|
779
|
+
try {
|
|
780
|
+
new Notification("dsh 任务需要你回答", {
|
|
781
|
+
body: (task.title || "(无标题)") + " · " + (head || "有一问在等你选择"),
|
|
782
|
+
tag: "dsh-dock-question-" + task.sessionId,
|
|
783
|
+
});
|
|
784
|
+
} catch { /* 系统通知失败不影响页面内通知 */ }
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}, [snap.status]);
|
|
788
|
+
|
|
789
|
+
// 等人介入的项消失(approval/decided 移出 approvals,或回答落地移出 questions)
|
|
790
|
+
// → 对应提醒卡自动收起。只标记 closing 走淡出,不直接删:与手动点 ✕ 的退场一致。
|
|
791
|
+
// 卡片绑定的 keys 全部不在仍在等待的集合里才收;空 keys(老数据)不动。
|
|
792
|
+
// 两类卡共用这段对账:kind=confirm 的卡片要么绑 approvalKeys、要么绑 questionKeys。
|
|
656
793
|
useEffect(() => {
|
|
657
794
|
const st = snap.status;
|
|
658
795
|
if (!st || !st.active) return;
|
|
659
796
|
const live = new Set();
|
|
660
797
|
for (const task of st.active) {
|
|
661
|
-
const
|
|
662
|
-
for (const
|
|
798
|
+
for (const a of taskApprovals(task)) if (a && a.id) live.add(task.sessionId + "|" + a.id);
|
|
799
|
+
for (const q of taskQuestions(task)) if (q && q.id) live.add(task.sessionId + "|" + q.id);
|
|
663
800
|
}
|
|
664
801
|
setToasts((prev) => {
|
|
665
802
|
let changed = false;
|
|
666
803
|
const next = prev.map((x) => {
|
|
667
804
|
if (x.kind !== "confirm" || x.closing) return x;
|
|
668
|
-
const keys =
|
|
805
|
+
const keys = []
|
|
806
|
+
.concat(Array.isArray(x.approvalKeys) ? x.approvalKeys : [])
|
|
807
|
+
.concat(Array.isArray(x.questionKeys) ? x.questionKeys : []);
|
|
669
808
|
if (keys.length === 0 || keys.some((k) => live.has(k))) return x;
|
|
670
809
|
changed = true;
|
|
671
810
|
return Object.assign({}, x, { closing: true });
|
|
@@ -726,20 +865,27 @@ export function NotifyOverlay(props) {
|
|
|
726
865
|
if (t.isSubtask && t.parentSessionId) parentsWaitingOnSubtask.add(t.parentSessionId);
|
|
727
866
|
}
|
|
728
867
|
for (const task of st.active) {
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
868
|
+
// 停在原地等人的会话不算卡住——那是「需确认 / 需回答」提醒的职责,别重复轰炸。
|
|
869
|
+
// 两类都要排除:① 工具等批准(approvals);② 提问等回答(questions)。
|
|
870
|
+
// ②是 2026-11-01 用户实测的误报来源:会话调 ask_user_question 后挂起等人选答案,
|
|
871
|
+
// 其间会话一条事件都没有,18 秒安静被当成停滞,播报「主任务卡住了」并弹卡——
|
|
872
|
+
// 界面上明明是在等用户回答(截图为证),报「卡住」完全是错的口径。
|
|
873
|
+
if (taskWaiting(task)) { stuckSeenRef.current.delete(task.sessionId); continue; }
|
|
732
874
|
if (task.phase !== "code") { continue; }
|
|
733
875
|
// 该会话有活跃子任务在跑 → 它在等子任务,不算卡住(否则主任务进行中会误报卡住)
|
|
734
876
|
if (parentsWaitingOnSubtask.has(task.sessionId)) continue;
|
|
735
|
-
|
|
736
|
-
|
|
877
|
+
// 卡住判定统一走 src/shared.js 的 taskStalled:它已经排除「有在飞的工具调用」与
|
|
878
|
+
// 「正在压缩上下文」这两种**正在干活**的安静。2026-11-02 用户实测:25 分钟里 18 次
|
|
879
|
+
// 误报全落在 compaction 窗口内(最长 96.7 秒),界面在刷「上下文已压缩」却报卡住。
|
|
880
|
+
// 停点指纹改用同一个活动表达式(taskActivityAt),对账 effect 必须一致,否则卡片闪。
|
|
881
|
+
const lastAct = taskActivityAt(task);
|
|
882
|
+
const stuckMs = taskStalled(task, now, stuckMsOf(cfg));
|
|
883
|
+
if (!stuckMs) continue;
|
|
737
884
|
const prev = stuckSeenRef.current.get(task.sessionId);
|
|
738
885
|
if (prev === lastAct) continue; // 同一停点已报过
|
|
739
886
|
stuckSeenRef.current.set(task.sessionId, lastAct);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (cfg.voiceNotify === true && cfg.voiceOnStuck !== false) {
|
|
887
|
+
// —— 语音:说清主/子任务、标题、停滞时长与最后动作(服务端总开关 + 本机开关)——
|
|
888
|
+
if (voiceAllowed(cfg) && cfg.voiceOnStuck !== false) {
|
|
743
889
|
speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: stuckMs })), cfg);
|
|
744
890
|
}
|
|
745
891
|
// —— 卡片:只受「卡住提醒」与「页内卡片」总开关控制(总开关在 pushToast 里把关)——
|
|
@@ -771,8 +917,10 @@ export function NotifyOverlay(props) {
|
|
|
771
917
|
}, [snap.status]);
|
|
772
918
|
|
|
773
919
|
// 卡住卡的自动收起(与需确认卡同哲学:不设倒计时,状态没变就一直挂着):
|
|
774
|
-
//
|
|
775
|
-
//
|
|
920
|
+
// 恢复活动 / 离开活跃列表(结束或取消)/ 转为等人介入(需确认卡或需回答卡接管)
|
|
921
|
+
// → 标记 closing 走同一套 240ms 淡出。只标记不直删,与手动 ✕ 退场一致。
|
|
922
|
+
// 「恢复活动」的判据必须与卡住判定处**完全一致**(都走 taskActivityAt),否则同一停点
|
|
923
|
+
// 两处算出的指纹不同,卡片会反复收起又弹出。
|
|
776
924
|
useEffect(() => {
|
|
777
925
|
const st = snap.status;
|
|
778
926
|
if (!st || !st.active) return;
|
|
@@ -783,8 +931,8 @@ export function NotifyOverlay(props) {
|
|
|
783
931
|
if (x.kind !== "stuck" || x.closing) return x;
|
|
784
932
|
const task = byId.get(x.sessionId);
|
|
785
933
|
const stillStalled = !!task
|
|
786
|
-
&& (task
|
|
787
|
-
&& (
|
|
934
|
+
&& taskActivityAt(task) === x.stuckAt
|
|
935
|
+
&& !taskWaiting(task);
|
|
788
936
|
if (stillStalled) return x;
|
|
789
937
|
changed = true;
|
|
790
938
|
return Object.assign({}, x, { closing: true });
|
|
@@ -799,8 +947,8 @@ export function NotifyOverlay(props) {
|
|
|
799
947
|
const info = endInfo(reason);
|
|
800
948
|
const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
|
|
801
949
|
// 语音播报先走自己的开关(与卡片开关正交:关卡片不关耳朵)。
|
|
802
|
-
//
|
|
803
|
-
if (cfg
|
|
950
|
+
// 服务端总开关 + 本机开关都开才出声;播报音量归 0 / 浏览器不支持时静默跳过即可。
|
|
951
|
+
if (voiceAllowed(cfg)
|
|
804
952
|
&& (success ? cfg.voiceOnComplete !== false : cfg.voiceOnError !== false)) {
|
|
805
953
|
speakVoice(endBroadcastText(success, record, task, cfg), cfg);
|
|
806
954
|
}
|
|
@@ -876,6 +1024,7 @@ export function NotifyOverlay(props) {
|
|
|
876
1024
|
function NotifyView(props) {
|
|
877
1025
|
const snap = useNotify();
|
|
878
1026
|
const voices = useLocalVoices(); // 音色下拉用(本机 speechSynthesis 音色表)
|
|
1027
|
+
const [voiceClientOn, setVoiceClientOn] = useVoiceClientEnabled(); // 本机播报开关(localStorage)
|
|
879
1028
|
const [cfg, setCfg] = useState(null); // null = 尚未加载
|
|
880
1029
|
const [saveErr, setSaveErr] = useState("");
|
|
881
1030
|
const [testing, setTesting] = useState(false);
|
|
@@ -1048,7 +1197,7 @@ function NotifyView(props) {
|
|
|
1048
1197
|
onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
|
|
1049
1198
|
{cfg.notifyOnConfirm !== false ? "开" : "关"}
|
|
1050
1199
|
</button>
|
|
1051
|
-
<span className="dknt-row-sub"
|
|
1200
|
+
<span className="dknt-row-sub">工具等你批准、或会话提问等你选答案时弹卡片并响确认音;处理完自动收起,没处理完就一直显示(点卡片直达会话)。这两种「停在原地等人」都不算卡住</span>
|
|
1052
1201
|
</div>
|
|
1053
1202
|
<div className="dknt-row">
|
|
1054
1203
|
<span className="dknt-row-label">卡住提醒</span>
|
|
@@ -1056,7 +1205,7 @@ function NotifyView(props) {
|
|
|
1056
1205
|
onClick={() => patch({ notifyOnStuck: cfg.notifyOnStuck === false })}>
|
|
1057
1206
|
{cfg.notifyOnStuck !== false ? "开" : "关"}
|
|
1058
1207
|
</button>
|
|
1059
|
-
<span className="dknt-row-sub"
|
|
1208
|
+
<span className="dknt-row-sub">编程阶段长时间没有动静时弹卡片,写明是主任务还是子任务、停滞了多久、最后在执行什么;恢复活动 / 任务结束自动收起,同一停点只弹一次。等批准、等回答、命令还在跑、正在压缩上下文的会话都不算卡住(那些都是在干活,别重复轰炸)(语音播报走「语音播报」区的卡住开关,互不干扰)</span>
|
|
1060
1209
|
</div>
|
|
1061
1210
|
</div>
|
|
1062
1211
|
</div>,
|
|
@@ -1120,15 +1269,25 @@ function NotifyView(props) {
|
|
|
1120
1269
|
<div key="voice" className="dknt-sec">
|
|
1121
1270
|
<div className="dknt-sec-head">
|
|
1122
1271
|
<span className="dknt-sec-title">语音播报</span>
|
|
1123
|
-
<span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 卡住时用浏览器语音(TTS
|
|
1272
|
+
<span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 需回答 / 卡住时用浏览器语音(TTS)说一句话;服务端总开关(跨浏览器)与本机开关(当前浏览器)都开启才播报</span>
|
|
1124
1273
|
<span className="dknt-sec-sw">
|
|
1125
|
-
<span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "
|
|
1274
|
+
<span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "服务端:已开启" : "服务端:已关闭"}</span>
|
|
1126
1275
|
<button type="button" className={"dock-sw" + (cfg.voiceNotify ? " on" : "")}
|
|
1127
|
-
role="switch" aria-checked={cfg.voiceNotify} aria-label="
|
|
1128
|
-
title={cfg.voiceNotify ? "
|
|
1276
|
+
role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报(服务端总开关)"
|
|
1277
|
+
title={cfg.voiceNotify ? "关闭语音播报(服务端总开关)" : "开启语音播报(服务端总开关)"}
|
|
1129
1278
|
onClick={() => patch({ voiceNotify: !cfg.voiceNotify })} />
|
|
1130
1279
|
</span>
|
|
1131
1280
|
</div>
|
|
1281
|
+
<div className="dknt-row">
|
|
1282
|
+
<span className="dknt-row-label">本机播报</span>
|
|
1283
|
+
<button type="button" className={"dknt-miniswitch" + (voiceClientOn ? " on" : "")}
|
|
1284
|
+
role="switch" aria-checked={voiceClientOn} aria-label="开关本机语音播报"
|
|
1285
|
+
title={voiceClientOn ? "关闭本机语音播报" : "开启本机语音播报"}
|
|
1286
|
+
onClick={() => setVoiceClientOn(!voiceClientOn)}>{voiceClientOn ? "开" : "关"}</button>
|
|
1287
|
+
<span className="dknt-row-sub">{voiceClientOn
|
|
1288
|
+
? "本浏览器会播报(设置保存在当前浏览器;服务端总开关关闭时不生效)"
|
|
1289
|
+
: "本浏览器不播报(设置保存在当前浏览器,不影响其他浏览器)"}</span>
|
|
1290
|
+
</div>
|
|
1132
1291
|
{cfg.voiceNotify ? <div className="dknt-rows-narrow">
|
|
1133
1292
|
<div className="dknt-row">
|
|
1134
1293
|
<span className="dknt-row-label">播报口径</span>
|
|
@@ -1147,12 +1306,12 @@ function NotifyView(props) {
|
|
|
1147
1306
|
{[
|
|
1148
1307
|
["voiceOnComplete", "完成", cfg.voiceOnComplete !== false],
|
|
1149
1308
|
["voiceOnError", "停止", cfg.voiceOnError !== false],
|
|
1150
|
-
["voiceOnConfirm", "
|
|
1309
|
+
["voiceOnConfirm", "需授权/需回答", cfg.voiceOnConfirm !== false],
|
|
1151
1310
|
["voiceOnStuck", "卡住", cfg.voiceOnStuck !== false],
|
|
1152
1311
|
].map(([key, label, on]) => (
|
|
1153
1312
|
<button type="button" key={key} className={"dknt-miniswitch" + (on ? " on" : "")}
|
|
1154
1313
|
style={{ marginRight: 6 }}
|
|
1155
|
-
title={key === "voiceOnStuck" ? "
|
|
1314
|
+
title={key === "voiceOnStuck" ? "编程阶段长时间没有动静时播报一次(恢复后可再报;命令在跑 / 正在压缩上下文不算)" : ""}
|
|
1156
1315
|
onClick={() => patch({ [key]: !on })}>{label}{on ? "·开" : "·关"}</button>
|
|
1157
1316
|
))}
|
|
1158
1317
|
</span>
|
|
@@ -1230,7 +1389,7 @@ function NotifyView(props) {
|
|
|
1230
1389
|
</select>
|
|
1231
1390
|
<span className="dknt-row-sub">未指定音色时按语言自动挑;已选音色的语言会覆盖这里</span>
|
|
1232
1391
|
</div>
|
|
1233
|
-
</div> : <div className="dknt-note"
|
|
1392
|
+
</div> : <div className="dknt-note">服务端总开关未开启——任务事件不播报语音(提示音、系统通知不受影响);上面的「本机播报」只决定本浏览器是否出声。</div>}
|
|
1234
1393
|
</div>,
|
|
1235
1394
|
<div key="dingtalk" className="dknt-sec">
|
|
1236
1395
|
<div className="dknt-sec-head">
|
|
@@ -1315,10 +1474,10 @@ function NotifyView(props) {
|
|
|
1315
1474
|
);
|
|
1316
1475
|
}
|
|
1317
1476
|
if (saveErr) rows.push(<div key="err" className="dknt-note dknt-err">{saveErr}</div>);
|
|
1318
|
-
// 任务运行状态(进行中 / 最近完成 /
|
|
1477
|
+
// 任务运行状态(进行中 / 最近完成 / 等人介入)已独立成左侧「运行状态」菜单项,这里只留一句指路
|
|
1319
1478
|
rows.push(
|
|
1320
1479
|
<div key="hint" className="dknt-note">
|
|
1321
|
-
|
|
1480
|
+
任务运行状态(进行中任务、等人介入(等批准 / 等回答)与最近完成)已独立成左侧「运行状态」菜单;本页只负责结束与需确认 / 需回答时的提醒。
|
|
1322
1481
|
</div>
|
|
1323
1482
|
);
|
|
1324
1483
|
return <div className="dknt-root">{rows}</div>;
|
|
@@ -1332,8 +1491,10 @@ function NotifyStat(props) {
|
|
|
1332
1491
|
const cfg = snap.status && snap.status.config;
|
|
1333
1492
|
if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
|
|
1334
1493
|
if (!cfg) return <span>等待通知配置…</span>;
|
|
1335
|
-
const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "
|
|
1336
|
-
const
|
|
1494
|
+
const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "需确认/需回答" : ""].filter(Boolean);
|
|
1495
|
+
const voiceMode = cfg.voiceMode === "detailed" ? "·详" : cfg.voiceMode === "medium" ? "·适" : "·简";
|
|
1496
|
+
const voice = !cfg.voiceNotify ? "" : (voiceClientEnabled() ? "语音播报" + voiceMode : "语音播报·本机已关");
|
|
1497
|
+
const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", voice].filter(Boolean);
|
|
1337
1498
|
const push = [cfg.dingtalkEnabled ? "钉钉" : "", cfg.feishuEnabled ? "飞书" : ""].filter(Boolean);
|
|
1338
1499
|
return <span>{(events.length ? "通知:" + events.join("/") : "通知事件全关")
|
|
1339
1500
|
+ (extras.length ? " · " + extras.join("/") : "")
|
|
@@ -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',
|