dsh-dock 0.18.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.
@@ -23,6 +23,12 @@ function defaultConfig() {
23
23
  notifyOnComplete: true,
24
24
  notifyOnError: true,
25
25
  notifyOnConfirm: true,
26
+ // 卡住提醒卡片(与 voiceOnStuck 同口径 18 秒无活动,但卡片与语音各自独立开关)
27
+ notifyOnStuck: true,
28
+ // 页内卡片总开关(关闭 → 右侧所有类型的通知卡片都不再弹出)
29
+ notifyCards: true,
30
+ // 卡片口径三挡(正文写多少):默认 detailed =历史内容,未设置时卡片不缩水
31
+ cardMode: 'detailed',
26
32
  notifyStayMs: 8000,
27
33
  systemNotify: false,
28
34
  soundNotify: true,
@@ -60,7 +66,8 @@ function readConfig(ctx) {
60
66
  if (n[key] !== undefined) cfg[key] = n[key]
61
67
  }
62
68
  if (!SOUND_EFFECTS.includes(cfg.soundEffect)) cfg.soundEffect = 'chime'
63
- if (cfg.voiceMode !== 'brief' && cfg.voiceMode !== 'detailed') cfg.voiceMode = 'brief'
69
+ if (cfg.voiceMode !== 'brief' && cfg.voiceMode !== 'medium' && cfg.voiceMode !== 'detailed') cfg.voiceMode = 'brief'
70
+ if (cfg.cardMode !== 'brief' && cfg.cardMode !== 'medium' && cfg.cardMode !== 'detailed') cfg.cardMode = 'detailed'
64
71
  }
65
72
  } catch { /* settings 未挂载,用默认 */ }
66
73
  return cfg
@@ -277,6 +284,13 @@ export const feature = {
277
284
  if (typeof p.notifyOnComplete === 'boolean') cfg.notifyOnComplete = p.notifyOnComplete
278
285
  if (typeof p.notifyOnError === 'boolean') cfg.notifyOnError = p.notifyOnError
279
286
  if (typeof p.notifyOnConfirm === 'boolean') cfg.notifyOnConfirm = p.notifyOnConfirm
287
+ // 卡住提醒卡 + 页内卡片总开关(总开关只管卡片弹不弹,不管语音/提示音/系统通知)
288
+ if (typeof p.notifyOnStuck === 'boolean') cfg.notifyOnStuck = p.notifyOnStuck
289
+ if (typeof p.notifyCards === 'boolean') cfg.notifyCards = p.notifyCards
290
+ // 卡片口径三挡(正文写多少);非法值忽略(保持已存的值)
291
+ if (typeof p.cardMode === 'string' && (p.cardMode === 'brief' || p.cardMode === 'medium' || p.cardMode === 'detailed')) {
292
+ cfg.cardMode = p.cardMode
293
+ }
280
294
  if (typeof p.notifyStayMs === 'number' && Number.isFinite(p.notifyStayMs)) {
281
295
  cfg.notifyStayMs = Math.max(0, Math.min(600000, Math.round(p.notifyStayMs)))
282
296
  }
@@ -305,7 +319,7 @@ export const feature = {
305
319
  }
306
320
  // ===== 语音播报字段 =====
307
321
  if (typeof p.voiceNotify === 'boolean') cfg.voiceNotify = p.voiceNotify
308
- if (typeof p.voiceMode === 'string' && (p.voiceMode === 'brief' || p.voiceMode === 'detailed')) {
322
+ if (typeof p.voiceMode === 'string' && (p.voiceMode === 'brief' || p.voiceMode === 'medium' || p.voiceMode === 'detailed')) {
309
323
  cfg.voiceMode = p.voiceMode
310
324
  }
311
325
  for (const key of ['voiceOnComplete', 'voiceOnError', 'voiceOnConfirm', 'voiceOnStuck']) {
@@ -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/、任务状态一览在
@@ -261,7 +263,18 @@ export function speakVoice(text, cfg) {
261
263
  return true;
262
264
  }
263
265
 
264
- // ---------- 播报文案模板(简洁 = 一句话;详细 = 带标题/耗时/统计/原因) ----------
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
+ }
265
278
  function endReasonSpeech(reason) {
266
279
  if (reason === "error") return "出错了";
267
280
  if (reason === "aborted") return "已停止";
@@ -272,13 +285,23 @@ function endReasonSpeech(reason) {
272
285
  }
273
286
  /** 结束播报文案(success 由调用方按 isSuccessReason 给)。kind: complete|error */
274
287
  export function endBroadcastText(success, record, task, cfg) {
275
- const mode = cfg && cfg.voiceMode === "detailed" ? "detailed" : "brief";
288
+ const mode = voiceTone(cfg);
276
289
  const reason = (record && record.endReason) || "";
277
290
  const title = (record && record.title) || (task && task.title) || "";
278
291
  if (mode === "brief") {
279
292
  // 简洁口径不分原因细念:正常完成「任务完成」,其余一律「任务停止了」
280
293
  return success ? "任务完成" : "任务停止了";
281
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
+ }
282
305
  const parts = [];
283
306
  if (title) parts.push("任务:" + truncate(title, 30) + "。");
284
307
  parts.push(success ? "已完成。" : "结束,原因:" + endReasonSpeech(reason) + "。");
@@ -297,22 +320,41 @@ export function endBroadcastText(success, record, task, cfg) {
297
320
  }
298
321
  /** 需授权播报文案。 */
299
322
  export function confirmBroadcastText(task, approval, cfg) {
300
- const mode = cfg && cfg.voiceMode === "detailed" ? "detailed" : "brief";
323
+ const mode = voiceTone(cfg);
301
324
  if (mode === "brief") return "任务需要授权";
302
325
  const tool = (approval && approval.toolName) || "工具";
303
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
+ }
304
332
  const parts = [];
305
333
  if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
306
334
  parts.push("正在等待你批准" + (scene ? "执行" + scene : "") + ",工具是" + tool + ",请回到会话处理。");
307
335
  return parts.join("");
308
336
  }
309
- /** 卡住播报文案。 */
337
+ /**
338
+ * 卡住播报文案:必须回答「哪里卡住了」——主任务还是子任务、哪个会话、停了多久、
339
+ * 最后在干什么。子任务静默会把「主任务明明在跑却突然播报」放大成困惑(2026-10-30
340
+ * 用户实测:主任务进行中突然播「任务卡住了」,屏幕上却看不到卡住的东西),
341
+ * 所以简洁口径也带「主/子 + 标题」,详细口径再补停滞时长与最后动作。
342
+ * 归属字段来自宿主追踪快照(task-track 的 isSubtask/lastToolName)。
343
+ */
310
344
  export function stuckBroadcastText(task, cfg) {
311
- const mode = cfg && cfg.voiceMode === "detailed" ? "detailed" : "brief";
312
- if (mode === "brief") return "任务卡住了";
313
- const parts = [];
314
- if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
315
- parts.push("编程阶段已经" + fmtDur(Math.max(0, (cfg && cfg.stuckMs) || STUCK_MS)) + "没有任何动静,可能卡住了,请查看。");
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("请查看。");
316
358
  return parts.join("");
317
359
  }
318
360
 
@@ -437,9 +479,14 @@ function Toast(props) {
437
479
  // 导航(抽屉收起也有效),回退按标题点侧栏行;都失败(会话已删)卡片原样保留。
438
480
  const jump = useCallback(() => {
439
481
  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]);
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]);
443
490
  // 外部要求收起(审批已处理 / 已点进会话)走同一套淡出动画
444
491
  useEffect(() => { if (t.closing && !closing) setClosing(true); }, [t.closing, closing]);
445
492
  // 所有卡片都没有计时器:结束卡常驻到「看过」(点卡跳转 / 点进会话 / 手动 ✕),
@@ -466,7 +513,7 @@ function Toast(props) {
466
513
  <div className="dknt-toast-head">
467
514
  <span className="dknt-toast-mark" style={{ background: markColor }} />
468
515
  <span className="dknt-toast-title" title={t.sessionTitle || t.title}>
469
- {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}
470
517
  {!props.collapsed && t.sessionTitle && t.kind !== "confirm" ? " · " + t.sessionTitle : ""}
471
518
  </span>
472
519
  <button type="button" className="dknt-toast-close" aria-label="关闭"
@@ -489,10 +536,22 @@ export function NotifyOverlay(props) {
489
536
  const toastSeqRef = useRef(0);
490
537
 
491
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;
492
544
  // 上限 12:结束卡现在常驻到看过才收,4 张容易见底;多了靠叠放收纳而不是丢弃。
493
545
  setToasts((prev) => prev.concat([toast]).slice(-12));
494
546
  }, []);
495
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
+
496
555
  // 轮询:任务中 850ms,实时捕捉结束/待确认;空闲 6s / 出错 15s;页面切回立即刷新。
497
556
  useEffect(() => {
498
557
  let stopped = false;
@@ -554,12 +613,18 @@ export function NotifyOverlay(props) {
554
613
  if (cfg.notifyOnConfirm === false) continue;
555
614
  const toolName = fresh[0].toolName || "";
556
615
  const scene = toolLabel(toolName);
616
+ // 卡片正文按「卡片口径」三挡裁剪(title 行永远在,卡片标题不受口径影响)
617
+ const tone = cardTone(cfg);
557
618
  const lines = [
558
619
  "任务:" + (task.title || "(无标题)"),
559
620
  "工具:" + (toolName || "未知") + (scene ? "(" + scene + ")" : ""),
560
621
  ];
561
- if (fresh[0].reason) lines.push("说明:" + truncate(fresh[0].reason, 140));
562
- 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
+ }
563
628
  pushToast({
564
629
  id: ++toastSeqRef.current,
565
630
  kind: "confirm",
@@ -622,7 +687,8 @@ export function NotifyOverlay(props) {
622
687
  setToasts((prev) => {
623
688
  let changed = false;
624
689
  const next = prev.map((x) => {
625
- if (x.kind === "confirm" || x.closing) return x;
690
+ // 需确认卡挂到审批处理完、卡住卡挂到恢复活动——都不走「看过即收」
691
+ if (x.kind === "confirm" || x.kind === "stuck" || x.closing) return x;
626
692
  if (!x.sessionTitle && !x.sessionId) return x;
627
693
  const hit = (cur.id && x.sessionId && cur.id === x.sessionId)
628
694
  || (!cur.id && x.sessionTitle && titleMatches(x.sessionTitle, cur.title));
@@ -636,9 +702,11 @@ export function NotifyOverlay(props) {
636
702
  return () => clearInterval(timer);
637
703
  }, []);
638
704
 
639
- // ===== 卡住检测:编程阶段长时间无活动 → 播报一次(与【任务动画】同口径 18s) =====
640
- // 每个会话只报一次:恢复活动(lastActivityAt 变新)即清除标记,之后再卡住可再报;
705
+ // ===== 卡住检测:编程阶段长时间无活动 → 播报 + 弹卡(与【任务动画】同口径 18s) =====
706
+ // 每个会话每个停点只报一次:恢复活动(lastActivityAt 变新)即清除标记,之后再卡住可再报;
641
707
  // 会话离开活跃列表(结束/取消)同样清除。阈值到点才轮询比对,850ms 的节奏足够。
708
+ // 语音与卡片**各自独立开关**(关卡片不关耳朵、关语音不关眼睛),所以停点标记要在
709
+ // 两个开关之外都记——否则关掉一边会在同一停点重复打扰另一边。
642
710
  const stuckSeenRef = useRef(new Map()); // sessionId -> 已播报时的 lastActivityAt
643
711
  useEffect(() => {
644
712
  const st = snap.status;
@@ -649,21 +717,82 @@ export function NotifyOverlay(props) {
649
717
  for (const sid of Array.from(stuckSeenRef.current.keys())) {
650
718
  if (!activeIds.has(sid)) stuckSeenRef.current.delete(sid);
651
719
  }
652
- if (cfg.voiceNotify !== true || cfg.voiceOnStuck === false) return;
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
+ }
653
728
  for (const task of st.active) {
654
729
  // 等用户批准的会话不算卡住——那是「需确认」提醒的职责,别重复轰炸
655
730
  const approvals = Array.isArray(task.approvals) ? task.approvals : [];
656
731
  if (approvals.length > 0) { stuckSeenRef.current.delete(task.sessionId); continue; }
657
732
  if (task.phase !== "code") { continue; }
733
+ // 该会话有活跃子任务在跑 → 它在等子任务,不算卡住(否则主任务进行中会误报卡住)
734
+ if (parentsWaitingOnSubtask.has(task.sessionId)) continue;
658
735
  const lastAct = task.lastActivityAt || task.phaseAt || 0;
659
736
  if (!lastAct || now - lastAct < STUCK_MS) continue;
660
737
  const prev = stuckSeenRef.current.get(task.sessionId);
661
738
  if (prev === lastAct) continue; // 同一停点已报过
662
739
  stuckSeenRef.current.set(task.sessionId, lastAct);
663
- speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: now - lastAct })), cfg);
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
+ });
664
770
  }
665
771
  }, [snap.status]);
666
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
+
667
796
  const handleTaskEnd = (task, record, cfg) => {
668
797
  const reason = (record && record.endReason) || "";
669
798
  const success = isSuccessReason(reason);
@@ -681,19 +810,24 @@ export function NotifyOverlay(props) {
681
810
  const provider = (record && record.provider) || task.provider || "";
682
811
  const startTs = (record && record.startTime) || task.startTime;
683
812
  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)
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)
690
824
  + " · 步骤 " + ((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));
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
+ }
697
831
  const title = success ? "任务完成" : "任务" + info.label;
698
832
  pushToast({
699
833
  id: ++toastSeqRef.current,
@@ -884,6 +1018,14 @@ function NotifyView(props) {
884
1018
  <span className="dknt-sec-sub">哪些时刻提醒你;与【任务动画】的动效互不依赖</span>
885
1019
  </div>
886
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>
887
1029
  <div className="dknt-row">
888
1030
  <span className="dknt-row-label">完成通知</span>
889
1031
  <button type="button" className={"dknt-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
@@ -908,6 +1050,14 @@ function NotifyView(props) {
908
1050
  </button>
909
1051
  <span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音;审批处理完自动收起,没处理完就一直显示(点卡片直达会话)</span>
910
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>
911
1061
  </div>
912
1062
  </div>,
913
1063
  <div key="style" className="dknt-sec">
@@ -916,9 +1066,20 @@ function NotifyView(props) {
916
1066
  <span className="dknt-sec-sub">页内卡片、提示音、浏览器系统通知可自由组合</span>
917
1067
  </div>
918
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>
919
1080
  <div className="dknt-row">
920
1081
  <span className="dknt-row-label">卡片收纳</span>
921
- <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完。3 张及以上自动叠放成标题条,鼠标移上去整体展开</span>
1082
+ <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完;卡住卡片挂到恢复活动。3 张及以上自动叠放成标题条,鼠标移上去整体展开;要整体不弹看上面「页内卡片」总开关</span>
922
1083
  </div>
923
1084
  <div className="dknt-row">
924
1085
  <span className="dknt-row-label">系统通知</span>
@@ -971,11 +1132,14 @@ function NotifyView(props) {
971
1132
  {cfg.voiceNotify ? <div className="dknt-rows-narrow">
972
1133
  <div className="dknt-row">
973
1134
  <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>
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>
979
1143
  </div>
980
1144
  <div className="dknt-row">
981
1145
  <span className="dknt-row-label">播报时刻</span>
@@ -1169,7 +1333,7 @@ function NotifyStat(props) {
1169
1333
  if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
1170
1334
  if (!cfg) return <span>等待通知配置…</span>;
1171
1335
  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);
1336
+ const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", cfg.voiceNotify ? ("语音播报" + (cfg.voiceMode === "detailed" ? "·详" : cfg.voiceMode === "medium" ? "·适" : "·简")) : ""].filter(Boolean);
1173
1337
  const push = [cfg.dingtalkEnabled ? "钉钉" : "", cfg.feishuEnabled ? "飞书" : ""].filter(Boolean);
1174
1338
  return <span>{(events.length ? "通知:" + events.join("/") : "通知事件全关")
1175
1339
  + (extras.length ? " · " + extras.join("/") : "")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、语音播报可选音色、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览)、语音朗读(浏览器 TTS 读回复:回合下方喇叭按钮 + 自动朗读);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/client.jsx CHANGED
@@ -33,7 +33,7 @@ import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
33
33
  import { feature as fMobile } from "../features/mobile/view.js";
34
34
 
35
35
  const name = "dsh-dock";
36
- const DOCK_VERSION = "0.18.0";
36
+ const DOCK_VERSION = "0.19.0";
37
37
 
38
38
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
39
39
  const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fProviderConfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fTts, fGames, fMobileRelay, fMobile];
package/src/host-core.js CHANGED
@@ -101,6 +101,15 @@ export const DockConfig = z.object({
101
101
  notifyOnError: z.boolean().default(true),
102
102
  // 工具等待用户确认/批准时提醒(dsh 会话流 approval/asked → decided)
103
103
  notifyOnConfirm: z.boolean().default(true),
104
+ // 卡住提醒卡片:编程阶段长时间无活动时弹一张(与 voiceOnStuck 同口径,但开关独立)
105
+ notifyOnStuck: z.boolean().default(true),
106
+ // 页内卡片总开关:关闭后右侧所有通知卡片一律不再弹出(含已挂着的)。
107
+ // 只管卡片——语音播报、提示音、系统通知、钉钉/飞书推送各有独立开关,不受它影响。
108
+ notifyCards: z.boolean().default(true),
109
+ // 卡片口径三挡(右侧卡片正文写多少):brief = 任务与耗时/谁卡住;medium = 加模型、
110
+ // 起止时间、停滞时长;detailed = 全部(回合步骤、Token、摘要或错误、最后动作)。
111
+ // 默认 detailed =历史内容——未设置时卡片不缩水。
112
+ cardMode: z.string().default('detailed'),
104
113
  // 通知停留毫秒数(0 = 常驻直到手动关闭)
105
114
  notifyStayMs: z.number().default(8000),
106
115
  // 浏览器系统通知(页面后台时推送)
@@ -118,7 +127,8 @@ export const DockConfig = z.object({
118
127
  // ===== 语音播报(浏览器 speechSynthesis;与【语音朗读】同一套声音但相互独立开关) =====
119
128
  // 总开关:关闭时下面所有播报都不发声(默认关,避免升级后突然说话吓人)。
120
129
  voiceNotify: z.boolean().default(false),
121
- // 播报口径:brief = 一句话(「任务完成」);detailed = 带标题/耗时/回合/工具/错误原因。
130
+ // 播报口径三挡:brief = 一句话;medium = 点名任务 + 原因/耗时/停滞时长;
131
+ // detailed = 带标题/耗时/回合统计/工具名/最后动作。
122
132
  voiceMode: z.string().default('brief'),
123
133
  // 四个播报时刻,各自独立开关(开总开关后按这里筛选):
124
134
  voiceOnComplete: z.boolean().default(true), // 任务正常完成
@@ -259,6 +269,7 @@ export async function mutateDockSection(ctx, path, value, expectedRevision) {
259
269
  /** 通知配置字段清单(schema、迁移、客户端默认值三处共用同一份键名)。 */
260
270
  export const NOTIFY_FIELDS = [
261
271
  'notifyOnComplete', 'notifyOnError', 'notifyOnConfirm', 'notifyStayMs',
272
+ 'notifyOnStuck', 'notifyCards', 'cardMode',
262
273
  'systemNotify', 'soundNotify', 'soundEffect',
263
274
  'dingtalkEnabled', 'dingtalkWebhook', 'feishuEnabled', 'feishuWebhook',
264
275
  // 语音播报(新增段字段;旧 animation 段里不存在,迁移循环自然不会搬它们)
package/src/task-track.js CHANGED
@@ -88,6 +88,32 @@ function createTracker(ctx) {
88
88
  return ''
89
89
  }
90
90
 
91
+ /**
92
+ * 会话标题 + 主/子任务归属:优先 readTitleSnapshot(一次观测同时拿到标题与会话头),
93
+ * 会话头里的 origin === 'subagent' / delegationDepth 是权威的子任务标记(见 dsh-session
94
+ * SessionHeader),fallback 路径只拿标题、按主任务处理。取不到头不报错——播报退化为
95
+ * 不带归属的旧口径,功能不受影响(测试桩只需实现 readTitle/readSession 也能跑)。
96
+ * @returns {Promise<{ title: string, header: object|null }>}
97
+ */
98
+ async function readSessionFacts(sessionId, sessionQuery) {
99
+ const facts = { title: '', header: null }
100
+ if (!sessionQuery) return facts
101
+ try {
102
+ if (typeof sessionQuery.readTitleSnapshot === 'function') {
103
+ const obs = await sessionQuery.readTitleSnapshot(sessionId)
104
+ if (obs) {
105
+ const t = obs.title
106
+ if (typeof t === 'string') facts.title = t
107
+ else if (t && typeof t.title === 'string') facts.title = t.title
108
+ if (obs.session && typeof obs.session === 'object') facts.header = obs.session
109
+ if (facts.title || facts.header) return facts
110
+ }
111
+ }
112
+ } catch { /* 换下一种读法 */ }
113
+ facts.title = await getSessionTitle(sessionId, sessionQuery)
114
+ return facts
115
+ }
116
+
91
117
  function addActiveSession(sid, sessionQuery, extra = {}) {
92
118
  if (activeSessions.has(sid)) return activeSessions.get(sid)
93
119
  // 不在终端打「任务开始」日志:开始/结束状态界面(运行状态页)已实时可见
@@ -108,6 +134,11 @@ function createTracker(ctx) {
108
134
  errorMessage: '',
109
135
  lastText: '',
110
136
  firstPrompt: '',
137
+ // 主/子任务归属 + 最近一次调用的工具:卡住播报与卡住卡要说清「哪里卡住、最后在干什么」
138
+ //(isSubtask 由会话头 origin/delegationDepth 异步判定,判定前按主任务处理)
139
+ isSubtask: false,
140
+ parentSessionId: '',
141
+ lastToolName: '',
111
142
  // 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
112
143
  approvals: new Map(), // approvalId -> { toolName, reason, askedAt }
113
144
  phase: 'think',
@@ -120,10 +151,17 @@ function createTracker(ctx) {
120
151
  ...extra,
121
152
  }
122
153
  activeSessions.set(sid, record)
123
- getSessionTitle(sid, sessionQuery).then((title) => {
154
+ readSessionFacts(sid, sessionQuery).then((facts) => {
124
155
  const s = activeSessions.get(sid)
125
- if (s) s.title = title
126
- })
156
+ if (!s) return
157
+ if (facts.title) s.title = facts.title
158
+ const h = facts.header
159
+ if (h) {
160
+ s.isSubtask = h.origin === 'subagent'
161
+ || (typeof h.delegationDepth === 'number' && h.delegationDepth > 0)
162
+ s.parentSessionId = typeof h.parentSession === 'string' && h.parentSession ? h.parentSession : ''
163
+ }
164
+ }).catch(() => { /* 标题/归属读失败:title 走 firstPrompt 兜底,归属按主任务 */ })
127
165
  return record
128
166
  }
129
167
 
@@ -229,6 +267,9 @@ function createTracker(ctx) {
229
267
  s.toolCalls++
230
268
  s.phase = phaseOfToolName(event.data && event.data.name)
231
269
  s.phaseAt = now
270
+ // 记下最后调用的工具名:卡住播报/卡住卡要回答「最后在干什么」
271
+ const called = event.data && typeof event.data.name === 'string' ? event.data.name : ''
272
+ if (called) s.lastToolName = called
232
273
  // 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
233
274
  s.motionTicks += s.phase === 'search' ? 7 : 9
234
275
  s.lastMotionAt = now
@@ -329,6 +370,10 @@ function createTracker(ctx) {
329
370
  lastActivityAt: s.lastActivityAt,
330
371
  motionTicks: s.motionTicks,
331
372
  lastMotionAt: s.lastMotionAt,
373
+ // 卡住播报/卡住卡专用:主/子归属(会话头判定)与最后调用的工具
374
+ isSubtask: !!s.isSubtask,
375
+ parentSessionId: s.parentSessionId || '',
376
+ lastToolName: s.lastToolName || '',
332
377
  approvals: [...s.approvals.entries()].map(([id, v]) => ({ id, ...v })),
333
378
  })
334
379
  }