dsh-dock 0.4.1 → 0.6.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.
@@ -0,0 +1,1631 @@
1
+ // dsh-dock · 功能模块【任务动画】· 客户端视图(v0.5.0,参照 @wycto/dsh-task-pulse client 会话追踪消费,同作者 MIT)
2
+ //
3
+ // 三部分:
4
+ // 1. View —— 功能坞面板页:动画/通知两组独立开关 + 动画模式选择(带缩微预览)+ 运行状态列表;
5
+ // 2. HomeStat —— 首页总揽概要(N 个任务进行中 / 空闲);
6
+ // 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
7
+ // 任务进行中渲染克制的动效(顶部流光细线 / 呼吸光点 / 轨道光环)+ 右下角状态徽标,
8
+ // 任务结束时弹通知卡片(可选浏览器系统通知),完成瞬间一缕流光掠过。
9
+ //
10
+ // 设计取向:不做满屏粒子彩带,动效全部走主题变量(暗/亮色自适应)、低透明度、慢节奏。
11
+ // 动画与通知是两个独立开关(可只开其一);配置全部经 Host settings 持久化,重启后恢复。
12
+ //
13
+ // Host 通信:fetch('/dsh-dock/animation/<method>')(见 features/animation/host.js)。
14
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
15
+ import { openPanel, panelNav, subscribePanel } from "../../src/shared.js";
16
+
17
+ // ---------- Host RPC 桥接 ----------
18
+ function rpcCall(method, args) {
19
+ return fetch("/dsh-dock/animation/" + method, {
20
+ method: "POST",
21
+ headers: { "content-type": "application/json" },
22
+ body: JSON.stringify(args === undefined ? {} : args),
23
+ })
24
+ .then(async (res) => {
25
+ const data = await res.json().catch(() => ({}));
26
+ if (res.ok && data && data.ok === true) return data.data;
27
+ // 旧宿主进程没有动画路由(405/404):给出可操作提示而非裸状态码
28
+ if (res.status === 405 || res.status === 404) {
29
+ throw new Error("宿主进程是旧版本(没有任务动画路由),重启 dsh web 后重试");
30
+ }
31
+ if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
32
+ throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
33
+ });
34
+ }
35
+
36
+ // ---------- 动画模式 ----------
37
+ const EFFECT_MODES = [
38
+ { id: "flow", name: "流光细线", desc: "顶部细线流光往返,速度随任务吞吐加快" },
39
+ { id: "breathe", name: "呼吸光点", desc: "醒目呼吸光点带扩散光晕:颜色随任务阶段(思考蓝/输出绿/代码橙/查资料青),呼吸随吞吐加快" },
40
+ { id: "ring", name: "轨道光环", desc: "细环绕圆点旋转,转速随任务速度" },
41
+ { id: "orbit", name: "环屏巡航", desc: "一颗光点沿屏幕边缘巡航整圈,醒目不遮挡" },
42
+ { id: "robot", name: "桌面伙伴", desc: "真人化双工位:查资料看左屏、写入/改代码移到右屏;座椅和转头随实时任务节奏加速" },
43
+ { id: "matrix", name: "代码雨", desc: "字符沿屏幕缓落如数据流,速度随任务吞吐,克制的低透明度" },
44
+ { id: "stars", name: "星野", desc: "细碎星点缓慢飘移闪烁,安静耐看的背景氛围" },
45
+ { id: "aurora", name: "极光", desc: "屏幕顶部柔光带缓慢呼吸流动,像极光拂过" },
46
+ ];
47
+
48
+ // 任务阶段 → 机器人行为/文案(host 按 chunk/事件实时推导:
49
+ // reasoning-delta=think、text-delta=write、tool 名分类=search/code)
50
+ const PHASE_LABELS = { think: "思考中", write: "输出中", code: "编写代码", search: "查资料" };
51
+ function phaseLabel(p) { return PHASE_LABELS[p] || "工作中"; }
52
+ // 阶段配色(徽标呼吸点/光环随任务阶段变色,--dkan-phase 下发到 CSS)
53
+ const PHASE_COLORS = { think: "#60a5fa", write: "#34d399", code: "#fbbf24", search: "#2dd4bf" };
54
+ function phaseColor(p) { return PHASE_COLORS[p] || "#4d9fff"; }
55
+
56
+ // ---------- 结束原因 ----------
57
+ const END_LABELS = {
58
+ completed: { label: "完成", cls: "ok" },
59
+ error: { label: "出错", cls: "err" },
60
+ aborted: { label: "已中止", cls: "warn" },
61
+ blocked: { label: "受阻", cls: "warn" },
62
+ "max-tokens": { label: "达输出上限", cls: "warn" },
63
+ interrupted: { label: "中断", cls: "warn" },
64
+ };
65
+ function endInfo(reason) {
66
+ return END_LABELS[reason] || END_LABELS.completed;
67
+ }
68
+ function isSuccessReason(reason) {
69
+ return !reason || reason === "completed";
70
+ }
71
+
72
+ // ---------- 格式化 ----------
73
+ function fmtNum(n) { return (Number(n) || 0).toLocaleString("en-US"); }
74
+ function fmtCompact(n) {
75
+ n = Number(n) || 0;
76
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
77
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
78
+ return String(Math.round(n));
79
+ }
80
+ // 毫秒 → "3分21秒" / "1小时2分"
81
+ function fmtDur(ms) {
82
+ const s = Math.max(0, Math.round((ms || 0) / 1000));
83
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
84
+ if (h > 0) return h + "小时" + m + "分" + sec + "秒";
85
+ if (m > 0) return m + "分" + sec + "秒";
86
+ return sec + "秒";
87
+ }
88
+ // 毫秒 → "12:34" / "1:02:11"(徽标计时)
89
+ function fmtClock(ms) {
90
+ const s = Math.max(0, Math.floor((ms || 0) / 1000));
91
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
92
+ const p = (x) => String(x).padStart(2, "0");
93
+ return h > 0 ? h + ":" + p(m) + ":" + p(sec) : p(m) + ":" + p(sec);
94
+ }
95
+ function fmtTime(ts) {
96
+ if (!ts) return "";
97
+ const d = new Date(ts);
98
+ const p = (x) => String(x).padStart(2, "0");
99
+ return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
100
+ }
101
+ function truncate(str, max) {
102
+ if (!str) return "";
103
+ return String(str).length > max ? String(str).slice(0, max) + "…" : str;
104
+ }
105
+
106
+ // ---------- 共享快照:浮层(轮询) / 面板页 / 首页概要共用一份数据 ----------
107
+ const animationStore = {
108
+ snap: { status: null, loading: false, error: null },
109
+ listeners: new Set(),
110
+ subscribe(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; },
111
+ emit() { for (const fn of this.listeners) fn(); },
112
+ applyConfig(cfg) {
113
+ if (this.snap.status) {
114
+ this.snap = Object.assign({}, this.snap, { status: Object.assign({}, this.snap.status, { config: cfg }) });
115
+ this.emit();
116
+ }
117
+ },
118
+ refresh() {
119
+ if (this.snap.loading) return Promise.resolve();
120
+ this.snap = Object.assign({}, this.snap, { loading: true });
121
+ this.emit();
122
+ return rpcCall("status")
123
+ .then((data) => {
124
+ this.snap = { status: data, loading: false, error: null };
125
+ this.emit();
126
+ })
127
+ .catch((e) => {
128
+ this.snap = Object.assign({}, this.snap, { loading: false, error: (e && e.message) || String(e) });
129
+ this.emit();
130
+ });
131
+ },
132
+ };
133
+ function useAnimation() {
134
+ const [snap, setSnap] = useState(animationStore.snap);
135
+ useEffect(() => animationStore.subscribe(() => setSnap(animationStore.snap)), []);
136
+ return snap;
137
+ }
138
+
139
+ // 秒级滴答(徽标计时用;ctx.interval 优先,退回 setInterval)
140
+ function useTicker(ctx, active) {
141
+ const [, bump] = useState(0);
142
+ useEffect(() => {
143
+ if (!active) return;
144
+ if (ctx && typeof ctx.interval === "function") return ctx.interval(bump, 1000);
145
+ const t = setInterval(bump, 1000);
146
+ return () => clearInterval(t);
147
+ }, [active]);
148
+ }
149
+
150
+ // ---------- 桌面场景(CSS 3D:长方体拼装的 3D 动漫人物,侧身面向镜头、面朝三屏) ----------
151
+ // data-phase 驱动四态(think/write/code/search,host 由 assistant/chunk 流级事件实时同步);
152
+ // --dkan-speed 随任务吞吐加速;卡片可整卡自由拖拽(位置 localStorage 持久化)。
153
+ // Box3 = 六面长方体(w×h×d),faces: 前(+Z)/后/右(+X)/左/顶/底;世界层统一转角取 3/4 视角。
154
+ function Box3(props) {
155
+ const w = props.w, h = props.h, d = props.d;
156
+ const faces = [
157
+ [w, h, "translate(-50%,-50%) translateZ(" + (d / 2) + "px)"],
158
+ [w, h, "translate(-50%,-50%) rotateY(180deg) translateZ(" + (d / 2) + "px)"],
159
+ [d, h, "translate(-50%,-50%) rotateY(90deg) translateZ(" + (w / 2) + "px)"],
160
+ [d, h, "translate(-50%,-50%) rotateY(-90deg) translateZ(" + (w / 2) + "px)"],
161
+ [w, d, "translate(-50%,-50%) rotateX(90deg) translateZ(" + (h / 2) + "px)"],
162
+ [w, d, "translate(-50%,-50%) rotateX(-90deg) translateZ(" + (h / 2) + "px)"],
163
+ ];
164
+ return (
165
+ <span className={"dk3-box " + (props.cls || "")}
166
+ style={Object.assign({ width: w, height: h, left: props.x - w / 2, top: props.y - h / 2 },
167
+ props.z ? { transform: "translateZ(" + props.z + "px)" } : null, props.style)}>
168
+ {faces.map((f, i) => (
169
+ <span key={i} className="dk3-face" style={{ width: f[0], height: f[1], transform: f[2] }} />
170
+ ))}
171
+ {props.children}
172
+ </span>
173
+ );
174
+ }
175
+
176
+ function Monitor3(props) {
177
+ const w = props.w, h = props.h;
178
+ const searchScreen = props.mode === "search";
179
+ // 屏幕内容:10 行代码(5 行×2 循环)+ 光标,整体向上滚动(速度随 --dkan-speed)
180
+ const lines = [1, 2, 3, 4, 5, 1, 2, 3, 4, 5];
181
+ return (
182
+ <span className={"dk3-mon3 " + (props.cls || "")}
183
+ style={{ left: props.x - w / 2, top: props.y - h / 2, width: w, height: h, transform: "translateZ(" + (props.z || -3) + "px) rotateY(" + (props.ry || 0) + "deg)" }}>
184
+ <Box3 w={w} h={h} d={5} cls="dk3-frame" x={w / 2} y={h / 2} />
185
+ <span className="dk3-screen" style={{ width: w - 4, height: h - 4, transform: "translate(-50%,-50%) translateZ(3.1px)" }}>
186
+ {searchScreen ? <span className="dk3-search-ui">
187
+ <span className="dk3-search-box"><b /></span>
188
+ <span className="dk3-search-results">{lines.slice(0, 6).map((n, i) => <i key={i} />)}</span>
189
+ </span> : <span className="dk3-code">
190
+ {lines.map((n, i) => <i key={i} />)}
191
+ <span className="dk3-cur" />
192
+ </span>}
193
+ </span>
194
+ </span>
195
+ );
196
+ }
197
+
198
+ // 3D 动漫人物:更拟人的比例与细节(圆角头/ volumetric 头发/五官/脖颈/弯肘/腿脚鞋)。
199
+ // 紧凑双工位:左侧专做资料检索,右侧专做写作/改文件;顶部小屏放任务状态。
200
+ // 人物坐滚轮椅在两侧工位间滑动,思考时停在两屏之间。
201
+ function RobotScene(props) {
202
+ const phase = props && props.phase ? props.phase : "code";
203
+ const tasks = Array.isArray(props && props.tasks) ? props.tasks : null;
204
+ const n = tasks ? Math.max(1, Math.min(3, tasks.length)) : 1;
205
+ // 左工位只做资料检索;正文输出和工具写入都在右工位,思考回到中位。
206
+ const station = phase === "search" ? "left" : (phase === "write" || phase === "code" ? "right" : "center");
207
+ return (
208
+ <div className="dkan-bot-scene" data-phase={phase} data-station={station} data-tasks={tasks ? String(n) : undefined} aria-hidden="true">
209
+ <div className="dk3-world">
210
+ {/* 紧凑单人书桌:只留左右两个真实工作位,消除左侧空桌面。 */}
211
+ <Box3 w={134} h={7} d={38} cls="dk3-desk" x={145} y={76} />
212
+ <Box3 w={5} h={24} d={30} cls="dk3-metal dk3-leg" x={84} y={89} />
213
+ <Box3 w={5} h={24} d={30} cls="dk3-metal dk3-leg" x={206} y={89} />
214
+ {/* 左检索、右编码,顶部小屏显示任务进度。 */}
215
+ <Monitor3 w={34} h={24} x={140} y={59} ry={20} cls="left" mode="search" />
216
+ <Monitor3 w={42} h={30} x={180} y={57} ry={-16} cls="right" />
217
+ <Monitor3 w={28} h={18} x={160} y={31} ry={0} cls="top" />
218
+ {/* 两套键鼠紧贴相应屏幕,中央仅保留咖啡杯。 */}
219
+ <Box3 w={16} h={2.5} d={9} cls="dk3-metal dk3-kb3" x={130} y={71} z={14} />
220
+ <Box3 w={4} h={2.5} d={6} cls="dk3-metal dk3-mouse3" x={141} y={71} z={14} />
221
+ <Box3 w={4} h={5} d={4} cls="dk3-mug" x={154} y={69} z={14} />
222
+ <Box3 w={16} h={2.5} d={9} cls="dk3-metal dk3-kb3" x={170} y={71} z={14} />
223
+ <Box3 w={4} h={2.5} d={6} cls="dk3-metal dk3-mouse3" x={181} y={71} z={14} />
224
+ {/* 人物组:按当前任务在左检索/中思考/右编码两个工位间平滑移动。 */}
225
+ <div className="dk3-person">
226
+ {/* 椅子:靠背只到肩胛、降低支柱和底盘,贴近真实坐姿。 */}
227
+ <Box3 w={4} h={20} d={20} cls="dk3-chairback" x={2} y={47} />
228
+ <Box3 w={18} h={4} d={20} cls="dk3-chairseat" x={12} y={59} />
229
+ <Box3 w={3} h={9} d={3} cls="dk3-metal" x={12} y={66} />
230
+ <Box3 w={13} h={2} d={13} cls="dk3-metal" x={12} y={71} />
231
+ {/* 椅子滚轮(左右各一,滑动时可见) */}
232
+ <Box3 w={3} h={3} d={3} cls="dk3-wheel" x={6} y={74} />
233
+ <Box3 w={3} h={3} d={3} cls="dk3-wheel" x={18} y={74} />
234
+ {/* 腿:大腿/小腿/鞋 */}
235
+ <Box3 w={12} h={5} d={9} cls="dk3-pants" x={20} y={54} />
236
+ <Box3 w={4} h={10} d={4} cls="dk3-pants" x={26} y={62} />
237
+ <Box3 w={6} h={3} d={5} cls="dk3-shoe" x={28} y={70} />
238
+ {/* 上身整体始终面向桌面;阶段动作只改变前倾、肩肘腕和视线。 */}
239
+ <div className="dk3-upper3">
240
+ {/* 肩线、胸腔和腰部做成上宽下窄,摆脱直筒积木感。 */}
241
+ <Box3 w={18} h={5} d={12} cls="dk3-hood dk3-shoulders3" x={13} y={30} />
242
+ <Box3 w={15} h={14} d={11} cls="dk3-hood dk3-torso dk3-chest3" x={12} y={38} />
243
+ <Box3 w={12} h={7} d={10} cls="dk3-hood dk3-torso dk3-waist3" x={12} y={47} />
244
+ <Box3 w={5} h={8} d={9} cls="dk3-hood dk3-hoodbump" x={5} y={32} />
245
+ {/* 颈部加长并与下颌、衣领重叠,避免悬空。 */}
246
+ <Box3 w={5} h={7} d={5} cls="dk3-skin dk3-neck3" x={14} y={24} />
247
+ <Box3 w={8} h={3} d={8} cls="dk3-collar3" x={14} y={28} />
248
+ {/* 头组与颈部同轴:颅骨 + 收窄下颌 + 短侧渐层发型。 */}
249
+ <div className="dk3-head3">
250
+ <Box3 w={12} h={11} d={11} cls="dk3-skin dk3-cranium" x={8} y={8}>
251
+ <span className="dk3-brows" style={{ width: 9, height: 4, transform: "translate(-50%,-50%) rotateY(90deg) translateZ(5.8px)" }}><i /><i /></span>
252
+ <span className="dk3-eyes" style={{ width: 9, height: 6, transform: "translate(-50%,-50%) rotateY(90deg) translateZ(5.7px)" }}><i /><i /></span>
253
+ <span className="dk3-nose" style={{ transform: "translate(-50%,-50%) rotateY(90deg) translateZ(6.1px)" }} />
254
+ </Box3>
255
+ <Box3 w={9} h={7} d={9} cls="dk3-skin dk3-jaw3" x={8.5} y={14}>
256
+ <span className="dk3-mouth" style={{ transform: "translate(-50%,-50%) rotateY(90deg) translateZ(4.7px)" }} />
257
+ </Box3>
258
+ <Box3 w={12} h={5} d={12} cls="dk3-hair dk3-hair-crown" x={8} y={3.5} />
259
+ <Box3 w={4} h={8} d={11} cls="dk3-hair dk3-hair-back" x={3} y={7.5} />
260
+ <span className="dk3-fringe3"><i /><i /><i /></span>
261
+ <span className="dk3-ear" />
262
+ </div>
263
+ {/* 近侧手臂:肩、肘、腕、手指分段动作。 */}
264
+ <div className="dk3-arm3 dk3-near">
265
+ <Box3 w={4} h={11} d={4} cls="dk3-hood dk3-uarm" x={2} y={6} />
266
+ <div className="dk3-elbow">
267
+ <Box3 w={10} h={3.5} d={3.5} cls="dk3-hood dk3-farm" x={5.5} y={2} />
268
+ <div className="dk3-wrist3">
269
+ <Box3 w={3.5} h={3} d={3} cls="dk3-skin dk3-hand" x={2} y={2} />
270
+ <span className="dk3-fingers3"><i /><i /></span>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ {/* 远侧手臂:在桌面深处,和近侧手错开节奏。 */}
275
+ <div className="dk3-arm3 dk3-far">
276
+ <Box3 w={4} h={11} d={4} cls="dk3-hood dk3-uarm" x={2} y={6} />
277
+ <div className="dk3-elbow">
278
+ <Box3 w={10} h={3.5} d={3.5} cls="dk3-hood dk3-farm" x={5.5} y={2} />
279
+ <div className="dk3-wrist3">
280
+ <Box3 w={3.5} h={3} d={3} cls="dk3-skin dk3-hand" x={2} y={2} />
281
+ <span className="dk3-fingers3"><i /><i /></span>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ {/* 思考泡泡(贴镜头的 2D 层,think 阶段显示) */}
289
+ <span className="dkan-bubble"><i /><i /><i /></span>
290
+ </div>
291
+ );
292
+ }
293
+
294
+ // ---------- 提示音(WebAudio 合成;macOS/Windows 通用,无音频文件) ----------
295
+ // 音效库:每种含完成音(done)与异常音(err)两套音符序列。
296
+ // 音符 [频率, 起始秒, 时长秒, 波形?],波形缺省 sine。
297
+ const SOUND_LIBRARY = {
298
+ chime: { name: "清脆双音", notes: { done: [[659.25, 0, .14], [880, .13, .24]], err: [[220, 0, .16], [164.81, .15, .3]] } },
299
+ ding: { name: "叮", notes: { done: [[987.77, 0, .35]], err: [[246.94, 0, .4]] } },
300
+ 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]] } },
301
+ 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]] } },
302
+ 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]] } },
303
+ 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]] } },
304
+ };
305
+ // AudioContext 按需创建(浏览器自动播放策略:首次用户交互后才能出声,静默失败不报错)
306
+ let soundCtx = null;
307
+ function playTone(seq) {
308
+ try {
309
+ if (typeof window === "undefined" || !window.AudioContext && !window.webkitAudioContext) return;
310
+ const AC = window.AudioContext || window.webkitAudioContext;
311
+ if (!soundCtx) soundCtx = new AC();
312
+ if (soundCtx.state === "suspended") { soundCtx.resume().catch(() => {}); }
313
+ const t0 = soundCtx.currentTime;
314
+ for (const [f, at, dur, wave] of seq) {
315
+ const osc = soundCtx.createOscillator();
316
+ const gain = soundCtx.createGain();
317
+ osc.type = wave || "sine";
318
+ osc.frequency.value = f;
319
+ gain.gain.setValueAtTime(0, t0 + at);
320
+ gain.gain.linearRampToValueAtTime(0.18, t0 + at + 0.015);
321
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + at + dur);
322
+ osc.connect(gain).connect(soundCtx.destination);
323
+ osc.start(t0 + at);
324
+ osc.stop(t0 + at + dur + 0.05);
325
+ }
326
+ } catch { /* 音频不可用静默 */ }
327
+ }
328
+ // 按音效播放:完成音 / 异常音(未知音效回退 chime)
329
+ function playDoneSound(success, effect) {
330
+ const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
331
+ playTone(success ? lib.notes.done : lib.notes.err);
332
+ }
333
+ // 试听:完成音 + 异常音 连播(间隔 0.55s)
334
+ function previewSound(effect) {
335
+ const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
336
+ playTone(lib.notes.done);
337
+ const delayed = lib.notes.err.map(([f, at, dur, wave]) => [f, at + 0.55, dur, wave]);
338
+ playTone(delayed);
339
+ }
340
+
341
+
342
+ function Toast(props) {
343
+ const t = props.t;
344
+ const [closing, setClosing] = useState(false);
345
+ const close = useCallback(() => { if (!closing) setClosing(true); }, [closing]);
346
+ // 停留时长(stayMs=0 常驻,仅手动关闭)
347
+ useEffect(() => {
348
+ if (!t.stayMs) return;
349
+ const timer = setTimeout(close, t.stayMs);
350
+ return () => clearTimeout(timer);
351
+ }, []);
352
+ useEffect(() => {
353
+ if (!closing) return;
354
+ const timer = setTimeout(() => props.onClose(t.id), 240);
355
+ return () => clearTimeout(timer);
356
+ }, [closing]);
357
+ const markColor = t.kind === "success"
358
+ ? "var(--dsw-alias-state-success-primary,#34d399)"
359
+ : t.kind === "error"
360
+ ? "var(--dsw-alias-state-error-primary,#f87171)"
361
+ : "var(--dsw-alias-state-warning-primary,#fbbf24)";
362
+ return (
363
+ <div className={"dkan-toast" + (closing ? " out" : "")}>
364
+ <div className="dkan-toast-head">
365
+ <span className="dkan-toast-mark" style={{ background: markColor }} />
366
+ <span className="dkan-toast-title" title={t.title}>{t.title}</span>
367
+ <button type="button" className="dkan-toast-close" aria-label="关闭" onClick={close}>✕</button>
368
+ </div>
369
+ {t.body ? <div className="dkan-toast-body">{t.body}</div> : null}
370
+ </div>
371
+ );
372
+ }
373
+
374
+ // ---------- 交互反馈层:对用户操作即时回应(纸飞机/火花/流光/微光/光涌/彩带) ----------
375
+ // 事件来源:document 点击监听(发送/新会话/切会话/切工作目录)+ 任务状态变化(开始/完成)。
376
+ // 每个 burst = { id, type, x, y, bits },bits 为预生成的随机粒子参数(内联样式驱动 CSS 动画)。
377
+ function makeBits(n, fn) {
378
+ const arr = [];
379
+ for (let i = 0; i < n; i++) arr.push(fn(i));
380
+ return arr;
381
+ }
382
+ function BurstLayer(props) {
383
+ const bursts = props.bursts || [];
384
+ return (
385
+ <div className="dkan-fxwrap" aria-hidden="true">
386
+ {bursts.map((b) => {
387
+ if (b.type === "plane") {
388
+ return <span key={b.id} className="dkan-fx dkan-fx-plane" style={{ left: b.x, top: b.y }}>➤</span>;
389
+ }
390
+ if (b.type === "spark") {
391
+ return (
392
+ <span key={b.id} className="dkan-fx dkan-fx-spark" style={{ left: b.x, top: b.y }}>
393
+ {b.bits.map((p, i) => <i key={i} style={{ "--dx": p.dx + "px", "--dy": p.dy + "px", background: p.c, boxShadow: "0 0 6px " + p.c }} />)}
394
+ </span>
395
+ );
396
+ }
397
+ if (b.type === "streak") return <span key={b.id} className="dkan-fx dkan-fx-streak" />;
398
+ if (b.type === "flash") return <span key={b.id} className="dkan-fx dkan-fx-flash" />;
399
+ if (b.type === "surge") return <span key={b.id} className="dkan-fx dkan-fx-surge" />;
400
+ if (b.type === "confetti") {
401
+ return (
402
+ <span key={b.id} className="dkan-fx dkan-fx-confetti">
403
+ {b.bits.map((p, i) => <i key={i} style={{ left: p.l + "%", "--dx": p.dx + "px", "--r": p.r + "deg", background: p.c, boxShadow: "0 0 4px " + p.c, animationDelay: p.d + "s" }} />)}
404
+ </span>
405
+ );
406
+ }
407
+ return null;
408
+ })}
409
+ </div>
410
+ );
411
+ }
412
+
413
+ // ---------- 氛围动效:代码雨 / 星野 / 极光(粒子配置 useMemo 稳定,避免重渲染抖动) ----------
414
+ const MATRIX_CHARS = "01</>;{}=+*#";
415
+ function AmbientLayer(props) {
416
+ const mode = props.mode;
417
+ const speed = props.speed;
418
+ const matrix = useMemo(() => makeBits(34, () => ({
419
+ l: Math.random() * 100, d: 4 + Math.random() * 6, delay: -Math.random() * 8, o: 0.28 + Math.random() * 0.34, s: 11 + Math.round(Math.random() * 4),
420
+ c: MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)] + MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)] + MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)],
421
+ })), []);
422
+ const stars = useMemo(() => makeBits(80, () => ({
423
+ l: Math.random() * 100, t: Math.random() * 100, sz: 1.5 + Math.random() * 2, d: 2 + Math.random() * 4, delay: -Math.random() * 6, dx: (Math.random() - 0.5) * 40, dy: (Math.random() - 0.5) * 24,
424
+ })), []);
425
+ if (mode === "matrix") {
426
+ return (
427
+ <div className="dkan-amb dkan-matrix" style={{ "--dkan-speed": speed }} aria-hidden="true">
428
+ {matrix.map((p, i) => (
429
+ <span key={i} style={{ left: p.l + "%", animationDuration: "calc(" + p.d + "s / var(--dkan-speed,1))", animationDelay: p.delay + "s", opacity: p.o, fontSize: p.s }}>{p.c}</span>
430
+ ))}
431
+ </div>
432
+ );
433
+ }
434
+ if (mode === "stars") {
435
+ return (
436
+ <div className="dkan-amb dkan-stars" style={{ "--dkan-speed": speed }} aria-hidden="true">
437
+ {stars.map((p, i) => (
438
+ <i key={i} className="dkan-star" style={{ left: p.l + "%", top: p.t + "%", width: p.sz, height: p.sz, animationDuration: "calc(" + p.d + "s / var(--dkan-speed,1))", animationDelay: p.delay + "s", "--dx": p.dx + "px", "--dy": p.dy + "px" }} />
439
+ ))}
440
+ <span className="dkan-moon" />
441
+ <i className="dkan-meteor" style={{ top: "12%", left: "72%", animationDelay: "0s" }} />
442
+ <i className="dkan-meteor" style={{ top: "28%", left: "88%", animationDelay: "2.8s" }} />
443
+ <i className="dkan-meteor" style={{ top: "6%", left: "42%", animationDelay: "5.6s" }} />
444
+ </div>
445
+ );
446
+ }
447
+ if (mode === "aurora") {
448
+ return (
449
+ <div className="dkan-amb dkan-aurora" style={{ "--dkan-speed": speed }} aria-hidden="true">
450
+ <i /><i /><i />
451
+ </div>
452
+ );
453
+ }
454
+ return null;
455
+ }
456
+
457
+ // ---------- 全局浮层:轮询 + 动效 + 通知(功能启用即常驻) ----------
458
+ export function AnimationOverlay(props) {
459
+ const ctx = props && props.ctx;
460
+ const snap = useAnimation();
461
+ const [toasts, setToasts] = useState([]);
462
+ const [flourish, setFlourish] = useState(null); // { key, err } 完成瞬间的一次性流光
463
+ const [bursts, setBursts] = useState([]); // 交互反馈动画队列
464
+ const prevActiveRef = useRef(null);
465
+ const burstIdRef = useRef(0);
466
+
467
+ // 触发一个交互反馈动画(自动清理)
468
+ const pushBurst = useCallback((type, x, y, bits) => {
469
+ const id = ++burstIdRef.current;
470
+ setBursts((prev) => prev.concat([{ id, type, x, y, bits }]).slice(-6));
471
+ setTimeout(() => setBursts((prev) => prev.filter((b) => b.id !== id)), 2600);
472
+ }, []);
473
+
474
+ // 点击监听:识别用户操作 → 即时反馈(发送/新会话/切会话/切工作目录)
475
+ useEffect(() => {
476
+ if (typeof document === "undefined") return;
477
+ const onClick = (e) => {
478
+ const t = e.target;
479
+ if (!t || !t.closest) return;
480
+ const sendBtn = t.closest('button[aria-label="Send message"], button[aria-label*="发送"]');
481
+ const newSession = t.closest('button[aria-label*="New session"], button[aria-label*="新会话"], button[aria-label*="New Session"]');
482
+ const workspace = t.closest('button[aria-label*="Choose workspace"], button[aria-label*="工作目录"], button[aria-label*="workspace"]');
483
+ const sessionItem = t.closest('[role="treeitem"]');
484
+ if (sendBtn) {
485
+ pushBurst("plane", e.clientX, e.clientY);
486
+ } else if (newSession) {
487
+ pushBurst("spark", e.clientX, e.clientY, makeBits(10, () => ({
488
+ dx: (Math.random() - 0.5) * 70, dy: (Math.random() - 0.5) * 70,
489
+ c: ["#4d9fff", "#34d399", "#fbbf24", "#f472b6"][Math.floor(Math.random() * 4)],
490
+ })));
491
+ } else if (workspace) {
492
+ pushBurst("flash");
493
+ } else if (sessionItem) {
494
+ pushBurst("streak");
495
+ }
496
+ };
497
+ document.addEventListener("click", onClick, true);
498
+ return () => document.removeEventListener("click", onClick, true);
499
+ }, [pushBurst]);
500
+
501
+ // 任务状态变化 → 开始光涌 / 完成彩带
502
+ const taskCountRef = useRef(0);
503
+ useEffect(() => {
504
+ const st = snap.status;
505
+ if (!st || !st.active) return;
506
+ const n = st.active.length;
507
+ if (n > taskCountRef.current) pushBurst("surge");
508
+ taskCountRef.current = n;
509
+ }, [snap.status, pushBurst]);
510
+
511
+ // 轮询:任务中 850ms,实时捕捉 chunk/工具活动;空闲 6s / 出错 15s;页面切回立即刷新。
512
+ useEffect(() => {
513
+ let stopped = false;
514
+ let timer = null;
515
+ const loop = async () => {
516
+ await animationStore.refresh();
517
+ if (stopped) return;
518
+ const s = animationStore.snap;
519
+ timer = setTimeout(loop, s.error ? 15000 : (s.status && s.status.active && s.status.active.length > 0 ? 850 : 6000));
520
+ };
521
+ loop();
522
+ const onVisible = () => {
523
+ if (!stopped && typeof document !== "undefined" && document.visibilityState === "visible") animationStore.refresh();
524
+ };
525
+ document.addEventListener("visibilitychange", onVisible);
526
+ return () => { stopped = true; if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", onVisible); };
527
+ }, []);
528
+
529
+ // 任务结束检测:上一轮活跃、本轮消失 → 通知 + 流光(找最近完成记录补全信息)
530
+ useEffect(() => {
531
+ const st = snap.status;
532
+ if (!st || !st.active) return;
533
+ const prev = prevActiveRef.current;
534
+ if (prev) {
535
+ const currIds = new Set(st.active.map((x) => x.sessionId));
536
+ for (const task of prev) {
537
+ if (currIds.has(task.sessionId)) continue;
538
+ const record = (st.recent || []).find((r) => r.sessionId === task.sessionId) || null;
539
+ handleTaskEnd(task, record, st.config || {});
540
+ }
541
+ }
542
+ prevActiveRef.current = st.active.slice();
543
+ }, [snap.status]);
544
+
545
+ const handleTaskEnd = (task, record, cfg) => {
546
+ const reason = (record && record.endReason) || "";
547
+ const success = isSuccessReason(reason);
548
+ const info = endInfo(reason);
549
+ // 动画侧:完成瞬间一缕流光掠过 + 成功时顶部彩带庆祝(动画关时不渲染)
550
+ if (cfg.animationEnabled) {
551
+ setFlourish({ key: Date.now(), err: !success });
552
+ if (success) {
553
+ pushBurst("confetti", 0, 0, makeBits(18, (i) => ({
554
+ l: 4 + (i / 18) * 92 + Math.random() * 3,
555
+ dx: (Math.random() - 0.5) * 60,
556
+ r: Math.random() * 720 - 360,
557
+ d: Math.random() * 0.35,
558
+ c: ["#4d9fff", "#34d399", "#fbbf24", "#f472b6", "#a78bfa"][i % 5],
559
+ })));
560
+ }
561
+ }
562
+ // 通知侧:与动画开关完全独立
563
+ if (!cfg.notifyEnabled) return;
564
+ const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
565
+ if (!wanted) return;
566
+ const models = record && record.models && record.models.length ? record.models.join(", ")
567
+ : (task.models && task.models.length ? task.models.join(", ") : "未知模型");
568
+ const provider = (record && record.provider) || task.provider || "";
569
+ const startTs = (record && record.startTime) || task.startTime;
570
+ const endTs = (record && record.endTime) || Date.now();
571
+ const lines = [
572
+ "任务:" + ((record && record.title) || task.title || "(无标题)"),
573
+ "模型:" + models + (provider ? "(" + provider + ")" : ""),
574
+ "耗时:" + fmtDur((record && record.duration) || (endTs - startTs))
575
+ + "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")",
576
+ "回合 " + ((record && record.turns) || task.turns || 0)
577
+ + " · 步骤 " + ((record && record.steps) || task.steps || 0)
578
+ + (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""),
579
+ "Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
580
+ + " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens),
581
+ ];
582
+ if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
583
+ if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
584
+ const title = success ? "任务完成" : "任务" + info.label;
585
+ const toast = {
586
+ id: Date.now() + Math.random(),
587
+ kind: success ? "success" : (info.cls === "err" ? "error" : "warn"),
588
+ title,
589
+ body: lines.join("\n"),
590
+ stayMs: typeof cfg.notifyStayMs === "number" ? cfg.notifyStayMs : 8000,
591
+ };
592
+ setToasts((prev) => prev.concat([toast]).slice(-4));
593
+ // 提示音:任务结束时播放(完成/异常配套音);与系统通知独立开关
594
+ if (cfg.soundNotify !== false) playDoneSound(success, cfg.soundEffect);
595
+ // 系统通知:仅页面处于后台时推送,避免前台重复打扰
596
+ if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
597
+ && typeof Notification !== "undefined" && Notification.permission === "granted") {
598
+ try {
599
+ new Notification("dsh " + title, {
600
+ body: ((record && record.title) || task.title || "(无标题)") + " · " + fmtDur((record && record.duration) || (endTs - startTs)),
601
+ tag: "dsh-dock-animation-" + task.sessionId,
602
+ });
603
+ } catch { /* 系统通知失败不影响页面内通知 */ }
604
+ }
605
+ };
606
+
607
+ const st = snap.status;
608
+ const cfg = st && st.config;
609
+ const active = st && st.active ? st.active : [];
610
+ const animOn = !!(cfg && cfg.animationEnabled && active.length > 0);
611
+ const mode = cfg && cfg.effectMode ? cfg.effectMode : "flow";
612
+ // 机器人阶段:取最近发生阶段变化的活跃任务。
613
+ const phase = active.length > 0
614
+ ? active.reduce((best, x) => (!best || (x.phaseAt || 0) > (best.phaseAt || 0) ? x : best), null).phase
615
+ : "think";
616
+ useTicker(ctx, animOn);
617
+ // 徽标计时:以最早开始的任务为基准
618
+ const now = Date.now();
619
+ const elapsed = active.length > 0 ? fmtClock(now - Math.min(...active.map((x) => x.startTime || now))) : "";
620
+ // 面板打开时隐藏环境动效(徽标/细线/巡航/机器人),避免与弹窗重叠错位
621
+ const [panelOpen, setPanelOpen] = useState(false);
622
+ useEffect(() => {
623
+ setPanelOpen(panelNav.open);
624
+ return subscribePanel(() => setPanelOpen(panelNav.open));
625
+ }, []);
626
+ // 任务速度:优先使用 host 从 chunk/tool 实时累积的活动脉冲;
627
+ // token usage 只在完整消息后才到达,不能再作为唯一速度来源。
628
+ const [speed, setSpeed] = useState(1);
629
+ const speedRef = useRef({ ticks: -1, at: 0 });
630
+ useEffect(() => {
631
+ if (!st || active.length === 0) { speedRef.current = { ticks: -1, at: 0 }; setSpeed(1); return; }
632
+ const ticks = active.reduce((a, x) => a + (x.motionTicks || 0), 0);
633
+ const t = Date.now();
634
+ const prev = speedRef.current;
635
+ if (prev.ticks < 0 || t - prev.at < 300) { speedRef.current = { ticks, at: t }; return; }
636
+ const rate = Math.max(0, (ticks - prev.ticks) / ((t - prev.at) / 1000));
637
+ const phaseBoost = phase === "write" || phase === "code" ? .28 : (phase === "search" ? .18 : 0);
638
+ const next = Math.max(.9, Math.min(4, Number((.9 + rate / 14 + phaseBoost).toFixed(2))));
639
+ setSpeed((current) => Math.abs(current - next) < .03 ? current : next);
640
+ speedRef.current = { ticks, at: t };
641
+ }, [snap.status]);
642
+ const ambientOn = animOn && !panelOpen;
643
+ const robotScaleFromConfig = Math.max(.85, Math.min(2.2, Number(cfg && cfg.robotScale) || 1.35));
644
+ const speedStyle = { "--dkan-speed": Number(speed).toFixed(2) };
645
+ const saveRobotScale = useCallback((robotScale) => {
646
+ rpcCall("config", { robotScale })
647
+ .then((d) => animationStore.applyConfig(d && d.config))
648
+ .catch(() => { /* 浮层保存失败不打断任务动画;面板页会显示可操作的错误 */ });
649
+ }, []);
650
+
651
+ // ===== 机器人卡片自由拖拽:整卡可拖到屏幕任意位置(localStorage 持久化),防止固定遮挡 =====
652
+ const [botPos, setBotPos] = useState(null); // null = 停泊默认位(右下、输入卡上方)
653
+ const botPosRef = useRef(null);
654
+ const botDragRef = useRef(null); // { sx, sy, ox, oy, moved }
655
+ const botResizeRef = useRef(null); // { sx, sy, scale }
656
+ const [botScaleDraft, setBotScaleDraft] = useState(null);
657
+ const botClickBlockRef = useRef(false);
658
+ useEffect(() => {
659
+ try {
660
+ if (typeof localStorage === "undefined") return;
661
+ const raw = localStorage.getItem("dsh-dock/anim/robot-pos/v1");
662
+ const p = raw ? JSON.parse(raw) : null;
663
+ if (p && typeof p.x === "number" && typeof p.y === "number" && typeof window !== "undefined") {
664
+ // 恢复时钳制回视口内(窗口可能变小了)
665
+ const card = document.querySelector(".dkan-botcard");
666
+ const w = card ? card.offsetWidth : 200, h = card ? card.offsetHeight : 160;
667
+ const x = Math.min(Math.max(8, p.x), Math.max(8, window.innerWidth - w - 8));
668
+ const y = Math.min(Math.max(8, p.y), Math.max(8, window.innerHeight - h - 8));
669
+ botPosRef.current = { x, y };
670
+ setBotPos({ x, y });
671
+ }
672
+ } catch { /* localStorage 不可用时停默认位 */ }
673
+ }, []);
674
+ const onBotPointerDown = (e) => {
675
+ if (e.button !== 0) return;
676
+ const r = e.currentTarget.getBoundingClientRect();
677
+ botDragRef.current = { sx: e.clientX, sy: e.clientY, ox: r.left, oy: r.top, moved: false };
678
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* 无指针捕获也能拖 */ }
679
+ };
680
+ const onBotPointerMove = (e) => {
681
+ const d = botDragRef.current;
682
+ if (!d || typeof window === "undefined") return;
683
+ const dx = e.clientX - d.sx, dy = e.clientY - d.sy;
684
+ if (!d.moved && Math.hypot(dx, dy) < 4) return;
685
+ d.moved = true;
686
+ const el = e.currentTarget;
687
+ const w = el.offsetWidth || 200, h = el.offsetHeight || 160;
688
+ const x = Math.min(Math.max(8, d.ox + dx), window.innerWidth - w - 8);
689
+ const y = Math.min(Math.max(8, d.oy + dy), window.innerHeight - h - 8);
690
+ botPosRef.current = { x, y };
691
+ setBotPos({ x, y });
692
+ };
693
+ const onBotPointerUp = () => {
694
+ const d = botDragRef.current;
695
+ botDragRef.current = null;
696
+ if (d && d.moved) {
697
+ try { if (typeof localStorage !== "undefined") localStorage.setItem("dsh-dock/anim/robot-pos/v1", JSON.stringify(botPosRef.current)); } catch { /* 持久化失败静默 */ }
698
+ botClickBlockRef.current = true; // 拖动结束的 pointerup 不触发面板跳转
699
+ setTimeout(() => { botClickBlockRef.current = false; }, 0);
700
+ }
701
+ };
702
+ const onBotResizePointerDown = (e) => {
703
+ if (e.button !== 0) return;
704
+ e.preventDefault();
705
+ e.stopPropagation();
706
+ botResizeRef.current = { sx: e.clientX, sy: e.clientY, scale: botScaleDraft == null ? robotScaleFromConfig : botScaleDraft };
707
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* 无指针捕获也能缩放 */ }
708
+ };
709
+ const onBotResizePointerMove = (e) => {
710
+ const d = botResizeRef.current;
711
+ if (!d) return;
712
+ e.preventDefault();
713
+ e.stopPropagation();
714
+ // 保持场景比例;以右下角手柄向外拖动放大,向内拖动缩小。
715
+ const delta = ((e.clientX - d.sx) + (e.clientY - d.sy)) / 2;
716
+ const next = Math.max(.85, Math.min(2.2, Math.round((d.scale + delta / 220) * 100) / 100));
717
+ setBotScaleDraft(next);
718
+ // 已拖到自定义位置时,放大也始终留在可视区域内。
719
+ if (botPosRef.current && typeof window !== "undefined") {
720
+ const w = 220 * next + 24, h = 132 * next + 46;
721
+ const x = Math.min(Math.max(8, botPosRef.current.x), Math.max(8, window.innerWidth - w - 8));
722
+ const y = Math.min(Math.max(8, botPosRef.current.y), Math.max(8, window.innerHeight - h - 8));
723
+ botPosRef.current = { x, y };
724
+ setBotPos({ x, y });
725
+ }
726
+ };
727
+ const onBotResizePointerUp = (e) => {
728
+ if (!botResizeRef.current) return;
729
+ e.preventDefault();
730
+ e.stopPropagation();
731
+ const next = botScaleDraft == null ? robotScaleFromConfig : botScaleDraft;
732
+ botResizeRef.current = null;
733
+ saveRobotScale(next);
734
+ try { if (botPosRef.current && typeof localStorage !== "undefined") localStorage.setItem("dsh-dock/anim/robot-pos/v1", JSON.stringify(botPosRef.current)); } catch { /* 位置保存失败不影响缩放 */ }
735
+ setBotScaleDraft(null);
736
+ };
737
+ const onBotClick = () => {
738
+ if (botClickBlockRef.current) return;
739
+ openPanel("animation");
740
+ };
741
+ const displayRobotScale = botScaleDraft == null ? robotScaleFromConfig : botScaleDraft;
742
+ const botCardStyle = botPos
743
+ ? Object.assign({}, speedStyle, { "--dkan-bot-scale": displayRobotScale, left: botPos.x, top: botPos.y, right: "auto", bottom: "auto" })
744
+ : Object.assign({}, speedStyle, { "--dkan-bot-scale": displayRobotScale });
745
+
746
+ // Host 不可用(旧宿主未重启等):浮层整体静默,面板页会给提示
747
+ if (!st) return null;
748
+ return (<>
749
+ {/* 顶部流光细线(flow 模式 / 任务进行中) */}
750
+ {ambientOn && mode === "flow" ? <div className="dkan-line" style={speedStyle} aria-hidden="true" /> : null}
751
+ {/* 氛围动效(代码雨/星野/极光,任务进行中渲染,速度随吞吐) */}
752
+ {ambientOn && (mode === "matrix" || mode === "stars" || mode === "aurora")
753
+ ? <AmbientLayer mode={mode} speed={Number(speed).toFixed(2)} /> : null}
754
+ {/* 环屏巡航:一颗光点沿屏幕边缘转圈(orbit 模式) */}
755
+ {ambientOn && mode === "orbit" ? <div className="dkan-orbit" style={speedStyle} aria-hidden="true" /> : null}
756
+ {/* 交互反馈层:纸飞机/火花/流光/微光/光涌/彩带(与动画开关独立,只要功能启用即回应操作) */}
757
+ <BurstLayer bursts={bursts} />
758
+ {/* 桌面伙伴:背侧视角紧凑双工位人物,阶段与任务同步;整卡可拖到任意位置(robot 模式) */}
759
+ {ambientOn && mode === "robot" ? (
760
+ <div className="dkan-botcard" role="button" tabIndex={0} style={botCardStyle}
761
+ title={active.length + " 个任务 · " + phaseLabel(phase) + " · 点击查看任务动画页 · 可拖动到任意位置"}
762
+ onPointerDown={onBotPointerDown}
763
+ onPointerMove={onBotPointerMove}
764
+ onPointerUp={onBotPointerUp}
765
+ onPointerCancel={onBotPointerUp}
766
+ onClick={onBotClick}
767
+ onKeyDown={(e) => { if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); onBotClick(); } }}>
768
+ <RobotScene phase={phase} tasks={active} />
769
+ <span className="dkan-bot-cap">
770
+ <span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {phaseLabel(phase)}
771
+ </span>
772
+ <button type="button" className="dkan-bot-resize" aria-label="调整桌面伙伴大小"
773
+ title="拖动调整桌面伙伴大小"
774
+ onPointerDown={onBotResizePointerDown}
775
+ onPointerMove={onBotResizePointerMove}
776
+ onPointerUp={onBotResizePointerUp}
777
+ onPointerCancel={onBotResizePointerUp}
778
+ onClick={(e) => e.stopPropagation()}
779
+ onKeyDown={(e) => {
780
+ if (e.key !== "ArrowUp" && e.key !== "ArrowRight" && e.key !== "ArrowDown" && e.key !== "ArrowLeft") return;
781
+ e.preventDefault(); e.stopPropagation();
782
+ const d = e.key === "ArrowUp" || e.key === "ArrowRight" ? .05 : -.05;
783
+ saveRobotScale(Math.max(.85, Math.min(2.2, Math.round((displayRobotScale + d) * 100) / 100)));
784
+ }} />
785
+ </div>
786
+ ) : null}
787
+ {/* 右下角状态徽标:dot 随模式变化(breathe/ring 装饰),颜色随任务阶段、呼吸随吞吐;点击进功能坞动画页 */}
788
+ {ambientOn && mode !== "robot" ? (
789
+ <button type="button" className={"dkan-badge" + (mode === "breathe" ? " dkan-badge-breathe" : "")}
790
+ style={Object.assign({}, speedStyle, { "--dkan-phase": phaseColor(phase) })}
791
+ title={active.length + " 个任务进行中 · " + phaseLabel(phase) + " · 点击查看任务动画页"}
792
+ onClick={() => openPanel("animation")}>
793
+ <span className="dkan-dotwrap">
794
+ <span className="dkan-dot" />
795
+ {mode === "breathe" ? <span className="dkan-halo" /> : null}
796
+ {mode === "ring" ? <span className="dkan-ring" /> : null}
797
+ </span>
798
+ <span className="dkan-badge-txt"><span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {phaseLabel(phase)}</span>
799
+ </button>
800
+ ) : null}
801
+ {/* 完成瞬间的一次性流光(成功绿 / 异常红),动画结束自动清场 */}
802
+ {flourish ? (
803
+ <div key={flourish.key} className={"dkan-done" + (flourish.err ? " err" : "")}
804
+ onAnimationEnd={() => setFlourish(null)} aria-hidden="true" />
805
+ ) : null}
806
+ {/* 通知卡片栈(右下→右上不遮 dsh 自身 UI;右上角更常规) */}
807
+ {toasts.length > 0 ? (
808
+ <div className="dkan-toasts">
809
+ {toasts.map((t) => <Toast key={t.id} t={t} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />)}
810
+ </div>
811
+ ) : null}
812
+ </>);
813
+ }
814
+
815
+ // ---------- 面板页 ----------
816
+ function ModePreview({ id }) {
817
+ if (id === "flow") return <span className="dkan-prev"><span className="dkan-prev-flow" /></span>;
818
+ if (id === "ring") {
819
+ return (
820
+ <span className="dkan-prev">
821
+ <span className="dkan-dotwrap"><span className="dkan-dot" /><span className="dkan-ring" /></span>
822
+ </span>
823
+ );
824
+ }
825
+ if (id === "orbit") {
826
+ return (
827
+ <span className="dkan-prev dkan-prev-box">
828
+ <span className="dkan-prev-orbit" />
829
+ </span>
830
+ );
831
+ }
832
+ if (id === "matrix") {
833
+ return (
834
+ <span className="dkan-prev dkan-prev-matrix">
835
+ <i /><i /><i /><i /><i />
836
+ </span>
837
+ );
838
+ }
839
+ if (id === "stars") {
840
+ return (
841
+ <span className="dkan-prev dkan-prev-stars">
842
+ <i /><i /><i /><i /><i /><i />
843
+ </span>
844
+ );
845
+ }
846
+ if (id === "aurora") {
847
+ return (
848
+ <span className="dkan-prev dkan-prev-aurora">
849
+ <i /><i />
850
+ </span>
851
+ );
852
+ }
853
+ if (id === "robot") {
854
+ return (
855
+ <span className="dkan-prev dkan-prev-bot">
856
+ <RobotScene phase="code" />
857
+ </span>
858
+ );
859
+ }
860
+ return (
861
+ <span className="dkan-prev">
862
+ <span className="dkan-dotwrap dkan-breathe"><span className="dkan-dot" /><span className="dkan-halo" /></span>
863
+ </span>
864
+ );
865
+ }
866
+
867
+ function TaskRow(props) {
868
+ const t = props.t;
869
+ const info = endInfo(t.endReason);
870
+ return (
871
+ <div className="dkan-task">
872
+ <div className="dkan-task-head">
873
+ <span className="dkan-task-title" title={t.title}>{t.title || "(无标题)"}</span>
874
+ {props.done ? <span className={"dkan-tag " + info.cls}>{info.label}</span>
875
+ : <span className="dkan-tag on">{phaseLabel(t.phase)}</span>}
876
+ </div>
877
+ <div className="dkan-task-meta">
878
+ <span>{(t.models && t.models.length ? t.models.join(", ") : "未知") + (t.provider ? "(" + t.provider + ")" : "")}</span>
879
+ <span>{props.done ? fmtDur(t.duration) : fmtClock(t.elapsed)}</span>
880
+ {"turns" in t ? <span>{"回合 " + (t.turns || 0) + " · 步骤 " + (t.steps || 0) + (t.toolCalls ? " · 工具 " + t.toolCalls : "")}</span> : null}
881
+ {t.totalTokens ? <span>{"↧" + fmtCompact(t.inputTokens) + " ↥" + fmtCompact(t.outputTokens)}</span> : null}
882
+ {props.done && t.endTime ? <span>{fmtTime(t.endTime)}</span> : null}
883
+ </div>
884
+ {props.done && t.errorMessage ? <div className="dkan-task-err">{truncate(t.errorMessage, 120)}</div> : null}
885
+ </div>
886
+ );
887
+ }
888
+
889
+ function AnimationView(props) {
890
+ const ctx = props && props.ctx;
891
+ const snap = useAnimation(ctx);
892
+ const [cfg, setCfg] = useState(null); // null = 尚未加载
893
+ const [saveErr, setSaveErr] = useState("");
894
+ const [testing, setTesting] = useState(false);
895
+ const [testState, setTestState] = useState(null); // { ok, msg } 机器人测试结果
896
+ const [feishuTestState, setFeishuTestState] = useState(null); // { ok, msg } 飞书测试结果
897
+ const [feishuTesting, setFeishuTesting] = useState(false);
898
+ const cfgRef = useRef(null);
899
+ const pendingSavesRef = useRef(0); // 进行中的保存(轮询回包不覆盖乐观值)
900
+ const editingWebhookRef = useRef(false); // 钉钉 Webhook 输入中(轮询不覆盖草稿)
901
+ const editingFeishuRef = useRef(false); // 飞书 Webhook 输入中(轮询不覆盖草稿)
902
+ // 拉到新配置(含保存回包)后同步本地编辑态;保存中/输入中不覆盖
903
+ useEffect(() => {
904
+ const c = snap.status && snap.status.config;
905
+ if (!c) return;
906
+ if (pendingSavesRef.current > 0 || editingWebhookRef.current || editingFeishuRef.current) return;
907
+ if (c !== cfgRef.current) {
908
+ cfgRef.current = c;
909
+ setCfg(Object.assign({}, c));
910
+ }
911
+ }, [snap.status]);
912
+ // 面板打开时兜底拉一次(浮层通常已在轮询)
913
+ useEffect(() => {
914
+ if (!animationStore.snap.status) animationStore.refresh();
915
+ }, []);
916
+ // 乐观更新 + 立即持久化(每个开关独立保存);返回 Promise 供需要串联的操作使用
917
+ const patch = (p) => {
918
+ setSaveErr("");
919
+ setCfg(Object.assign({}, cfg, p));
920
+ pendingSavesRef.current++;
921
+ return rpcCall("config", p)
922
+ .then((d) => {
923
+ pendingSavesRef.current--;
924
+ animationStore.applyConfig(d && d.config);
925
+ })
926
+ .catch((e) => {
927
+ pendingSavesRef.current--;
928
+ setSaveErr("保存失败:" + ((e && e.message) || String(e)));
929
+ });
930
+ };
931
+ // Webhook 草稿保存:清编辑态后立即对齐一次(同步 effect 在编辑期被跳过)
932
+ const saveWebhook = async () => {
933
+ if (!editingWebhookRef.current) return;
934
+ const hook = String(cfg.dingtalkWebhook || "").trim();
935
+ await patch({ dingtalkWebhook: hook });
936
+ editingWebhookRef.current = false;
937
+ const c = animationStore.snap.status && animationStore.snap.status.config;
938
+ if (c && c !== cfgRef.current) {
939
+ cfgRef.current = c;
940
+ setCfg(Object.assign({}, c));
941
+ }
942
+ };
943
+ // 飞书 Webhook 草稿保存
944
+ const saveFeishuWebhook = async () => {
945
+ if (!editingFeishuRef.current) return;
946
+ const hook = String(cfg.feishuWebhook || "").trim();
947
+ await patch({ feishuWebhook: hook });
948
+ editingFeishuRef.current = false;
949
+ const c = animationStore.snap.status && animationStore.snap.status.config;
950
+ if (c && c !== cfgRef.current) {
951
+ cfgRef.current = c;
952
+ setCfg(Object.assign({}, c));
953
+ }
954
+ };
955
+ // 钉钉测试:草稿未保存先保存,再发测试消息
956
+ const runDingtalkTest = async () => {
957
+ setTesting(true);
958
+ setTestState(null);
959
+ try {
960
+ if (editingWebhookRef.current) {
961
+ const hook = String(cfg.dingtalkWebhook || "").trim();
962
+ if (!hook) throw new Error("请先填写 Webhook 地址");
963
+ const d = await rpcCall("config", { dingtalkWebhook: hook });
964
+ animationStore.applyConfig(d && d.config);
965
+ editingWebhookRef.current = false;
966
+ cfgRef.current = (d && d.config) || cfgRef.current;
967
+ setCfg(Object.assign({}, cfg, { dingtalkWebhook: hook }));
968
+ }
969
+ const r = await rpcCall("test");
970
+ setTestState(r && r.sent
971
+ ? { ok: true, msg: "测试消息已发送,去群里看看" }
972
+ : { ok: false, msg: (r && r.error) || "发送失败" });
973
+ } catch (e) {
974
+ setTestState({ ok: false, msg: (e && e.message) || String(e) });
975
+ } finally {
976
+ setTesting(false);
977
+ }
978
+ };
979
+ // 飞书测试:草稿未保存先保存,再发测试消息
980
+ const runFeishuTest = async () => {
981
+ setFeishuTesting(true);
982
+ setFeishuTestState(null);
983
+ try {
984
+ if (editingFeishuRef.current) {
985
+ const hook = String(cfg.feishuWebhook || "").trim();
986
+ if (!hook) throw new Error("请先填写 Webhook 地址");
987
+ const d = await rpcCall("config", { feishuWebhook: hook });
988
+ animationStore.applyConfig(d && d.config);
989
+ editingFeishuRef.current = false;
990
+ cfgRef.current = (d && d.config) || cfgRef.current;
991
+ setCfg(Object.assign({}, cfg, { feishuWebhook: hook }));
992
+ }
993
+ const r = await rpcCall("test", { target: "feishu" });
994
+ setFeishuTestState(r && r.sent
995
+ ? { ok: true, msg: "测试消息已发送,去群里看看" }
996
+ : { ok: false, msg: (r && r.error) || "发送失败" });
997
+ } catch (e) {
998
+ setFeishuTestState({ ok: false, msg: (e && e.message) || String(e) });
999
+ } finally {
1000
+ setFeishuTesting(false);
1001
+ }
1002
+ };
1003
+ const enableSystemNotify = async (next) => {
1004
+ if (next && typeof Notification !== "undefined" && Notification.permission !== "granted") {
1005
+ try {
1006
+ const perm = await Notification.requestPermission(); // 开关点击即用户手势
1007
+ if (perm !== "granted") {
1008
+ setSaveErr("浏览器未授权系统通知(可在地址栏权限设置里重新允许)");
1009
+ return;
1010
+ }
1011
+ } catch {
1012
+ setSaveErr("浏览器不支持系统通知");
1013
+ return;
1014
+ }
1015
+ }
1016
+ patch({ systemNotify: next });
1017
+ };
1018
+
1019
+ const st = snap.status;
1020
+ const active = st && st.active ? st.active : [];
1021
+ const recent = st && st.recent ? st.recent.slice(0, 6) : [];
1022
+ const permNote = typeof Notification === "undefined"
1023
+ ? "当前浏览器不支持系统通知"
1024
+ : Notification.permission === "granted" ? "已授权 · 仅页面后台时推送"
1025
+ : Notification.permission === "denied" ? "已被浏览器拒绝(需在浏览器权限设置里重新允许)" : "未授权 · 开启时会请求授权,仅页面后台时推送";
1026
+
1027
+ const rows = [];
1028
+ if (!cfg) {
1029
+ rows.push(<div key="load" className="dkan-note">{snap.error ? "状态不可用:" + snap.error : (snap.loading ? "正在拉取任务状态…" : "等待任务状态")}</div>);
1030
+ } else {
1031
+ rows.push(
1032
+ <div key="anim" className="dkan-sec">
1033
+ <div className="dkan-sec-head">
1034
+ <span className="dkan-sec-title">运行动画</span>
1035
+ <span className="dkan-sec-sub">任务进行中才出现;克制的动效,暗/亮色自适应</span>
1036
+ <span className="dkan-sec-sw">
1037
+ <span className={"dkan-sec-swlabel" + (cfg.animationEnabled ? " on" : "")}>{cfg.animationEnabled ? "已开启" : "已关闭"}</span>
1038
+ <button type="button" className={"dock-sw" + (cfg.animationEnabled ? " on" : "")}
1039
+ role="switch" aria-checked={cfg.animationEnabled} aria-label="开关运行动画"
1040
+ title={cfg.animationEnabled ? "关闭运行动画" : "开启运行动画"}
1041
+ onClick={() => patch({ animationEnabled: !cfg.animationEnabled })} />
1042
+ </span>
1043
+ </div>
1044
+ {cfg.animationEnabled ? (
1045
+ <>
1046
+ <div className="dkan-modes">
1047
+ {EFFECT_MODES.map((m) => (
1048
+ <button type="button" key={m.id}
1049
+ className={"dkan-mode" + (cfg.effectMode === m.id ? " on" : "")}
1050
+ onClick={() => patch({ effectMode: m.id })}>
1051
+ <span className="dkan-mode-name">{m.name}</span>
1052
+ <ModePreview id={m.id} />
1053
+ <span className="dkan-mode-desc">{m.desc}</span>
1054
+ </button>
1055
+ ))}
1056
+ </div>
1057
+ {cfg.effectMode === "robot" ? <div className="dkan-robot-controls">
1058
+ <div className="dkan-robot-controls-head">
1059
+ <span>桌面伙伴大小</span>
1060
+ <strong>{Math.round((Number(cfg.robotScale) || 1.35) * 100)}%</strong>
1061
+ </div>
1062
+ <div className="dkan-robot-size-options" role="group" aria-label="桌面伙伴大小预设">
1063
+ {[[1, "紧凑"], [1.35, "默认"], [1.7, "加大"], [2.1, "特大"]].map(([scale, label]) => (
1064
+ <button type="button" key={scale} className={Math.abs((Number(cfg.robotScale) || 1.35) - scale) < .03 ? "on" : ""}
1065
+ onClick={() => patch({ robotScale: scale })}>{label}</button>
1066
+ ))}
1067
+ </div>
1068
+ <div className="dkan-robot-slider-row">
1069
+ <input type="range" min="0.85" max="2.2" step="0.05" value={Number(cfg.robotScale) || 1.35}
1070
+ aria-label="桌面伙伴大小百分比"
1071
+ onChange={(e) => patch({ robotScale: Number(e.target.value) })} />
1072
+ <span>浮层右下角也可直接拖动缩放,大小会自动保存。</span>
1073
+ </div>
1074
+ </div> : null}
1075
+ </>
1076
+ ) : <div className="dkan-note">动画已关闭——只保留通知(或全部关闭)时,页面不会有任何动效。</div>}
1077
+ </div>,
1078
+ <div key="notify" className="dkan-sec">
1079
+ <div className="dkan-sec-head">
1080
+ <span className="dkan-sec-title">完成通知</span>
1081
+ <span className="dkan-sec-sub">与动画互不依赖,可单独开启</span>
1082
+ <span className="dkan-sec-sw">
1083
+ <span className={"dkan-sec-swlabel" + (cfg.notifyEnabled ? " on" : "")}>{cfg.notifyEnabled ? "已开启" : "已关闭"}</span>
1084
+ <button type="button" className={"dock-sw" + (cfg.notifyEnabled ? " on" : "")}
1085
+ role="switch" aria-checked={cfg.notifyEnabled} aria-label="开关完成通知"
1086
+ title={cfg.notifyEnabled ? "关闭完成通知" : "开启完成通知"}
1087
+ onClick={() => patch({ notifyEnabled: !cfg.notifyEnabled })} />
1088
+ </span>
1089
+ </div>
1090
+ {cfg.notifyEnabled ? <div className="dkan-rows-narrow">
1091
+ <div className="dkan-row">
1092
+ <span className="dkan-row-label">完成通知</span>
1093
+ <button type="button" className={"dkm-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
1094
+ onClick={() => patch({ notifyOnComplete: !cfg.notifyOnComplete })}>
1095
+ {cfg.notifyOnComplete ? "开" : "关"}
1096
+ </button>
1097
+ <span className="dkan-row-sub">任务正常完成时通知</span>
1098
+ </div>
1099
+ <div className="dkan-row">
1100
+ <span className="dkan-row-label">异常通知</span>
1101
+ <button type="button" className={"dkm-miniswitch" + (cfg.notifyOnError ? " on" : "")}
1102
+ onClick={() => patch({ notifyOnError: !cfg.notifyOnError })}>
1103
+ {cfg.notifyOnError ? "开" : "关"}
1104
+ </button>
1105
+ <span className="dkan-row-sub">出错 / 中止 / 达输出上限时通知</span>
1106
+ </div>
1107
+ <div className="dkan-row">
1108
+ <span className="dkan-row-label">停留时长</span>
1109
+ <select className="dkan-select" value={String(cfg.notifyStayMs)}
1110
+ onChange={(e) => patch({ notifyStayMs: Number(e.target.value) })}>
1111
+ <option value="4000">4 秒</option>
1112
+ <option value="8000">8 秒</option>
1113
+ <option value="15000">15 秒</option>
1114
+ <option value="30000">30 秒</option>
1115
+ <option value="0">常驻(手动关闭)</option>
1116
+ </select>
1117
+ </div>
1118
+ <div className="dkan-row">
1119
+ <span className="dkan-row-label">系统通知</span>
1120
+ <button type="button" className={"dkm-miniswitch" + (cfg.systemNotify ? " on" : "")}
1121
+ onClick={() => enableSystemNotify(!cfg.systemNotify)}>
1122
+ {cfg.systemNotify ? "开" : "关"}
1123
+ </button>
1124
+ <span className="dkan-row-sub">{permNote}</span>
1125
+ </div>
1126
+ <div className="dkan-row">
1127
+ <span className="dkan-row-label">提示音</span>
1128
+ <button type="button" className={"dkm-miniswitch" + (cfg.soundNotify !== false ? " on" : "")}
1129
+ onClick={() => patch({ soundNotify: cfg.soundNotify === false })}>
1130
+ {cfg.soundNotify !== false ? "开" : "关"}
1131
+ </button>
1132
+ <span className="dkan-row-sub">任务结束时播放(试听为先播完成音、后播异常音)</span>
1133
+ </div>
1134
+ {cfg.soundNotify !== false ? (
1135
+ <div className="dkan-sounds">
1136
+ {Object.keys(SOUND_LIBRARY).map((key) => (
1137
+ <button type="button" key={key}
1138
+ className={"dkan-sound" + (cfg.soundEffect === key ? " on" : "")}
1139
+ onClick={() => patch({ soundEffect: key })}>
1140
+ <span className="dkan-sound-name">
1141
+ {SOUND_LIBRARY[key].name}
1142
+ {cfg.soundEffect === key ? <span className="dkan-sound-cur">✓</span> : null}
1143
+ </span>
1144
+ <span className="dkan-sound-play" role="button" tabIndex={0}
1145
+ title={"试听 " + SOUND_LIBRARY[key].name}
1146
+ onClick={(e) => { e.stopPropagation(); previewSound(key); }}
1147
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); previewSound(key); } }}>▶</span>
1148
+ </button>
1149
+ ))}
1150
+ </div>
1151
+ ) : null}
1152
+ </div> : <div className="dkan-note">通知已关闭——任务结束时既不弹卡片也不推系统通知。</div>}
1153
+ </div>,
1154
+ <div key="dingtalk" className="dkan-sec">
1155
+ <div className="dkan-sec-head">
1156
+ <span className="dkan-sec-title">钉钉推送</span>
1157
+ <span className="dkan-sec-sub">任务结束推送到钉钉群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
1158
+ <span className="dkan-sec-sw">
1159
+ <span className={"dkan-sec-swlabel" + (cfg.dingtalkEnabled ? " on" : "")}>{cfg.dingtalkEnabled ? "已开启" : "已关闭"}</span>
1160
+ <button type="button" className={"dock-sw" + (cfg.dingtalkEnabled ? " on" : "")}
1161
+ role="switch" aria-checked={cfg.dingtalkEnabled} aria-label="开关钉钉推送"
1162
+ title={cfg.dingtalkEnabled ? "关闭钉钉推送" : "开启钉钉推送"}
1163
+ onClick={() => patch({ dingtalkEnabled: !cfg.dingtalkEnabled })} />
1164
+ </span>
1165
+ </div>
1166
+ {cfg.dingtalkEnabled ? <div className="dkan-rows-narrow">
1167
+ <div className="dkan-row dkan-row-webhook">
1168
+ <span className="dkan-row-label">Webhook</span>
1169
+ <input type="text" className="dkan-input" spellCheck={false}
1170
+ value={cfg.dingtalkWebhook || ""}
1171
+ placeholder="https://oapi.dingtalk.com/robot/send?access_token=…"
1172
+ onChange={(e) => {
1173
+ editingWebhookRef.current = true;
1174
+ setCfg(Object.assign({}, cfg, { dingtalkWebhook: e.target.value }));
1175
+ }} />
1176
+ <button type="button" className="dkan-btn" disabled={!editingWebhookRef.current}
1177
+ onClick={saveWebhook}>
1178
+ {editingWebhookRef.current ? "保存" : "已保存"}
1179
+ </button>
1180
+ </div>
1181
+ <div className="dkan-row">
1182
+ <span className="dkan-row-label">连通测试</span>
1183
+ <button type="button" className="dkan-btn" disabled={testing} onClick={runDingtalkTest}>
1184
+ {testing ? "发送中…" : "发送测试消息"}
1185
+ </button>
1186
+ {testState ? <span className={"dkan-row-sub" + (testState.ok ? " dkan-ok" : " dkan-err")}>
1187
+ {testState.ok ? "✓ " : "✗ "}{testState.msg}
1188
+ </span> : <span className="dkan-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
1189
+ </div>
1190
+ <div className="dkan-note">机器人创建:钉钉群 → 设置 → 智能群助手 → 添加机器人 → 自定义(Webhook),
1191
+ 安全设置选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
1192
+ </div> : <div className="dkan-note">未开启——任务结束不推送钉钉。</div>}
1193
+ </div>,
1194
+ <div key="feishu" className="dkan-sec">
1195
+ <div className="dkan-sec-head">
1196
+ <span className="dkan-sec-title">飞书推送</span>
1197
+ <span className="dkan-sec-sub">任务结束推送到飞书群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
1198
+ <span className="dkan-sec-sw">
1199
+ <span className={"dkan-sec-swlabel" + (cfg.feishuEnabled ? " on" : "")}>{cfg.feishuEnabled ? "已开启" : "已关闭"}</span>
1200
+ <button type="button" className={"dock-sw" + (cfg.feishuEnabled ? " on" : "")}
1201
+ role="switch" aria-checked={cfg.feishuEnabled} aria-label="开关飞书推送"
1202
+ title={cfg.feishuEnabled ? "关闭飞书推送" : "开启飞书推送"}
1203
+ onClick={() => patch({ feishuEnabled: !cfg.feishuEnabled })} />
1204
+ </span>
1205
+ </div>
1206
+ {cfg.feishuEnabled ? <div className="dkan-rows-narrow">
1207
+ <div className="dkan-row dkan-row-webhook">
1208
+ <span className="dkan-row-label">Webhook</span>
1209
+ <input type="text" className="dkan-input" spellCheck={false}
1210
+ value={cfg.feishuWebhook || ""}
1211
+ placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…"
1212
+ onChange={(e) => {
1213
+ editingFeishuRef.current = true;
1214
+ setCfg(Object.assign({}, cfg, { feishuWebhook: e.target.value }));
1215
+ }} />
1216
+ <button type="button" className="dkan-btn" disabled={!editingFeishuRef.current}
1217
+ onClick={saveFeishuWebhook}>
1218
+ {editingFeishuRef.current ? "保存" : "已保存"}
1219
+ </button>
1220
+ </div>
1221
+ <div className="dkan-row">
1222
+ <span className="dkan-row-label">连通测试</span>
1223
+ <button type="button" className="dkan-btn" disabled={feishuTesting} onClick={runFeishuTest}>
1224
+ {feishuTesting ? "发送中…" : "发送测试消息"}
1225
+ </button>
1226
+ {feishuTestState ? <span className={"dkan-row-sub" + (feishuTestState.ok ? " dkan-ok" : " dkan-err")}>
1227
+ {feishuTestState.ok ? "✓ " : "✗ "}{feishuTestState.msg}
1228
+ </span> : <span className="dkan-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
1229
+ </div>
1230
+ <div className="dkan-note">机器人创建:飞书群 → 设置 → 群机器人 → 添加机器人 → 自定义机器人(获取 Webhook 地址);
1231
+ 安全设置如选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
1232
+ </div> : <div className="dkan-note">未开启——任务结束不推送飞书。</div>}
1233
+ </div>
1234
+ );
1235
+ }
1236
+ if (saveErr) rows.push(<div key="err" className="dkan-note dkan-err">{saveErr}</div>);
1237
+ rows.push(
1238
+ <div key="state" className="dkan-sec">
1239
+ <div className="dkan-sec-head">
1240
+ <span className="dkan-sec-title">运行状态</span>
1241
+ <span className="dkan-sec-sub">
1242
+ {snap.error ? "状态拉取失败:" + snap.error
1243
+ : active.length > 0 ? active.length + " 个任务进行中"
1244
+ : recent.length > 0 ? "空闲 · 显示最近完成" : "空闲 · 暂无任务记录"}
1245
+ </span>
1246
+ <button type="button" className="dkan-refresh" onClick={() => animationStore.refresh()}>
1247
+ {snap.loading ? "刷新中…" : "刷新"}
1248
+ </button>
1249
+ </div>
1250
+ {active.length > 0 ? <div className="dkan-tasks">{active.map((t) => <TaskRow key={t.sessionId} t={t} />)}</div> : null}
1251
+ {active.length === 0 && recent.length === 0
1252
+ ? <div className="dkan-note">发起新会话任务后,这里会显示进行中与最近完成的任务;动画与通知同时在页面生效。</div> : null}
1253
+ {recent.length > 0 ? <div className="dkan-tasks dkan-tasks-done">{recent.map((t) => <TaskRow key={t.sessionId + ":" + t.endTime} t={t} done />)}</div> : null}
1254
+ </div>
1255
+ );
1256
+ return <div className="dkan-root">{rows}</div>;
1257
+ }
1258
+
1259
+ // ---------- 首页总揽概要 ----------
1260
+ function AnimationStat(props) {
1261
+ const snap = useAnimation(props && props.ctx);
1262
+ const st = snap.status;
1263
+ if (snap.error) return <span className="dkan-err">任务状态不可用(宿主需重启加载动画路由)</span>;
1264
+ if (!st) return <span>等待任务状态…</span>;
1265
+ if (st.active && st.active.length > 0) {
1266
+ return <span>{st.active.length + " 个任务进行中 · " + fmtDur(Math.max(...st.active.map((x) => x.elapsed || 0)))}</span>;
1267
+ }
1268
+ const last = st.recent && st.recent[0];
1269
+ return <span>{last ? "空闲 · 最近完成 " + truncate(last.title, 24) : "空闲 · 暂无任务记录"}</span>;
1270
+ }
1271
+
1272
+ // ---------- 样式(dkan- 前缀;全部走主题变量,暗/亮色自适应;动效时长 = 基准 / --dkan-speed) ----------
1273
+ const css = [
1274
+ // 顶部流光细线(3px,比首版更醒目)
1275
+ ".dkan-line{position:fixed;top:0;left:0;right:0;height:3px;z-index:9990;pointer-events:none;background:color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 30%,transparent);}",
1276
+ ".dkan-line::after{content:\"\";position:absolute;top:0;bottom:0;left:-42%;width:42%;background:linear-gradient(90deg,transparent,var(--dsw-alias-accent,#4d9fff) 60%,#fff);box-shadow:0 0 10px 1px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 70%,transparent);animation:dkan-flow calc(1.9s / var(--dkan-speed,1)) linear infinite;}",
1277
+ "@keyframes dkan-flow{to{left:100%}}",
1278
+ // 完成瞬间的一次性流光(成功绿 / 异常红),动画结束自动清场
1279
+ ".dkan-done{position:fixed;top:0;left:0;right:0;height:3px;z-index:9991;pointer-events:none;background:linear-gradient(90deg,transparent,var(--dsw-alias-state-success-primary,#34d399) 50%,#fff);background-size:50% 100%;background-repeat:no-repeat;animation:dkan-done 1.1s var(--ds-ease-in-out) forwards;}",
1280
+ ".dkan-done.err{background-image:linear-gradient(90deg,transparent,var(--dsw-alias-state-error-primary,#f87171) 50%,#fff);}",
1281
+ "@keyframes dkan-done{0%{background-position:-60% 0;opacity:0}25%{opacity:1}100%{background-position:160% 0;opacity:0}}",
1282
+ // 环屏巡航:光点沿屏幕边缘转一整圈
1283
+ ".dkan-orbit{position:fixed;top:0;left:0;width:12px;height:12px;z-index:9990;pointer-events:none;border-radius:50%;background:var(--dsw-alias-accent,#4d9fff);box-shadow:0 0 14px 4px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 60%,transparent),0 0 30px 8px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 25%,transparent);animation:dkan-orbit calc(14s / var(--dkan-speed,1)) linear infinite;}",
1284
+ "@keyframes dkan-orbit{0%{top:0;left:0}25%{top:0;left:calc(100vw - 12px)}50%{top:calc(100vh - 12px);left:calc(100vw - 12px)}75%{top:calc(100vh - 12px);left:0}100%{top:0;left:0}}",
1285
+ // ===== 氛围动效 =====
1286
+ ".dkan-amb{position:fixed;inset:0;z-index:9989;pointer-events:none;overflow:hidden;}",
1287
+ // pointer-events 不继承:全屏装饰层的子粒子(星点/流星/极光/火花/彩带)必须各自透明于点击
1288
+ ".dkan-amb *,.dkan-fxwrap *{pointer-events:none;}",
1289
+ // 代码雨:字符列缓落(提亮加大,速度随吞吐)
1290
+ ".dkan-matrix span{position:absolute;top:-12%;writing-mode:vertical-rl;font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-accent,#4d9fff);text-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 60%,transparent);animation:dkan-fall linear infinite;will-change:transform;}",
1291
+ "@keyframes dkan-fall{to{transform:translateY(125vh)}}",
1292
+ // 星野:星点缓慢飘移 + 闪烁(加亮加发光)+ 月亮 + 流星
1293
+ ".dkan-stars .dkan-star{position:absolute;border-radius:50%;background:#cfe3ff;box-shadow:0 0 4px 1px color-mix(in srgb,#8ab6ff 55%,transparent);animation:dkan-star ease-in-out infinite alternate;}",
1294
+ "@keyframes dkan-star{0%{opacity:.3;transform:translate(0,0)}50%{opacity:1}100%{opacity:.45;transform:translate(var(--dx),var(--dy))}}",
1295
+ // 月亮:右上弯月(radial-gradient 咬出月牙,透明区不遮挡背景;外圈柔光晕)
1296
+ ".dkan-moon{position:absolute;top:9%;right:10%;width:36px;height:36px;border-radius:50%;background:radial-gradient(circle at 66% 34%,transparent 0 44%,#fdf6d8 46%,#e8d9a0 78%,#d8c78a);box-shadow:0 0 18px 4px color-mix(in srgb,#fdf6d8 35%,transparent),0 0 44px 12px color-mix(in srgb,#fdf6d8 15%,transparent);}",
1297
+ // 流星:斜向划过的光带(亮头 + 渐隐尾),周期出现
1298
+ ".dkan-meteor{position:absolute;width:110px;height:2px;border-radius:2px;background:linear-gradient(90deg,#fff,color-mix(in srgb,#8ab6ff 60%,transparent) 40%,transparent);transform:rotate(35deg);transform-origin:left center;opacity:0;animation:dkan-meteor 8.4s linear infinite;}",
1299
+ ".dkan-meteor::before{content:\"\";position:absolute;left:-2px;top:-2px;width:6px;height:6px;border-radius:50%;background:#fff;box-shadow:0 0 8px 2px color-mix(in srgb,#cfe3ff 70%,transparent);}",
1300
+ "@keyframes dkan-meteor{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:1}10%{opacity:0;transform:rotate(35deg) translateX(46vh)}100%{opacity:0;transform:rotate(35deg) translateX(46vh)}}",
1301
+ // 极光:顶部柔光带呼吸流动(加浓)
1302
+ ".dkan-aurora i{position:absolute;top:-40%;left:-20%;width:70%;height:80%;border-radius:50%;filter:blur(50px);opacity:.32;animation:dkan-aurora ease-in-out infinite alternate;}",
1303
+ ".dkan-aurora i:nth-child(1){background:#4d9fff;}",
1304
+ ".dkan-aurora i:nth-child(2){background:#34d399;left:20%;animation-delay:-2s;}",
1305
+ ".dkan-aurora i:nth-child(3){background:#a78bfa;left:55%;animation-delay:-4s;}",
1306
+ "@keyframes dkan-aurora{0%{transform:translateX(-8%) scaleY(1)}100%{transform:translateX(10%) scaleY(1.3)}}",
1307
+ // ===== 交互反馈层 =====
1308
+ ".dkan-fxwrap{position:fixed;inset:0;z-index:9992;pointer-events:none;overflow:hidden;}",
1309
+ ".dkan-fx{position:absolute;}",
1310
+ // 纸飞机:从点击处向右上飞出淡出(加大加亮)
1311
+ ".dkan-fx-plane{color:var(--dsw-alias-accent,#4d9fff);font-size:24px;text-shadow:0 0 8px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 70%,transparent);animation:dkan-plane 1.2s var(--ds-ease-in-out) forwards;}",
1312
+ "@keyframes dkan-plane{0%{opacity:0;transform:translate(0,0) rotate(-20deg) scale(.6)}15%{opacity:1;transform:translate(20px,-14px) rotate(-20deg) scale(1.1)}100%{opacity:0;transform:translate(240px,-160px) rotate(-20deg) scale(1)}}",
1313
+ // 火花:粒子四散(加大加多散得更开)
1314
+ ".dkan-fx-spark i{position:absolute;width:7px;height:7px;border-radius:50%;box-shadow:0 0 6px currentColor;animation:dkan-spark .9s var(--ds-ease-in-out) forwards;}",
1315
+ "@keyframes dkan-spark{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--dx),var(--dy)) scale(.4)}}",
1316
+ // 流光:横向扫过(切会话,加高加亮)
1317
+ ".dkan-fx-streak{top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,transparent,var(--dsw-alias-accent,#4d9fff) 50%,#fff);background-size:45% 100%;background-repeat:no-repeat;box-shadow:0 0 12px 2px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 50%,transparent);animation:dkan-streak 1s var(--ds-ease-in-out) forwards;}",
1318
+ "@keyframes dkan-streak{0%{background-position:-50% 0;opacity:0}20%{opacity:1}100%{background-position:150% 0;opacity:0}}",
1319
+ // 微光:全屏柔光一闪(切工作目录,加浓)
1320
+ ".dkan-fx-flash{inset:0;background:radial-gradient(ellipse at center,color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 26%,transparent),transparent 75%);animation:dkan-flash .8s var(--ds-ease-in-out) forwards;}",
1321
+ "@keyframes dkan-flash{0%{opacity:0}30%{opacity:1}100%{opacity:0}}",
1322
+ // 光涌:底部向上涌起(任务开始,加高加浓)
1323
+ ".dkan-fx-surge{left:0;right:0;bottom:0;height:42%;background:linear-gradient(0deg,color-mix(in srgb,var(--dsw-alias-state-success-primary,#34d399) 38%,transparent),transparent);animation:dkan-surge 1.2s var(--ds-ease-in-out) forwards;}",
1324
+ "@keyframes dkan-surge{0%{opacity:0;transform:translateY(45%)}30%{opacity:1}100%{opacity:0;transform:translateY(-35%)}}",
1325
+ // 彩带:顶部飘落(任务完成庆祝,加大加多)
1326
+ ".dkan-fx-confetti{inset:0;}",
1327
+ ".dkan-fx-confetti i{position:absolute;top:-14px;width:8px;height:13px;border-radius:2px;box-shadow:0 0 4px currentColor;animation:dkan-confetti 2s var(--ds-ease-in-out) forwards;}",
1328
+ "@keyframes dkan-confetti{0%{opacity:1;transform:translateY(0) rotate(0)}100%{opacity:0;transform:translateY(75vh) translateX(var(--dx)) rotate(var(--r))}}",
1329
+ // 右下角状态徽标(玻璃拟态,可点击);抬到 dsh 输入卡上方避免重叠错位
1330
+ ".dkan-badge{position:fixed;right:20px;bottom:calc(var(--dsh-composer-height,152px) + 20px);z-index:9990;display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;cursor:pointer;border:1px solid var(--dsw-alias-border-l1);background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 78%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 6px 24px rgb(0 0 0 / .16);color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12px;line-height:18px;pointer-events:auto;animation:dkan-rise .3s var(--ds-ease-in-out);transition:color .15s var(--ds-ease-in-out);}",
1331
+ ".dkan-badge:hover{color:var(--dsw-alias-label-primary);}",
1332
+ ".dkan-badge .n{color:var(--dsw-alias-label-primary);font-weight:600;}",
1333
+ "@keyframes dkan-rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}",
1334
+ // 徽标圆点与两种动效(呼吸 / 轨道环)
1335
+ ".dkan-dotwrap{position:relative;width:20px;height:20px;flex:none;display:inline-flex;align-items:center;justify-content:center;}",
1336
+ ".dkan-dot{width:12px;height:12px;border-radius:50%;background:var(--dkan-phase,var(--dsw-alias-accent,#4d9fff));box-shadow:0 0 10px color-mix(in srgb,var(--dkan-phase,var(--dsw-alias-accent,#4d9fff)) 70%,transparent);transition:background .4s var(--ds-ease-in-out),box-shadow .4s var(--ds-ease-in-out);}",
1337
+ ".dkan-breathe .dkan-dot{animation:dkan-breathe calc(2.2s / var(--dkan-speed,1)) ease-in-out infinite;}",
1338
+ "@keyframes dkan-breathe{0%,100%{transform:scale(1);opacity:.5}50%{transform:scale(1.7);opacity:1}}",
1339
+ // 呼吸光晕:向外扩散的波纹环,与圆点同频(颜色随阶段)
1340
+ ".dkan-halo{position:absolute;inset:0;border-radius:50%;border:2px solid var(--dkan-phase,var(--dsw-alias-accent,#4d9fff));animation:dkan-halo calc(2.2s / var(--dkan-speed,1)) ease-out infinite;}",
1341
+ "@keyframes dkan-halo{0%{transform:scale(.5);opacity:.9}70%{transform:scale(1.5);opacity:.25}100%{transform:scale(1.7);opacity:0}}",
1342
+ // breathe 模式徽标更醒目:阶段色描边 + 呼吸辉光
1343
+ ".dkan-badge-breathe{border-color:color-mix(in srgb,var(--dkan-phase,#4d9fff) 55%,transparent);animation:dkan-rise .3s var(--ds-ease-in-out),dkan-badge-breathe calc(2.2s / var(--dkan-speed,1)) ease-in-out infinite;}",
1344
+ "@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)}}",
1345
+ ".dkan-ring{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 68%,var(--dsw-alias-accent,#4d9fff) 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;}",
1346
+ "@keyframes dkan-spin{to{transform:rotate(360deg)}}",
1347
+ // 通知卡片栈(右上角)
1348
+ ".dkan-toasts{position:fixed;top:16px;right:16px;z-index:9995;display:flex;flex-direction:column;gap:8px;width:min(380px,calc(100vw - 32px));}",
1349
+ ".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);}",
1350
+ ".dkan-toast.out{animation:dkan-toast-out .24s var(--ds-ease-in-out) forwards;}",
1351
+ "@keyframes dkan-toast-in{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}",
1352
+ "@keyframes dkan-toast-out{to{opacity:0;transform:translateX(10px)}}",
1353
+ ".dkan-toast-head{display:flex;align-items:center;gap:8px;margin-bottom:4px;}",
1354
+ ".dkan-toast-mark{width:8px;height:8px;border-radius:50%;flex:none;}",
1355
+ ".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;}",
1356
+ ".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;}",
1357
+ ".dkan-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
1358
+ ".dkan-toast-body{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.7;white-space:pre-line;word-break:break-word;}",
1359
+ // 面板页布局
1360
+ ".dkan-root{display:flex;flex-direction:column;gap:10px;}",
1361
+ ".dkan-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;}",
1362
+ ".dkan-err{color:var(--dsw-alias-state-error-primary);}",
1363
+ ".dkan-sec{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;}",
1364
+ ".dkan-sec-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
1365
+ ".dkan-sec-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary);flex:none;}",
1366
+ ".dkan-sec-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:1;min-width:120px;}",
1367
+ ".dkan-sec-sw{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px;}",
1368
+ ".dkan-sec-swlabel{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;}",
1369
+ ".dkan-sec-swlabel.on{color:var(--dsw-alias-state-success-primary);}",
1370
+ ".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;}",
1371
+ ".dkan-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);}",
1372
+ // 模式选择卡(带缩微预览)
1373
+ ".dkan-modes{display:flex;gap:8px;flex-wrap:wrap;}",
1374
+ ".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);}",
1375
+ ".dkan-mode:hover{border-color:var(--dsw-alias-accent,#4d9fff);}",
1376
+ ".dkan-mode.on{border-color:var(--dsw-alias-accent,#4d9fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 40%,transparent);}",
1377
+ ".dkan-mode-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px;}",
1378
+ ".dkan-mode.on .dkan-mode-name::after{content:\"✓\";color:var(--dsw-alias-accent,#4d9fff);}",
1379
+ ".dkan-mode-desc{font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5;}",
1380
+ ".dkan-robot-controls{display:flex;flex-direction:column;gap:8px;padding:9px 10px;border:1px solid color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 28%,var(--dsw-alias-border-l1));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 5%,var(--dsw-alias-bg-layer-2));}",
1381
+ ".dkan-robot-controls-head{display:flex;align-items:center;justify-content:space-between;font-size:12px;color:var(--dsw-alias-label-primary);}",
1382
+ ".dkan-robot-controls-head strong{font-variant-numeric:tabular-nums;color:var(--dsw-alias-accent,#4d9fff);}",
1383
+ ".dkan-robot-size-options{display:flex;gap:6px;flex-wrap:wrap;}",
1384
+ ".dkan-robot-size-options button{cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:3px 10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;}",
1385
+ ".dkan-robot-size-options button:hover,.dkan-robot-size-options button.on{border-color:var(--dsw-alias-accent,#4d9fff);color:var(--dsw-alias-label-primary);}",
1386
+ ".dkan-robot-slider-row{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-tertiary);}",
1387
+ ".dkan-robot-slider-row input{accent-color:var(--dsw-alias-accent,#4d9fff);width:min(240px,100%);}",
1388
+ ".dkan-prev{height:30px;border-radius:6px;background:var(--dsw-alias-bg-layer-1);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;}",
1389
+ ".dkan-prev-flow{position:absolute;top:0;left:0;right:0;height:3px;background:color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 30%,transparent);}",
1390
+ ".dkan-prev-flow::after{content:\"\";position:absolute;top:0;bottom:0;left:-40%;width:40%;background:linear-gradient(90deg,transparent,var(--dsw-alias-accent,#4d9fff));animation:dkan-flow 1.9s linear infinite;}",
1391
+ ".dkan-prev-box{--dkan-speed:1;}",
1392
+ ".dkan-prev-orbit{position:absolute;top:3px;left:3px;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-accent,#4d9fff);box-shadow:0 0 7px 2px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 55%,transparent);animation:dkan-prev-orbit 3s linear infinite;}",
1393
+ "@keyframes dkan-prev-orbit{0%{top:3px;left:3px}25%{top:3px;left:calc(100% - 10px)}50%{top:calc(100% - 10px);left:calc(100% - 10px)}75%{top:calc(100% - 10px);left:3px}100%{top:3px;left:3px}}",
1394
+ // 预览:代码雨(字符列下落)
1395
+ ".dkan-prev-matrix i{position:absolute;top:-100%;writing-mode:vertical-rl;font-size:8px;font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-accent,#4d9fff);opacity:.5;animation:dkan-prev-fall 2.2s linear infinite;}",
1396
+ ".dkan-prev-matrix i:nth-child(1){left:12%;}",
1397
+ ".dkan-prev-matrix i:nth-child(2){left:32%;animation-delay:-.6s;}",
1398
+ ".dkan-prev-matrix i:nth-child(3){left:52%;animation-delay:-1.2s;}",
1399
+ ".dkan-prev-matrix i:nth-child(4){left:72%;animation-delay:-.3s;}",
1400
+ ".dkan-prev-matrix i:nth-child(5){left:88%;animation-delay:-1.6s;}",
1401
+ ".dkan-prev-matrix i::after{content:\"01</>\";}",
1402
+ "@keyframes dkan-prev-fall{to{transform:translateY(300%)}}",
1403
+ // 预览:星野(星点闪烁)
1404
+ ".dkan-prev-stars i{position:absolute;width:2px;height:2px;border-radius:50%;background:var(--dsw-alias-label-secondary);animation:dkan-prev-star 1.6s ease-in-out infinite alternate;}",
1405
+ ".dkan-prev-stars i:nth-child(1){left:15%;top:25%;}",
1406
+ ".dkan-prev-stars i:nth-child(2){left:40%;top:60%;animation-delay:-.4s;}",
1407
+ ".dkan-prev-stars i:nth-child(3){left:60%;top:30%;animation-delay:-.8s;}",
1408
+ ".dkan-prev-stars i:nth-child(4){left:80%;top:55%;animation-delay:-1.2s;}",
1409
+ ".dkan-prev-stars i:nth-child(5){left:28%;top:70%;animation-delay:-.6s;}",
1410
+ ".dkan-prev-stars i:nth-child(6){left:70%;top:75%;animation-delay:-1s;}",
1411
+ "@keyframes dkan-prev-star{0%{opacity:.2}100%{opacity:.9}}",
1412
+ // 预览:极光(柔光带呼吸)
1413
+ ".dkan-prev-aurora i{position:absolute;top:-30%;width:60%;height:120%;border-radius:50%;filter:blur(8px);opacity:.35;animation:dkan-prev-aurora 2.4s ease-in-out infinite alternate;}",
1414
+ ".dkan-prev-aurora i:nth-child(1){left:5%;background:#4d9fff;}",
1415
+ ".dkan-prev-aurora i:nth-child(2){left:45%;background:#34d399;animation-delay:-1.2s;}",
1416
+ "@keyframes dkan-prev-aurora{0%{transform:translateX(-10%)}100%{transform:translateX(15%)}}",
1417
+ ".dkan-prev-bot{height:92px;justify-content:center;}",
1418
+ ".dkan-prev-bot .dkan-bot-scene{--dkan-bot-scale:1;transform:scale(.56);transform-origin:center;}",
1419
+ // ===== 桌面伙伴:具象人物 + 紧凑双工位(尺寸可调,整卡可拖拽) =====
1420
+ // 卡片:玻璃拟态;默认停泊右下(dsh 输入卡上方),拖动后位置持久化、可放屏幕任意处
1421
+ ".dkan-botcard{--dkan-bot-scale:1.35;position:fixed;right:20px;bottom:calc(var(--dsh-composer-height,152px) + 20px);z-index:9990;display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-radius:16px;cursor:grab;border:1px solid var(--dsw-alias-border-l1);background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 86%,transparent);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);box-shadow:0 12px 36px rgb(0 0 0 / .22);color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12px;pointer-events:auto;touch-action:none;user-select:none;animation:dkan-rise .3s var(--ds-ease-in-out);}",
1422
+ ".dkan-botcard:hover,.dkan-botcard:focus-visible{border-color:var(--dsw-alias-accent,#4d9fff);outline:none;}",
1423
+ ".dkan-botcard.dkan-dragging{cursor:grabbing;animation:none;}",
1424
+ ".dkan-botcard .n{color:var(--dsw-alias-label-primary);font-weight:600;}",
1425
+ ".dkan-bot-cap{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:18px;}",
1426
+ ".dkan-bot-resize{position:absolute;right:7px;bottom:7px;width:15px;height:15px;padding:0;border:0;border-radius:4px;background:linear-gradient(135deg,transparent 0 42%,var(--dsw-alias-label-tertiary) 44% 51%,transparent 53% 62%,var(--dsw-alias-label-tertiary) 64% 71%,transparent 73%);cursor:nwse-resize;touch-action:none;opacity:.72;}",
1427
+ ".dkan-bot-resize:hover,.dkan-bot-resize:focus-visible{opacity:1;outline:1px solid var(--dsw-alias-accent,#4d9fff);outline-offset:1px;}",
1428
+ // ===== 3D 场景(CSS 长方体拼装:世界层统一 3/4 视角——俯视 + 侧身面向镜头) =====
1429
+ ".dkan-bot-scene{position:relative;width:calc(220px * var(--dkan-bot-scale,1));height:calc(132px * var(--dkan-bot-scale,1));perspective:calc(620px * var(--dkan-bot-scale,1));perspective-origin:55% 18%;--dk3-side:#7c8ba0;--dk3-side-hi:#a9b8ca;--dk3-side-dk:#4c5870;--dk3-screen:#0d1526;--dkan-code-a:#4ade80;--dkan-code-b:#60a5fa;--dkan-code-c:#fbbf24;}",
1430
+ ".dk3-world{position:absolute;left:0;top:0;width:220px;height:132px;transform-style:preserve-3d;transform-origin:top left;transform:scale(var(--dkan-bot-scale,1)) rotateX(-15deg) rotateY(-30deg);}",
1431
+ ".dk3-box{position:absolute;transform-style:preserve-3d;}",
1432
+ ".dk3-face{position:absolute;left:50%;top:50%;display:block;background:var(--dk3-side);backface-visibility:hidden;border-radius:1px;}",
1433
+ // 金属件着色:顶面亮、底面暗,正/侧面中调(3D 光感)
1434
+ ".dk3-metal .dk3-face{background:linear-gradient(180deg,#93a5ba,#71809a);}",
1435
+ ".dk3-metal .dk3-face:nth-child(5){background:linear-gradient(180deg,var(--dk3-side-hi),#9dadc2);}",
1436
+ ".dk3-metal .dk3-face:nth-child(6){background:var(--dk3-side-dk);}",
1437
+ // 书桌与桌腿
1438
+ ".dk3-desk .dk3-face{background:linear-gradient(180deg,#9db0c6,#7f92aa);}",
1439
+ ".dk3-desk .dk3-face:nth-child(5){background:#c3d2e2;}",
1440
+ // 显示器(薄盒 + 贴在前面的屏幕;组内扇形微转,整组后撤出桌沿)
1441
+ ".dk3-mon3{position:absolute;transform-style:preserve-3d;}",
1442
+ ".dk3-frame .dk3-face{background:#3a465b;}",
1443
+ ".dk3-frame .dk3-face:nth-child(5){background:#55647d;}",
1444
+ ".dk3-screen{position:absolute;left:50%;top:50%;background:var(--dk3-screen);border-radius:2px;overflow:hidden;transition:opacity .4s var(--ds-ease-in-out),box-shadow .4s var(--ds-ease-in-out);}",
1445
+ // 屏幕内容:代码块整体向上滚动(10 行=5 行×2 循环,滚半程无缝接回)+ 闪烁光标,速度随 --dkan-speed
1446
+ ".dk3-code{position:absolute;left:0;top:0;right:0;display:block;animation:dk3-scroll calc(5s / var(--dkan-speed,1)) linear infinite;}",
1447
+ ".dk3-code i{display:block;height:2px;border-radius:1px;margin:2px 3px 0;background:var(--dkan-code-b);opacity:.55;}",
1448
+ ".dk3-code i:nth-child(5n+1){width:58%;background:var(--dkan-code-a);}",
1449
+ ".dk3-code i:nth-child(5n+2){width:82%;}",
1450
+ ".dk3-code i:nth-child(5n+3){width:46%;background:var(--dkan-code-c);}",
1451
+ ".dk3-code i:nth-child(5n+4){width:72%;}",
1452
+ ".dk3-code i:nth-child(5n){width:54%;background:var(--dkan-code-a);}",
1453
+ ".dk3-cur{position:absolute;left:3px;bottom:2px;width:4px;height:2px;background:var(--dkan-code-b);animation:dkan-blink3 1s steps(1) infinite;}",
1454
+ // 左屏检索视图:搜索框、放大镜与结果条,和右侧代码编辑器明确区分
1455
+ ".dk3-search-ui{position:absolute;inset:3px;display:block;overflow:hidden;}",
1456
+ ".dk3-search-box{display:block;height:5px;margin:0 1px 3px;border:1px solid color-mix(in srgb,var(--dkan-code-a) 72%,#d7fff4);border-radius:2px;box-sizing:border-box;}",
1457
+ ".dk3-search-box b{display:block;width:2px;height:2px;margin:1px 0 0 2px;border:1px solid var(--dkan-code-a);border-radius:50%;position:relative;}",
1458
+ ".dk3-search-box b:after{content:'';position:absolute;width:2px;height:1px;background:var(--dkan-code-a);right:-2px;bottom:-1px;transform:rotate(45deg);transform-origin:left center;}",
1459
+ ".dk3-search-results{display:block;animation:dk3-search-scroll calc(2.1s / var(--dkan-speed,1)) linear infinite;}",
1460
+ ".dk3-search-results i{display:block;height:2px;margin:2px 1px 0;border-radius:1px;background:var(--dkan-code-a);opacity:.72;}",
1461
+ ".dk3-search-results i:nth-child(2n){width:74%;background:var(--dkan-code-b);}",
1462
+ ".dk3-search-results i:nth-child(3n){width:48%;background:var(--dkan-code-c);}",
1463
+ "@keyframes dk3-search-scroll{to{transform:translateY(-5px)}}",
1464
+ "@keyframes dk3-scroll{to{transform:translateY(-50%)}}",
1465
+ // 人物与椅子始终正对桌面:工位切换只横移,不再旋转整个人和座椅。
1466
+ ".dk3-person{--dk3-seat-x:22px;position:absolute;left:98px;top:14px;width:44px;height:61px;transform-style:preserve-3d;transform:translateZ(30px) translateX(var(--dk3-seat-x));transition:transform max(.18s,calc(.38s / var(--dkan-speed,1))) cubic-bezier(.23,1,.32,1);will-change:transform;}",
1467
+ ".dkan-bot-scene[data-station=left] .dk3-person{--dk3-seat-x:0px;}",
1468
+ ".dkan-bot-scene[data-station=center] .dk3-person{--dk3-seat-x:22px;}",
1469
+ ".dkan-bot-scene[data-station=right] .dk3-person{--dk3-seat-x:44px;}",
1470
+ // 上身以髋部为旋转原点;只做前后倾,始终朝向桌面。
1471
+ ".dk3-upper3{position:absolute;inset:0;transform-style:preserve-3d;transform-origin:12px 52px;transform:translateZ(0);transition:transform .24s cubic-bezier(.77,0,.175,1);will-change:transform;}",
1472
+ // 动漫人物配色:皮肤/头发/卫衣/裤子
1473
+ ".dk3-skin .dk3-face{background:linear-gradient(180deg,#ffd9b8,#f3b98d);}",
1474
+ ".dk3-skin .dk3-face:nth-child(5){background:#ffe3c9;}",
1475
+ ".dk3-hair .dk3-face{background:linear-gradient(180deg,#5b4632,#3f3021);}",
1476
+ ".dk3-hair .dk3-face:nth-child(5){background:#6b543c;}",
1477
+ ".dk3-hood .dk3-face{background:linear-gradient(180deg,#7c8ba0,#5b6a80);}",
1478
+ ".dk3-hood .dk3-face:nth-child(5){background:#93a5ba;}",
1479
+ ".dk3-pants .dk3-face{background:linear-gradient(180deg,#3d4a5c,#2c3646);}",
1480
+ ".dk3-pants .dk3-face:nth-child(5){background:#4a5a70;}",
1481
+ // 人物细节:收窄下颌、连续肩颈、短侧渐层发型
1482
+ ".dk3-cranium .dk3-face{border-radius:42% 46% 38% 36%;}",
1483
+ ".dk3-jaw3 .dk3-face{border-radius:30% 30% 48% 48%;}",
1484
+ ".dk3-neck3 .dk3-face{border-radius:2px;}",
1485
+ ".dk3-collar3 .dk3-face{background:linear-gradient(180deg,#516176,#374459);border-radius:3px;}",
1486
+ ".dk3-shoulders3 .dk3-face{border-radius:4px;}",
1487
+ ".dk3-chest3 .dk3-face{border-radius:4px;}",
1488
+ ".dk3-waist3 .dk3-face{border-radius:3px;}",
1489
+ ".dk3-hair .dk3-face{border-radius:2px;}",
1490
+ ".dk3-hair-crown .dk3-face{border-radius:48% 42% 28% 30%;}",
1491
+ ".dk3-hair-back .dk3-face{background:linear-gradient(180deg,#493727,#2f241b);border-radius:46% 24% 38% 44%;}",
1492
+ ".dk3-shoe .dk3-face{background:#20262f;border-radius:2px;}",
1493
+ ".dk3-mug .dk3-face{background:#c2703d;border-radius:1px;}",
1494
+ ".dk3-mug .dk3-face:nth-child(5){background:#e08a52;}",
1495
+ ".dk3-mouth{position:absolute;left:50%;top:64%;width:3.5px;height:1px;border-radius:2px;background:#a9635c;}",
1496
+ // 椅子
1497
+ ".dk3-chairback .dk3-face{background:linear-gradient(180deg,#3f4c60,#2c3648);}",
1498
+ ".dk3-chairseat .dk3-face{background:#33415a;}",
1499
+ // 头组与颈部中心均为 x=14;只让头部观察左右屏,身体和椅子不转向。
1500
+ ".dk3-head3{--dk3-head-turn:rotateY(0deg);position:absolute;left:6px;top:3px;width:17px;height:21px;transform-style:preserve-3d;transform-origin:8px 18px;transform:var(--dk3-head-turn);transition:transform max(.18s,calc(.22s / var(--dkan-speed,1))) cubic-bezier(.23,1,.32,1);will-change:transform;}",
1501
+ ".dkan-bot-scene[data-station=left] .dk3-head3{--dk3-head-turn:rotateY(-18deg);}",
1502
+ ".dkan-bot-scene[data-station=center] .dk3-head3{--dk3-head-turn:rotateY(0deg);}",
1503
+ ".dkan-bot-scene[data-station=right] .dk3-head3{--dk3-head-turn:rotateY(14deg);}",
1504
+ ".dk3-brows{position:absolute;left:50%;top:29%;display:flex;gap:2.5px;align-items:center;justify-content:center;}",
1505
+ ".dk3-brows i{width:3.5px;height:1px;border-radius:2px;background:#3a2a21;transform:rotate(-7deg);}",
1506
+ ".dk3-brows i+ i{transform:rotate(7deg);}",
1507
+ ".dk3-eyes{position:absolute;left:50%;top:51%;display:flex;gap:2.5px;align-items:center;justify-content:center;}",
1508
+ ".dk3-eyes i{width:3.5px;height:2.5px;border-radius:50%;background:radial-gradient(circle at 62% 30%,#fff 0 .5px,#263141 .8px 1.7px,#111827 1.9px);animation:dkan-blink3 4.8s infinite;}",
1509
+ ".dk3-nose{position:absolute;left:50%;top:66%;width:2px;height:3px;border-radius:60% 40% 55% 45%;background:linear-gradient(90deg,#e7a67e,#c77f67);}",
1510
+ ".dk3-ear{position:absolute;left:1px;top:10px;width:3px;height:5px;border-radius:52% 44% 48% 52%;background:linear-gradient(90deg,#edaa82,#ffd2ad);transform:translateZ(2px);}",
1511
+ ".dk3-fringe3{position:absolute;left:13px;top:4px;width:5px;height:7px;transform-style:preserve-3d;transform:rotateY(90deg) translateZ(5.8px);}",
1512
+ ".dk3-fringe3 i{position:absolute;top:0;width:2px;height:6px;border-radius:70% 25% 60% 30%;background:linear-gradient(180deg,#6b5239,#392b20);transform-origin:top center;}",
1513
+ ".dk3-fringe3 i:nth-child(1){left:0;transform:rotate(-18deg);height:5px;}",
1514
+ ".dk3-fringe3 i:nth-child(2){left:1.8px;transform:rotate(-6deg);height:6px;}",
1515
+ ".dk3-fringe3 i:nth-child(3){left:3.5px;transform:rotate(12deg);height:4px;}",
1516
+ "@keyframes dkan-blink3{0%,91%,100%{transform:scaleY(1)}94%{transform:scaleY(.15)}}",
1517
+ // 手臂按肩→肘→腕分段,旋转原点位于真实关节连接处。
1518
+ ".dk3-arm3{position:absolute;left:20px;top:30px;width:16px;height:16px;transform-style:preserve-3d;transform-origin:2px 2px;will-change:transform;}",
1519
+ ".dk3-elbow{position:absolute;left:0;top:10px;width:15px;height:5px;transform-style:preserve-3d;transform-origin:1px 2px;will-change:transform;}",
1520
+ ".dk3-wrist3{position:absolute;left:9.5px;top:0;width:6px;height:5px;transform-style:preserve-3d;transform-origin:1px 2px;will-change:transform;}",
1521
+ ".dk3-fingers3{position:absolute;left:3px;top:1px;width:4px;height:3px;transform:translateZ(2px);}",
1522
+ ".dk3-fingers3 i{position:absolute;left:0;width:4px;height:.8px;border-radius:2px;background:#f2b58d;}",
1523
+ ".dk3-fingers3 i+ i{top:1.4px;width:3.5px;}",
1524
+ ".dk3-far{transform:translateZ(-7px);filter:brightness(.72);}",
1525
+ // 思考泡泡(贴镜头 2D 层,think 阶段显示)
1526
+ ".dkan-bubble{position:absolute;left:80px;top:4px;display:none;gap:3px;padding:4px 6px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 2px 6px rgb(0 0 0 / .15);z-index:3;}",
1527
+ ".dkan-bubble i{width:4px;height:4px;border-radius:50%;background:var(--dsw-alias-label-tertiary);animation:dkan-bubdot 1.2s ease-in-out infinite;}",
1528
+ ".dkan-bubble i:nth-child(2){animation-delay:.2s;}",
1529
+ ".dkan-bubble i:nth-child(3){animation-delay:.4s;}",
1530
+ "@keyframes dkan-bubdot{0%,100%{opacity:.25;transform:translateY(0)}50%{opacity:1;transform:translateY(-2px)}}",
1531
+ // ===== 多任务工位(data-tasks=活跃任务数,上限 3) =====
1532
+ // 椅子滚轮
1533
+ ".dk3-wheel .dk3-face{background:#1c232e;border-radius:50%;}",
1534
+ // 有活跃任务时,未轮到的侧工位稍暗;多任务则两个工位均保持可见。
1535
+ ".dkan-bot-scene[data-tasks] .dk3-mon3.left .dk3-screen,.dkan-bot-scene[data-tasks] .dk3-mon3.right .dk3-screen{opacity:.3;}",
1536
+ // 2 个及以上任务:左右工位同时点亮(各自带辉光、代码滚动加速)。
1537
+ ".dkan-bot-scene[data-tasks=\"2\"] .dk3-mon3.left .dk3-screen,.dkan-bot-scene[data-tasks=\"2\"] .dk3-mon3.right .dk3-screen,.dkan-bot-scene[data-tasks=\"3\"] .dk3-mon3.left .dk3-screen,.dkan-bot-scene[data-tasks=\"3\"] .dk3-mon3.right .dk3-screen{opacity:1;box-shadow:0 0 8px color-mix(in srgb,var(--dkan-code-b,#60a5fa) 42%,transparent);}",
1538
+ ".dkan-bot-scene[data-tasks=\"2\"] .dk3-mon3.left .dk3-code,.dkan-bot-scene[data-tasks=\"2\"] .dk3-mon3.right .dk3-code,.dkan-bot-scene[data-tasks=\"3\"] .dk3-mon3.left .dk3-code,.dkan-bot-scene[data-tasks=\"3\"] .dk3-mon3.right .dk3-code{animation-duration:calc(2.6s / var(--dkan-speed,1));}",
1539
+ // 座椅移动期间滚轮跟随任务节奏转动;思考中停下,避免无意义的常驻运动。
1540
+ ".dkan-bot-scene[data-station=left] .dk3-wheel,.dkan-bot-scene[data-station=right] .dk3-wheel{animation:dkan-wheel calc(.46s / var(--dkan-speed,1)) linear infinite;transform-origin:center;}",
1541
+ "@keyframes dkan-wheel{to{transform:rotateZ(360deg)}}",
1542
+ // ===== 阶段驱动(data-phase 四态,host 由 chunk 流实时同步) =====
1543
+ // think:上身轻靠、近侧手托下巴、远侧手留在桌面;动作有停顿而不是机械往返。
1544
+ ".dkan-bot-scene[data-phase=think] .dk3-screen{opacity:.32;}",
1545
+ ".dkan-bot-scene[data-phase=think] .dkan-bubble{display:inline-flex;}",
1546
+ ".dkan-bot-scene[data-phase=think] .dk3-upper3{animation:dkan-think-upper max(1.8s,calc(2.4s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1547
+ ".dkan-bot-scene[data-phase=think] .dk3-head3{animation:dkan-think-head max(1.8s,calc(2.4s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1548
+ ".dkan-bot-scene[data-phase=think] .dk3-arm3.dk3-near{animation:dkan-think-near max(1.8s,calc(2.4s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1549
+ ".dkan-bot-scene[data-phase=think] .dk3-arm3.dk3-far{transform:translateZ(-7px) rotateZ(3deg);}",
1550
+ ".dkan-bot-scene[data-phase=think] .dk3-near .dk3-elbow{transform:rotateZ(-28deg) translateY(-1px);}",
1551
+ ".dkan-bot-scene[data-phase=think] .dk3-near .dk3-wrist3{transform:rotateZ(-18deg) translateY(-1px);}",
1552
+ "@keyframes dkan-think-upper{0%,44%{transform:rotateZ(-1deg) translateY(0)}62%,100%{transform:rotateZ(-1.8deg) translateY(-.5px)}}",
1553
+ "@keyframes dkan-think-head{0%,44%{transform:var(--dk3-head-turn) rotateZ(-3deg)}62%,100%{transform:var(--dk3-head-turn) rotateZ(-6deg) translateY(-.5px)}}",
1554
+ "@keyframes dkan-think-near{0%,44%{transform:rotateZ(-31deg) translate(-8px,-14.5px)}62%,100%{transform:rotateZ(-34deg) translate(-8.5px,-15px)}}",
1555
+ // write/code:身体仍面向桌面,仅轻微前倾;主要由手腕和手指敲击。
1556
+ ".dkan-bot-scene[data-phase=write] .dk3-mon3.right .dk3-screen,.dkan-bot-scene[data-phase=code] .dk3-mon3.right .dk3-screen{opacity:1;box-shadow:0 0 11px color-mix(in srgb,var(--dkan-code-b,#60a5fa) 52%,transparent);}",
1557
+ ".dkan-bot-scene[data-phase=write] .dk3-mon3.right .dk3-code,.dkan-bot-scene[data-phase=code] .dk3-mon3.right .dk3-code{animation-duration:calc(1.35s / var(--dkan-speed,1));}",
1558
+ ".dkan-bot-scene[data-phase=write] .dk3-upper3,.dkan-bot-scene[data-phase=code] .dk3-upper3{animation:dkan-code-upper max(1.2s,calc(2.4s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1559
+ ".dkan-bot-scene[data-phase=write] .dk3-arm3.dk3-near,.dkan-bot-scene[data-phase=code] .dk3-arm3.dk3-near{transform:rotateZ(3deg);}",
1560
+ ".dkan-bot-scene[data-phase=write] .dk3-arm3.dk3-far,.dkan-bot-scene[data-phase=code] .dk3-arm3.dk3-far{transform:translateZ(-7px) rotateZ(1deg);}",
1561
+ ".dkan-bot-scene[data-phase=write] .dk3-elbow,.dkan-bot-scene[data-phase=code] .dk3-elbow{animation:dkan-type-elbow max(.28s,calc(.58s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1562
+ ".dkan-bot-scene[data-phase=write] .dk3-wrist3,.dkan-bot-scene[data-phase=code] .dk3-wrist3{animation:dkan-type-wrist max(.18s,calc(.29s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1563
+ ".dkan-bot-scene[data-phase=write] .dk3-far .dk3-elbow,.dkan-bot-scene[data-phase=code] .dk3-far .dk3-elbow{animation-delay:-.09s;animation-duration:max(.3s,calc(.63s / var(--dkan-speed,1)));}",
1564
+ ".dkan-bot-scene[data-phase=write] .dk3-far .dk3-wrist3,.dkan-bot-scene[data-phase=code] .dk3-far .dk3-wrist3{animation-delay:-.09s;animation-duration:max(.2s,calc(.33s / var(--dkan-speed,1)));}",
1565
+ ".dkan-bot-scene[data-phase=write] .dk3-head3,.dkan-bot-scene[data-phase=code] .dk3-head3{animation:dkan-output-head max(1.2s,calc(2.4s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1566
+ "@keyframes dkan-code-upper{0%,38%{transform:rotateZ(2deg) translate(1px,0)}62%,100%{transform:rotateZ(3deg) translate(1px,1px)}}",
1567
+ "@keyframes dkan-type-elbow{0%,38%{transform:rotateZ(-4deg)}62%,100%{transform:rotateZ(5deg) translateY(1px)}}",
1568
+ "@keyframes dkan-type-wrist{0%,34%{transform:rotateZ(-2deg) translateY(0)}62%,100%{transform:rotateZ(3deg) translateY(1.5px)}}",
1569
+ "@keyframes dkan-output-head{0%,46%,100%{transform:var(--dk3-head-turn) rotateZ(3deg)}58%{transform:var(--dk3-head-turn) rotateZ(5deg) translateY(1px)}}",
1570
+ // search:身体正对桌面,头看左屏,近侧手腕小幅操作鼠标。
1571
+ ".dkan-bot-scene[data-phase=search] .dk3-mon3.left .dk3-screen{opacity:1;box-shadow:0 0 10px color-mix(in srgb,var(--dkan-code-a,#4ade80) 52%,transparent);}",
1572
+ ".dkan-bot-scene[data-phase=search] .dk3-mon3.left .dk3-search-results{animation-duration:calc(1.05s / var(--dkan-speed,1));}",
1573
+ ".dkan-bot-scene[data-phase=search] .dk3-upper3{transform:rotateZ(2deg) translateY(1px);}",
1574
+ ".dkan-bot-scene[data-phase=search] .dk3-head3{animation:dkan-search-head max(.72s,calc(1.15s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1575
+ ".dkan-bot-scene[data-phase=search] .dk3-near .dk3-wrist3{animation:dkan-search-wrist max(.45s,calc(.72s / var(--dkan-speed,1))) cubic-bezier(.77,0,.175,1) infinite;}",
1576
+ ".dkan-bot-scene[data-phase=search] .dk3-far{transform:translateZ(-7px) rotateZ(2deg);}",
1577
+ "@keyframes dkan-search-head{0%,28%{transform:var(--dk3-head-turn) rotateZ(1deg)}48%,68%{transform:var(--dk3-head-turn) rotateZ(6deg) translate(1px,1px)}88%,100%{transform:var(--dk3-head-turn) rotateZ(2deg)}}",
1578
+ "@keyframes dkan-search-wrist{0%,34%{transform:translateX(-1px)}62%,100%{transform:translateX(1.5px)}}",
1579
+ // 减少动态时停止空间位移,保留屏幕亮暗与思考状态的淡入反馈。
1580
+ "@media (prefers-reduced-motion:reduce){.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}}",
1581
+ // 通知子选项行
1582
+ ".dkan-rows-narrow{display:flex;flex-direction:column;gap:6px;}",
1583
+ ".dkan-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;}",
1584
+ ".dkan-row-label{flex:none;min-width:60px;color:var(--dsw-alias-label-primary);}",
1585
+ ".dkan-row-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
1586
+ ".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;}",
1587
+ ".dkm-miniswitch.on{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
1588
+ ".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;}",
1589
+ // 音效选择卡(名称 + 播放键;选中态描边)
1590
+ ".dkan-sounds{display:flex;gap:6px;flex-wrap:wrap;}",
1591
+ ".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);}",
1592
+ ".dkan-sound:hover{border-color:var(--dsw-alias-accent,#4d9fff);}",
1593
+ ".dkan-sound.on{border-color:var(--dsw-alias-accent,#4d9fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 40%,transparent);}",
1594
+ ".dkan-sound-name{font-size:12px;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:5px;}",
1595
+ ".dkan-sound-cur{color:var(--dsw-alias-accent,#4d9fff);font-size:11px;}",
1596
+ ".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);}",
1597
+ ".dkan-sound-play:hover{color:var(--dsw-alias-label-primary);border-color:currentColor;}",
1598
+ ".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;}",
1599
+ ".dkan-input:focus{outline:none;border-color:var(--dsw-alias-accent,#4d9fff);}",
1600
+ ".dkan-row-webhook{flex-wrap:nowrap;}",
1601
+ ".dkan-row-webhook .dkan-input{min-width:0;}",
1602
+ ".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;}",
1603
+ ".dkan-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
1604
+ ".dkan-btn[disabled]{opacity:.5;cursor:default;}",
1605
+ ".dkan-ok{color:var(--dsw-alias-state-success-primary);}",
1606
+ // 任务列表
1607
+ ".dkan-tasks{display:flex;flex-direction:column;gap:6px;}",
1608
+ ".dkan-tasks-done{border-top:1px dashed var(--dsw-alias-border-l2);padding-top:6px;}",
1609
+ ".dkan-task{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;display:flex;flex-direction:column;gap:3px;}",
1610
+ ".dkan-task-head{display:flex;align-items:center;gap:8px;min-width:0;}",
1611
+ ".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;}",
1612
+ ".dkan-task-meta{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-tertiary);}",
1613
+ ".dkan-task-err{font-size:11px;color:var(--dsw-alias-state-error-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
1614
+ ".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);}",
1615
+ ".dkan-tag.on{color:var(--dsw-alias-accent,#4d9fff);border-color:currentColor;}",
1616
+ ".dkan-tag.ok{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
1617
+ ".dkan-tag.err{color:var(--dsw-alias-state-error-primary);border-color:currentColor;}",
1618
+ ".dkan-tag.warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor;}",
1619
+ ].join("\n");
1620
+
1621
+ export const feature = {
1622
+ id: "animation",
1623
+ name: "任务动画",
1624
+ order: 130,
1625
+ accent: "#f472b6",
1626
+ description: "任务运行动画(流光细线/呼吸光点/轨道光环)与完成通知,两组开关独立、配置持久化",
1627
+ css,
1628
+ View: AnimationView,
1629
+ HomeStat: AnimationStat,
1630
+ Overlay: AnimationOverlay,
1631
+ };