dsh-dock 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,22 @@
4
4
  // 1. View —— 功能坞面板页:通知事件 / 呈现方式 / 钉钉推送 / 飞书推送 四组设置;
5
5
  // 2. HomeStat —— 首页总揽概要(当前通知事件与推送通道);
6
6
  // 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
7
- // 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等待确认时弹常驻提醒卡。
7
+ // 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等批准 / 提问等回答时弹常驻提醒卡,
8
+ // 编程阶段长时间无活动弹「卡住」提醒卡(写明主/子任务、停滞时长、最后动作)。
9
+ // 卡住判定的口径统一在 src/shared.js 的 taskStalled:等人介入、命令还在跑、
10
+ // 正在压缩上下文都不算卡住(那都是「在干活」),只有真的安静下来才报。
11
+ // 所有卡片受「页内卡片」总开关与各类型开关控制(见 View 的通知事件区)。
8
12
  // 结束卡常驻到「看过」(点卡直达会话 / 点进会话 / 手动 ✕),≥3 张叠放收纳。
9
13
  //
10
14
  // 与【任务动画】【运行状态】完全独立:动效在 features/animation/、任务状态一览在
11
15
  // features/runstate/,本模块只管通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书群机器人推送)。
12
16
  // 模块启停 = 功能坞里的开关(独立菜单项),本页不再有第二个总开关。
17
+ // 语音播报是双层开关:服务端 voiceNotify(settings,跨浏览器)+ 本机开关(localStorage,
18
+ // 当前浏览器),两者都开才播报——团队共用一个 dsh 实例时可按浏览器各自静音。
13
19
  //
14
20
  // Host 通信:fetch('dsh-dock/notify/<method>')(见 features/notify/host.js)。
15
21
  import { useState, useEffect, useRef, useCallback } from "react";
22
+ import { taskWaiting, taskApprovals, taskQuestions, taskStalled, taskActivityAt } from "../../src/shared.js";
16
23
 
17
24
  // ---------- Host RPC 桥接 ----------
18
25
  function rpcCall(method, args) {
@@ -158,6 +165,42 @@ function utterCtor() {
158
165
  export function voiceSupported() {
159
166
  return synthOf() !== null && utterCtor() !== null;
160
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
+ }
161
204
  /** 本机音色表(可能为空:Chrome 首次调用是异步加载,靠 voiceschanged 补)。 */
162
205
  export function localVoices() {
163
206
  const s = synthOf();
@@ -210,7 +253,7 @@ function useLocalVoices() {
210
253
  }
211
254
  let voiceEpoch = 0;
212
255
  /**
213
- * 播一句话(幂等键防重)。cfg 取 voiceNotify 总开关之外的调用方已判过;这里读
256
+ * 播一句话(幂等键防重)。调用方已用 voiceAllowed 判过服务端总开关 + 本机开关;这里读
214
257
  * voiceRate/voiceVolume/voiceLang/voiceURI,voiceURI 在本机不存在时按语言回落,
215
258
  * lang 空时中文文本优先挑中文音色。
216
259
  * @returns 是否真的开始播(不支持/文本为空返回 false)
@@ -261,7 +304,18 @@ export function speakVoice(text, cfg) {
261
304
  return true;
262
305
  }
263
306
 
264
- // ---------- 播报文案模板(简洁 = 一句话;详细 = 带标题/耗时/统计/原因) ----------
307
+ // ---------- 播报文案模板(三挡:简洁 = 一句话;适中 = 带标题与关键量;详细 = 带统计与摘要) ----------
308
+ // 口径取值 brief | medium | detailed;语音看 cfg.voiceMode(缺省 brief,与宿主 schema 同款),
309
+ // 卡片正文看 cfg.cardMode(缺省 detailed =历史内容,见下方卡片正文构建处)。
310
+ function voiceTone(cfg) {
311
+ const m = cfg && cfg.voiceMode;
312
+ return m === "detailed" || m === "medium" ? m : "brief";
313
+ }
314
+ /** 卡片正文口径(三挡):缺省 detailed =历史内容——老宿主还没下发 cardMode 时卡片不缩水。 */
315
+ function cardTone(cfg) {
316
+ const m = cfg && cfg.cardMode;
317
+ return m === "brief" || m === "medium" ? m : "detailed";
318
+ }
265
319
  function endReasonSpeech(reason) {
266
320
  if (reason === "error") return "出错了";
267
321
  if (reason === "aborted") return "已停止";
@@ -272,13 +326,23 @@ function endReasonSpeech(reason) {
272
326
  }
273
327
  /** 结束播报文案(success 由调用方按 isSuccessReason 给)。kind: complete|error */
274
328
  export function endBroadcastText(success, record, task, cfg) {
275
- const mode = cfg && cfg.voiceMode === "detailed" ? "detailed" : "brief";
329
+ const mode = voiceTone(cfg);
276
330
  const reason = (record && record.endReason) || "";
277
331
  const title = (record && record.title) || (task && task.title) || "";
278
332
  if (mode === "brief") {
279
333
  // 简洁口径不分原因细念:正常完成「任务完成」,其余一律「任务停止了」
280
334
  return success ? "任务完成" : "任务停止了";
281
335
  }
336
+ if (mode === "medium") {
337
+ // 适中口径:点名是哪个任务 + 结果 + (停止时)原因 + 耗时;不念回合/Token 统计
338
+ const who = title ? "任务「" + truncate(title, 30) + "」" : "任务";
339
+ const parts = [success ? who + "完成了" : who + "停止了"];
340
+ if (!success) parts.push(",原因:" + endReasonSpeech(reason));
341
+ const dur = (record && record.duration) || 0;
342
+ if (dur > 0) parts.push(",耗时" + fmtDur(dur));
343
+ parts.push("。");
344
+ return parts.join("");
345
+ }
282
346
  const parts = [];
283
347
  if (title) parts.push("任务:" + truncate(title, 30) + "。");
284
348
  parts.push(success ? "已完成。" : "结束,原因:" + endReasonSpeech(reason) + "。");
@@ -297,27 +361,77 @@ export function endBroadcastText(success, record, task, cfg) {
297
361
  }
298
362
  /** 需授权播报文案。 */
299
363
  export function confirmBroadcastText(task, approval, cfg) {
300
- const mode = cfg && cfg.voiceMode === "detailed" ? "detailed" : "brief";
364
+ const mode = voiceTone(cfg);
301
365
  if (mode === "brief") return "任务需要授权";
302
366
  const tool = (approval && approval.toolName) || "工具";
303
367
  const scene = toolLabel(tool);
368
+ if (mode === "medium") {
369
+ // 适中口径:点名任务 + 要批准的场景;不念工具名与「回到会话」的叮嘱
370
+ const who = task && task.title ? "任务「" + truncate(task.title, 30) + "」" : "任务";
371
+ return who + "需要授权,正在等你批准" + (scene || "工具 " + tool) + "。";
372
+ }
304
373
  const parts = [];
305
374
  if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
306
375
  parts.push("正在等待你批准" + (scene ? "执行" + scene : "") + ",工具是" + tool + ",请回到会话处理。");
307
376
  return parts.join("");
308
377
  }
309
- /** 卡住播报文案。 */
310
- export function stuckBroadcastText(task, cfg) {
311
- const mode = cfg && cfg.voiceMode === "detailed" ? "detailed" : "brief";
312
- if (mode === "brief") return "任务卡住了";
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
+ }
313
395
  const parts = [];
314
396
  if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
315
- parts.push("编程阶段已经" + fmtDur(Math.max(0, (cfg && cfg.stuckMs) || STUCK_MS)) + "没有任何动静,可能卡住了,请查看。");
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
+ }
403
+ /**
404
+ * 卡住播报文案:必须回答「哪里卡住了」——主任务还是子任务、哪个会话、停了多久、
405
+ * 最后在干什么。子任务静默会把「主任务明明在跑却突然播报」放大成困惑(2026-10-30
406
+ * 用户实测:主任务进行中突然播「任务卡住了」,屏幕上却看不到卡住的东西),
407
+ * 所以简洁口径也带「主/子 + 标题」,详细口径再补停滞时长与最后动作。
408
+ * 归属字段来自宿主追踪快照(task-track 的 isSubtask/lastToolName)。
409
+ */
410
+ export function stuckBroadcastText(task, cfg) {
411
+ const mode = voiceTone(cfg);
412
+ const where = (task && task.isSubtask ? "子任务" : "主任务")
413
+ + (task && task.title ? "「" + truncate(task.title, 30) + "」" : "");
414
+ if (mode === "brief") return where + "卡住了";
415
+ if (mode === "medium") {
416
+ return where + "卡住了,已经" + fmtDur(Math.max(0, (cfg && cfg.stuckMs) || STUCK_MS)) + "没有任何动静。";
417
+ }
418
+ const parts = [where + "卡住了。"];
419
+ parts.push("编程阶段已经" + fmtDur(Math.max(0, (cfg && cfg.stuckMs) || STUCK_MS)) + "没有任何动静,");
420
+ const tool = (task && task.lastToolName) || "";
421
+ const scene = tool ? toolLabel(tool) : "";
422
+ parts.push(tool ? "最后在执行" + tool + (scene ? "(" + scene + ")" : "") + "," : "");
423
+ parts.push("请查看。");
316
424
  return parts.join("");
317
425
  }
318
426
 
319
- /** 卡住判定阈值:与【任务动画】同款——phase=code 且 18 秒无活动视为卡住(正常长任务不误报)。 */
427
+ /** 卡住判定阈值:与【任务动画】同款——phase=code 且这么久没动静才视为卡住(正常长任务不误报)。
428
+ * 「没动静」由 src/shared.js 的 taskStalled 定义:等人介入、命令在跑、正在压缩上下文都算在干活。 */
320
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
+ }
321
435
 
322
436
  // ---------- 共享快照:浮层(轮询) / 面板页 / 首页概要共用一份数据 ----------
323
437
  const notifyStore = {
@@ -437,9 +551,14 @@ function Toast(props) {
437
551
  // 导航(抽屉收起也有效),回退按标题点侧栏行;都失败(会话已删)卡片原样保留。
438
552
  const jump = useCallback(() => {
439
553
  if (!t.sessionTitle && !t.sessionId) return;
440
- const ok = openSessionById(t.sessionId) || (t.sessionTitle ? openSessionByTitle(t.sessionTitle) : false);
441
- if (ok && t.kind !== "confirm") setClosing(true);
442
- }, [t.sessionId, t.sessionTitle, t.kind]);
554
+ // 子任务会话未必在侧栏里:先按自身 id 走官方导航,再试父会话 id(fallbackSessionId),
555
+ // 最后按标题点行。需确认卡只跳不收(审批没处理完它就该在);卡住卡也不因跳转收起
556
+ // ——它由「恢复活动 / 任务结束」的对账收(跳过去还是卡着,卡片就该继续提醒)。
557
+ const ok = openSessionById(t.sessionId)
558
+ || (t.fallbackSessionId ? openSessionById(t.fallbackSessionId) : false)
559
+ || (t.sessionTitle ? openSessionByTitle(t.sessionTitle) : false);
560
+ if (ok && t.kind !== "confirm" && t.kind !== "stuck") setClosing(true);
561
+ }, [t.sessionId, t.sessionTitle, t.fallbackSessionId, t.kind]);
443
562
  // 外部要求收起(审批已处理 / 已点进会话)走同一套淡出动画
444
563
  useEffect(() => { if (t.closing && !closing) setClosing(true); }, [t.closing, closing]);
445
564
  // 所有卡片都没有计时器:结束卡常驻到「看过」(点卡跳转 / 点进会话 / 手动 ✕),
@@ -466,7 +585,7 @@ function Toast(props) {
466
585
  <div className="dknt-toast-head">
467
586
  <span className="dknt-toast-mark" style={{ background: markColor }} />
468
587
  <span className="dknt-toast-title" title={t.sessionTitle || t.title}>
469
- {t.kind === "confirm" ? "✋ " : ""}{props.collapsed ? (t.sessionTitle || t.title) : t.title}
588
+ {t.kind === "confirm" ? "✋ " : t.kind === "stuck" ? "⏳ " : ""}{props.collapsed ? (t.sessionTitle || t.title) : t.title}
470
589
  {!props.collapsed && t.sessionTitle && t.kind !== "confirm" ? " · " + t.sessionTitle : ""}
471
590
  </span>
472
591
  <button type="button" className="dknt-toast-close" aria-label="关闭"
@@ -486,13 +605,26 @@ export function NotifyOverlay(props) {
486
605
  const [stackHover, setStackHover] = useState(false); // 卡片栈被悬停(叠放时整体展开)
487
606
  const prevActiveRef = useRef(null);
488
607
  const remindedApprovalsRef = useRef(new Map()); // sessionId -> 提醒过的 approvalId 集合
608
+ const remindedQuestionsRef = useRef(new Map()); // sessionId -> 提醒过的提问 callId 集合
489
609
  const toastSeqRef = useRef(0);
490
610
 
491
611
  const pushToast = useCallback((toast) => {
612
+ // 「页内卡片」总开关(设置面板里关掉 → 右侧所有卡片都不再弹出)。只管卡片:
613
+ // 语音播报 / 提示音 / 系统通知 / 群推送各有独立开关,与它正交。
614
+ const st = notifyStore.snap.status;
615
+ const cfg = st && st.config;
616
+ if (cfg && cfg.notifyCards === false) return;
492
617
  // 上限 12:结束卡现在常驻到看过才收,4 张容易见底;多了靠叠放收纳而不是丢弃。
493
618
  setToasts((prev) => prev.concat([toast]).slice(-12));
494
619
  }, []);
495
620
 
621
+ // 总开关中途关闭:已挂着的卡片也一并收掉(否则「设置里关了、右边还挂着」像开关失灵)。
622
+ // 返回原数组避免空转重渲染。
623
+ useEffect(() => {
624
+ const cfg = snap.status && snap.status.config;
625
+ if (cfg && cfg.notifyCards === false) setToasts((prev) => (prev.length ? [] : prev));
626
+ }, [snap.status]);
627
+
496
628
  // 轮询:任务中 850ms,实时捕捉结束/待确认;空闲 6s / 出错 15s;页面切回立即刷新。
497
629
  useEffect(() => {
498
630
  let stopped = false;
@@ -539,27 +671,34 @@ export function NotifyOverlay(props) {
539
671
  if (!activeIds.has(sid)) remindedApprovalsRef.current.delete(sid);
540
672
  }
541
673
  for (const task of st.active) {
542
- const approvals = Array.isArray(task.approvals) ? task.approvals : [];
674
+ const approvals = taskApprovals(task);
543
675
  if (approvals.length === 0) continue;
544
676
  let seen = remindedApprovalsRef.current.get(task.sessionId);
545
677
  if (!seen) { seen = new Set(); remindedApprovalsRef.current.set(task.sessionId, seen); }
546
678
  const fresh = approvals.filter((a) => a && !seen.has(a.id));
547
679
  if (fresh.length === 0) continue;
548
680
  for (const a of fresh) seen.add(a.id);
549
- // 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)
550
- if (cfg.voiceNotify === true && cfg.voiceOnConfirm !== false) {
681
+ // 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)。
682
+ // 服务端总开关 + 本机开关都开才出声(团队共用一个实例时各自可静音)。
683
+ if (voiceAllowed(cfg) && cfg.voiceOnConfirm !== false) {
551
684
  speakVoice(confirmBroadcastText(task, fresh[0], cfg), cfg);
552
685
  }
553
686
  // 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
554
687
  if (cfg.notifyOnConfirm === false) continue;
555
688
  const toolName = fresh[0].toolName || "";
556
689
  const scene = toolLabel(toolName);
690
+ // 卡片正文按「卡片口径」三挡裁剪(title 行永远在,卡片标题不受口径影响)
691
+ const tone = cardTone(cfg);
557
692
  const lines = [
558
693
  "任务:" + (task.title || "(无标题)"),
559
694
  "工具:" + (toolName || "未知") + (scene ? "(" + scene + ")" : ""),
560
695
  ];
561
- if (fresh[0].reason) lines.push("说明:" + truncate(fresh[0].reason, 140));
562
- lines.push(fresh.length > 1 ? "共 " + fresh.length + " 项等待你的确认" : "请在会话里确认后继续");
696
+ if (tone !== "brief" && fresh[0].reason) lines.push("说明:" + truncate(fresh[0].reason, 140));
697
+ if (fresh.length > 1) {
698
+ lines.push(tone === "brief" ? "共 " + fresh.length + " 项等待确认" : "共 " + fresh.length + " 项等待你的确认");
699
+ } else if (tone === "detailed") {
700
+ lines.push("请在会话里确认后继续");
701
+ }
563
702
  pushToast({
564
703
  id: ++toastSeqRef.current,
565
704
  kind: "confirm",
@@ -585,22 +724,87 @@ export function NotifyOverlay(props) {
585
724
  }
586
725
  }, [snap.status]);
587
726
 
588
- // 审批项消失(approval/decided 后宿主把它移出 approvals)→ 对应需确认卡自动收起。
589
- // 只标记 closing 走淡出,不直接删:与手动点 ✕ 的退场一致。
590
- // 卡片绑定的 approvalKeys 全部不在仍待确认的集合里才收;空 keys(老数据)不动。
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。
591
793
  useEffect(() => {
592
794
  const st = snap.status;
593
795
  if (!st || !st.active) return;
594
796
  const live = new Set();
595
797
  for (const task of st.active) {
596
- const approvals = Array.isArray(task.approvals) ? task.approvals : [];
597
- for (const a of approvals) if (a && a.id) live.add(task.sessionId + "|" + a.id);
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);
598
800
  }
599
801
  setToasts((prev) => {
600
802
  let changed = false;
601
803
  const next = prev.map((x) => {
602
804
  if (x.kind !== "confirm" || x.closing) return x;
603
- const keys = Array.isArray(x.approvalKeys) ? x.approvalKeys : [];
805
+ const keys = []
806
+ .concat(Array.isArray(x.approvalKeys) ? x.approvalKeys : [])
807
+ .concat(Array.isArray(x.questionKeys) ? x.questionKeys : []);
604
808
  if (keys.length === 0 || keys.some((k) => live.has(k))) return x;
605
809
  changed = true;
606
810
  return Object.assign({}, x, { closing: true });
@@ -622,7 +826,8 @@ export function NotifyOverlay(props) {
622
826
  setToasts((prev) => {
623
827
  let changed = false;
624
828
  const next = prev.map((x) => {
625
- if (x.kind === "confirm" || x.closing) return x;
829
+ // 需确认卡挂到审批处理完、卡住卡挂到恢复活动——都不走「看过即收」
830
+ if (x.kind === "confirm" || x.kind === "stuck" || x.closing) return x;
626
831
  if (!x.sessionTitle && !x.sessionId) return x;
627
832
  const hit = (cur.id && x.sessionId && cur.id === x.sessionId)
628
833
  || (!cur.id && x.sessionTitle && titleMatches(x.sessionTitle, cur.title));
@@ -636,9 +841,11 @@ export function NotifyOverlay(props) {
636
841
  return () => clearInterval(timer);
637
842
  }, []);
638
843
 
639
- // ===== 卡住检测:编程阶段长时间无活动 → 播报一次(与【任务动画】同口径 18s) =====
640
- // 每个会话只报一次:恢复活动(lastActivityAt 变新)即清除标记,之后再卡住可再报;
844
+ // ===== 卡住检测:编程阶段长时间无活动 → 播报 + 弹卡(与【任务动画】同口径 18s) =====
845
+ // 每个会话每个停点只报一次:恢复活动(lastActivityAt 变新)即清除标记,之后再卡住可再报;
641
846
  // 会话离开活跃列表(结束/取消)同样清除。阈值到点才轮询比对,850ms 的节奏足够。
847
+ // 语音与卡片**各自独立开关**(关卡片不关耳朵、关语音不关眼睛),所以停点标记要在
848
+ // 两个开关之外都记——否则关掉一边会在同一停点重复打扰另一边。
642
849
  const stuckSeenRef = useRef(new Map()); // sessionId -> 已播报时的 lastActivityAt
643
850
  useEffect(() => {
644
851
  const st = snap.status;
@@ -649,29 +856,99 @@ export function NotifyOverlay(props) {
649
856
  for (const sid of Array.from(stuckSeenRef.current.keys())) {
650
857
  if (!activeIds.has(sid)) stuckSeenRef.current.delete(sid);
651
858
  }
652
- if (cfg.voiceNotify !== true || cfg.voiceOnStuck === false) return;
859
+ // 主任务正在跑子任务(有活跃子会话挂在自己名下)时,主会话天然安静——它在等子代理回话,
860
+ // 不是卡住。这类「等待子任务」的安静不计入卡住判定,交由子任务自己命中(真卡在子任务上会
861
+ // 说「子任务…卡住了」),避免「主任务明明在跑、却报主任务卡住」的误报(2026-11-01 用户反馈)。
862
+ // 先扫一遍建集合、再逐条判定:同一帧里主/子顺序不影响结果。
863
+ const parentsWaitingOnSubtask = new Set();
864
+ for (const t of st.active) {
865
+ if (t.isSubtask && t.parentSessionId) parentsWaitingOnSubtask.add(t.parentSessionId);
866
+ }
653
867
  for (const task of st.active) {
654
- // 等用户批准的会话不算卡住——那是「需确认」提醒的职责,别重复轰炸
655
- const approvals = Array.isArray(task.approvals) ? task.approvals : [];
656
- if (approvals.length > 0) { stuckSeenRef.current.delete(task.sessionId); continue; }
868
+ // 停在原地等人的会话不算卡住——那是「需确认 / 需回答」提醒的职责,别重复轰炸。
869
+ // 两类都要排除:① 工具等批准(approvals);② 提问等回答(questions)。
870
+ // ②是 2026-11-01 用户实测的误报来源:会话调 ask_user_question 后挂起等人选答案,
871
+ // 其间会话一条事件都没有,18 秒安静被当成停滞,播报「主任务卡住了」并弹卡——
872
+ // 界面上明明是在等用户回答(截图为证),报「卡住」完全是错的口径。
873
+ if (taskWaiting(task)) { stuckSeenRef.current.delete(task.sessionId); continue; }
657
874
  if (task.phase !== "code") { continue; }
658
- const lastAct = task.lastActivityAt || task.phaseAt || 0;
659
- if (!lastAct || now - lastAct < STUCK_MS) continue;
875
+ // 该会话有活跃子任务在跑 → 它在等子任务,不算卡住(否则主任务进行中会误报卡住)
876
+ if (parentsWaitingOnSubtask.has(task.sessionId)) continue;
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;
660
884
  const prev = stuckSeenRef.current.get(task.sessionId);
661
885
  if (prev === lastAct) continue; // 同一停点已报过
662
886
  stuckSeenRef.current.set(task.sessionId, lastAct);
663
- speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: now - lastAct })), cfg);
887
+ // —— 语音:说清主/子任务、标题、停滞时长与最后动作(服务端总开关 + 本机开关)——
888
+ if (voiceAllowed(cfg) && cfg.voiceOnStuck !== false) {
889
+ speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: stuckMs })), cfg);
890
+ }
891
+ // —— 卡片:只受「卡住提醒」与「页内卡片」总开关控制(总开关在 pushToast 里把关)——
892
+ if (cfg.notifyOnStuck === false) continue;
893
+ const label = task.isSubtask ? "子任务" : "主任务";
894
+ const title = task.title || "(无标题)";
895
+ const tool = task.lastToolName || "";
896
+ // 卡片正文按「卡片口径」三挡裁剪:简洁只报谁卡住;适中加停滞时长;详细再加最后动作
897
+ const tone = cardTone(cfg);
898
+ const lines = ["任务:" + label + "·" + title];
899
+ if (tone !== "brief") lines.push("已停滞:" + fmtDur(stuckMs) + "(编程阶段无活动)");
900
+ if (tone === "detailed") {
901
+ if (tool) lines.push("最后动作:" + tool + (toolLabel(tool) ? "(" + toolLabel(tool) + ")" : ""));
902
+ lines.push("恢复活动或任务结束后自动收起;点击卡片直达会话");
903
+ }
904
+ pushToast({
905
+ id: ++toastSeqRef.current,
906
+ kind: "stuck",
907
+ title: label + "卡住了",
908
+ body: lines.join("\n"),
909
+ sessionId: task.sessionId || "",
910
+ sessionTitle: title,
911
+ // 子任务会话通常不在侧栏里:跳不到自己时回退到父会话(看主会话里的子代理进度)
912
+ fallbackSessionId: (task.isSubtask && task.parentSessionId) ? task.parentSessionId : "",
913
+ // 停点指纹:恢复活动(lastActivityAt 变了)即由下面的对账 effect 收起
914
+ stuckAt: lastAct,
915
+ });
664
916
  }
665
917
  }, [snap.status]);
666
918
 
919
+ // 卡住卡的自动收起(与需确认卡同哲学:不设倒计时,状态没变就一直挂着):
920
+ // 恢复活动 / 离开活跃列表(结束或取消)/ 转为等人介入(需确认卡或需回答卡接管)
921
+ // → 标记 closing 走同一套 240ms 淡出。只标记不直删,与手动 ✕ 退场一致。
922
+ // 「恢复活动」的判据必须与卡住判定处**完全一致**(都走 taskActivityAt),否则同一停点
923
+ // 两处算出的指纹不同,卡片会反复收起又弹出。
924
+ useEffect(() => {
925
+ const st = snap.status;
926
+ if (!st || !st.active) return;
927
+ const byId = new Map(st.active.map((x) => [x.sessionId, x]));
928
+ setToasts((prev) => {
929
+ let changed = false;
930
+ const next = prev.map((x) => {
931
+ if (x.kind !== "stuck" || x.closing) return x;
932
+ const task = byId.get(x.sessionId);
933
+ const stillStalled = !!task
934
+ && taskActivityAt(task) === x.stuckAt
935
+ && !taskWaiting(task);
936
+ if (stillStalled) return x;
937
+ changed = true;
938
+ return Object.assign({}, x, { closing: true });
939
+ });
940
+ return changed ? next : prev;
941
+ });
942
+ }, [snap.status]);
943
+
667
944
  const handleTaskEnd = (task, record, cfg) => {
668
945
  const reason = (record && record.endReason) || "";
669
946
  const success = isSuccessReason(reason);
670
947
  const info = endInfo(reason);
671
948
  const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
672
949
  // 语音播报先走自己的开关(与卡片开关正交:关卡片不关耳朵)。
673
- // 播报音量归 0 / 浏览器不支持时静默跳过即可。
674
- if (cfg.voiceNotify === true
950
+ // 服务端总开关 + 本机开关都开才出声;播报音量归 0 / 浏览器不支持时静默跳过即可。
951
+ if (voiceAllowed(cfg)
675
952
  && (success ? cfg.voiceOnComplete !== false : cfg.voiceOnError !== false)) {
676
953
  speakVoice(endBroadcastText(success, record, task, cfg), cfg);
677
954
  }
@@ -681,19 +958,24 @@ export function NotifyOverlay(props) {
681
958
  const provider = (record && record.provider) || task.provider || "";
682
959
  const startTs = (record && record.startTime) || task.startTime;
683
960
  const endTs = (record && record.endTime) || Date.now();
684
- const lines = [
685
- "任务:" + ((record && record.title) || task.title || "(无标题)"),
686
- "模型:" + models + (provider ? "(" + provider + ")" : ""),
687
- "耗时:" + fmtDur((record && record.duration) || (endTs - startTs))
688
- + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")",
689
- "回合 " + ((record && record.turns) || task.turns || 0)
961
+ // 卡片正文按「卡片口径」三挡裁剪(cfg.cardMode,缺省 detailed =历史内容):
962
+ // 简洁=任务+耗时;适中=加模型与起止时间;详细=再加回合/步骤、Token、摘要或错误。
963
+ const tone = cardTone(cfg);
964
+ const durText = fmtDur((record && record.duration) || (endTs - startTs));
965
+ const lines = ["任务:" + ((record && record.title) || task.title || "(无标题)")];
966
+ if (tone !== "brief") lines.push("模型:" + models + (provider ? "(" + provider + ")" : ""));
967
+ lines.push(tone === "brief"
968
+ ? "耗时:" + durText
969
+ : "耗时:" + durText + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")");
970
+ if (tone === "detailed") {
971
+ lines.push("回合 " + ((record && record.turns) || task.turns || 0)
690
972
  + " · 步骤 " + ((record && record.steps) || task.steps || 0)
691
- + (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""),
692
- "Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
693
- + " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens),
694
- ];
695
- if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
696
- if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
973
+ + (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""));
974
+ lines.push("Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
975
+ + " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens));
976
+ if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
977
+ if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
978
+ }
697
979
  const title = success ? "任务完成" : "任务" + info.label;
698
980
  pushToast({
699
981
  id: ++toastSeqRef.current,
@@ -742,6 +1024,7 @@ export function NotifyOverlay(props) {
742
1024
  function NotifyView(props) {
743
1025
  const snap = useNotify();
744
1026
  const voices = useLocalVoices(); // 音色下拉用(本机 speechSynthesis 音色表)
1027
+ const [voiceClientOn, setVoiceClientOn] = useVoiceClientEnabled(); // 本机播报开关(localStorage)
745
1028
  const [cfg, setCfg] = useState(null); // null = 尚未加载
746
1029
  const [saveErr, setSaveErr] = useState("");
747
1030
  const [testing, setTesting] = useState(false);
@@ -884,6 +1167,14 @@ function NotifyView(props) {
884
1167
  <span className="dknt-sec-sub">哪些时刻提醒你;与【任务动画】的动效互不依赖</span>
885
1168
  </div>
886
1169
  <div className="dknt-rows-narrow">
1170
+ <div className="dknt-row">
1171
+ <span className="dknt-row-label">页内卡片</span>
1172
+ <button type="button" className={"dknt-miniswitch" + (cfg.notifyCards !== false ? " on" : "")}
1173
+ onClick={() => patch({ notifyCards: cfg.notifyCards === false })}>
1174
+ {cfg.notifyCards !== false ? "开" : "关"}
1175
+ </button>
1176
+ <span className="dknt-row-sub">右侧通知卡片总开关:关闭后下面各类卡片(完成 / 异常 / 需确认 / 卡住)一律不弹,已挂着的一并收起;语音播报、提示音、系统通知、钉钉飞书推送不受影响</span>
1177
+ </div>
887
1178
  <div className="dknt-row">
888
1179
  <span className="dknt-row-label">完成通知</span>
889
1180
  <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
@@ -906,7 +1197,15 @@ function NotifyView(props) {
906
1197
  onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
907
1198
  {cfg.notifyOnConfirm !== false ? "开" : "关"}
908
1199
  </button>
909
- <span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音;审批处理完自动收起,没处理完就一直显示(点卡片直达会话)</span>
1200
+ <span className="dknt-row-sub">工具等你批准、或会话提问等你选答案时弹卡片并响确认音;处理完自动收起,没处理完就一直显示(点卡片直达会话)。这两种「停在原地等人」都不算卡住</span>
1201
+ </div>
1202
+ <div className="dknt-row">
1203
+ <span className="dknt-row-label">卡住提醒</span>
1204
+ <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnStuck !== false ? " on" : "")}
1205
+ onClick={() => patch({ notifyOnStuck: cfg.notifyOnStuck === false })}>
1206
+ {cfg.notifyOnStuck !== false ? "开" : "关"}
1207
+ </button>
1208
+ <span className="dknt-row-sub">编程阶段长时间没有动静时弹卡片,写明是主任务还是子任务、停滞了多久、最后在执行什么;恢复活动 / 任务结束自动收起,同一停点只弹一次。等批准、等回答、命令还在跑、正在压缩上下文的会话都不算卡住(那些都是在干活,别重复轰炸)(语音播报走「语音播报」区的卡住开关,互不干扰)</span>
910
1209
  </div>
911
1210
  </div>
912
1211
  </div>,
@@ -916,9 +1215,20 @@ function NotifyView(props) {
916
1215
  <span className="dknt-sec-sub">页内卡片、提示音、浏览器系统通知可自由组合</span>
917
1216
  </div>
918
1217
  <div className="dknt-rows-narrow">
1218
+ <div className="dknt-row">
1219
+ <span className="dknt-row-label">卡片口径</span>
1220
+ {[["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]].map(([v, label]) => (
1221
+ <button type="button" key={v}
1222
+ className={"dknt-miniswitch" + (cardTone(cfg) === v ? " on" : "")}
1223
+ style={{ marginRight: 6 }}
1224
+ title={"右侧通知卡片正文按" + label + "档显示"}
1225
+ onClick={() => patch({ cardMode: v })}>{label}</button>
1226
+ ))}
1227
+ <span className="dknt-row-sub">右侧卡片正文写多少(与各类型开关、语音播报互不影响):简洁=任务与耗时 / 谁卡住;适中=加模型、起止时间、停滞时长;详细=全部(回合步骤、Token、摘要或错误、最后动作)</span>
1228
+ </div>
919
1229
  <div className="dknt-row">
920
1230
  <span className="dknt-row-label">卡片收纳</span>
921
- <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完。3 张及以上自动叠放成标题条,鼠标移上去整体展开</span>
1231
+ <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完;卡住卡片挂到恢复活动。3 张及以上自动叠放成标题条,鼠标移上去整体展开;要整体不弹看上面「页内卡片」总开关</span>
922
1232
  </div>
923
1233
  <div className="dknt-row">
924
1234
  <span className="dknt-row-label">系统通知</span>
@@ -959,23 +1269,36 @@ function NotifyView(props) {
959
1269
  <div key="voice" className="dknt-sec">
960
1270
  <div className="dknt-sec-head">
961
1271
  <span className="dknt-sec-title">语音播报</span>
962
- <span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 卡住时用浏览器语音(TTS)说一句话;与提示音、系统通知互不冲突</span>
1272
+ <span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 需回答 / 卡住时用浏览器语音(TTS)说一句话;服务端总开关(跨浏览器)与本机开关(当前浏览器)都开启才播报</span>
963
1273
  <span className="dknt-sec-sw">
964
- <span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "已开启" : "已关闭"}</span>
1274
+ <span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "服务端:已开启" : "服务端:已关闭"}</span>
965
1275
  <button type="button" className={"dock-sw" + (cfg.voiceNotify ? " on" : "")}
966
- role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报"
967
- title={cfg.voiceNotify ? "关闭语音播报" : "开启语音播报"}
1276
+ role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报(服务端总开关)"
1277
+ title={cfg.voiceNotify ? "关闭语音播报(服务端总开关)" : "开启语音播报(服务端总开关)"}
968
1278
  onClick={() => patch({ voiceNotify: !cfg.voiceNotify })} />
969
1279
  </span>
970
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>
971
1291
  {cfg.voiceNotify ? <div className="dknt-rows-narrow">
972
1292
  <div className="dknt-row">
973
1293
  <span className="dknt-row-label">播报口径</span>
974
- <button type="button" className={"dknt-miniswitch" + (cfg.voiceMode !== "detailed" ? " on" : "")}
975
- onClick={() => patch({ voiceMode: "brief" })}>简洁</button>
976
- <button type="button" className={"dknt-miniswitch" + (cfg.voiceMode === "detailed" ? " on" : "")}
977
- onClick={() => patch({ voiceMode: "detailed" })}>详细</button>
978
- <span className="dknt-row-sub">简洁:一句「任务完成 / 任务停止了 / 任务需要授权 / 任务卡住了」;详细:带上任务标题、耗时、回合统计与原因</span>
1294
+ {[["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]].map(([v, label]) => (
1295
+ <button type="button" key={v}
1296
+ className={"dknt-miniswitch" + (voiceTone(cfg) === v ? " on" : "")}
1297
+ style={{ marginRight: 6 }}
1298
+ title={"语音播报按" + label + "档说"}
1299
+ onClick={() => patch({ voiceMode: v })}>{label}</button>
1300
+ ))}
1301
+ <span className="dknt-row-sub">简洁:一句「任务完成 / 任务停止了 / 任务需要授权 / 主任务(或子任务)·标题 卡住了」;适中:点名任务,结束带原因与耗时、卡住带停滞时长;详细:结束带回合统计与回复摘要,需授权带工具名,卡住带最后动作</span>
979
1302
  </div>
980
1303
  <div className="dknt-row">
981
1304
  <span className="dknt-row-label">播报时刻</span>
@@ -983,12 +1306,12 @@ function NotifyView(props) {
983
1306
  {[
984
1307
  ["voiceOnComplete", "完成", cfg.voiceOnComplete !== false],
985
1308
  ["voiceOnError", "停止", cfg.voiceOnError !== false],
986
- ["voiceOnConfirm", "需授权", cfg.voiceOnConfirm !== false],
1309
+ ["voiceOnConfirm", "需授权/需回答", cfg.voiceOnConfirm !== false],
987
1310
  ["voiceOnStuck", "卡住", cfg.voiceOnStuck !== false],
988
1311
  ].map(([key, label, on]) => (
989
1312
  <button type="button" key={key} className={"dknt-miniswitch" + (on ? " on" : "")}
990
1313
  style={{ marginRight: 6 }}
991
- title={key === "voiceOnStuck" ? "编程阶段 18 秒没有动静时播报一次(恢复后可再报)" : ""}
1314
+ title={key === "voiceOnStuck" ? "编程阶段长时间没有动静时播报一次(恢复后可再报;命令在跑 / 正在压缩上下文不算)" : ""}
992
1315
  onClick={() => patch({ [key]: !on })}>{label}{on ? "·开" : "·关"}</button>
993
1316
  ))}
994
1317
  </span>
@@ -1066,7 +1389,7 @@ function NotifyView(props) {
1066
1389
  </select>
1067
1390
  <span className="dknt-row-sub">未指定音色时按语言自动挑;已选音色的语言会覆盖这里</span>
1068
1391
  </div>
1069
- </div> : <div className="dknt-note">未开启——任务事件不播报语音(提示音、系统通知不受影响)。</div>}
1392
+ </div> : <div className="dknt-note">服务端总开关未开启——任务事件不播报语音(提示音、系统通知不受影响);上面的「本机播报」只决定本浏览器是否出声。</div>}
1070
1393
  </div>,
1071
1394
  <div key="dingtalk" className="dknt-sec">
1072
1395
  <div className="dknt-sec-head">
@@ -1151,10 +1474,10 @@ function NotifyView(props) {
1151
1474
  );
1152
1475
  }
1153
1476
  if (saveErr) rows.push(<div key="err" className="dknt-note dknt-err">{saveErr}</div>);
1154
- // 任务运行状态(进行中 / 最近完成 / 等待确认)已独立成左侧「运行状态」菜单项,这里只留一句指路
1477
+ // 任务运行状态(进行中 / 最近完成 / 等人介入)已独立成左侧「运行状态」菜单项,这里只留一句指路
1155
1478
  rows.push(
1156
1479
  <div key="hint" className="dknt-note">
1157
- 任务运行状态(进行中任务、等待确认与最近完成)已独立成左侧「运行状态」菜单;本页只负责结束与需确认时的提醒。
1480
+ 任务运行状态(进行中任务、等人介入(等批准 / 等回答)与最近完成)已独立成左侧「运行状态」菜单;本页只负责结束与需确认 / 需回答时的提醒。
1158
1481
  </div>
1159
1482
  );
1160
1483
  return <div className="dknt-root">{rows}</div>;
@@ -1168,8 +1491,10 @@ function NotifyStat(props) {
1168
1491
  const cfg = snap.status && snap.status.config;
1169
1492
  if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
1170
1493
  if (!cfg) return <span>等待通知配置…</span>;
1171
- const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "需确认" : ""].filter(Boolean);
1172
- const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", cfg.voiceNotify ? ("语音播报" + (cfg.voiceMode === "detailed" ? "·详" : "·简")) : ""].filter(Boolean);
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);
1173
1498
  const push = [cfg.dingtalkEnabled ? "钉钉" : "", cfg.feishuEnabled ? "飞书" : ""].filter(Boolean);
1174
1499
  return <span>{(events.length ? "通知:" + events.join("/") : "通知事件全关")
1175
1500
  + (extras.length ? " · " + extras.join("/") : "")