dsh-dock 0.17.0 → 0.19.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,7 +4,9 @@
4
4
  // 1. View —— 功能坞面板页:通知事件 / 呈现方式 / 钉钉推送 / 飞书推送 四组设置;
5
5
  // 2. HomeStat —— 首页总揽概要(当前通知事件与推送通道);
6
6
  // 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
7
- // 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等待确认时弹常驻提醒卡。
7
+ // 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等待确认时弹常驻提醒卡,
8
+ // 编程阶段长时间无活动弹「卡住」提醒卡(写明主/子任务、停滞时长、最后动作)。
9
+ // 所有卡片受「页内卡片」总开关与各类型开关控制(见 View 的通知事件区)。
8
10
  // 结束卡常驻到「看过」(点卡直达会话 / 点进会话 / 手动 ✕),≥3 张叠放收纳。
9
11
  //
10
12
  // 与【任务动画】【运行状态】完全独立:动效在 features/animation/、任务状态一览在
@@ -132,6 +134,233 @@ function previewSound(effect) {
132
134
  playTone(delayed);
133
135
  }
134
136
 
137
+ // ---------- 语音播报(speechSynthesis 短句引擎;沙箱安全:绝不裸引用全局 speechSynthesis) ----------
138
+ // 与【语音朗读】是同一套浏览器能力、两套独立开关:tts 读「回复正文」,本引擎只播
139
+ // 「完成/停止/需授权/卡住」四类短句(简洁/详细两种口径)。两边共用浏览器全局队列:
140
+ // 播报先 cancel 再播——通知语义就是要打断(正在读回复时任务完成了,先打断报信)。
141
+ // 已知小边距:播报 cancel 掉【语音朗读】正在读的正文时,tts 引擎自己的进度态不会
142
+ // 同步清零(它收不到被 cancel 的 onend),表现为喇叭仍显示「正在朗读」——再点一下
143
+ // 即停;打断本身符合「先报任务信」的预期,不做跨模块耦合。
144
+ function synthOf() {
145
+ try {
146
+ const g = typeof globalThis !== "undefined" ? globalThis : null;
147
+ if (g && g.speechSynthesis) return g.speechSynthesis;
148
+ if (typeof window !== "undefined" && window.speechSynthesis) return window.speechSynthesis;
149
+ } catch { /* 取不到当不支持 */ }
150
+ return null;
151
+ }
152
+ function utterCtor() {
153
+ try {
154
+ const g = typeof globalThis !== "undefined" && globalThis.SpeechSynthesisUtterance
155
+ ? globalThis.SpeechSynthesisUtterance
156
+ : (typeof window !== "undefined" ? window.SpeechSynthesisUtterance : null);
157
+ return g;
158
+ } catch { return null; }
159
+ }
160
+ export function voiceSupported() {
161
+ return synthOf() !== null && utterCtor() !== null;
162
+ }
163
+ /** 本机音色表(可能为空:Chrome 首次调用是异步加载,靠 voiceschanged 补)。 */
164
+ export function localVoices() {
165
+ const s = synthOf();
166
+ if (!s) return [];
167
+ try { return (typeof s.getVoices === "function" ? s.getVoices() : []) || []; } catch { return []; }
168
+ }
169
+ /**
170
+ * 按配置挑音色(与【语音朗读】pickVoice 同规则):显式选过的 voiceURI 必须在**本机**
171
+ * 音色表里存在,否则按语言回落——音色是设备相关的,别的机器上存的 URI 在这里可能没有。
172
+ */
173
+ export function pickVoice(cfg, text) {
174
+ const voices = localVoices();
175
+ if (voices.length === 0) return null;
176
+ const want = String((cfg && cfg.voiceURI) || "");
177
+ if (want) {
178
+ const hit = voices.find((v) => v && v.voiceURI === want);
179
+ if (hit) return hit;
180
+ }
181
+ const lang = String((cfg && cfg.voiceLang) || "");
182
+ if (lang) {
183
+ const base = lang.split("-")[0].toLowerCase();
184
+ return voices.find((v) => String(v.lang || "").toLowerCase() === lang.toLowerCase())
185
+ || voices.find((v) => String(v.lang || "").toLowerCase().startsWith(base))
186
+ || null;
187
+ }
188
+ // 未指定语言:播报文本大概率是中文,优先中文音色
189
+ if (!text || /[\u4E00-\u9FFF]/.test(text)) {
190
+ const zh = voices.find((v) => /^zh/i.test(String(v.lang || "")));
191
+ if (zh) return zh;
192
+ }
193
+ return null;
194
+ }
195
+ // 本机音色表(面板下拉用):Chrome 的 getVoices() 首次是异步的,订阅 voiceschanged
196
+ // 后再渲染一次;与【语音朗读】面板同款处理。
197
+ function useLocalVoices() {
198
+ const [, force] = useState(0);
199
+ const ref = useRef(null);
200
+ if (ref.current === null) ref.current = localVoices();
201
+ useEffect(() => {
202
+ const s = synthOf();
203
+ const onChange = () => { ref.current = localVoices(); force((n) => n + 1); };
204
+ if (s && typeof s.addEventListener === "function") {
205
+ try { s.addEventListener("voiceschanged", onChange); } catch { /* ignore */ }
206
+ }
207
+ // 首帧可能已就绪但没触发事件:补读一次
208
+ if (ref.current.length === 0) onChange();
209
+ return () => { if (s && typeof s.removeEventListener === "function") { try { s.removeEventListener("voiceschanged", onChange); } catch { /* ignore */ } } };
210
+ }, []);
211
+ return ref.current;
212
+ }
213
+ let voiceEpoch = 0;
214
+ /**
215
+ * 播一句话(幂等键防重)。cfg 取 voiceNotify 总开关之外的调用方已判过;这里读
216
+ * voiceRate/voiceVolume/voiceLang/voiceURI,voiceURI 在本机不存在时按语言回落,
217
+ * lang 空时中文文本优先挑中文音色。
218
+ * @returns 是否真的开始播(不支持/文本为空返回 false)
219
+ */
220
+ export function speakVoice(text, cfg) {
221
+ const t = String(text || "").trim();
222
+ if (!t) return false;
223
+ const s = synthOf();
224
+ const Ctor = utterCtor();
225
+ if (!s || !Ctor) return false;
226
+ const c = cfg || {};
227
+ let utt = null;
228
+ try { utt = new Ctor(t); } catch { return false; }
229
+ if (!utt) return false;
230
+ utt.rate = typeof c.voiceRate === "number" ? Math.min(2, Math.max(0.5, c.voiceRate)) : 1;
231
+ utt.volume = typeof c.voiceVolume === "number" ? Math.min(1, Math.max(0, c.voiceVolume)) : 1;
232
+ let lang = String(c.voiceLang || "");
233
+ if (!lang && /[\u4E00-\u9FFF]/.test(t)) lang = "zh-CN";
234
+ if (lang) utt.lang = lang;
235
+ // 指定音色(与【语音朗读】同一份本机音色表):命中就用它,并把 lang 对齐到音色自己的
236
+ // 语言,避免「英文音色念中文」;取不到(换设备/系统语音被删)保持按语言回落。
237
+ const voice = pickVoice(c, t);
238
+ if (voice) {
239
+ utt.voice = voice;
240
+ if (voice.lang) utt.lang = voice.lang;
241
+ }
242
+ const epoch = ++voiceEpoch;
243
+ // 打断式:先清掉上一句(含【语音朗读】正文朗读——Chrome cancel 是异步冲刷,
244
+ // 但播报都经这里串行,同一引擎连续 cancel+speak 的竞态对短句影响可忽略)。
245
+ try { s.cancel(); } catch { /* ignore */ }
246
+ const delay = () => {
247
+ // epoch 落后说明期间有新的播报要抢话,本句作废
248
+ if (epoch !== voiceEpoch) return;
249
+ try { s.speak(utt); } catch { /* ignore */ }
250
+ };
251
+ // cancel 后同一 tick speak 会排进未清空的旧队列(Chrome 已知异步冲刷):
252
+ // 与【语音朗读】同款——等 speaking/pending 落空再播,短句轮询 20ms 足够。
253
+ let waited = 0;
254
+ const poll = () => {
255
+ if (epoch !== voiceEpoch) return;
256
+ let busy = false;
257
+ try { busy = s.speaking === true || s.pending === true; } catch { busy = false; }
258
+ if (!busy || waited >= 800) { delay(); return; }
259
+ waited += 20;
260
+ setTimeout(poll, 20);
261
+ };
262
+ poll();
263
+ return true;
264
+ }
265
+
266
+ // ---------- 播报文案模板(三挡:简洁 = 一句话;适中 = 带标题与关键量;详细 = 带统计与摘要) ----------
267
+ // 口径取值 brief | medium | detailed;语音看 cfg.voiceMode(缺省 brief,与宿主 schema 同款),
268
+ // 卡片正文看 cfg.cardMode(缺省 detailed =历史内容,见下方卡片正文构建处)。
269
+ function voiceTone(cfg) {
270
+ const m = cfg && cfg.voiceMode;
271
+ return m === "detailed" || m === "medium" ? m : "brief";
272
+ }
273
+ /** 卡片正文口径(三挡):缺省 detailed =历史内容——老宿主还没下发 cardMode 时卡片不缩水。 */
274
+ function cardTone(cfg) {
275
+ const m = cfg && cfg.cardMode;
276
+ return m === "brief" || m === "medium" ? m : "detailed";
277
+ }
278
+ function endReasonSpeech(reason) {
279
+ if (reason === "error") return "出错了";
280
+ if (reason === "aborted") return "已停止";
281
+ if (reason === "blocked") return "受阻停止了";
282
+ if (reason === "max-tokens") return "达到输出上限停止了";
283
+ if (reason === "interrupted") return "被打断了";
284
+ return "完成了";
285
+ }
286
+ /** 结束播报文案(success 由调用方按 isSuccessReason 给)。kind: complete|error */
287
+ export function endBroadcastText(success, record, task, cfg) {
288
+ const mode = voiceTone(cfg);
289
+ const reason = (record && record.endReason) || "";
290
+ const title = (record && record.title) || (task && task.title) || "";
291
+ if (mode === "brief") {
292
+ // 简洁口径不分原因细念:正常完成「任务完成」,其余一律「任务停止了」
293
+ return success ? "任务完成" : "任务停止了";
294
+ }
295
+ if (mode === "medium") {
296
+ // 适中口径:点名是哪个任务 + 结果 + (停止时)原因 + 耗时;不念回合/Token 统计
297
+ const who = title ? "任务「" + truncate(title, 30) + "」" : "任务";
298
+ const parts = [success ? who + "完成了" : who + "停止了"];
299
+ if (!success) parts.push(",原因:" + endReasonSpeech(reason));
300
+ const dur = (record && record.duration) || 0;
301
+ if (dur > 0) parts.push(",耗时" + fmtDur(dur));
302
+ parts.push("。");
303
+ return parts.join("");
304
+ }
305
+ const parts = [];
306
+ if (title) parts.push("任务:" + truncate(title, 30) + "。");
307
+ parts.push(success ? "已完成。" : "结束,原因:" + endReasonSpeech(reason) + "。");
308
+ const dur = (record && record.duration) || 0;
309
+ if (dur > 0) parts.push("耗时" + fmtDur(dur) + "。");
310
+ const turns = (record && record.turns) || (task && task.turns) || 0;
311
+ const steps = (record && record.steps) || (task && task.steps) || 0;
312
+ const tools = (record && record.toolCalls) || (task && task.toolCalls) || 0;
313
+ if (turns || steps) parts.push("共" + turns + "回合" + steps + "步" + (tools ? "、工具" + tools + "次" : "") + "。");
314
+ if (!success && record && record.errorMessage) parts.push("错误:" + truncate(String(record.errorMessage).replace(/[`*_#>\[\]()]/g, " "), 50) + "。");
315
+ if (success && record && record.lastText) {
316
+ const plain = truncate(String(record.lastText).replace(/[#*_`>\[\]()!]/g, "").replace(/\s+/g, " "), 40);
317
+ if (plain) parts.push("回复摘要:" + plain);
318
+ }
319
+ return parts.join("");
320
+ }
321
+ /** 需授权播报文案。 */
322
+ export function confirmBroadcastText(task, approval, cfg) {
323
+ const mode = voiceTone(cfg);
324
+ if (mode === "brief") return "任务需要授权";
325
+ const tool = (approval && approval.toolName) || "工具";
326
+ const scene = toolLabel(tool);
327
+ if (mode === "medium") {
328
+ // 适中口径:点名任务 + 要批准的场景;不念工具名与「回到会话」的叮嘱
329
+ const who = task && task.title ? "任务「" + truncate(task.title, 30) + "」" : "任务";
330
+ return who + "需要授权,正在等你批准" + (scene || "工具 " + tool) + "。";
331
+ }
332
+ const parts = [];
333
+ if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
334
+ parts.push("正在等待你批准" + (scene ? "执行" + scene : "") + ",工具是" + tool + ",请回到会话处理。");
335
+ return parts.join("");
336
+ }
337
+ /**
338
+ * 卡住播报文案:必须回答「哪里卡住了」——主任务还是子任务、哪个会话、停了多久、
339
+ * 最后在干什么。子任务静默会把「主任务明明在跑却突然播报」放大成困惑(2026-10-30
340
+ * 用户实测:主任务进行中突然播「任务卡住了」,屏幕上却看不到卡住的东西),
341
+ * 所以简洁口径也带「主/子 + 标题」,详细口径再补停滞时长与最后动作。
342
+ * 归属字段来自宿主追踪快照(task-track 的 isSubtask/lastToolName)。
343
+ */
344
+ export function stuckBroadcastText(task, cfg) {
345
+ const mode = voiceTone(cfg);
346
+ const where = (task && task.isSubtask ? "子任务" : "主任务")
347
+ + (task && task.title ? "「" + truncate(task.title, 30) + "」" : "");
348
+ if (mode === "brief") return where + "卡住了";
349
+ if (mode === "medium") {
350
+ return where + "卡住了,已经" + fmtDur(Math.max(0, (cfg && cfg.stuckMs) || STUCK_MS)) + "没有任何动静。";
351
+ }
352
+ const parts = [where + "卡住了。"];
353
+ parts.push("编程阶段已经" + fmtDur(Math.max(0, (cfg && cfg.stuckMs) || STUCK_MS)) + "没有任何动静,");
354
+ const tool = (task && task.lastToolName) || "";
355
+ const scene = tool ? toolLabel(tool) : "";
356
+ parts.push(tool ? "最后在执行" + tool + (scene ? "(" + scene + ")" : "") + "," : "");
357
+ parts.push("请查看。");
358
+ return parts.join("");
359
+ }
360
+
361
+ /** 卡住判定阈值:与【任务动画】同款——phase=code 且 18 秒无活动视为卡住(正常长任务不误报)。 */
362
+ const STUCK_MS = 18000;
363
+
135
364
  // ---------- 共享快照:浮层(轮询) / 面板页 / 首页概要共用一份数据 ----------
136
365
  const notifyStore = {
137
366
  snap: { status: null, loading: false, error: null },
@@ -173,35 +402,65 @@ function pageVisible() {
173
402
  return typeof document === "undefined" || document.visibilityState === "visible";
174
403
  }
175
404
 
176
- // 当前正在看的会话标题(侧栏高亮行)。与【任务动画】的 currentSessionTitle 同款判定:
177
- // dsh 会话树行 = role="treeitem" + aria-selected,行文本即标题;侧栏在左,
178
- // 跳过正文区可能出现的同名高亮节点(右上搜索结果等)。
179
- function currentSelectedSessionTitle() {
180
- if (typeof document === "undefined" || typeof window === "undefined") return "";
181
- if (typeof document.querySelectorAll !== "function") return "";
405
+ // 当前正在看的会话(侧栏高亮行)。返回 { id, title }:
406
+ // ① 首选权威标识:dsh 0.1.7+ 的会话行带 data-row-key="session:<id>"(Rows.tsx),
407
+ // 高亮行取 id——**不做位置过滤**(旧版按「左缘 35vw 内」筛行,窄窗口/宽侧栏时
408
+ // 行右缘超限被全部滤掉 → 跳转找不到行、巡检读不到选中 → 卡片永不收起,2026-10-23 实测);
409
+ // ② 无 data-row-key 的旧宿主:回退「可见 + 左缘 35vw + 行文本」的标题判定。
410
+ function currentSelectedSession() {
411
+ if (typeof document === "undefined" || typeof window === "undefined") return { id: "", title: "" };
412
+ if (typeof document.querySelectorAll !== "function") return { id: "", title: "" };
413
+ let byTitle = null;
182
414
  for (const el of document.querySelectorAll('[role="treeitem"][aria-selected="true"]')) {
183
- const r = el.getBoundingClientRect();
184
- if (r.width > 0 && r.height > 0 && r.right <= window.innerWidth * .35) {
185
- const text = (el.textContent || "").trim();
186
- if (text) return text;
415
+ const k = typeof el.getAttribute === "function" ? el.getAttribute("data-row-key") : null;
416
+ if (typeof k === "string" && k.indexOf("session:") === 0) {
417
+ return { id: k.slice(8), title: (el.textContent || "").trim() };
418
+ }
419
+ if (!byTitle) {
420
+ const r = typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect() : null;
421
+ if (r && r.width > 0 && r.height > 0 && r.right <= window.innerWidth * .35) {
422
+ const text = (el.textContent || "").trim();
423
+ if (text) byTitle = text;
424
+ }
187
425
  }
188
426
  }
189
- return "";
427
+ return { id: "", title: byTitle || "" };
190
428
  }
191
429
  // 标题模糊对齐(与任务动画 taskOnScreen 同规则):相等或互为子串即算同一会话
192
430
  function titleMatches(a, b) {
193
431
  return !!a && !!b && (a === b || a.indexOf(b) !== -1 || b.indexOf(a) !== -1);
194
432
  }
195
433
 
196
- // 点卡片跳转会话:在侧栏会话树里找标题对上的一行,点它(优先行内的 a/button)。
197
- // 判定与 currentSelectedSessionTitle 同口径(左缘 35vw 内、可见);找不到可见行
198
- // (抽屉收起 / 手机端 / 会话被删)返回 false,调用方保持卡片不收起。
434
+ // 浮层挂载时记下的 ctx(跳转会话要用宿主官方导航服务;见 NotifyOverlay 里的赋值)
435
+ let overlayCtx = null;
436
+
437
+ // 按 sessionId 精确跳转:宿主 ui-workspace 的官方 API(ctx.get('uiWorkspace')
438
+ // .openSession(id)),选会话 + 显示其 Conversation 一步到位,抽屉收起也能跳。
439
+ // 服务不可用(独立打包/旧宿主/测试沙箱)返回 false,调用方回退标题点行。
440
+ function openSessionById(sessionId) {
441
+ if (!sessionId || !overlayCtx) return false;
442
+ let uiw = null;
443
+ try {
444
+ uiw = typeof overlayCtx.get === "function" ? overlayCtx.get("uiWorkspace") : overlayCtx.uiWorkspace;
445
+ } catch { return false; }
446
+ if (!uiw || typeof uiw.openSession !== "function") return false;
447
+ try { uiw.openSession(sessionId); return true; } catch { return false; }
448
+ }
449
+
450
+ // 点卡片跳转会话(标题回退路径):在侧栏会话树里找标题对上的一行,点它(优先行内的 a/button)。
451
+ // 找不到可见行(会话已删)返回 false,调用方保持卡片不收起。
199
452
  function openSessionByTitle(title) {
200
453
  if (typeof document === "undefined" || typeof window === "undefined") return false;
201
454
  if (typeof document.querySelectorAll !== "function" || !title) return false;
202
455
  for (const el of document.querySelectorAll('[role="treeitem"]')) {
203
- const r = typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect() : null;
204
- if (!r || r.width <= 0 || r.height <= 0 || r.right > window.innerWidth * .35) continue;
456
+ const k = typeof el.getAttribute === "function" ? el.getAttribute("data-row-key") : null;
457
+ // 有权威标识的行按标识筛会话行;没有的(旧宿主/搜索行)按位置 + 文本
458
+ const isSessionRow = typeof k === "string" ? k.indexOf("session:") === 0 : true;
459
+ if (!isSessionRow) continue;
460
+ if (typeof k !== "string") {
461
+ const r = typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect() : null;
462
+ if (!r || r.width <= 0 || r.height <= 0 || r.right > window.innerWidth * .35) continue;
463
+ }
205
464
  if (!titleMatches((el.textContent || "").trim(), title)) continue;
206
465
  const link = typeof el.querySelector === "function" ? el.querySelector("a,button") : null;
207
466
  const target = link || el;
@@ -216,12 +475,18 @@ function Toast(props) {
216
475
  const [closing, setClosing] = useState(!!t.closing);
217
476
  const close = useCallback(() => { setClosing(true); }, []);
218
477
  // 点卡片 = 跳转到对应会话。结束卡跳成功就地收起(这一看就把通知的活干完了);
219
- // 需确认卡只跳转不收——审批没处理完它就该在。找不到可见会话行(抽屉收起/
220
- // 手机端/会话已删)不收起,卡片原样保留。
478
+ // 需确认卡只跳转不收——审批没处理完它就该在。跳转优先按 sessionId 走宿主官方
479
+ // 导航(抽屉收起也有效),回退按标题点侧栏行;都失败(会话已删)卡片原样保留。
221
480
  const jump = useCallback(() => {
222
- if (!t.sessionTitle) return;
223
- if (openSessionByTitle(t.sessionTitle) && t.kind !== "confirm") setClosing(true);
224
- }, [t.sessionTitle, t.kind]);
481
+ if (!t.sessionTitle && !t.sessionId) return;
482
+ // 子任务会话未必在侧栏里:先按自身 id 走官方导航,再试父会话 id(fallbackSessionId),
483
+ // 最后按标题点行。需确认卡只跳不收(审批没处理完它就该在);卡住卡也不因跳转收起
484
+ // ——它由「恢复活动 / 任务结束」的对账收(跳过去还是卡着,卡片就该继续提醒)。
485
+ const ok = openSessionById(t.sessionId)
486
+ || (t.fallbackSessionId ? openSessionById(t.fallbackSessionId) : false)
487
+ || (t.sessionTitle ? openSessionByTitle(t.sessionTitle) : false);
488
+ if (ok && t.kind !== "confirm" && t.kind !== "stuck") setClosing(true);
489
+ }, [t.sessionId, t.sessionTitle, t.fallbackSessionId, t.kind]);
225
490
  // 外部要求收起(审批已处理 / 已点进会话)走同一套淡出动画
226
491
  useEffect(() => { if (t.closing && !closing) setClosing(true); }, [t.closing, closing]);
227
492
  // 所有卡片都没有计时器:结束卡常驻到「看过」(点卡跳转 / 点进会话 / 手动 ✕),
@@ -241,14 +506,14 @@ function Toast(props) {
241
506
  // 叠放收起态(collapsed):只留标题条,正文隐藏;悬停卡片栈时整体展开
242
507
  return (
243
508
  <div className={"dknt-toast" + (t.kind === "confirm" ? " dknt-toast-confirm" : "")
244
- + (t.sessionTitle ? " clickable" : "")
509
+ + (t.sessionTitle || t.sessionId ? " clickable" : "")
245
510
  + (props.collapsed ? " collapsed" : "")
246
511
  + (closing ? " out" : "")}
247
- onClick={t.sessionTitle ? jump : undefined}>
512
+ onClick={t.sessionTitle || t.sessionId ? jump : undefined}>
248
513
  <div className="dknt-toast-head">
249
514
  <span className="dknt-toast-mark" style={{ background: markColor }} />
250
515
  <span className="dknt-toast-title" title={t.sessionTitle || t.title}>
251
- {t.kind === "confirm" ? "✋ " : ""}{props.collapsed ? (t.sessionTitle || t.title) : t.title}
516
+ {t.kind === "confirm" ? "✋ " : t.kind === "stuck" ? "⏳ " : ""}{props.collapsed ? (t.sessionTitle || t.title) : t.title}
252
517
  {!props.collapsed && t.sessionTitle && t.kind !== "confirm" ? " · " + t.sessionTitle : ""}
253
518
  </span>
254
519
  <button type="button" className="dknt-toast-close" aria-label="关闭"
@@ -261,6 +526,8 @@ function Toast(props) {
261
526
 
262
527
  // ---------- 全局浮层:轮询 + 通知(功能启用即常驻) ----------
263
528
  export function NotifyOverlay(props) {
529
+ // 记下外壳 ctx:点卡片跳转会话要用宿主官方导航服务(openSessionById)
530
+ if (props && props.ctx) overlayCtx = props.ctx;
264
531
  const snap = useNotify();
265
532
  const [toasts, setToasts] = useState([]);
266
533
  const [stackHover, setStackHover] = useState(false); // 卡片栈被悬停(叠放时整体展开)
@@ -269,10 +536,22 @@ export function NotifyOverlay(props) {
269
536
  const toastSeqRef = useRef(0);
270
537
 
271
538
  const pushToast = useCallback((toast) => {
539
+ // 「页内卡片」总开关(设置面板里关掉 → 右侧所有卡片都不再弹出)。只管卡片:
540
+ // 语音播报 / 提示音 / 系统通知 / 群推送各有独立开关,与它正交。
541
+ const st = notifyStore.snap.status;
542
+ const cfg = st && st.config;
543
+ if (cfg && cfg.notifyCards === false) return;
272
544
  // 上限 12:结束卡现在常驻到看过才收,4 张容易见底;多了靠叠放收纳而不是丢弃。
273
545
  setToasts((prev) => prev.concat([toast]).slice(-12));
274
546
  }, []);
275
547
 
548
+ // 总开关中途关闭:已挂着的卡片也一并收掉(否则「设置里关了、右边还挂着」像开关失灵)。
549
+ // 返回原数组避免空转重渲染。
550
+ useEffect(() => {
551
+ const cfg = snap.status && snap.status.config;
552
+ if (cfg && cfg.notifyCards === false) setToasts((prev) => (prev.length ? [] : prev));
553
+ }, [snap.status]);
554
+
276
555
  // 轮询:任务中 850ms,实时捕捉结束/待确认;空闲 6s / 出错 15s;页面切回立即刷新。
277
556
  useEffect(() => {
278
557
  let stopped = false;
@@ -326,22 +605,33 @@ export function NotifyOverlay(props) {
326
605
  const fresh = approvals.filter((a) => a && !seen.has(a.id));
327
606
  if (fresh.length === 0) continue;
328
607
  for (const a of fresh) seen.add(a.id);
608
+ // 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)
609
+ if (cfg.voiceNotify === true && cfg.voiceOnConfirm !== false) {
610
+ speakVoice(confirmBroadcastText(task, fresh[0], cfg), cfg);
611
+ }
329
612
  // 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
330
613
  if (cfg.notifyOnConfirm === false) continue;
331
614
  const toolName = fresh[0].toolName || "";
332
615
  const scene = toolLabel(toolName);
616
+ // 卡片正文按「卡片口径」三挡裁剪(title 行永远在,卡片标题不受口径影响)
617
+ const tone = cardTone(cfg);
333
618
  const lines = [
334
619
  "任务:" + (task.title || "(无标题)"),
335
620
  "工具:" + (toolName || "未知") + (scene ? "(" + scene + ")" : ""),
336
621
  ];
337
- if (fresh[0].reason) lines.push("说明:" + truncate(fresh[0].reason, 140));
338
- lines.push(fresh.length > 1 ? "共 " + fresh.length + " 项等待你的确认" : "请在会话里确认后继续");
622
+ if (tone !== "brief" && fresh[0].reason) lines.push("说明:" + truncate(fresh[0].reason, 140));
623
+ if (fresh.length > 1) {
624
+ lines.push(tone === "brief" ? "共 " + fresh.length + " 项等待确认" : "共 " + fresh.length + " 项等待你的确认");
625
+ } else if (tone === "detailed") {
626
+ lines.push("请在会话里确认后继续");
627
+ }
339
628
  pushToast({
340
629
  id: ++toastSeqRef.current,
341
630
  kind: "confirm",
342
631
  title: "任务需要确认",
343
632
  body: lines.join("\n"),
344
633
  // 点卡片直达该会话去处理审批(需确认卡跳转后不收起——审批没处理完它就该在)
634
+ sessionId: task.sessionId || "",
345
635
  sessionTitle: task.title || "",
346
636
  // 卡片与这批审批项绑定:approval/decided 后宿主把它们移出 approvals,
347
637
  // 下面的对账 effect 据此自动收起卡片(处理完就不该再占着右上角)。
@@ -384,21 +674,25 @@ export function NotifyOverlay(props) {
384
674
  });
385
675
  }, [snap.status]);
386
676
 
387
- // 结束卡(完成/异常)的"看过即收":用户点进那个会话(侧栏高亮行标题与卡片
388
- // 绑定的 sessionTitle 对上)就视为已看到结果,卡片自动收起。结束卡没有超时
389
- // 自毁——没看过就一直挂着;这条巡检是它唯一的自动出口(另有手动 ✕ 与点卡跳转)。
677
+ // 结束卡(完成/异常)的"看过即收":用户点进那个会话(侧栏高亮行 = 卡片绑定的
678
+ // sessionId,或旧宿主回退到标题对上)就视为已看到结果,卡片自动收起。结束卡没有
679
+ // 超时自毁——没看过就一直挂着;这条巡检是它唯一的自动出口(另有手动 ✕ 与点卡跳转)。
390
680
  // 侧栏选中没有事件可订阅,用轻量轮询(500ms 一次 querySelectorAll);页面在
391
681
  // 后台不检查(回来时若仍停在该会话,收起发生在切回后)。
392
682
  useEffect(() => {
393
683
  const timer = setInterval(() => {
394
684
  if (!pageVisible()) return;
395
- const current = currentSelectedSessionTitle();
396
- if (!current) return;
685
+ const cur = currentSelectedSession();
686
+ if (!cur.id && !cur.title) return;
397
687
  setToasts((prev) => {
398
688
  let changed = false;
399
689
  const next = prev.map((x) => {
400
- if (x.kind === "confirm" || x.closing || !x.sessionTitle) return x;
401
- if (!titleMatches(x.sessionTitle, current)) return x;
690
+ // 需确认卡挂到审批处理完、卡住卡挂到恢复活动——都不走「看过即收」
691
+ if (x.kind === "confirm" || x.kind === "stuck" || x.closing) return x;
692
+ if (!x.sessionTitle && !x.sessionId) return x;
693
+ const hit = (cur.id && x.sessionId && cur.id === x.sessionId)
694
+ || (!cur.id && x.sessionTitle && titleMatches(x.sessionTitle, cur.title));
695
+ if (!hit) return x;
402
696
  changed = true;
403
697
  return Object.assign({}, x, { closing: true });
404
698
  });
@@ -408,38 +702,142 @@ export function NotifyOverlay(props) {
408
702
  return () => clearInterval(timer);
409
703
  }, []);
410
704
 
705
+ // ===== 卡住检测:编程阶段长时间无活动 → 播报 + 弹卡(与【任务动画】同口径 18s) =====
706
+ // 每个会话每个停点只报一次:恢复活动(lastActivityAt 变新)即清除标记,之后再卡住可再报;
707
+ // 会话离开活跃列表(结束/取消)同样清除。阈值到点才轮询比对,850ms 的节奏足够。
708
+ // 语音与卡片**各自独立开关**(关卡片不关耳朵、关语音不关眼睛),所以停点标记要在
709
+ // 两个开关之外都记——否则关掉一边会在同一停点重复打扰另一边。
710
+ const stuckSeenRef = useRef(new Map()); // sessionId -> 已播报时的 lastActivityAt
711
+ useEffect(() => {
712
+ const st = snap.status;
713
+ if (!st || !st.active) return;
714
+ const cfg = st.config || {};
715
+ const now = st.now || Date.now();
716
+ const activeIds = new Set(st.active.map((x) => x.sessionId));
717
+ for (const sid of Array.from(stuckSeenRef.current.keys())) {
718
+ if (!activeIds.has(sid)) stuckSeenRef.current.delete(sid);
719
+ }
720
+ // 主任务正在跑子任务(有活跃子会话挂在自己名下)时,主会话天然安静——它在等子代理回话,
721
+ // 不是卡住。这类「等待子任务」的安静不计入卡住判定,交由子任务自己命中(真卡在子任务上会
722
+ // 说「子任务…卡住了」),避免「主任务明明在跑、却报主任务卡住」的误报(2026-11-01 用户反馈)。
723
+ // 先扫一遍建集合、再逐条判定:同一帧里主/子顺序不影响结果。
724
+ const parentsWaitingOnSubtask = new Set();
725
+ for (const t of st.active) {
726
+ if (t.isSubtask && t.parentSessionId) parentsWaitingOnSubtask.add(t.parentSessionId);
727
+ }
728
+ for (const task of st.active) {
729
+ // 等用户批准的会话不算卡住——那是「需确认」提醒的职责,别重复轰炸
730
+ const approvals = Array.isArray(task.approvals) ? task.approvals : [];
731
+ if (approvals.length > 0) { stuckSeenRef.current.delete(task.sessionId); continue; }
732
+ if (task.phase !== "code") { continue; }
733
+ // 该会话有活跃子任务在跑 → 它在等子任务,不算卡住(否则主任务进行中会误报卡住)
734
+ if (parentsWaitingOnSubtask.has(task.sessionId)) continue;
735
+ const lastAct = task.lastActivityAt || task.phaseAt || 0;
736
+ if (!lastAct || now - lastAct < STUCK_MS) continue;
737
+ const prev = stuckSeenRef.current.get(task.sessionId);
738
+ if (prev === lastAct) continue; // 同一停点已报过
739
+ stuckSeenRef.current.set(task.sessionId, lastAct);
740
+ const stuckMs = now - lastAct;
741
+ // —— 语音:说清主/子任务、标题、停滞时长与最后动作 ——
742
+ if (cfg.voiceNotify === true && cfg.voiceOnStuck !== false) {
743
+ speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: stuckMs })), cfg);
744
+ }
745
+ // —— 卡片:只受「卡住提醒」与「页内卡片」总开关控制(总开关在 pushToast 里把关)——
746
+ if (cfg.notifyOnStuck === false) continue;
747
+ const label = task.isSubtask ? "子任务" : "主任务";
748
+ const title = task.title || "(无标题)";
749
+ const tool = task.lastToolName || "";
750
+ // 卡片正文按「卡片口径」三挡裁剪:简洁只报谁卡住;适中加停滞时长;详细再加最后动作
751
+ const tone = cardTone(cfg);
752
+ const lines = ["任务:" + label + "·" + title];
753
+ if (tone !== "brief") lines.push("已停滞:" + fmtDur(stuckMs) + "(编程阶段无活动)");
754
+ if (tone === "detailed") {
755
+ if (tool) lines.push("最后动作:" + tool + (toolLabel(tool) ? "(" + toolLabel(tool) + ")" : ""));
756
+ lines.push("恢复活动或任务结束后自动收起;点击卡片直达会话");
757
+ }
758
+ pushToast({
759
+ id: ++toastSeqRef.current,
760
+ kind: "stuck",
761
+ title: label + "卡住了",
762
+ body: lines.join("\n"),
763
+ sessionId: task.sessionId || "",
764
+ sessionTitle: title,
765
+ // 子任务会话通常不在侧栏里:跳不到自己时回退到父会话(看主会话里的子代理进度)
766
+ fallbackSessionId: (task.isSubtask && task.parentSessionId) ? task.parentSessionId : "",
767
+ // 停点指纹:恢复活动(lastActivityAt 变了)即由下面的对账 effect 收起
768
+ stuckAt: lastAct,
769
+ });
770
+ }
771
+ }, [snap.status]);
772
+
773
+ // 卡住卡的自动收起(与需确认卡同哲学:不设倒计时,状态没变就一直挂着):
774
+ // 恢复活动(lastActivityAt 变了)/ 离开活跃列表(结束或取消)/ 转为等审批
775
+ // (需确认卡接管)→ 标记 closing 走同一套 240ms 淡出。只标记不直删,与手动 ✕ 退场一致。
776
+ useEffect(() => {
777
+ const st = snap.status;
778
+ if (!st || !st.active) return;
779
+ const byId = new Map(st.active.map((x) => [x.sessionId, x]));
780
+ setToasts((prev) => {
781
+ let changed = false;
782
+ const next = prev.map((x) => {
783
+ if (x.kind !== "stuck" || x.closing) return x;
784
+ const task = byId.get(x.sessionId);
785
+ const stillStalled = !!task
786
+ && (task.lastActivityAt || task.phaseAt || 0) === x.stuckAt
787
+ && (Array.isArray(task.approvals) ? task.approvals.length : 0) === 0;
788
+ if (stillStalled) return x;
789
+ changed = true;
790
+ return Object.assign({}, x, { closing: true });
791
+ });
792
+ return changed ? next : prev;
793
+ });
794
+ }, [snap.status]);
795
+
411
796
  const handleTaskEnd = (task, record, cfg) => {
412
797
  const reason = (record && record.endReason) || "";
413
798
  const success = isSuccessReason(reason);
414
799
  const info = endInfo(reason);
415
800
  const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
801
+ // 语音播报先走自己的开关(与卡片开关正交:关卡片不关耳朵)。
802
+ // 播报音量归 0 / 浏览器不支持时静默跳过即可。
803
+ if (cfg.voiceNotify === true
804
+ && (success ? cfg.voiceOnComplete !== false : cfg.voiceOnError !== false)) {
805
+ speakVoice(endBroadcastText(success, record, task, cfg), cfg);
806
+ }
416
807
  if (wanted === false) return;
417
808
  const models = record && record.models && record.models.length ? record.models.join(", ")
418
809
  : (task.models && task.models.length ? task.models.join(", ") : "未知模型");
419
810
  const provider = (record && record.provider) || task.provider || "";
420
811
  const startTs = (record && record.startTime) || task.startTime;
421
812
  const endTs = (record && record.endTime) || Date.now();
422
- const lines = [
423
- "任务:" + ((record && record.title) || task.title || "(无标题)"),
424
- "模型:" + models + (provider ? "(" + provider + ")" : ""),
425
- "耗时:" + fmtDur((record && record.duration) || (endTs - startTs))
426
- + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")",
427
- "回合 " + ((record && record.turns) || task.turns || 0)
813
+ // 卡片正文按「卡片口径」三挡裁剪(cfg.cardMode,缺省 detailed =历史内容):
814
+ // 简洁=任务+耗时;适中=加模型与起止时间;详细=再加回合/步骤、Token、摘要或错误。
815
+ const tone = cardTone(cfg);
816
+ const durText = fmtDur((record && record.duration) || (endTs - startTs));
817
+ const lines = ["任务:" + ((record && record.title) || task.title || "(无标题)")];
818
+ if (tone !== "brief") lines.push("模型:" + models + (provider ? "(" + provider + ")" : ""));
819
+ lines.push(tone === "brief"
820
+ ? "耗时:" + durText
821
+ : "耗时:" + durText + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")");
822
+ if (tone === "detailed") {
823
+ lines.push("回合 " + ((record && record.turns) || task.turns || 0)
428
824
  + " · 步骤 " + ((record && record.steps) || task.steps || 0)
429
- + (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""),
430
- "Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
431
- + " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens),
432
- ];
433
- if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
434
- if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
825
+ + (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""));
826
+ lines.push("Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
827
+ + " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens));
828
+ if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
829
+ if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
830
+ }
435
831
  const title = success ? "任务完成" : "任务" + info.label;
436
832
  pushToast({
437
833
  id: ++toastSeqRef.current,
438
834
  kind: success ? "success" : (info.cls === "err" ? "error" : "warn"),
439
835
  title,
440
836
  body: lines.join("\n"),
441
- // 绑定会话标题:点卡片直达该会话;用户点进这个会话(侧栏选中)也视为
837
+ // 绑定会话:点卡片直达该会话;用户点进这个会话(侧栏选中)也视为
442
838
  // 看到了结果,巡检 effect 据此自动收起卡片。没看过就一直挂着。
839
+ // sessionId 是权威标识(跳转/对账都用它),标题只作旧宿主回退。
840
+ sessionId: task.sessionId || "",
443
841
  sessionTitle: (record && record.title) || task.title || "",
444
842
  });
445
843
  // 提示音:任务结束时播放(完成/异常配套音);与系统通知独立开关
@@ -477,6 +875,7 @@ export function NotifyOverlay(props) {
477
875
  // ---------- 面板页 ----------
478
876
  function NotifyView(props) {
479
877
  const snap = useNotify();
878
+ const voices = useLocalVoices(); // 音色下拉用(本机 speechSynthesis 音色表)
480
879
  const [cfg, setCfg] = useState(null); // null = 尚未加载
481
880
  const [saveErr, setSaveErr] = useState("");
482
881
  const [testing, setTesting] = useState(false);
@@ -619,6 +1018,14 @@ function NotifyView(props) {
619
1018
  <span className="dknt-sec-sub">哪些时刻提醒你;与【任务动画】的动效互不依赖</span>
620
1019
  </div>
621
1020
  <div className="dknt-rows-narrow">
1021
+ <div className="dknt-row">
1022
+ <span className="dknt-row-label">页内卡片</span>
1023
+ <button type="button" className={"dknt-miniswitch" + (cfg.notifyCards !== false ? " on" : "")}
1024
+ onClick={() => patch({ notifyCards: cfg.notifyCards === false })}>
1025
+ {cfg.notifyCards !== false ? "开" : "关"}
1026
+ </button>
1027
+ <span className="dknt-row-sub">右侧通知卡片总开关:关闭后下面各类卡片(完成 / 异常 / 需确认 / 卡住)一律不弹,已挂着的一并收起;语音播报、提示音、系统通知、钉钉飞书推送不受影响</span>
1028
+ </div>
622
1029
  <div className="dknt-row">
623
1030
  <span className="dknt-row-label">完成通知</span>
624
1031
  <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
@@ -643,6 +1050,14 @@ function NotifyView(props) {
643
1050
  </button>
644
1051
  <span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音;审批处理完自动收起,没处理完就一直显示(点卡片直达会话)</span>
645
1052
  </div>
1053
+ <div className="dknt-row">
1054
+ <span className="dknt-row-label">卡住提醒</span>
1055
+ <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnStuck !== false ? " on" : "")}
1056
+ onClick={() => patch({ notifyOnStuck: cfg.notifyOnStuck === false })}>
1057
+ {cfg.notifyOnStuck !== false ? "开" : "关"}
1058
+ </button>
1059
+ <span className="dknt-row-sub">编程阶段 18 秒没有动静时弹卡片,写明是主任务还是子任务、停滞了多久、最后在执行什么;恢复活动 / 任务结束自动收起,同一停点只弹一次(语音播报走「语音播报」区的卡住开关,互不干扰)</span>
1060
+ </div>
646
1061
  </div>
647
1062
  </div>,
648
1063
  <div key="style" className="dknt-sec">
@@ -651,9 +1066,20 @@ function NotifyView(props) {
651
1066
  <span className="dknt-sec-sub">页内卡片、提示音、浏览器系统通知可自由组合</span>
652
1067
  </div>
653
1068
  <div className="dknt-rows-narrow">
1069
+ <div className="dknt-row">
1070
+ <span className="dknt-row-label">卡片口径</span>
1071
+ {[["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]].map(([v, label]) => (
1072
+ <button type="button" key={v}
1073
+ className={"dknt-miniswitch" + (cardTone(cfg) === v ? " on" : "")}
1074
+ style={{ marginRight: 6 }}
1075
+ title={"右侧通知卡片正文按" + label + "档显示"}
1076
+ onClick={() => patch({ cardMode: v })}>{label}</button>
1077
+ ))}
1078
+ <span className="dknt-row-sub">右侧卡片正文写多少(与各类型开关、语音播报互不影响):简洁=任务与耗时 / 谁卡住;适中=加模型、起止时间、停滞时长;详细=全部(回合步骤、Token、摘要或错误、最后动作)</span>
1079
+ </div>
654
1080
  <div className="dknt-row">
655
1081
  <span className="dknt-row-label">卡片收纳</span>
656
- <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完。3 张及以上自动叠放成标题条,鼠标移上去整体展开</span>
1082
+ <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完;卡住卡片挂到恢复活动。3 张及以上自动叠放成标题条,鼠标移上去整体展开;要整体不弹看上面「页内卡片」总开关</span>
657
1083
  </div>
658
1084
  <div className="dknt-row">
659
1085
  <span className="dknt-row-label">系统通知</span>
@@ -691,6 +1117,121 @@ function NotifyView(props) {
691
1117
  ) : null}
692
1118
  </div>
693
1119
  </div>,
1120
+ <div key="voice" className="dknt-sec">
1121
+ <div className="dknt-sec-head">
1122
+ <span className="dknt-sec-title">语音播报</span>
1123
+ <span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 卡住时用浏览器语音(TTS)说一句话;与提示音、系统通知互不冲突</span>
1124
+ <span className="dknt-sec-sw">
1125
+ <span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "已开启" : "已关闭"}</span>
1126
+ <button type="button" className={"dock-sw" + (cfg.voiceNotify ? " on" : "")}
1127
+ role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报"
1128
+ title={cfg.voiceNotify ? "关闭语音播报" : "开启语音播报"}
1129
+ onClick={() => patch({ voiceNotify: !cfg.voiceNotify })} />
1130
+ </span>
1131
+ </div>
1132
+ {cfg.voiceNotify ? <div className="dknt-rows-narrow">
1133
+ <div className="dknt-row">
1134
+ <span className="dknt-row-label">播报口径</span>
1135
+ {[["brief", "简洁"], ["medium", "适中"], ["detailed", "详细"]].map(([v, label]) => (
1136
+ <button type="button" key={v}
1137
+ className={"dknt-miniswitch" + (voiceTone(cfg) === v ? " on" : "")}
1138
+ style={{ marginRight: 6 }}
1139
+ title={"语音播报按" + label + "档说"}
1140
+ onClick={() => patch({ voiceMode: v })}>{label}</button>
1141
+ ))}
1142
+ <span className="dknt-row-sub">简洁:一句「任务完成 / 任务停止了 / 任务需要授权 / 主任务(或子任务)·标题 卡住了」;适中:点名任务,结束带原因与耗时、卡住带停滞时长;详细:结束带回合统计与回复摘要,需授权带工具名,卡住带最后动作</span>
1143
+ </div>
1144
+ <div className="dknt-row">
1145
+ <span className="dknt-row-label">播报时刻</span>
1146
+ <span className="dknt-row-sub" style={{ minWidth: 0 }}>
1147
+ {[
1148
+ ["voiceOnComplete", "完成", cfg.voiceOnComplete !== false],
1149
+ ["voiceOnError", "停止", cfg.voiceOnError !== false],
1150
+ ["voiceOnConfirm", "需授权", cfg.voiceOnConfirm !== false],
1151
+ ["voiceOnStuck", "卡住", cfg.voiceOnStuck !== false],
1152
+ ].map(([key, label, on]) => (
1153
+ <button type="button" key={key} className={"dknt-miniswitch" + (on ? " on" : "")}
1154
+ style={{ marginRight: 6 }}
1155
+ title={key === "voiceOnStuck" ? "编程阶段 18 秒没有动静时播报一次(恢复后可再报)" : ""}
1156
+ onClick={() => patch({ [key]: !on })}>{label}{on ? "·开" : "·关"}</button>
1157
+ ))}
1158
+ </span>
1159
+ </div>
1160
+ <div className="dknt-row">
1161
+ <span className="dknt-row-label">语速</span>
1162
+ <input type="range" min="0.5" max="2" step="0.05" value={String(cfg.voiceRate || 1)}
1163
+ aria-label="播报语速" style={{ width: 120, accentColor: "var(--dk-accent)" }}
1164
+ onChange={(e) => patch({ voiceRate: Number(e.target.value) || 1 })} />
1165
+ <span className="dknt-row-sub">{Number(cfg.voiceRate || 1).toFixed(2)}×(播报比正文朗读略快更利落)</span>
1166
+ </div>
1167
+ <div className="dknt-row">
1168
+ <span className="dknt-row-label">音量</span>
1169
+ <input type="range" min="0" max="1" step="0.05" value={String(cfg.voiceVolume == null ? 1 : cfg.voiceVolume)}
1170
+ aria-label="播报音量" style={{ width: 120, accentColor: "var(--dk-accent)" }}
1171
+ onChange={(e) => patch({ voiceVolume: Number(e.target.value) })} />
1172
+ <span className="dknt-row-sub">{Math.round((cfg.voiceVolume == null ? 1 : cfg.voiceVolume) * 100)}%</span>
1173
+ </div>
1174
+ <div className="dknt-row">
1175
+ <span className="dknt-row-label">音色</span>
1176
+ <select className="dknt-input" style={{ maxWidth: 260 }} value={cfg.voiceURI || ""}
1177
+ aria-label="播报音色"
1178
+ onChange={(e) => patch({ voiceURI: e.target.value })}>
1179
+ <option value="">自动(按语言挑)</option>
1180
+ {(() => {
1181
+ // 与【语音朗读】同款分组:中文在前,其余按语言名排序,长列表也能找
1182
+ const zh = [], other = [];
1183
+ for (const v of voices) {
1184
+ if (!v || !v.voiceURI) continue;
1185
+ if (/^zh/i.test(String(v.lang || ""))) zh.push(v); else other.push(v);
1186
+ }
1187
+ const groups = [];
1188
+ if (zh.length) groups.push(["中文音色", zh]);
1189
+ if (other.length) groups.push(["其他音色", other]);
1190
+ return groups.map(([gname, vs]) => (
1191
+ <optgroup key={gname} label={gname}>
1192
+ {vs.map((v) => (
1193
+ <option key={v.voiceURI} value={v.voiceURI}>
1194
+ {(v.name || v.voiceURI) + "(" + (v.lang || "?") + ")"}
1195
+ </option>
1196
+ ))}
1197
+ </optgroup>
1198
+ ));
1199
+ })()}
1200
+ </select>
1201
+ <span className="dknt-row-sub">{voices.length
1202
+ ? "音色是本机浏览器给的,换设备可能要重选;本机找不到所选音色时自动按语言回落"
1203
+ : "本机暂未读到音色(系统语音未装/未启用),将按语言自动挑"}</span>
1204
+ </div>
1205
+ <div className="dknt-row">
1206
+ <span className="dknt-row-label">试听</span>
1207
+ <button type="button" className="dknt-btn" disabled={!voiceSupported()}
1208
+ onClick={() => {
1209
+ const preview = Object.assign({}, cfg, { voiceNotify: true });
1210
+ speakVoice(endBroadcastText(true, { title: "示例任务", duration: 125000, turns: 3, steps: 9, toolCalls: 4, lastText: "好的,三份文档都已更新完毕。" }, null, preview), preview);
1211
+ }}>播一句</button>
1212
+ <button type="button" className="dknt-btn" disabled={!voiceSupported()}
1213
+ onClick={() => {
1214
+ const preview = Object.assign({}, cfg, { voiceNotify: true });
1215
+ speakVoice(confirmBroadcastText({ title: "示例任务" }, { toolName: "pwsh" }, preview), preview);
1216
+ }}>需授权</button>
1217
+ <span className="dknt-row-sub">{voiceSupported()
1218
+ ? "浏览器语音合成(与【语音朗读】同一套声音,独立开关;打断式——播报信时正在朗读的正文会让位)"
1219
+ : "本浏览器不支持语音合成(speechSynthesis 不可用)"}</span>
1220
+ </div>
1221
+ <div className="dknt-row">
1222
+ <span className="dknt-row-label">语言</span>
1223
+ <select className="dknt-input" style={{ maxWidth: 180 }} value={cfg.voiceLang || ""}
1224
+ aria-label="播报语言"
1225
+ onChange={(e) => patch({ voiceLang: e.target.value })}>
1226
+ <option value="">自动(中文优先)</option>
1227
+ {["zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR"].map((l) => (
1228
+ <option key={l} value={l}>{l}</option>
1229
+ ))}
1230
+ </select>
1231
+ <span className="dknt-row-sub">未指定音色时按语言自动挑;已选音色的语言会覆盖这里</span>
1232
+ </div>
1233
+ </div> : <div className="dknt-note">未开启——任务事件不播报语音(提示音、系统通知不受影响)。</div>}
1234
+ </div>,
694
1235
  <div key="dingtalk" className="dknt-sec">
695
1236
  <div className="dknt-sec-head">
696
1237
  <span className="dknt-sec-title">钉钉推送</span>
@@ -792,7 +1333,7 @@ function NotifyStat(props) {
792
1333
  if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
793
1334
  if (!cfg) return <span>等待通知配置…</span>;
794
1335
  const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "需确认" : ""].filter(Boolean);
795
- const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : ""].filter(Boolean);
1336
+ const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", cfg.voiceNotify ? ("语音播报" + (cfg.voiceMode === "detailed" ? "·详" : cfg.voiceMode === "medium" ? "·适" : "·简")) : ""].filter(Boolean);
796
1337
  const push = [cfg.dingtalkEnabled ? "钉钉" : "", cfg.feishuEnabled ? "飞书" : ""].filter(Boolean);
797
1338
  return <span>{(events.length ? "通知:" + events.join("/") : "通知事件全关")
798
1339
  + (extras.length ? " · " + extras.join("/") : "")