dsh-dock 0.9.9 → 0.10.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.
@@ -1,14 +1,16 @@
1
- // dsh-dock · 功能模块【任务动画】· 客户端视图(v0.5.0,参照 @wycto/dsh-task-pulse client 会话追踪消费,同作者 MIT)
1
+ // dsh-dock · 功能模块【任务动画】· 客户端视图
2
2
  //
3
3
  // 三部分:
4
- // 1. View —— 功能坞面板页:动画/通知两组独立开关 + 动画模式选择(带缩微预览)+ 运行状态列表;
5
- // 2. HomeStat —— 首页总揽概要(N 个任务进行中 / 空闲);
4
+ // 1. View —— 功能坞面板页:动画开关 + 19 种模式选择(带缩微预览);
5
+ // 2. HomeStat —— 首页总揽概要(当前动效模式 + 开关状态);
6
6
  // 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
7
7
  // 任务进行中渲染克制的动效(顶部流光细线 / 呼吸光点 / 轨道光环)+ 右下角状态徽标,
8
- // 任务结束时弹通知卡片(可选浏览器系统通知),完成瞬间一缕流光掠过。
8
+ // 完成瞬间一缕流光掠过(星际远征另有货运舰归航)。
9
9
  //
10
10
  // 设计取向:不做满屏粒子彩带,动效全部走主题变量(暗/亮色自适应)、低透明度、慢节奏。
11
- // 动画与通知是两个独立开关(可只开其一);配置全部经 Host settings 持久化,重启后恢复。
11
+ // 通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书推送)独立在 features/notify/,
12
+ // 运行状态(进行中任务与最近完成)独立在 features/runstate/:三个功能各自启停、互不依赖,
13
+ // 会话级任务追踪在宿主侧共用一份。
12
14
  //
13
15
  // Host 通信:fetch('/dsh-dock/animation/<method>')(见 features/animation/host.js)。
14
16
  import { useState, useEffect, useRef, useCallback, useMemo } from "react";
@@ -24,9 +26,9 @@ function rpcCall(method, args) {
24
26
  .then(async (res) => {
25
27
  const data = await res.json().catch(() => ({}));
26
28
  if (res.ok && data && data.ok === true) return data.data;
27
- // 旧宿主进程没有动画路由(405/404):给出可操作提示而非裸状态码
29
+ // 宿主侧没有动画路由(405/404):宿主进程可能是旧版本,或本功能在宿主侧未 setup
28
30
  if (res.status === 405 || res.status === 404) {
29
- throw new Error("宿主进程是旧版本(没有任务动画路由),重启 dsh web 后重试");
31
+ throw new Error("宿主侧没有「任务动画」接口:宿主进程可能是旧版本,或该功能在宿主侧未启用(重启 dsh web,或把功能坞里的开关关掉再打开)");
30
32
  }
31
33
  if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
32
34
  throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
@@ -66,47 +68,19 @@ function phaseLabel(p) { return PHASE_LABELS[p] || "工作中"; }
66
68
  const PHASE_COLORS = { think: "#2f6fed", write: "#0d9488", code: "#b45309", search: "#0e7490" };
67
69
  function phaseColor(p) { return PHASE_COLORS[p] || "#2f6fed"; }
68
70
 
69
- // 工具名 → 中文场景("需确认"通知里说清楚是什么在等确认)
70
- function toolLabel(name) {
71
- const n = String(name || "").toLowerCase();
72
- if (/bash|shell|terminal|cmd|pwsh|powershell|exec/.test(n)) return "执行命令";
73
- if (/write|edit|patch|apply|create|mkdir|remove|delete/.test(n)) return "修改文件";
74
- if (/web|fetch|browser|navigate|search/.test(n)) return "访问网页";
75
- return "";
76
- }
77
-
78
71
  // ---------- 结束原因 ----------
79
- const END_LABELS = {
80
- completed: { label: "完成", cls: "ok" },
81
- error: { label: "出错", cls: "err" },
82
- aborted: { label: "已中止", cls: "warn" },
83
- blocked: { label: "受阻", cls: "warn" },
84
- "max-tokens": { label: "达输出上限", cls: "warn" },
85
- interrupted: { label: "中断", cls: "warn" },
86
- };
87
- function endInfo(reason) {
88
- return END_LABELS[reason] || END_LABELS.completed;
89
- }
72
+ // (结束原因的展示文案随「运行状态」页一起搬去 features/runstate/,这里只保留动效判断用的成功判定)
90
73
  function isSuccessReason(reason) {
91
74
  return !reason || reason === "completed";
92
75
  }
93
76
 
94
77
  // ---------- 格式化 ----------
95
- function fmtNum(n) { return (Number(n) || 0).toLocaleString("en-US"); }
96
78
  function fmtCompact(n) {
97
79
  n = Number(n) || 0;
98
80
  if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
99
81
  if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
100
82
  return String(Math.round(n));
101
83
  }
102
- // 毫秒 → "3分21秒" / "1小时2分"
103
- function fmtDur(ms) {
104
- const s = Math.max(0, Math.round((ms || 0) / 1000));
105
- const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
106
- if (h > 0) return h + "小时" + m + "分" + sec + "秒";
107
- if (m > 0) return m + "分" + sec + "秒";
108
- return sec + "秒";
109
- }
110
84
  // 毫秒 → "12:34" / "1:02:11"(徽标计时)
111
85
  function fmtClock(ms) {
112
86
  const s = Math.max(0, Math.floor((ms || 0) / 1000));
@@ -114,12 +88,6 @@ function fmtClock(ms) {
114
88
  const p = (x) => String(x).padStart(2, "0");
115
89
  return h > 0 ? h + ":" + p(m) + ":" + p(sec) : p(m) + ":" + p(sec);
116
90
  }
117
- function fmtTime(ts) {
118
- if (!ts) return "";
119
- const d = new Date(ts);
120
- const p = (x) => String(x).padStart(2, "0");
121
- return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
122
- }
123
91
  function truncate(str, max) {
124
92
  if (!str) return "";
125
93
  return String(str).length > max ? String(str).slice(0, max) + "…" : str;
@@ -313,93 +281,6 @@ function RobotScene(props) {
313
281
  );
314
282
  }
315
283
 
316
- // ---------- 提示音(WebAudio 合成;macOS/Windows 通用,无音频文件) ----------
317
- // 音效库:每种含完成音(done)、异常音(err)与确认音(ask)三套音符序列。
318
- // 音符 [频率, 起始秒, 时长秒, 波形?],波形缺省 sine。确认音双音上扬、偏长,
319
- // 听感是"来人搭话",和完成音的收束感、异常音的下坠感区分开。
320
- const SOUND_LIBRARY = {
321
- chime: { name: "清脆双音", notes: { done: [[659.25, 0, .14], [880, .13, .24]], err: [[220, 0, .16], [164.81, .15, .3]], ask: [[587.33, 0, .16], [880, .19, .34]] } },
322
- ding: { name: "叮", notes: { done: [[987.77, 0, .35]], err: [[246.94, 0, .4]], ask: [[783.99, 0, .18], [1174.66, .2, .42]] } },
323
- coin: { name: "金币", notes: { done: [[988, 0, .08], [1319, .08, .35], [988, 0, .08, "square"], [1319, .08, .3, "square"]], err: [[196, 0, .12], [147, .11, .35]], ask: [[880, 0, .09], [1108.73, .1, .12], [1318.51, .22, .3]] } },
324
- bell: { name: "钟声", notes: { done: [[523.25, 0, .5], [659.25, .02, .45], [783.99, .04, .4]], err: [[174.61, 0, .5], [130.81, .05, .5]], ask: [[659.25, 0, .4], [523.25, .05, .55]] } },
325
- pulse: { name: "脉冲", notes: { done: [[440, 0, .09], [440, .14, .09], [440, .28, .16]], err: [[174.61, 0, .1], [174.61, .14, .1], [174.61, .28, .18]], ask: [[523.25, 0, .09], [523.25, .15, .09], [659.25, .3, .22]] } },
326
- arp: { name: "琶音", notes: { done: [[523.25, 0, .12], [659.25, .09, .12], [783.99, .18, .12], [1046.5, .27, .3]], err: [[392, 0, .12], [329.63, .1, .12], [261.63, .2, .12], [196, .3, .32]], ask: [[440, 0, .11], [554.37, .12, .11], [659.25, .24, .11], [880, .36, .34]] } },
327
- };
328
- // AudioContext 按需创建(浏览器自动播放策略:首次用户交互后才能出声,静默失败不报错)
329
- let soundCtx = null;
330
- function playTone(seq) {
331
- try {
332
- if (typeof window === "undefined" || !window.AudioContext && !window.webkitAudioContext) return;
333
- const AC = window.AudioContext || window.webkitAudioContext;
334
- if (!soundCtx) soundCtx = new AC();
335
- if (soundCtx.state === "suspended") { soundCtx.resume().catch(() => {}); }
336
- const t0 = soundCtx.currentTime;
337
- for (const [f, at, dur, wave] of seq) {
338
- const osc = soundCtx.createOscillator();
339
- const gain = soundCtx.createGain();
340
- osc.type = wave || "sine";
341
- osc.frequency.value = f;
342
- gain.gain.setValueAtTime(0, t0 + at);
343
- gain.gain.linearRampToValueAtTime(0.18, t0 + at + 0.015);
344
- gain.gain.exponentialRampToValueAtTime(0.0001, t0 + at + dur);
345
- osc.connect(gain).connect(soundCtx.destination);
346
- osc.start(t0 + at);
347
- osc.stop(t0 + at + dur + 0.05);
348
- }
349
- } catch { /* 音频不可用静默 */ }
350
- }
351
- // 按音效播放:完成音 / 异常音 / 确认音(未知音效回退 chime)
352
- function playDoneSound(success, effect) {
353
- const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
354
- playTone(success ? lib.notes.done : lib.notes.err);
355
- }
356
- function playAskSound(effect) {
357
- const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
358
- playTone(lib.notes.ask || lib.notes.done);
359
- }
360
- // 试听:完成音 + 异常音 连播(间隔 0.55s)
361
- function previewSound(effect) {
362
- const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
363
- playTone(lib.notes.done);
364
- const delayed = lib.notes.err.map(([f, at, dur, wave]) => [f, at + 0.55, dur, wave]);
365
- playTone(delayed);
366
- }
367
-
368
-
369
- function Toast(props) {
370
- const t = props.t;
371
- const [closing, setClosing] = useState(false);
372
- const close = useCallback(() => { if (!closing) setClosing(true); }, [closing]);
373
- // 停留时长(stayMs=0 常驻,仅手动关闭)
374
- useEffect(() => {
375
- if (!t.stayMs) return;
376
- const timer = setTimeout(close, t.stayMs);
377
- return () => clearTimeout(timer);
378
- }, []);
379
- useEffect(() => {
380
- if (!closing) return;
381
- const timer = setTimeout(() => props.onClose(t.id), 240);
382
- return () => clearTimeout(timer);
383
- }, [closing]);
384
- const markColor = t.kind === "success"
385
- ? "var(--dk-ok)"
386
- : t.kind === "error"
387
- ? "var(--dk-err)"
388
- : t.kind === "confirm"
389
- ? "var(--dk-warn)"
390
- : "var(--dk-warn)";
391
- return (
392
- <div className={"dkan-toast" + (t.kind === "confirm" ? " dkan-toast-confirm" : "") + (closing ? " out" : "")}>
393
- <div className="dkan-toast-head">
394
- <span className="dkan-toast-mark" style={{ background: markColor }} />
395
- <span className="dkan-toast-title" title={t.title}>{t.kind === "confirm" ? "✋ " : ""}{t.title}</span>
396
- <button type="button" className="dkan-toast-close" aria-label="关闭" onClick={close}>✕</button>
397
- </div>
398
- {t.body ? <div className="dkan-toast-body">{t.body}</div> : null}
399
- </div>
400
- );
401
- }
402
-
403
284
  // ---------- 交互反馈层:对用户操作即时回应(纸飞机/火花/流光/微光/光涌/彩带) ----------
404
285
  // 事件来源:document 点击监听(发送/新会话/切会话/切工作目录)+ 任务状态变化(开始/完成)。
405
286
  // 每个 burst = { id, type, x, y, bits },bits 为预生成的随机粒子参数(内联样式驱动 CSS 动画)。
@@ -843,11 +724,10 @@ function ProgrammingStuck(props) {
843
724
  return <div className={"dkan-space-stuck" + (waiting ? " waiting" : "")} aria-hidden="true"><FreighterHull units={3} empty /><span className="dkan-stuck-code"><i />{`{ }`}</span><strong>{waiting ? "等待确认" : "编程等待中"}</strong><small>{truncate((props.task && props.task.title) || "工具暂无新响应", 28)}</small></div>;
844
725
  }
845
726
 
846
- // ---------- 全局浮层:轮询 + 动效 + 通知(功能启用即常驻) ----------
727
+ // ---------- 全局浮层:轮询 + 动效(功能启用即常驻;通知在 features/notify/) ----------
847
728
  export function AnimationOverlay(props) {
848
729
  const ctx = props && props.ctx;
849
730
  const snap = useAnimation();
850
- const [toasts, setToasts] = useState([]);
851
731
  const [flourish, setFlourish] = useState(null); // { key, err } 完成瞬间的一次性流光
852
732
  const [bursts, setBursts] = useState([]); // 交互反馈动画队列
853
733
  const [deliveries, setDeliveries] = useState([]); // 星际模式完成时停泊在输入框上方的归航货运舰
@@ -952,7 +832,7 @@ export function AnimationOverlay(props) {
952
832
  return () => { stopped = true; if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", onVisible); };
953
833
  }, []);
954
834
 
955
- // 任务结束检测:上一轮活跃、本轮消失 → 通知 + 流光(找最近完成记录补全信息)
835
+ // 任务结束检测:上一轮活跃、本轮消失 → 完成瞬间的流光(找最近完成记录补全信息)
956
836
  useEffect(() => {
957
837
  const st = snap.status;
958
838
  if (!st || !st.active) return;
@@ -968,133 +848,35 @@ export function AnimationOverlay(props) {
968
848
  prevActiveRef.current = st.active.slice();
969
849
  }, [snap.status]);
970
850
 
971
- // ===== 需确认检测:工具等待用户批准时通知(页内卡片 + 确认音 + 后台系统通知) =====
972
- // host 从会话流 approval/asked 收集待确认项,approval/decided 后移除;
973
- // 这里对"任务首次出现待确认"触发一次提醒,已提醒过的会话不再重复打扰。
974
- const remindedApprovalsRef = useRef(new Map()); // sessionId -> 提醒过的 approvalId 集合
975
- useEffect(() => {
976
- const st = snap.status;
977
- if (!st || !st.active) return;
978
- const cfg = st.config || {};
979
- const activeIds = new Set(st.active.map((x) => x.sessionId));
980
- for (const [sid, seen] of remindedApprovalsRef.current) {
981
- if (!activeIds.has(sid)) remindedApprovalsRef.current.delete(sid);
982
- }
983
- for (const task of st.active) {
984
- const approvals = Array.isArray(task.approvals) ? task.approvals : [];
985
- if (approvals.length === 0) continue;
986
- let seen = remindedApprovalsRef.current.get(task.sessionId);
987
- if (!seen) { seen = new Set(); remindedApprovalsRef.current.set(task.sessionId, seen); }
988
- const fresh = approvals.filter((a) => a && !seen.has(a.id));
989
- if (fresh.length === 0) continue;
990
- for (const a of fresh) seen.add(a.id);
991
- // 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
992
- if (!cfg.notifyEnabled || cfg.notifyOnConfirm === false) continue;
993
- const toolName = fresh[0].toolName || "";
994
- const scene = toolLabel(toolName);
995
- const lines = [
996
- "任务:" + (task.title || "(无标题)"),
997
- "工具:" + (toolName || "未知") + (scene ? "(" + scene + ")" : ""),
998
- ];
999
- if (fresh[0].reason) lines.push("说明:" + truncate(fresh[0].reason, 140));
1000
- lines.push(fresh.length > 1 ? "共 " + fresh.length + " 项等待你的确认" : "请在会话里确认后继续");
1001
- const toast = {
1002
- id: Date.now() + Math.random(),
1003
- kind: "confirm",
1004
- title: "任务需要确认",
1005
- body: lines.join("\n"),
1006
- // 需确认常驻到用户处理(notifyStayMs=0 同款语义),关掉开关的间隙也不会自动消失
1007
- stayMs: 0,
1008
- };
1009
- setToasts((prev) => prev.concat([toast]).slice(-4));
1010
- if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
1011
- if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
1012
- && typeof Notification !== "undefined" && Notification.permission === "granted") {
1013
- try {
1014
- new Notification("dsh 任务需要确认", {
1015
- body: (task.title || "(无标题)") + " · " + (toolName || "工具") + " 等待确认",
1016
- tag: "dsh-dock-approval-" + task.sessionId,
1017
- });
1018
- } catch { /* 系统通知失败不影响页面内通知 */ }
1019
- }
1020
- }
1021
- }, [snap.status]);
1022
-
1023
851
  const handleTaskEnd = (task, record, cfg) => {
1024
- const reason = (record && record.endReason) || "";
1025
- const success = isSuccessReason(reason);
1026
- const info = endInfo(reason);
1027
- // 动画侧:完成瞬间一缕流光掠过 + 成功时顶部彩带庆祝(动画关时不渲染)
1028
- if (cfg.animationEnabled) {
1029
- setFlourish({ key: Date.now(), err: !success });
1030
- // 星际远征把完成结果送回用户正在输入的位置。速度继承最后一帧任务节奏;
1031
- // 货舱数量及标签只取 Host 已记录的真实 outputTokens。
1032
- if (cfg.effectMode === "space") {
1033
- const cargo = outputCargo((record && record.outputTokens) || task.outputTokens);
1034
- const id = ++deliveryIdRef.current;
1035
- const taskSpeed = Math.max(.9, Number(speed) || 1);
1036
- const duration = Math.round(Math.max(8000, Math.min(9400, 10000 / Math.sqrt(taskSpeed))));
1037
- // 从三个星系中随机挑选归航起点,任务完成时才能知道本次货舰从哪里回来。
1038
- const galaxies = spaceGalaxyLayout();
1039
- const returnGalaxy = galaxies[["alpha", "beta", "gamma"][Math.floor(Math.random() * 3)]];
1040
- const from = freighterAnchorAt({ x: returnGalaxy.cx, y: returnGalaxy.cy });
1041
- const to = composerDockPoint("left");
1042
- // 满载舰最终停入左侧归航位,和右上出发星系形成清晰的往返方向。
1043
- const delivery = Object.assign({ id, from, to, duration }, cargo);
1044
- setDeliveries([delivery]);
1045
- }
1046
- if (success) {
1047
- pushBurst("confetti", 0, 0, makeBits(18, (i) => ({
1048
- l: 4 + (i / 18) * 92 + Math.random() * 3,
1049
- dx: (Math.random() - 0.5) * 60,
1050
- r: Math.random() * 720 - 360,
1051
- d: Math.random() * 0.35,
1052
- c: ["#2563eb", "#0d9488", "#b45309", "#be185d", "#7c3aed"][i % 5],
1053
- })));
1054
- }
852
+ if (!cfg.animationEnabled) return;
853
+ const success = isSuccessReason((record && record.endReason) || "");
854
+ setFlourish({ key: Date.now(), err: !success });
855
+ // 星际远征把完成结果送回用户正在输入的位置。速度继承最后一帧任务节奏;
856
+ // 货舱数量及标签只取 Host 已记录的真实 outputTokens。
857
+ if (cfg.effectMode === "space") {
858
+ const cargo = outputCargo((record && record.outputTokens) || task.outputTokens);
859
+ const id = ++deliveryIdRef.current;
860
+ const taskSpeed = Math.max(.9, Number(speed) || 1);
861
+ const duration = Math.round(Math.max(8000, Math.min(9400, 10000 / Math.sqrt(taskSpeed))));
862
+ // 从三个星系中随机挑选归航起点,任务完成时才能知道本次货舰从哪里回来。
863
+ const galaxies = spaceGalaxyLayout();
864
+ const returnGalaxy = galaxies[["alpha", "beta", "gamma"][Math.floor(Math.random() * 3)]];
865
+ const from = freighterAnchorAt({ x: returnGalaxy.cx, y: returnGalaxy.cy });
866
+ const to = composerDockPoint("left");
867
+ // 满载舰最终停入左侧归航位,和右上出发星系形成清晰的往返方向。
868
+ const delivery = Object.assign({ id, from, to, duration }, cargo);
869
+ setDeliveries([delivery]);
1055
870
  }
1056
- // 通知侧:与动画开关完全独立
1057
- if (!cfg.notifyEnabled) return;
1058
- const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
1059
- if (!wanted) return;
1060
- const models = record && record.models && record.models.length ? record.models.join(", ")
1061
- : (task.models && task.models.length ? task.models.join(", ") : "未知模型");
1062
- const provider = (record && record.provider) || task.provider || "";
1063
- const startTs = (record && record.startTime) || task.startTime;
1064
- const endTs = (record && record.endTime) || Date.now();
1065
- const lines = [
1066
- "任务:" + ((record && record.title) || task.title || "(无标题)"),
1067
- "模型:" + models + (provider ? "(" + provider + ")" : ""),
1068
- "耗时:" + fmtDur((record && record.duration) || (endTs - startTs))
1069
- + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")",
1070
- "回合 " + ((record && record.turns) || task.turns || 0)
1071
- + " · 步骤 " + ((record && record.steps) || task.steps || 0)
1072
- + (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""),
1073
- "Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
1074
- + " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens),
1075
- ];
1076
- if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
1077
- if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
1078
- const title = success ? "任务完成" : "任务" + info.label;
1079
- const toast = {
1080
- id: Date.now() + Math.random(),
1081
- kind: success ? "success" : (info.cls === "err" ? "error" : "warn"),
1082
- title,
1083
- body: lines.join("\n"),
1084
- stayMs: typeof cfg.notifyStayMs === "number" ? cfg.notifyStayMs : 8000,
1085
- };
1086
- setToasts((prev) => prev.concat([toast]).slice(-4));
1087
- // 提示音:任务结束时播放(完成/异常配套音);与系统通知独立开关
1088
- if (cfg.soundNotify !== false) playDoneSound(success, cfg.soundEffect);
1089
- // 系统通知:仅页面处于后台时推送,避免前台重复打扰
1090
- if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
1091
- && typeof Notification !== "undefined" && Notification.permission === "granted") {
1092
- try {
1093
- new Notification("dsh " + title, {
1094
- body: ((record && record.title) || task.title || "(无标题)") + " · " + fmtDur((record && record.duration) || (endTs - startTs)),
1095
- tag: "dsh-dock-animation-" + task.sessionId,
1096
- });
1097
- } catch { /* 系统通知失败不影响页面内通知 */ }
871
+ // 成功时顶部彩带庆祝(通知卡片由【任务通知】模块负责,与本模块无关)
872
+ if (success) {
873
+ pushBurst("confetti", 0, 0, makeBits(18, (i) => ({
874
+ l: 4 + (i / 18) * 92 + Math.random() * 3,
875
+ dx: (Math.random() - 0.5) * 60,
876
+ r: Math.random() * 720 - 360,
877
+ d: Math.random() * 0.35,
878
+ c: ["#2563eb", "#0d9488", "#b45309", "#be185d", "#7c3aed"][i % 5],
879
+ })));
1098
880
  }
1099
881
  };
1100
882
 
@@ -1324,12 +1106,6 @@ export function AnimationOverlay(props) {
1324
1106
  ) : null}
1325
1107
  {/* 星际任务完成:货运舰沿航线飞到输入框上方并保持停泊,直到下一次任务出航。 */}
1326
1108
  {!panelOpen ? deliveries.map((delivery) => <DeliveryFreighter key={delivery.id} delivery={delivery} />) : null}
1327
- {/* 通知卡片栈(右下→右上不遮 dsh 自身 UI;右上角更常规) */}
1328
- {toasts.length > 0 ? (
1329
- <div className="dkan-toasts">
1330
- {toasts.map((t) => <Toast key={t.id} t={t} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />)}
1331
- </div>
1332
- ) : null}
1333
1109
  </>);
1334
1110
  }
1335
1111
 
@@ -1391,53 +1167,18 @@ function ModePreview({ id }) {
1391
1167
  );
1392
1168
  }
1393
1169
 
1394
- function TaskRow(props) {
1395
- const t = props.t;
1396
- const info = endInfo(t.endReason);
1397
- const waiting = !props.done && Array.isArray(t.approvals) && t.approvals.length > 0;
1398
- return (
1399
- <div className="dkan-task">
1400
- <div className="dkan-task-head">
1401
- <span className="dkan-task-title" title={t.title}>{t.title || "(无标题)"}</span>
1402
- {props.done ? <span className={"dkan-tag " + info.cls}>{info.label}</span>
1403
- : waiting ? <span className="dkan-tag warn">等待确认</span>
1404
- : <span className="dkan-tag on">{phaseLabel(t.phase)}</span>}
1405
- </div>
1406
- <div className="dkan-task-meta">
1407
- <span>{(t.models && t.models.length ? t.models.join(", ") : "未知") + (t.provider ? "(" + t.provider + ")" : "")}</span>
1408
- <span>{props.done ? fmtDur(t.duration) : fmtClock(t.elapsed)}</span>
1409
- {"turns" in t ? <span>{"回合 " + (t.turns || 0) + " · 步骤 " + (t.steps || 0) + (t.toolCalls ? " · 工具 " + t.toolCalls : "")}</span> : null}
1410
- {t.totalTokens ? <span>{"↧" + fmtCompact(t.inputTokens) + " ↥" + fmtCompact(t.outputTokens)}</span> : null}
1411
- {props.done && t.endTime ? <span>{fmtTime(t.endTime)}</span> : null}
1412
- </div>
1413
- {waiting ? (
1414
- <div className="dkan-task-err">
1415
- {t.approvals.map((a, i) => (a && a.toolName ? a.toolName : "未知工具") + (a && a.reason ? ":" + truncate(a.reason, 80) : "")).join(";")}
1416
- </div>
1417
- ) : null}
1418
- {props.done && t.errorMessage ? <div className="dkan-task-err">{truncate(t.errorMessage, 120)}</div> : null}
1419
- </div>
1420
- );
1421
- }
1422
-
1423
1170
  function AnimationView(props) {
1424
1171
  const ctx = props && props.ctx;
1425
1172
  const snap = useAnimation(ctx);
1426
1173
  const [cfg, setCfg] = useState(null); // null = 尚未加载
1427
1174
  const [saveErr, setSaveErr] = useState("");
1428
- const [testing, setTesting] = useState(false);
1429
- const [testState, setTestState] = useState(null); // { ok, msg } 机器人测试结果
1430
- const [feishuTestState, setFeishuTestState] = useState(null); // { ok, msg } 飞书测试结果
1431
- const [feishuTesting, setFeishuTesting] = useState(false);
1432
1175
  const cfgRef = useRef(null);
1433
1176
  const pendingSavesRef = useRef(0); // 进行中的保存(轮询回包不覆盖乐观值)
1434
- const editingWebhookRef = useRef(false); // 钉钉 Webhook 输入中(轮询不覆盖草稿)
1435
- const editingFeishuRef = useRef(false); // 飞书 Webhook 输入中(轮询不覆盖草稿)
1436
- // 拉到新配置(含保存回包)后同步本地编辑态;保存中/输入中不覆盖
1177
+ // 拉到新配置(含保存回包)后同步本地编辑态;保存中不覆盖
1437
1178
  useEffect(() => {
1438
1179
  const c = snap.status && snap.status.config;
1439
1180
  if (!c) return;
1440
- if (pendingSavesRef.current > 0 || editingWebhookRef.current || editingFeishuRef.current) return;
1181
+ if (pendingSavesRef.current > 0) return;
1441
1182
  if (c !== cfgRef.current) {
1442
1183
  cfgRef.current = c;
1443
1184
  setCfg(Object.assign({}, c));
@@ -1447,7 +1188,7 @@ function AnimationView(props) {
1447
1188
  useEffect(() => {
1448
1189
  if (!animationStore.snap.status) animationStore.refresh();
1449
1190
  }, []);
1450
- // 乐观更新 + 立即持久化(每个开关独立保存);返回 Promise 供需要串联的操作使用
1191
+ // 乐观更新 + 立即持久化(每个开关独立保存)
1451
1192
  const patch = (p) => {
1452
1193
  setSaveErr("");
1453
1194
  setCfg(Object.assign({}, cfg, p));
@@ -1462,105 +1203,6 @@ function AnimationView(props) {
1462
1203
  setSaveErr("保存失败:" + ((e && e.message) || String(e)));
1463
1204
  });
1464
1205
  };
1465
- // Webhook 草稿保存:清编辑态后立即对齐一次(同步 effect 在编辑期被跳过)
1466
- const saveWebhook = async () => {
1467
- if (!editingWebhookRef.current) return;
1468
- const hook = String(cfg.dingtalkWebhook || "").trim();
1469
- await patch({ dingtalkWebhook: hook });
1470
- editingWebhookRef.current = false;
1471
- const c = animationStore.snap.status && animationStore.snap.status.config;
1472
- if (c && c !== cfgRef.current) {
1473
- cfgRef.current = c;
1474
- setCfg(Object.assign({}, c));
1475
- }
1476
- };
1477
- // 飞书 Webhook 草稿保存
1478
- const saveFeishuWebhook = async () => {
1479
- if (!editingFeishuRef.current) return;
1480
- const hook = String(cfg.feishuWebhook || "").trim();
1481
- await patch({ feishuWebhook: hook });
1482
- editingFeishuRef.current = false;
1483
- const c = animationStore.snap.status && animationStore.snap.status.config;
1484
- if (c && c !== cfgRef.current) {
1485
- cfgRef.current = c;
1486
- setCfg(Object.assign({}, c));
1487
- }
1488
- };
1489
- // 钉钉测试:草稿未保存先保存,再发测试消息
1490
- const runDingtalkTest = async () => {
1491
- setTesting(true);
1492
- setTestState(null);
1493
- try {
1494
- if (editingWebhookRef.current) {
1495
- const hook = String(cfg.dingtalkWebhook || "").trim();
1496
- if (!hook) throw new Error("请先填写 Webhook 地址");
1497
- const d = await rpcCall("config", { dingtalkWebhook: hook });
1498
- animationStore.applyConfig(d && d.config);
1499
- editingWebhookRef.current = false;
1500
- cfgRef.current = (d && d.config) || cfgRef.current;
1501
- setCfg(Object.assign({}, cfg, { dingtalkWebhook: hook }));
1502
- }
1503
- const r = await rpcCall("test");
1504
- setTestState(r && r.sent
1505
- ? { ok: true, msg: "测试消息已发送,去群里看看" }
1506
- : { ok: false, msg: (r && r.error) || "发送失败" });
1507
- } catch (e) {
1508
- setTestState({ ok: false, msg: (e && e.message) || String(e) });
1509
- } finally {
1510
- setTesting(false);
1511
- }
1512
- };
1513
- // 飞书测试:草稿未保存先保存,再发测试消息
1514
- const runFeishuTest = async () => {
1515
- setFeishuTesting(true);
1516
- setFeishuTestState(null);
1517
- try {
1518
- if (editingFeishuRef.current) {
1519
- const hook = String(cfg.feishuWebhook || "").trim();
1520
- if (!hook) throw new Error("请先填写 Webhook 地址");
1521
- const d = await rpcCall("config", { feishuWebhook: hook });
1522
- animationStore.applyConfig(d && d.config);
1523
- editingFeishuRef.current = false;
1524
- cfgRef.current = (d && d.config) || cfgRef.current;
1525
- setCfg(Object.assign({}, cfg, { feishuWebhook: hook }));
1526
- }
1527
- const r = await rpcCall("test", { target: "feishu" });
1528
- setFeishuTestState(r && r.sent
1529
- ? { ok: true, msg: "测试消息已发送,去群里看看" }
1530
- : { ok: false, msg: (r && r.error) || "发送失败" });
1531
- } catch (e) {
1532
- setFeishuTestState({ ok: false, msg: (e && e.message) || String(e) });
1533
- } finally {
1534
- setFeishuTesting(false);
1535
- }
1536
- };
1537
- const enableSystemNotify = async (next) => {
1538
- if (next && typeof Notification !== "undefined" && Notification.permission !== "granted") {
1539
- try {
1540
- const perm = await Notification.requestPermission(); // 开关点击即用户手势
1541
- if (perm !== "granted") {
1542
- setSaveErr("浏览器未授权系统通知(可在地址栏权限设置里重新允许)");
1543
- return;
1544
- }
1545
- } catch {
1546
- setSaveErr("浏览器不支持系统通知");
1547
- return;
1548
- }
1549
- }
1550
- patch({ systemNotify: next });
1551
- };
1552
-
1553
- const st = snap.status;
1554
- const active = st && st.active ? st.active : [];
1555
- const recent = st && st.recent ? st.recent.slice(0, 6) : [];
1556
- // 等待确认的审批项总数:运行状态标题与页脚都用,必须在 if/else 之外声明。
1557
- // ⚠️ 曾误写在 if(!cfg) 的 else 块内、却在块外引用 → ReferenceError: waitingCount is not defined,
1558
- // 整个视图渲染抛错、宿主把面板插槽换成空 div(点「任务动画」界面直接消失)。
1559
- const waitingCount = active.reduce((n, t) => n + (Array.isArray(t.approvals) ? t.approvals.length : 0), 0);
1560
- const permNote = typeof Notification === "undefined"
1561
- ? "当前浏览器不支持系统通知"
1562
- : Notification.permission === "granted" ? "已授权 · 仅页面后台时推送"
1563
- : Notification.permission === "denied" ? "已被浏览器拒绝(需在浏览器权限设置里重新允许)" : "未授权 · 开启时会请求授权,仅页面后台时推送";
1564
1206
 
1565
1207
  const rows = [];
1566
1208
  if (!cfg) {
@@ -1611,209 +1253,34 @@ function AnimationView(props) {
1611
1253
  </div>
1612
1254
  </div> : null}
1613
1255
  </>
1614
- ) : <div className="dkan-note">动画已关闭——只保留通知(或全部关闭)时,页面不会有任何动效。</div>}
1615
- </div>,
1616
- <div key="notify" className="dkan-sec">
1617
- <div className="dkan-sec-head">
1618
- <span className="dkan-sec-title">完成通知</span>
1619
- <span className="dkan-sec-sub">与动画互不依赖,可单独开启</span>
1620
- <span className="dkan-sec-sw">
1621
- <span className={"dkan-sec-swlabel" + (cfg.notifyEnabled ? " on" : "")}>{cfg.notifyEnabled ? "已开启" : "已关闭"}</span>
1622
- <button type="button" className={"dock-sw" + (cfg.notifyEnabled ? " on" : "")}
1623
- role="switch" aria-checked={cfg.notifyEnabled} aria-label="开关完成通知"
1624
- title={cfg.notifyEnabled ? "关闭完成通知" : "开启完成通知"}
1625
- onClick={() => patch({ notifyEnabled: !cfg.notifyEnabled })} />
1626
- </span>
1627
- </div>
1628
- {cfg.notifyEnabled ? <div className="dkan-rows-narrow">
1629
- <div className="dkan-row">
1630
- <span className="dkan-row-label">完成通知</span>
1631
- <button type="button" className={"dkm-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
1632
- onClick={() => patch({ notifyOnComplete: !cfg.notifyOnComplete })}>
1633
- {cfg.notifyOnComplete ? "开" : "关"}
1634
- </button>
1635
- <span className="dkan-row-sub">任务正常完成时通知</span>
1636
- </div>
1637
- <div className="dkan-row">
1638
- <span className="dkan-row-label">异常通知</span>
1639
- <button type="button" className={"dkm-miniswitch" + (cfg.notifyOnError ? " on" : "")}
1640
- onClick={() => patch({ notifyOnError: !cfg.notifyOnError })}>
1641
- {cfg.notifyOnError ? "开" : "关"}
1642
- </button>
1643
- <span className="dkan-row-sub">出错 / 中止 / 达输出上限时通知</span>
1644
- </div>
1645
- <div className="dkan-row">
1646
- <span className="dkan-row-label">需确认提醒</span>
1647
- <button type="button" className={"dkm-miniswitch" + (cfg.notifyOnConfirm !== false ? " on" : "")}
1648
- onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
1649
- {cfg.notifyOnConfirm !== false ? "开" : "关"}
1650
- </button>
1651
- <span className="dkan-row-sub">工具等待你批准时弹卡片并响确认音(常驻不自动消失)</span>
1652
- </div>
1653
- <div className="dkan-row">
1654
- <span className="dkan-row-label">停留时长</span>
1655
- <select className="dkan-select" value={String(cfg.notifyStayMs)}
1656
- onChange={(e) => patch({ notifyStayMs: Number(e.target.value) })}>
1657
- <option value="4000">4 秒</option>
1658
- <option value="8000">8 秒</option>
1659
- <option value="15000">15 秒</option>
1660
- <option value="30000">30 秒</option>
1661
- <option value="0">常驻(手动关闭)</option>
1662
- </select>
1663
- </div>
1664
- <div className="dkan-row">
1665
- <span className="dkan-row-label">系统通知</span>
1666
- <button type="button" className={"dkm-miniswitch" + (cfg.systemNotify ? " on" : "")}
1667
- onClick={() => enableSystemNotify(!cfg.systemNotify)}>
1668
- {cfg.systemNotify ? "开" : "关"}
1669
- </button>
1670
- <span className="dkan-row-sub">{permNote}</span>
1671
- </div>
1672
- <div className="dkan-row">
1673
- <span className="dkan-row-label">提示音</span>
1674
- <button type="button" className={"dkm-miniswitch" + (cfg.soundNotify !== false ? " on" : "")}
1675
- onClick={() => patch({ soundNotify: cfg.soundNotify === false })}>
1676
- {cfg.soundNotify !== false ? "开" : "关"}
1677
- </button>
1678
- <span className="dkan-row-sub">任务结束/需确认时播放(试听为先播完成音、后播异常音)</span>
1679
- </div>
1680
- {cfg.soundNotify !== false ? (
1681
- <div className="dkan-sounds">
1682
- {Object.keys(SOUND_LIBRARY).map((key) => (
1683
- <button type="button" key={key}
1684
- className={"dkan-sound" + (cfg.soundEffect === key ? " on" : "")}
1685
- onClick={() => patch({ soundEffect: key })}>
1686
- <span className="dkan-sound-name">
1687
- {SOUND_LIBRARY[key].name}
1688
- {cfg.soundEffect === key ? <span className="dkan-sound-cur">✓</span> : null}
1689
- </span>
1690
- <span className="dkan-sound-play" role="button" tabIndex={0}
1691
- title={"试听 " + SOUND_LIBRARY[key].name}
1692
- onClick={(e) => { e.stopPropagation(); previewSound(key); }}
1693
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); previewSound(key); } }}>▶</span>
1694
- </button>
1695
- ))}
1696
- </div>
1697
- ) : null}
1698
- </div> : <div className="dkan-note">通知已关闭——任务结束时既不弹卡片也不推系统通知。</div>}
1699
- </div>,
1700
- <div key="dingtalk" className="dkan-sec">
1701
- <div className="dkan-sec-head">
1702
- <span className="dkan-sec-title">钉钉推送</span>
1703
- <span className="dkan-sec-sub">任务结束推送到钉钉群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
1704
- <span className="dkan-sec-sw">
1705
- <span className={"dkan-sec-swlabel" + (cfg.dingtalkEnabled ? " on" : "")}>{cfg.dingtalkEnabled ? "已开启" : "已关闭"}</span>
1706
- <button type="button" className={"dock-sw" + (cfg.dingtalkEnabled ? " on" : "")}
1707
- role="switch" aria-checked={cfg.dingtalkEnabled} aria-label="开关钉钉推送"
1708
- title={cfg.dingtalkEnabled ? "关闭钉钉推送" : "开启钉钉推送"}
1709
- onClick={() => patch({ dingtalkEnabled: !cfg.dingtalkEnabled })} />
1710
- </span>
1711
- </div>
1712
- {cfg.dingtalkEnabled ? <div className="dkan-rows-narrow">
1713
- <div className="dkan-row dkan-row-webhook">
1714
- <span className="dkan-row-label">Webhook</span>
1715
- <input type="text" className="dkan-input" spellCheck={false}
1716
- value={cfg.dingtalkWebhook || ""}
1717
- placeholder="https://oapi.dingtalk.com/robot/send?access_token=…"
1718
- onChange={(e) => {
1719
- editingWebhookRef.current = true;
1720
- setCfg(Object.assign({}, cfg, { dingtalkWebhook: e.target.value }));
1721
- }} />
1722
- <button type="button" className="dkan-btn" disabled={!editingWebhookRef.current}
1723
- onClick={saveWebhook}>
1724
- {editingWebhookRef.current ? "保存" : "已保存"}
1725
- </button>
1726
- </div>
1727
- <div className="dkan-row">
1728
- <span className="dkan-row-label">连通测试</span>
1729
- <button type="button" className="dkan-btn" disabled={testing} onClick={runDingtalkTest}>
1730
- {testing ? "发送中…" : "发送测试消息"}
1731
- </button>
1732
- {testState ? <span className={"dkan-row-sub" + (testState.ok ? " dkan-ok" : " dkan-err")}>
1733
- {testState.ok ? "✓ " : "✗ "}{testState.msg}
1734
- </span> : <span className="dkan-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
1735
- </div>
1736
- <div className="dkan-note">机器人创建:钉钉群 → 设置 → 智能群助手 → 添加机器人 → 自定义(Webhook),
1737
- 安全设置选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
1738
- </div> : <div className="dkan-note">未开启——任务结束不推送钉钉。</div>}
1739
- </div>,
1740
- <div key="feishu" className="dkan-sec">
1741
- <div className="dkan-sec-head">
1742
- <span className="dkan-sec-title">飞书推送</span>
1743
- <span className="dkan-sec-sub">任务结束推送到飞书群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
1744
- <span className="dkan-sec-sw">
1745
- <span className={"dkan-sec-swlabel" + (cfg.feishuEnabled ? " on" : "")}>{cfg.feishuEnabled ? "已开启" : "已关闭"}</span>
1746
- <button type="button" className={"dock-sw" + (cfg.feishuEnabled ? " on" : "")}
1747
- role="switch" aria-checked={cfg.feishuEnabled} aria-label="开关飞书推送"
1748
- title={cfg.feishuEnabled ? "关闭飞书推送" : "开启飞书推送"}
1749
- onClick={() => patch({ feishuEnabled: !cfg.feishuEnabled })} />
1750
- </span>
1751
- </div>
1752
- {cfg.feishuEnabled ? <div className="dkan-rows-narrow">
1753
- <div className="dkan-row dkan-row-webhook">
1754
- <span className="dkan-row-label">Webhook</span>
1755
- <input type="text" className="dkan-input" spellCheck={false}
1756
- value={cfg.feishuWebhook || ""}
1757
- placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…"
1758
- onChange={(e) => {
1759
- editingFeishuRef.current = true;
1760
- setCfg(Object.assign({}, cfg, { feishuWebhook: e.target.value }));
1761
- }} />
1762
- <button type="button" className="dkan-btn" disabled={!editingFeishuRef.current}
1763
- onClick={saveFeishuWebhook}>
1764
- {editingFeishuRef.current ? "保存" : "已保存"}
1765
- </button>
1766
- </div>
1767
- <div className="dkan-row">
1768
- <span className="dkan-row-label">连通测试</span>
1769
- <button type="button" className="dkan-btn" disabled={feishuTesting} onClick={runFeishuTest}>
1770
- {feishuTesting ? "发送中…" : "发送测试消息"}
1771
- </button>
1772
- {feishuTestState ? <span className={"dkan-row-sub" + (feishuTestState.ok ? " dkan-ok" : " dkan-err")}>
1773
- {feishuTestState.ok ? "✓ " : "✗ "}{feishuTestState.msg}
1774
- </span> : <span className="dkan-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
1775
- </div>
1776
- <div className="dkan-note">机器人创建:飞书群 → 设置 → 群机器人 → 添加机器人 → 自定义机器人(获取 Webhook 地址);
1777
- 安全设置如选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
1778
- </div> : <div className="dkan-note">未开启——任务结束不推送飞书。</div>}
1256
+ ) : <div className="dkan-note">动画已关闭——任务进行中不会有任何动效(通知设置见左侧「任务通知」)。</div>}
1779
1257
  </div>
1780
1258
  );
1781
1259
  }
1782
1260
  if (saveErr) rows.push(<div key="err" className="dkan-note dkan-err">{saveErr}</div>);
1261
+ // 运行状态(进行中任务 / 最近完成 / 等待确认)已独立成左侧菜单项,这里只留一句指路
1783
1262
  rows.push(
1784
- <div key="state" className="dkan-sec">
1785
- <div className="dkan-sec-head">
1786
- <span className="dkan-sec-title">运行状态</span>
1787
- <span className="dkan-sec-sub">
1788
- {snap.error ? "状态拉取失败:" + snap.error
1789
- : waitingCount > 0 ? waitingCount + " 项等待确认 · " + active.length + " 个任务进行中"
1790
- : active.length > 0 ? active.length + " 个任务进行中"
1791
- : recent.length > 0 ? "空闲 · 显示最近完成" : "空闲 · 暂无任务记录"}
1792
- </span>
1793
- <button type="button" className="dkan-refresh" onClick={() => animationStore.refresh()}>
1794
- {snap.loading ? "刷新中…" : "刷新"}
1795
- </button>
1796
- </div>
1797
- {active.length > 0 ? <div className="dkan-tasks">{active.map((t) => <TaskRow key={t.sessionId} t={t} />)}</div> : null}
1798
- {active.length === 0 && recent.length === 0
1799
- ? <div className="dkan-note">发起新会话任务后,这里会显示进行中与最近完成的任务;动画与通知同时在页面生效。</div> : null}
1800
- {recent.length > 0 ? <div className="dkan-tasks dkan-tasks-done">{recent.map((t) => <TaskRow key={t.sessionId + ":" + t.endTime} t={t} done />)}</div> : null}
1263
+ <div key="hint" className="dkan-note">
1264
+ 任务运行状态(进行中任务、阶段、Token、等待确认与最近完成)已独立成左侧「运行状态」菜单;本页只负责动效。
1801
1265
  </div>
1802
1266
  );
1803
1267
  return <div className="dkan-root">{rows}</div>;
1804
1268
  }
1805
1269
 
1806
1270
  // ---------- 首页总揽概要 ----------
1271
+ // 任务进行中 / 空闲这类任务信息归【运行状态】模块,这里只报动画自身的状态:
1272
+ // 动效开关 + 当前模式(桌面伙伴另报大小),与面板页开关一一对应。
1807
1273
  function AnimationStat(props) {
1808
1274
  const snap = useAnimation(props && props.ctx);
1809
1275
  const st = snap.status;
1810
- if (snap.error) return <span className="dkan-err">任务状态不可用(宿主需重启加载动画路由)</span>;
1811
- if (!st) return <span>等待任务状态…</span>;
1812
- if (st.active && st.active.length > 0) {
1813
- return <span>{st.active.length + " 个任务进行中 · " + fmtDur(Math.max(...st.active.map((x) => x.elapsed || 0)))}</span>;
1814
- }
1815
- const last = st.recent && st.recent[0];
1816
- return <span>{last ? "空闲 · 最近完成 " + truncate(last.title, 24) : "空闲 · 暂无任务记录"}</span>;
1276
+ if (snap.error) return <span className="dkan-err">动画状态不可用(宿主需重启加载动画路由)</span>;
1277
+ if (!st) return <span>等待动画配置…</span>;
1278
+ const cfg = st.config;
1279
+ if (!cfg) return <span>等待动画配置…</span>;
1280
+ if (!cfg.animationEnabled) return <span>动效已关闭 · 任务进行中不显示动画</span>;
1281
+ const mode = EFFECT_MODES.find((m) => m.id === cfg.effectMode) || EFFECT_MODES[0];
1282
+ const scale = Math.round((Number(cfg.robotScale) || 1.35) * 100);
1283
+ return <span>{"动效:" + mode.name + (cfg.effectMode === "robot" ? " · 大小 " + scale + "%" : "")}</span>;
1817
1284
  }
1818
1285
 
1819
1286
  // ---------- 样式(dkan- 前缀;全部走主题变量,暗/亮色自适应;动效时长 = 基准 / --dkan-speed) ----------
@@ -1947,21 +1414,6 @@ const css = [
1947
1414
  "@keyframes dkan-badge-breathe{0%,100%{box-shadow:0 0 6px color-mix(in srgb,var(--dkan-phase,#4d9fff) 22%,transparent),0 6px 24px rgb(0 0 0 / .16)}50%{box-shadow:0 0 18px color-mix(in srgb,var(--dkan-phase,#4d9fff) 55%,transparent),0 6px 24px rgb(0 0 0 / .16)}}",
1948
1415
  ".dkan-ring{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 68%,var(--dk-accent) 92%,#fff);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 2px));mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 2px));animation:dkan-spin calc(2.2s / var(--dkan-speed,1)) linear infinite;opacity:.9;}",
1949
1416
  "@keyframes dkan-spin{to{transform:rotate(360deg)}}",
1950
- // 通知卡片栈(右上角)
1951
- ".dkan-toasts{position:fixed;top:16px;right:16px;z-index:9995;display:flex;flex-direction:column;gap:8px;width:min(380px,calc(100vw - 32px));}",
1952
- ".dkan-toast{border-radius:12px;padding:12px 14px;pointer-events:auto;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 88%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 10px 36px rgb(0 0 0 / .22);animation:dkan-toast-in .32s var(--ds-ease-in-out);}",
1953
- ".dkan-toast.out{animation:dkan-toast-out .24s var(--ds-ease-in-out) forwards;}",
1954
- "@keyframes dkan-toast-in{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}",
1955
- "@keyframes dkan-toast-out{to{opacity:0;transform:translateX(10px)}}",
1956
- ".dkan-toast-head{display:flex;align-items:center;gap:8px;margin-bottom:4px;}",
1957
- ".dkan-toast-mark{width:8px;height:8px;border-radius:50%;flex:none;}",
1958
- ".dkan-toast-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
1959
- ".dkan-toast-close{cursor:pointer;flex:none;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:6px;width:22px;height:22px;font-size:11px;line-height:1;display:inline-flex;align-items:center;justify-content:center;font-family:inherit;}",
1960
- ".dkan-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
1961
- ".dkan-toast-body{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.7;white-space:pre-line;word-break:break-word;}",
1962
- // 需确认卡片:琥珀描边 + 缓慢呼吸光晕,常驻提醒直到用户处理
1963
- ".dkan-toast-confirm{border-color:color-mix(in srgb,var(--dk-warn) 55%,transparent);animation:dkan-toast-in .32s var(--ds-ease-in-out),dkan-confirm-glow 2.4s ease-in-out 0.4s infinite;}",
1964
- "@keyframes dkan-confirm-glow{0%,100%{box-shadow:0 10px 36px rgb(0 0 0 / .22),0 0 0 0 color-mix(in srgb,var(--dk-warn) 30%,transparent)}50%{box-shadow:0 10px 36px rgb(0 0 0 / .22),0 0 14px 2px color-mix(in srgb,var(--dk-warn) 32%,transparent)}}",
1965
1417
  // 面板页布局
1966
1418
  ".dkan-root{display:flex;flex-direction:column;gap:10px;}",
1967
1419
  ".dkan-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;}",
@@ -1973,8 +1425,6 @@ const css = [
1973
1425
  ".dkan-sec-sw{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px;}",
1974
1426
  ".dkan-sec-swlabel{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;}",
1975
1427
  ".dkan-sec-swlabel.on{color:var(--dsw-alias-state-success-primary);}",
1976
- ".dkan-refresh{cursor:pointer;flex:none;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:2px 10px;font-family:inherit;font-size:12px;}",
1977
- ".dkan-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);}",
1978
1428
  // 模式选择卡(带缩微预览)
1979
1429
  ".dkan-modes{display:flex;gap:8px;flex-wrap:wrap;}",
1980
1430
  ".dkan-mode{flex:1;min-width:150px;display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;font-family:inherit;text-align:left;transition:border-color .15s var(--ds-ease-in-out);}",
@@ -2197,44 +1647,6 @@ const css = [
2197
1647
  "@keyframes dkan-search-wrist{0%,34%{transform:translateX(-1px)}62%,100%{transform:translateX(1.5px)}}",
2198
1648
  // 减少动态时停止空间位移,保留屏幕亮暗与思考状态的淡入反馈。
2199
1649
  "@media (prefers-reduced-motion:reduce){.dkan-amb *,.dkan-prev *,.dkan-space-system,.dkan-space-dust,.dkan-space-runner,.dkan-delivery,.dkan-departure,.dkan-freighter-engine i,.dkan-stuck-code::after,.dkan-stuck-code i{animation:none!important}.dkan-space-runner,.dkan-departure{display:none}.dkan-delivery{opacity:1;transform:translate3d(var(--dkan-to-x),var(--dkan-to-y),0)}.dkan-delivery-trail{display:none}.dk3-person,.dk3-upper3,.dk3-head3,.dk3-arm3,.dk3-elbow,.dk3-wrist3,.dk3-wheel,.dk3-code,.dk3-search-results{animation:none!important;transition:none!important}.dk3-screen,.dkan-bubble{transition:opacity .2s cubic-bezier(.23,1,.32,1)!important}}",
2200
- // 通知子选项行
2201
- ".dkan-rows-narrow{display:flex;flex-direction:column;gap:6px;}",
2202
- ".dkan-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;}",
2203
- ".dkan-row-label{flex:none;min-width:60px;color:var(--dsw-alias-label-primary);}",
2204
- ".dkan-row-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
2205
- ".dkm-miniswitch{cursor:pointer;flex:none;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 12px;font-family:inherit;font-size:11px;line-height:18px;}",
2206
- ".dkm-miniswitch.on{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
2207
- ".dkan-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:3px 8px;font-size:12px;font-family:inherit;}",
2208
- // 音效选择卡(名称 + 播放键;选中态描边)
2209
- ".dkan-sounds{display:flex;gap:6px;flex-wrap:wrap;}",
2210
- ".dkan-sound{flex:1;min-width:104px;display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;font-family:inherit;transition:border-color .15s var(--ds-ease-in-out);}",
2211
- ".dkan-sound:hover{border-color:var(--dk-accent);}",
2212
- ".dkan-sound.on{border-color:var(--dk-accent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dk-accent) 40%,transparent);}",
2213
- ".dkan-sound-name{font-size:12px;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:5px;}",
2214
- ".dkan-sound-cur{color:var(--dk-accent);font-size:11px;}",
2215
- ".dkan-sound-play{flex:none;cursor:pointer;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1;border-radius:50%;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2);}",
2216
- ".dkan-sound-play:hover{color:var(--dsw-alias-label-primary);border-color:currentColor;}",
2217
- ".dkan-input{flex:1;min-width:220px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;}",
2218
- ".dkan-input:focus{outline:none;border-color:var(--dk-accent);}",
2219
- ".dkan-row-webhook{flex-wrap:nowrap;}",
2220
- ".dkan-row-webhook .dkan-input{min-width:0;}",
2221
- ".dkan-btn{cursor:pointer;flex:none;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 12px;font-family:inherit;font-size:12px;}",
2222
- ".dkan-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
2223
- ".dkan-btn[disabled]{opacity:.5;cursor:default;}",
2224
- ".dkan-ok{color:var(--dsw-alias-state-success-primary);}",
2225
- // 任务列表
2226
- ".dkan-tasks{display:flex;flex-direction:column;gap:6px;}",
2227
- ".dkan-tasks-done{border-top:1px dashed var(--dsw-alias-border-l2);padding-top:6px;}",
2228
- ".dkan-task{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;display:flex;flex-direction:column;gap:3px;}",
2229
- ".dkan-task-head{display:flex;align-items:center;gap:8px;min-width:0;}",
2230
- ".dkan-task-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
2231
- ".dkan-task-meta{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-tertiary);}",
2232
- ".dkan-task-err{font-size:11px;color:var(--dsw-alias-state-error-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
2233
- ".dkan-tag{flex:none;font-size:10px;border-radius:999px;padding:0 8px;color:var(--dsw-alias-label-tertiary);border:1px solid var(--dsw-alias-border-l2);}",
2234
- ".dkan-tag.on{color:var(--dk-accent);border-color:currentColor;}",
2235
- ".dkan-tag.ok{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
2236
- ".dkan-tag.err{color:var(--dsw-alias-state-error-primary);border-color:currentColor;}",
2237
- ".dkan-tag.warn{color:var(--dk-warn);border-color:currentColor;}",
2238
1650
  ].join("\n");
2239
1651
 
2240
1652
  export const feature = {
@@ -2242,7 +1654,7 @@ export const feature = {
2242
1654
  name: "任务动画",
2243
1655
  order: 130,
2244
1656
  accent: "#f472b6",
2245
- description: "19 种任务运行动画与完成通知:速度随任务活动联动,两组开关独立、配置持久化",
1657
+ description: "19 种任务运行动画:速度随任务活动联动,配置持久化(通知见「任务通知」,任务状态见「运行状态」)",
2246
1658
  css,
2247
1659
  View: AnimationView,
2248
1660
  HomeStat: AnimationStat,