dsh-conversation-aurora 0.1.0 → 0.2.1

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.
package/client.js CHANGED
@@ -2,630 +2,48 @@ window.__ModuleLoader__.load({
2
2
  id: 'dsh-conversation-aurora',
3
3
  factory() {
4
4
  /*
5
- * @local/conversation-aurora — Client 半区
5
+ * dsh-conversation-aurora — 对话区整屏动效背景(Client 半区)
6
6
  *
7
- * 4 种手势 + 2 个全局调制,全部落在同一条「列边光纤」上:
7
+ * 形态:在对话画布内部垫一层 z-index:-1 的 canvas,把整块画布当背景。
8
+ * - 只有"在干活"时才播:任何工具在跑 / 回合进行中;
9
+ * - 空闲、切会话、窗口隐藏时立刻停掉 rAF,画布淡出;
10
+ * - 每个会话按 session id 分配不同主题,Alt+T 手动轮换;
11
+ * - 文字可读性由 .dsh-stage__veil 保证:按正文列宽罩一层纱。
8
12
  *
9
- * idle 待命:两侧柔光极慢漂移,光纤只剩一条几乎看不见的呼吸发丝
10
- * read 向内:光纤上的亮段向下流动(检索 / 读文件)
11
- * web 向外:光纤心跳 + 一圈向外扩散的光晕(联网 / 派活)
12
- * write 向上:光纤自下而上逐段点亮(写码 / 跑命令)
13
- * think idle 的呼吸加快,不新增手势
14
- * wait 全体压到 ~2%、转灰蓝、缓慢向列内推
15
- * error 红色泛光,之后留一点暗红到下一次动作
16
- *
17
- * 几何:动效层挂在对话画布内部([data-conversation-content]),z-index:-1。
18
- * 左右各一个「翼」(.dsh-aurora__wing),翼宽正好等于正文列以外的空白带,
19
- * 且 overflow:hidden —— 会扩散的元素(光晕、脉冲环)都被裁在列边界之外,
20
- * 中间文字区零覆盖,这是几何保证,不是渐变赌出来的。
21
- *
22
- * 状态信号全部来自宿主已有的语义属性,不读类名:
23
- * [data-tool][data-state=running|preparing|error|stopped]
24
- * [data-chat-running] [data-streaming]
13
+ * 状态信号全部来自宿主已有语义属性,不读类名:
14
+ * [data-tool][data-state=running|preparing] [data-chat-running] [data-streaming]
25
15
  */
26
16
 
27
- /* ================= 可调常量(JS 侧) ================= */
17
+ /* ================= 可调常量 ================= */
28
18
 
29
- /** 时序与节流;视觉参数在下方 CSS 的 .dsh-aurora 块里。 */
30
19
  const TUNE = {
31
- /** 状态最短驻留:短于此值的切换会被忽略(防止连续调用工具时闪)。 */
32
- minDwellMs: 600,
33
- /** 切换瞬间光纤亮一下的时长与幅度。 */
34
- pulseMs: 420,
35
- pulseRise: 0.16,
36
- /** error 保持多久,之后转入残留暗红。 */
37
- errorHoldMs: 1300,
20
+ /** 停止后延迟多久真正淡出(防止工具间隙闪一下)。 */
21
+ holdIdleMs: 420,
38
22
  /** 最多跟踪多少个工具行(只留尾部,长会话也不拖慢)。 */
39
23
  maxTracked: 48,
40
- /** 首次挂载只回看最近多少行,避免历史里的失败行触发红光。 */
24
+ /** 首次挂载只回看最近多少行。 */
41
25
  seedRows: 24,
42
26
  /** React 重挂载引发的反复摘挂保护。 */
43
27
  remountGuard: 10,
44
28
  remountWindowMs: 2000,
45
- /** Alt+B 循环:标准 → 安静 → 关闭。 */
46
- intensity: [1, 0.55, 0],
47
- keyCode: 'b',
48
- };
49
-
50
- /** 优先级:高者可以打断低者的最短驻留。 */
51
- const MODE_PRIORITY = { idle: 0, think: 1, read: 2, web: 3, write: 4, wait: 5, error: 6 };
52
-
53
- /** 每种模式的主侧;另一侧以 60% 强度 + 0.7s 延迟跟随。 */
54
- const MODE_PRIMARY = {
55
- idle: 'both',
56
- think: 'both',
57
- read: 'left',
58
- web: 'right',
59
- write: 'left',
60
- wait: 'both',
61
- error: 'both',
62
- };
63
-
64
- /** 遥测小字用的短标签。 */
65
- const MODE_TAG = {
66
- idle: 'STANDBY',
67
- think: 'THINK',
68
- read: 'SCAN',
69
- web: 'NET',
70
- write: 'BUILD',
71
- wait: 'HOLD',
72
- error: 'ERR',
29
+ /** 切会话后的静默期,避免把上一轮的残留行当成"正在工作"。 */
30
+ switchQuietMs: 600,
73
31
  };
74
32
 
75
- /** 工具名 → 手势。表里没有的按 data-variant 归类,再兜底 read。 */
76
- const TOOL_MODE = {
77
- web_search: 'web',
78
- web_fetch: 'web',
79
- subagent: 'web',
80
- workflow: 'web',
81
- ralph: 'web',
82
- spawn_teammate: 'web',
83
- wait_agent: 'web',
84
- send_message: 'web',
85
- list_agents: 'web',
86
- grep: 'read',
87
- glob: 'read',
88
- read: 'read',
89
- read_image: 'read',
90
- lsp: 'read',
91
- session_search: 'read',
92
- session_trace: 'read',
93
- session_event_read: 'read',
94
- session_event_search: 'read',
95
- session_event_trace: 'read',
96
- cordis_inspect_list: 'read',
97
- cordis_inspect_query: 'read',
98
- cordis_inspect_self: 'read',
99
- cordis_package_inspect: 'read',
100
- cordis_runtime_inspect: 'read',
101
- job_list: 'read',
102
- job_output: 'read',
103
- get_goal: 'read',
104
- schedule_list: 'read',
105
- list_subagent_models: 'read',
106
- write: 'write',
107
- edit: 'write',
108
- run_code: 'write',
109
- pwsh: 'write',
110
- bash: 'write',
111
- terminal_open: 'write',
112
- terminal_read: 'write',
113
- terminal_list: 'write',
114
- terminal_signal: 'write',
115
- terminal_close: 'write',
116
- job_kill: 'write',
117
- create_goal: 'write',
118
- update_goal: 'write',
119
- todo_write: 'write',
120
- schedule_create: 'write',
121
- schedule_update: 'write',
122
- schedule_delete: 'write',
123
- interrupt_agent: 'write',
124
- cordis_run: 'write',
125
- cordis_stop: 'write',
126
- cordis_undefine: 'write',
127
- };
128
-
129
- const VARIANT_MODE = {
130
- web: 'web',
131
- search: 'read',
132
- read: 'read',
133
- write: 'write',
134
- edit: 'write',
135
- bash: 'write',
136
- code: 'write',
137
- others: 'write',
138
- };
139
-
140
- const NS = 'dsh-aurora';
141
- const HOST_CLASS = 'dsh-aurora-host';
142
- const FIBER_CLASS = NS + '__fiber';
33
+ const NS = 'dsh-stage';
34
+ const HOST_CLASS = 'dsh-stage-host';
143
35
  const ROW_SELECTOR = '[data-tool][data-state]';
36
+ const STORAGE = { theme: 'dsh.conversation-aurora.theme' };
144
37
 
145
- /* ================= 样式(视觉参数都在这里) ================= */
38
+ /* ================= 样式 ================= */
146
39
 
147
40
  const CSS = `
148
- .dsh-aurora-host{position:relative;isolation:isolate}
149
-
150
- /* 图层根:所有视觉参数集中在这个块,README「调参」一节有对照表。 */
151
- .dsh-aurora{
152
- --fiber-width:1.5px;
153
- --fiber-color:65,118,230; /* DeepSeek 蓝 #4176E6 */
154
- --glow-a:65,118,230;
155
- --glow-b:124,92,255; /* 柔紫 #7C5CFF */
156
- --glow-c:46,211,183; /* 青 #2ED3B7 */
157
- --glow-opacity:.22; /* 柔光强度:整条空白带的底色 */
158
- --wash-color:78,98,150; /* 底光色:低饱和冷灰蓝,像一层浅浅的面板 */
159
- --fiber-opacity:.16; /* 静息发丝亮度 */
160
- --gesture-opacity:.5; /* 手势亮度:检索的亮段 / 写码的生长条 */
161
- --pulse-opacity:.4; /* 联网时向外扩散的那圈光 */
162
- --secondary-strength:.6; /* 副侧强度 */
163
- --hud-ink:15,17,21; /* HUD 墨色:近黑,像印在纸上的仪器标记 */
164
- --hud-opacity:1; /* HUD 层的总开关:角标 / 遥测 / 扫描纹 / 数据柱 */
165
- --side-delay:0ms;
166
- --crossfade:700ms;
167
- --intensity:1;
168
- --aurora-shift:0px; /* 滚动条造成的列偏移,由 JS 量出来 */
169
- --drift-slow:40s; /* 两团柔光的周期,互不成整数比 */
170
- --drift-fast:52s;
171
- position:absolute;
172
- inset:0;
173
- z-index:8; /* 盖过输入区那条白色渐变,才能铺到窗口底边;拖宽手柄同为 8 且在 DOM 之后,仍在上面 */
174
- overflow:hidden;
175
- pointer-events:none;
176
- transition:opacity 600ms ease, visibility 0s linear 0s;
177
- }
178
-
179
- /* 空闲 = 什么都没有:整层淡出并停掉动画,两侧回到普通白纸。
180
- 一旦有输出或工具在跑(think / read / web / write / wait / error)才重新浮出来。 */
181
- .dsh-aurora[data-mode=idle]{
182
- opacity:0;
183
- visibility:hidden;
184
- transition:opacity 600ms ease, visibility 0s linear 600ms;
185
- }
186
- .dsh-aurora[data-mode=idle] *{animation-play-state:paused}
187
-
188
- .dsh-aurora[data-off]{display:none}
189
-
190
- body[data-ds-dark-theme] .dsh-aurora{
191
- --fiber-color:110,160,255;
192
- --glow-a:110,160,255;
193
- --glow-b:160,130,255;
194
- --glow-c:80,225,200;
195
- --glow-opacity:.3;
196
- --wash-color:40,62,110;
197
- --fiber-opacity:.22;
198
- --gesture-opacity:.56;
199
- --pulse-opacity:.46;
200
- --hud-ink:190,214,255;
201
- }
202
-
203
- /* ---------- 翼:正文列之外的空白带,最外层的裁切框 ---------- */
204
-
205
- .dsh-aurora__wing{
206
- position:absolute;
207
- top:0;
208
- bottom:0;
209
- overflow:hidden;
210
- pointer-events:none;
211
- }
212
- .dsh-aurora__wing[data-side=left]{
213
- left:0;
214
- width:max(0px, calc((100% - var(--dsh-chat-content-width, 748px)) / 2 - var(--aurora-shift, 0px)));
215
- --side:1;
216
- --push:26px;
217
- }
218
- .dsh-aurora__wing[data-side=right]{
219
- right:0;
220
- width:max(0px, calc((100% - var(--dsh-chat-content-width, 748px)) / 2 + var(--aurora-shift, 0px)));
221
- --side:1;
222
- --push:-26px;
223
- }
224
-
225
- /* ---------- 柔光:铺满整条空白带,向列边界方向淡出 ---------- */
226
-
227
- .dsh-aurora__glow{
228
- position:absolute;
229
- inset:0;
230
- overflow:hidden;
231
- opacity:calc(var(--glow-opacity) * var(--side) * var(--intensity));
232
- transition:opacity var(--crossfade) ease;
233
- }
234
- .dsh-aurora__wing[data-side=left] .dsh-aurora__glow{
235
- -webkit-mask-image:linear-gradient(to right,#000 0 55%,#0000 100%);
236
- mask-image:linear-gradient(to right,#000 0 55%,#0000 100%);
237
- }
238
- .dsh-aurora__wing[data-side=right] .dsh-aurora__glow{
239
- -webkit-mask-image:linear-gradient(to left,#000 0 55%,#0000 100%);
240
- mask-image:linear-gradient(to left,#000 0 55%,#0000 100%);
241
- }
242
-
243
- /* 底光:贯穿整条带子的静态渐变,保证中间不会空掉 */
244
- /* 底光:从窗口边淡向正文列,让空白带有一点纵深,但不变成一块色板 */
245
- .dsh-aurora__wash{
246
- position:absolute;
247
- inset:0;
248
- background:linear-gradient(to right,rgba(var(--wash-color),.5),rgba(var(--wash-color),0) 88%);
249
- }
250
- .dsh-aurora__wing[data-side=right] .dsh-aurora__wash{
251
- background:linear-gradient(to left,rgba(var(--wash-color),.5),rgba(var(--wash-color),0) 88%);
252
- }
253
-
254
- /* 漂移光团:尺寸按色带自身的宽高算,画布再高也不会只剩两个角 */
255
- .dsh-aurora__blob{
256
- position:absolute;
257
- border-radius:50%;
258
- }
259
- .dsh-aurora__blob[data-k=a]{
260
- width:150%;
261
- height:62%;
262
- background:radial-gradient(ellipse at 50% 50%, rgba(var(--glow-a),.6), rgba(var(--glow-a),0) 68%);
263
- }
264
- .dsh-aurora__blob[data-k=b]{
265
- width:165%;
266
- height:56%;
267
- background:radial-gradient(ellipse at 50% 50%, rgba(var(--glow-c),.55), rgba(var(--glow-c),0) 70%);
268
- }
269
- .dsh-aurora__wing[data-side=right] .dsh-aurora__blob[data-k=a]{background-image:radial-gradient(ellipse at 50% 50%, rgba(var(--glow-b),.62), rgba(var(--glow-b),0) 68%)}
270
- .dsh-aurora__wing[data-side=right] .dsh-aurora__blob[data-k=b]{background-image:radial-gradient(ellipse at 50% 50%, rgba(var(--glow-a),.5), rgba(var(--glow-a),0) 70%)}
271
-
272
- .dsh-aurora__wing[data-side=left] .dsh-aurora__blob[data-k=a]{top:-16%;left:-34%;animation:aurora-drift-a var(--drift-slow) ease-in-out infinite}
273
- .dsh-aurora__wing[data-side=left] .dsh-aurora__blob[data-k=b]{bottom:-14%;right:-34%;animation:aurora-drift-b var(--drift-fast) ease-in-out infinite;animation-delay:-13s}
274
- .dsh-aurora__wing[data-side=right] .dsh-aurora__blob[data-k=a]{top:-12%;right:-36%;animation:aurora-drift-b var(--drift-fast) ease-in-out infinite;animation-delay:-7s}
275
- .dsh-aurora__wing[data-side=right] .dsh-aurora__blob[data-k=b]{bottom:-12%;left:-34%;animation:aurora-drift-a var(--drift-slow) ease-in-out infinite;animation-delay:-21s}
276
-
277
- /* ---------- 列边光纤:所有手势的载体,离正文列 20px ---------- */
278
-
279
- .dsh-aurora__fiber{
280
- position:absolute;
281
- top:0;
282
- bottom:0;
283
- width:var(--fiber-width);
284
- animation-delay:var(--side-delay);
285
- }
286
- .dsh-aurora__wing[data-side=left] .dsh-aurora__fiber{right:20px}
287
- .dsh-aurora__wing[data-side=right] .dsh-aurora__fiber{left:20px}
288
-
289
- .dsh-aurora__fiber i{
290
- position:absolute;
291
- inset:0;
292
- display:block;
293
- transition:opacity var(--crossfade) ease;
294
- animation-delay:var(--side-delay);
295
- }
296
-
297
- /* 静息发丝:几乎看不见,靠呼吸幅度让人察觉 */
298
- .dsh-aurora__base{
299
- opacity:calc(var(--fiber-opacity) * var(--side) * var(--intensity));
300
- background:linear-gradient(to bottom,#0000,rgb(var(--fiber-color)) 12% 88%,#0000);
301
- }
302
-
303
- /* 检索:柔和的亮段向下流动(首尾都透明,循环处不会跳) */
304
- .dsh-aurora__scan{
305
- top:-56px;
306
- bottom:-56px;
307
- opacity:0;
308
- background-image:repeating-linear-gradient(to bottom,
309
- rgba(var(--fiber-color),0) 0px,
310
- rgba(var(--fiber-color),0) 12px,
311
- rgba(var(--fiber-color),.9) 20px,
312
- rgba(var(--fiber-color),.9) 36px,
313
- rgba(var(--fiber-color),0) 46px,
314
- rgba(var(--fiber-color),0) 56px);
315
- }
316
-
317
- /* 写码:从下往上长出来 */
318
- .dsh-aurora__fill{
319
- opacity:0;
320
- transform-origin:50% 100%;
321
- background-image:linear-gradient(to top,rgb(var(--fiber-color)),#0000 80%);
322
- }
323
-
324
- /* 联网:向外扩散的一圈光 */
325
- .dsh-aurora__pulse{
326
- position:absolute;
327
- top:50%;
328
- width:280px;
329
- height:280px;
330
- margin-top:-140px;
331
- border-radius:50%;
332
- opacity:0;
333
- pointer-events:none;
334
- background:radial-gradient(circle,#0000 52%,rgba(var(--glow-b),1) 58%,#0000 66%);
335
- animation-delay:var(--side-delay);
336
- }
337
- .dsh-aurora__wing[data-side=left] .dsh-aurora__pulse{
338
- right:20px;
339
- margin-right:-140px;
340
- -webkit-mask-image:linear-gradient(to right,#000 0 52%,#0000 58%);
341
- mask-image:linear-gradient(to right,#000 0 52%,#0000 58%);
342
- }
343
- .dsh-aurora__wing[data-side=right] .dsh-aurora__pulse{
344
- left:20px;
345
- margin-left:-140px;
346
- -webkit-mask-image:linear-gradient(to left,#000 0 52%,#0000 58%);
347
- mask-image:linear-gradient(to left,#000 0 52%,#0000 58%);
348
- }
349
-
350
- /* ---------- HUD:让两侧读起来像一块仪表,而不是一团渐变 ---------- */
351
-
352
- /* 扫描纹:静态 CRT 质感,只铺在空白带里 */
353
- .dsh-aurora__grid{
354
- position:absolute;
355
- inset:0;
356
- background-image:
357
- repeating-linear-gradient(to bottom,rgba(var(--hud-ink),.04) 0 1px,#0000 1px 4px),
358
- repeating-linear-gradient(to right,rgba(var(--hud-ink),.05) 0 1px,#0000 1px 96px);
359
- opacity:calc(var(--hud-opacity) * var(--side) * var(--intensity));
360
- transition:opacity var(--crossfade) ease;
361
- }
362
- body[data-ds-dark-theme] .dsh-aurora__grid{
363
- background-image:
364
- repeating-linear-gradient(to bottom,rgba(var(--hud-ink),.05) 0 1px,#0000 1px 4px),
365
- repeating-linear-gradient(to right,rgba(var(--hud-ink),.06) 0 1px,#0000 1px 96px);
366
- }
367
- .dsh-aurora__wing[data-side=left] .dsh-aurora__grid{
368
- -webkit-mask-image:linear-gradient(to right,#000 0 60%,#0000 100%);
369
- mask-image:linear-gradient(to right,#000 0 60%,#0000 100%);
370
- }
371
- .dsh-aurora__wing[data-side=right] .dsh-aurora__grid{
372
- -webkit-mask-image:linear-gradient(to left,#000 0 60%,#0000 100%);
373
- mask-image:linear-gradient(to left,#000 0 60%,#0000 100%);
374
- }
375
-
376
- /* 取景角标:把正文列框成一块"屏幕" */
377
- .dsh-aurora__corner{
378
- position:absolute;
379
- width:20px;
380
- height:20px;
381
- border:0 solid rgba(var(--hud-ink),.55);
382
- opacity:calc(var(--hud-opacity) * var(--side) * var(--intensity));
383
- transition:opacity var(--crossfade) ease;
384
- }
385
- .dsh-aurora__wing[data-side=left] .dsh-aurora__corner{right:20px}
386
- .dsh-aurora__wing[data-side=right] .dsh-aurora__corner{left:20px}
387
- .dsh-aurora__wing[data-side=left] .dsh-aurora__corner[data-at=top]{top:14px;border-top-width:1.5px;border-right-width:1.5px}
388
- .dsh-aurora__wing[data-side=left] .dsh-aurora__corner[data-at=bottom]{bottom:14px;border-bottom-width:1.5px;border-right-width:1.5px}
389
- .dsh-aurora__wing[data-side=right] .dsh-aurora__corner[data-at=top]{top:14px;border-top-width:1.5px;border-left-width:1.5px}
390
- .dsh-aurora__wing[data-side=right] .dsh-aurora__corner[data-at=bottom]{bottom:14px;border-bottom-width:1.5px;border-left-width:1.5px}
391
-
392
- /* 扫描光带:一直在扫,有动作时更亮 */
393
- .dsh-aurora__sweep{
394
- position:absolute;
395
- left:0;
396
- right:0;
397
- top:-10px;
398
- height:1.5px;
399
- opacity:calc(.22 * var(--hud-opacity) * var(--side) * var(--intensity));
400
- background:linear-gradient(to right,rgba(var(--hud-ink),0),rgba(var(--hud-ink),.55),rgba(var(--hud-ink),0));
401
- transition:opacity var(--crossfade) ease;
402
- animation:aurora-sweep 3.4s linear infinite;
403
- animation-delay:var(--side-delay);
404
- }
405
- .dsh-aurora[data-mode=think] .dsh-aurora__sweep,
406
- .dsh-aurora[data-mode=read] .dsh-aurora__sweep,
407
- .dsh-aurora[data-mode=web] .dsh-aurora__sweep,
408
- .dsh-aurora[data-mode=write] .dsh-aurora__sweep{
409
- opacity:calc(.55 * var(--hud-opacity) * var(--side) * var(--intensity));
410
- }
411
- .dsh-aurora[data-mode=wait] .dsh-aurora__sweep,
412
- .dsh-aurora[data-mode=error] .dsh-aurora__sweep{opacity:0}
413
-
414
- /* 数据柱:一直在流,只是"动手"时更亮更快 */
415
- .dsh-aurora__bars{
416
- position:absolute;
417
- inset:0;
418
- overflow:hidden;
419
- opacity:calc(.45 * var(--hud-opacity) * var(--side) * var(--intensity));
420
- transition:opacity var(--crossfade) ease;
421
- }
422
- .dsh-aurora[data-mode=write] .dsh-aurora__bars{
423
- opacity:calc(.7 * var(--hud-opacity) * var(--side) * var(--intensity));
424
- }
425
- .dsh-aurora__bars i{
426
- position:absolute;
427
- bottom:-90px;
428
- width:1px;
429
- height:var(--h,42px);
430
- background:repeating-linear-gradient(to top,rgba(var(--fiber-color),.85) 0 4px,#0000 4px 10px);
431
- -webkit-mask-image:linear-gradient(to top,#0000,#000 18%,#000 82%,#0000);
432
- mask-image:linear-gradient(to top,#0000,#000 18%,#000 82%,#0000);
433
- animation:aurora-rise var(--dur,5s) linear infinite;
434
- animation-delay:var(--del,0s);
435
- }
436
- .dsh-aurora[data-mode=write] .dsh-aurora__bars i{animation-duration:calc(var(--dur,5s) * .55)}
437
- .dsh-aurora__bars i:nth-child(1){left:5%;--h:34px;--dur:4.6s;--del:-1.1s}
438
- .dsh-aurora__bars i:nth-child(2){left:16%;--h:62px;--dur:6.2s;--del:-3.4s}
439
- .dsh-aurora__bars i:nth-child(3){left:27%;--h:26px;--dur:3.9s;--del:-0.6s}
440
- .dsh-aurora__bars i:nth-child(4){left:38%;--h:74px;--dur:7.1s;--del:-5.2s}
441
- .dsh-aurora__bars i:nth-child(5){left:50%;--h:44px;--dur:5.4s;--del:-2.3s}
442
- .dsh-aurora__bars i:nth-child(6){left:62%;--h:30px;--dur:4.2s;--del:-3.9s}
443
- .dsh-aurora__bars i:nth-child(7){left:74%;--h:58px;--dur:6.6s;--del:-1.8s}
444
- .dsh-aurora__bars i:nth-child(8){left:87%;--h:38px;--dur:5.1s;--del:-4.6s}
445
-
446
- /* 遥测:竖排小字,读起来像设备读数 */
447
- .dsh-aurora__telemetry{
448
- position:absolute;
449
- bottom:22px;
450
- z-index:2;
451
- font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
452
- font-size:9px;
453
- line-height:1;
454
- letter-spacing:.2em;
455
- color:rgba(var(--hud-ink),.62);
456
- writing-mode:vertical-rl;
457
- white-space:nowrap;
458
- opacity:0;
459
- transition:opacity var(--crossfade) ease;
460
- pointer-events:none;
461
- }
462
- .dsh-aurora__telemetry b{
463
- font-weight:500;
464
- display:inline-block;
465
- padding:0 2px;
466
- }
467
- .dsh-aurora__telemetry b + b{margin-inline-start:.85em}
468
- .dsh-aurora__telemetry .dsh-aurora__t-hex{color:rgba(var(--hud-ink),.34)}
469
- .dsh-aurora__telemetry .dsh-aurora__t-cur{animation:aurora-blink 1.2s steps(2,start) infinite}
470
- @keyframes aurora-blink{0%,100%{opacity:1}50%{opacity:.12}}
471
- .dsh-aurora__wing[data-side=left] .dsh-aurora__telemetry{left:10px;transform:rotate(180deg)}
472
- .dsh-aurora__wing[data-side=right] .dsh-aurora__telemetry{right:10px}
473
- .dsh-aurora:not([data-mode=idle]) .dsh-aurora__telemetry{
474
- opacity:calc(var(--hud-opacity) * var(--side) * var(--intensity));
475
- }
476
-
477
- /* glitch:状态切换和活动期间偶发一次,青/品红分离 + 轻微错位 */
478
- .dsh-aurora__fiber::before,
479
- .dsh-aurora__fiber::after{
480
- content:"";
481
- position:absolute;
482
- inset:0;
483
- opacity:0;
484
- pointer-events:none;
485
- transition:opacity 90ms linear;
486
- }
487
- .dsh-aurora__fiber::before{
488
- transform:translateX(-2px);
489
- background:linear-gradient(to bottom,#0000,rgba(0,229,255,.95) 12% 88%,#0000);
490
- }
491
- .dsh-aurora__fiber::after{
492
- transform:translateX(2px);
493
- background:linear-gradient(to bottom,#0000,rgba(255,0,132,.9) 12% 88%,#0000);
494
- }
495
- .dsh-aurora[data-glitch] .dsh-aurora__fiber::before{opacity:.6}
496
- .dsh-aurora[data-glitch] .dsh-aurora__fiber::after{opacity:.5}
497
- .dsh-aurora[data-glitch] .dsh-aurora__corner,
498
- .dsh-aurora[data-glitch] .dsh-aurora__bars{animation:aurora-jitter .14s steps(2,end) 1}
499
- @keyframes aurora-jitter{
500
- 0%{transform:translate3d(0,0,0)}
501
- 50%{transform:translate3d(1.5px,0,0)}
502
- 100%{transform:translate3d(0,0,0)}
503
- }
504
-
505
- /* ---------- 主侧 / 副侧:只改强度与相位,不改语义 ---------- */
506
-
507
- .dsh-aurora[data-primary=left] .dsh-aurora__wing[data-side=right],
508
- .dsh-aurora[data-primary=right] .dsh-aurora__wing[data-side=left]{
509
- --side:var(--secondary-strength);
510
- --side-delay:700ms;
511
- }
512
-
513
- /* ---------- 手势 ---------- */
514
-
515
- .dsh-aurora[data-mode=idle] .dsh-aurora__base{animation:aurora-breathe 9s ease-in-out infinite}
516
- .dsh-aurora[data-mode=think] .dsh-aurora__base{animation:aurora-breathe 3s ease-in-out infinite}
517
-
518
- .dsh-aurora[data-mode=read] .dsh-aurora__scan{
519
- --g:calc(var(--gesture-opacity) * var(--side) * var(--intensity));
520
- opacity:var(--g);
521
- animation:aurora-scan 1.1s linear infinite;
522
- }
523
- .dsh-aurora[data-mode=write] .dsh-aurora__fill{
524
- --g:calc(var(--gesture-opacity) * var(--side) * var(--intensity));
525
- animation:aurora-build 1.2s ease-in-out infinite;
526
- }
527
- .dsh-aurora[data-mode=web] .dsh-aurora__pulse{
528
- --g:calc(var(--pulse-opacity) * var(--side) * var(--intensity));
529
- animation:aurora-pulse 1.6s ease-out infinite;
530
- }
531
- .dsh-aurora[data-mode=web] .dsh-aurora__base{animation:aurora-breathe 1.1s ease-in-out infinite}
532
-
533
- /* ---------- 全局调制 ---------- */
534
-
535
- .dsh-aurora[data-mode=wait]{
536
- --glow-opacity:.018;
537
- --fiber-opacity:.02;
538
- --fiber-color:150,170,200;
539
- }
540
- .dsh-aurora[data-mode=wait] .dsh-aurora__base{animation:aurora-wait 4s ease-in-out infinite}
541
- .dsh-aurora[data-mode=wait] .dsh-aurora__scan,
542
- .dsh-aurora[data-mode=wait] .dsh-aurora__fill,
543
- .dsh-aurora[data-mode=wait] .dsh-aurora__pulse{opacity:0;animation:none}
544
-
545
- .dsh-aurora[data-mode=error],
546
- .dsh-aurora[data-tint=error]{--fiber-color:229,72,77}
547
- .dsh-aurora[data-mode=error]{--glow-a:229,72,77;--glow-b:229,72,77;--fiber-opacity:.08}
548
- .dsh-aurora[data-mode=error] .dsh-aurora__base{animation:aurora-breathe 1.2s ease-in-out infinite}
549
-
550
- /* ---------- 动画:只碰 transform / opacity ---------- */
551
-
552
- @keyframes aurora-drift-a{
553
- 0%{transform:translate3d(0,0,0) scale(1)}
554
- 50%{transform:translate3d(7%,-9%,0) scale(1.12)}
555
- 100%{transform:translate3d(0,0,0) scale(1)}
556
- }
557
- @keyframes aurora-drift-b{
558
- 0%{transform:translate3d(0,0,0) scale(1.1)}
559
- 50%{transform:translate3d(-6%,7%,0) scale(.94)}
560
- 100%{transform:translate3d(0,0,0) scale(1.1)}
561
- }
562
- @keyframes aurora-breathe{
563
- 0%,100%{opacity:calc(var(--fiber-opacity) * var(--side) * var(--intensity) * 1.2)}
564
- 50%{opacity:calc(var(--fiber-opacity) * var(--side) * var(--intensity) * 3.2)}
565
- }
566
- @keyframes aurora-scan{
567
- from{transform:translate3d(0,-56px,0)}
568
- to{transform:translate3d(0,0,0)}
569
- }
570
- @keyframes aurora-build{
571
- 0%{transform:scaleY(0);opacity:var(--g)}
572
- 70%{transform:scaleY(1);opacity:var(--g)}
573
- 100%{transform:scaleY(1);opacity:0}
574
- }
575
- @keyframes aurora-pulse{
576
- 0%{transform:scale(.35);opacity:var(--g)}
577
- 70%{opacity:calc(var(--g) * .22)}
578
- 100%{transform:scale(1.7);opacity:0}
579
- }
580
- @keyframes aurora-wait{
581
- 0%,100%{transform:translate3d(0,0,0)}
582
- 50%{transform:translate3d(var(--push),0,0)}
583
- }
584
- @keyframes aurora-sweep{
585
- from{transform:translate3d(0,0,0)}
586
- to{transform:translate3d(0,106vh,0)}
587
- }
588
- @keyframes aurora-rise{
589
- from{transform:translate3d(0,0,0)}
590
- to{transform:translate3d(0,-125vh,0)}
591
- }
592
-
593
- /* ---------- 降级 ---------- */
594
-
595
- .dsh-aurora[data-paused],
596
- .dsh-aurora[data-paused] *{animation-play-state:paused !important}
597
-
598
- @media (prefers-reduced-motion:reduce){
599
- .dsh-aurora,
600
- .dsh-aurora *{animation:none !important;transition:none !important}
601
- .dsh-stage{transition:none !important}
602
- .dsh-aurora__glow{opacity:calc(var(--glow-opacity) * var(--side) * var(--intensity) * 1.5)}
603
- .dsh-aurora__base{opacity:calc(var(--fiber-opacity) * var(--side) * var(--intensity) * 2)}
604
- .dsh-aurora[data-mode=read] .dsh-aurora__scan{
605
- opacity:calc(var(--gesture-opacity) * var(--side) * .6);
606
- transform:translate3d(0,-28px,0);
607
- }
608
- .dsh-aurora[data-mode=write] .dsh-aurora__fill{
609
- opacity:calc(var(--gesture-opacity) * var(--side) * .55);
610
- transform:scaleY(.45);
611
- }
612
- .dsh-aurora[data-mode=web] .dsh-aurora__pulse{
613
- opacity:calc(var(--pulse-opacity) * var(--side) * .7);
614
- transform:scale(.9);
615
- }
616
- .dsh-aurora[data-mode=wait]{--glow-opacity:.02;--fiber-opacity:.02}
617
- }
618
-
619
- /* ==========================================================================
620
- * 预设 B:整块画布当背景(stage)
621
- * 与预设 A 完全独立:自己的根元素、自己的 canvas、自己的开关,
622
- * 互斥显示,所以 A 的任何行为都不会被这套影响。
623
- * ========================================================================== */
41
+ .dsh-stage-host{position:relative;isolation:isolate}
624
42
 
625
43
  .dsh-stage{
626
44
  position:absolute;
627
45
  inset:0;
628
- z-index:-1; /* 在正文之下,所以文字永远压在上面 */
46
+ z-index:-1; /* 在正文之下:文字永远压在背景上 */
629
47
  overflow:hidden;
630
48
  pointer-events:none;
631
49
  opacity:0;
@@ -634,7 +52,7 @@ body[data-ds-dark-theme] .dsh-aurora__grid{
634
52
  .dsh-stage[data-on]{opacity:1}
635
53
  .dsh-stage canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
636
54
 
637
- /* 中间的纸面:按正文列宽罩住文字区,两侧留亮,让背景整体可读又不糊成一片 */
55
+ /* 中间的纸面:按正文列宽罩住文字区,两侧留亮 */
638
56
  .dsh-stage__veil{
639
57
  position:absolute;
640
58
  inset:-1px;
@@ -655,21 +73,49 @@ body[data-ds-dark-theme] .dsh-stage__veil{
655
73
  rgba(14,16,21,.4) calc(50% + var(--dsh-chat-content-width, 748px) / 2 + 110px),
656
74
  rgba(14,16,21,0) 100%);
657
75
  }
76
+
77
+ @media (prefers-reduced-motion:reduce){
78
+ .dsh-stage{transition:none !important}
79
+ }
658
80
  `;
659
81
 
660
82
  /* ================= 工具函数 ================= */
661
83
 
84
+ function readStore(key, fallback) {
85
+ try {
86
+ return window.localStorage.getItem(key) || fallback;
87
+ } catch {
88
+ return fallback;
89
+ }
90
+ }
91
+
92
+ function writeStore(key, value) {
93
+ try {
94
+ window.localStorage.setItem(key, value);
95
+ } catch {
96
+ /* 隐私模式下只在本次会话生效 */
97
+ }
98
+ }
99
+
100
+ /** 浅色用墨色 + multiply;深色用亮色 + lighter。 */
101
+ function stagePalette() {
102
+ const dark = document.body.hasAttribute('data-ds-dark-theme');
103
+ return dark
104
+ ? { a: '120,190,255', b: '170,140,255', c: '110,235,215', bg: 'rgba(12,14,19,0.14)', blend: 'lighter' }
105
+ : { a: '38,78,205', b: '112,44,214', c: '10,132,120', bg: 'rgba(255,255,255,0.14)', blend: 'multiply' };
106
+ }
107
+
662
108
  function addStyle() {
663
109
  const style = document.createElement('style');
664
- style.dataset.pluginCss = '@local/conversation-aurora/aurora.css';
110
+ style.dataset.pluginCss = 'dsh-conversation-aurora/stage.css';
665
111
  style.textContent = CSS;
666
112
  document.head.appendChild(style);
667
113
  return style;
668
114
  }
669
115
 
670
116
  /**
671
- * 找对话画布:优先用宿主稳定的语义属性,其次退回「从输入框往上找不透明背景」。
672
- * 同时存在多个会话面板时取可见面积最大的那个。找不到就返回 null,调用方静默跳过。
117
+ * 找对话画布:优先用宿主稳定的语义属性,同时存在多个时取可见面积最大的;
118
+ * 找不到就退回「从输入框往上找不透明背景」。
673
119
  */
674
120
  function findCanvas() {
675
121
  const all = document.querySelectorAll('[data-conversation-content]');
@@ -699,84 +145,17 @@ body[data-ds-dark-theme] .dsh-stage__veil{
699
145
  return null;
700
146
  }
701
147
 
702
- function createLayer() {
703
- const layer = document.createElement('div');
704
- layer.className = NS;
705
- layer.setAttribute('aria-hidden', 'true');
706
- layer.dataset.mode = 'idle';
707
- layer.dataset.primary = 'both';
708
- const wing = (side) =>
709
- '<div class="' + NS + '__wing" data-side="' + side + '">' +
710
- '<div class="' + NS + '__glow"><i class="' + NS + '__wash"></i><i class="' + NS + '__blob" data-k="a"></i><i class="' + NS + '__blob" data-k="b"></i></div>' +
711
- '<div class="' + NS + '__grid"></div>' +
712
- '<div class="' + NS + '__bars"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>' +
713
- '<div class="' + NS + '__sweep"></div>' +
714
- '<div class="' + NS + '__corner" data-at="top"></div>' +
715
- '<div class="' + NS + '__corner" data-at="bottom"></div>' +
716
- '<div class="' + NS + '__telemetry">' +
717
- '<b class="' + NS + '__t-tag"></b>' +
718
- '<b class="' + NS + '__t-tool"></b>' +
719
- '<b class="' + NS + '__t-hex"></b>' +
720
- '<b class="' + NS + '__t-cur">\u258C</b>' +
721
- '</div>' +
722
- '<div class="' + FIBER_CLASS + '">' +
723
- '<i class="' + NS + '__base"></i>' +
724
- '<i class="' + NS + '__fill"></i>' +
725
- '<i class="' + NS + '__scan"></i>' +
726
- '</div>' +
727
- '<div class="' + NS + '__pulse"></div>' +
728
- '</div>';
729
- layer.innerHTML = wing('left') + wing('right');
730
- return layer;
731
- }
732
-
733
- function toolMode(tool, variant) {
734
- return TOOL_MODE[tool] || VARIANT_MODE[variant || ''] || 'read';
735
- }
736
-
737
- /* ================= 预设 B:整屏背景的主题库 ================= */
738
-
739
- const STORAGE = {
740
- preset: 'dsh.conversation-aurora.preset',
741
- theme: 'dsh.conversation-aurora.theme',
742
- };
743
-
744
- function readStore(key, fallback) {
745
- try {
746
- return window.localStorage.getItem(key) || fallback;
747
- } catch {
748
- return fallback;
749
- }
750
- }
751
-
752
- function writeStore(key, value) {
753
- try {
754
- window.localStorage.setItem(key, value);
755
- } catch {
756
- /* 隐私模式等拿不到存储时,就只在本次会话生效 */
757
- }
148
+ function createStage() {
149
+ const el = document.createElement('div');
150
+ el.className = NS;
151
+ el.setAttribute('aria-hidden', 'true');
152
+ el.innerHTML = '<canvas></canvas><div class="' + NS + '__veil"></div>';
153
+ return el;
758
154
  }
759
155
 
760
- function hashString(text) {
761
- let h = 2166136261;
762
- for (let i = 0; i < text.length; i += 1) {
763
- h ^= text.charCodeAt(i);
764
- h = Math.imul(h, 16777619);
765
- }
766
- return h >>> 0;
767
- }
156
+ /* ================= 主题库 ================= */
768
157
 
769
- function stagePalette() {
770
- const dark = document.body.hasAttribute('data-ds-dark-theme');
771
- // 浅色:用更饱和的“墨色”,配合 multiply(正片叠底)画在白纸上
772
- // 深色:亮色 + lighter(加色),才会有发光感
773
- return dark
774
- ? { a: '120,190,255', b: '170,140,255', c: '110,235,215', bg: 'rgba(12,14,19,0.14)', blend: 'lighter' }
775
- : { a: '38,78,205', b: '112,44,214', c: '10,132,120', bg: 'rgba(255,255,255,0.14)', blend: 'multiply' };
776
- }
777
-
778
- /** 星空跃迁:星点从中心向外加速飞出。 */
779
- function themeWarp(g) {
158
+ function themeWarp(g) {
780
159
  const spawn = (z) => ({
781
160
  x: Math.random() * 2 - 1,
782
161
  y: Math.random() * 2 - 1,
@@ -1349,24 +728,14 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1349
728
  { id: 'bubbles', name: '肥皂泡', create: themeBubbles },
1350
729
  ];
1351
730
 
1352
- function createStage() {
1353
- const el = document.createElement('div');
1354
- el.className = 'dsh-stage';
1355
- el.setAttribute('aria-hidden', 'true');
1356
- el.innerHTML = '<canvas></canvas><div class="dsh-stage__veil"></div>';
1357
- return el;
1358
- }
1359
-
1360
731
  /* ================= 安装 / 拆卸 ================= */
1361
732
 
1362
733
  function install() {
1363
734
  if (typeof document === 'undefined' || typeof window === 'undefined') return function () {};
1364
735
 
1365
736
  const style = addStyle();
1366
- const layer = createLayer();
1367
737
  const stage = createStage();
1368
738
  const tracked = new Set();
1369
- const errorSeen = new WeakSet();
1370
739
  const media = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
1371
740
 
1372
741
  let host = null;
@@ -1374,169 +743,29 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1374
743
  let pending = 0;
1375
744
  let disposed = false;
1376
745
  let gaveUp = false;
1377
- let primed = false;
1378
746
  let booted = false;
1379
- let currentMode = 'idle';
1380
- let lastSwitch = 0;
1381
- let errorUntil = 0;
747
+ let busy = false;
748
+ let idleTimer = 0;
1382
749
  let remountCount = 0;
1383
750
  let remountAt = 0;
1384
- let intensity = 0;
1385
- let errorTimer = 0;
1386
- let currentTool = '';
1387
751
  let sessionKey = null;
1388
- let suppressUntil = 0;
1389
- let preset = readStore(STORAGE.preset, 'edge') === 'stage' ? 'stage' : 'edge';
752
+ let quietUntil = 0;
1390
753
  let forcedTheme = readStore(STORAGE.theme, '');
754
+ let activeTheme = '';
1391
755
  let stageRaf = 0;
1392
756
  let stageTheme = null;
1393
- let hexTimer = 0;
1394
- let hexSeed = 0x2f3a;
1395
- let hexTicks = 0;
1396
- let glitchTimer = 0;
1397
757
 
1398
758
  const reduced = () => media !== null && media.matches;
1399
759
 
1400
- /**
1401
- * 切换会话:上一轮的工具行可能还挂在 DOM 上,历史里的失败行也会被当成"新事件"。
1402
- * 这里把跟踪表清空、重新"预热",并静默一小段时间,等宿主把新会话渲染稳定。
1403
- */
1404
- function resetForSwitch(withSuppress) {
1405
- tracked.clear();
1406
- primed = false;
1407
- errorUntil = 0;
1408
- currentTool = '';
1409
- suppressUntil = withSuppress ? Date.now() + 700 : 0;
1410
- sessionKey = host ? host.getAttribute('data-conversation-session') || '' : null;
1411
- currentMode = '';
1412
- applyMode('idle');
1413
- syncTelemetry('idle');
1414
- syncStage('idle');
1415
- }
1416
-
1417
- /* ---------- 预设 B:整屏背景 ---------- */
1418
-
1419
- function applyPresetClass() {
1420
- layer.style.display = preset === 'edge' ? '' : 'none';
1421
- stage.style.display = preset === 'stage' ? '' : 'none';
1422
- }
1423
-
1424
- function stageThemeId() {
1425
- for (let i = 0; i < STAGE_THEMES.length; i += 1) {
1426
- if (forcedTheme && STAGE_THEMES[i].id === forcedTheme) return forcedTheme;
1427
- }
1428
- const key = (host && host.getAttribute('data-conversation-session')) || 'default';
1429
- return STAGE_THEMES[hashString(key) % STAGE_THEMES.length].id;
1430
- }
1431
-
1432
- function stopStage() {
1433
- if (stageRaf) window.cancelAnimationFrame(stageRaf);
1434
- stageRaf = 0;
1435
- stageTheme = null;
1436
- }
1437
-
1438
- function startStage() {
1439
- stopStage();
1440
- if (!host || preset !== 'stage') return;
1441
- const canvas = stage.querySelector('canvas');
1442
- if (!canvas) return;
1443
- const rect = host.getBoundingClientRect();
1444
- const dpr = Math.min(2, window.devicePixelRatio || 1);
1445
- const w = Math.max(1, Math.round(rect.width));
1446
- const h = Math.max(1, Math.round(rect.height));
1447
- canvas.width = Math.round(w * dpr);
1448
- canvas.height = Math.round(h * dpr);
1449
- const ctx = canvas.getContext('2d');
1450
- if (!ctx) return;
1451
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1452
- const pal = stagePalette();
1453
- ctx.globalCompositeOperation = pal.blend;
1454
-
1455
- const id = stageThemeId();
1456
- let factory = STAGE_THEMES[0];
1457
- for (let i = 0; i < STAGE_THEMES.length; i += 1) {
1458
- if (STAGE_THEMES[i].id === id) factory = STAGE_THEMES[i];
1459
- }
1460
- stageTheme = factory.create({ ctx, w, h, pal });
1461
-
1462
- // 预热若干帧:第一帧不空,静止降级时也有完整画面
1463
- for (let i = 0; i < 24; i += 1) stageTheme.step(16);
1464
-
1465
- if (reduced()) return;
1466
- let last = 0;
1467
- const frame = (now) => {
1468
- if (!stageRaf) return;
1469
- stageRaf = window.requestAnimationFrame(frame);
1470
- if (document.hidden || !stageTheme) {
1471
- last = now;
1472
- return;
1473
- }
1474
- const dt = last ? Math.min(64, now - last) : 16;
1475
- last = now;
1476
- stageTheme.step(dt);
1477
- };
1478
- stageRaf = window.requestAnimationFrame(frame);
1479
- }
1480
-
1481
- /** 只有"在干活"时才播;空闲、隐藏、或者不在这个预设里,就彻底停下。 */
1482
- function syncStage(mode) {
1483
- const want = preset === 'stage' && mode !== 'idle' && !document.hidden && !disposed;
1484
- if (!want) {
1485
- delete stage.dataset.on;
1486
- stopStage();
1487
- return;
1488
- }
1489
- stage.dataset.on = '1';
1490
- if (!stageRaf) startStage();
1491
- }
1492
-
1493
- function togglePreset() {
1494
- preset = preset === 'edge' ? 'stage' : 'edge';
1495
- writeStore(STORAGE.preset, preset);
1496
- applyPresetClass();
1497
- syncStage(currentMode);
1498
- }
1499
-
1500
- function cycleTheme() {
1501
- const ids = [];
1502
- for (let i = 0; i < STAGE_THEMES.length; i += 1) ids.push(STAGE_THEMES[i].id);
1503
- let at = ids.indexOf(forcedTheme);
1504
- if (at < 0) at = ids.indexOf(stageThemeId());
1505
- forcedTheme = ids[(at + 1) % ids.length];
1506
- writeStore(STORAGE.theme, forcedTheme);
1507
- if (preset === 'stage') {
1508
- stopStage();
1509
- syncStage(currentMode);
1510
- }
1511
- }
1512
-
1513
760
  /* ---------- 挂载 ---------- */
1514
761
 
1515
- /**
1516
- * 正文列在滚动容器里居中,但滚动容器右侧有滚动条 + 2px 外边距,
1517
- * 于是列中心比画布中心偏左一点。量出来补到 --aurora-shift,
1518
- * 让左右光纤到列的距离真正相等。
1519
- */
1520
- function updateShift() {
1521
- if (!host) return;
1522
- try {
1523
- const scroller = host.querySelector('[data-conversation-scroll]');
1524
- if (!scroller) return;
1525
- const shift = (scroller.offsetWidth - scroller.clientWidth + 2) / 2;
1526
- layer.style.setProperty('--aurora-shift', shift.toFixed(1) + 'px');
1527
- } catch {
1528
- /* 量不到就按 0 处理,最多差几像素 */
1529
- }
1530
- }
1531
-
1532
762
  function ensureHost() {
1533
763
  if (disposed || gaveUp) return;
1534
- // 快路径:宿主还在、图层还挂着,就不要每帧重新找画布(避免强制布局)。
1535
- if (host && host.isConnected && host.contains(layer)) return;
764
+ if (host && host.isConnected && host.contains(stage)) return;
1536
765
 
1537
766
  const canvas = findCanvas();
1538
767
  if (!canvas) return;
1539
- if (canvas === host && canvas.contains(layer)) return;
768
+ if (canvas === host && canvas.contains(stage)) return;
1540
769
 
1541
770
  const now = Date.now();
1542
771
  if (now - remountAt > TUNE.remountWindowMs) {
@@ -1545,7 +774,6 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1545
774
  }
1546
775
  remountCount += 1;
1547
776
  if (remountCount > TUNE.remountGuard) {
1548
- // 宿主在和我们抢节点:放弃,宁可没有效果也不要抖动。
1549
777
  gaveUp = true;
1550
778
  return;
1551
779
  }
@@ -1554,14 +782,27 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1554
782
  if (host && host !== canvas) host.classList.remove(HOST_CLASS);
1555
783
  host = canvas;
1556
784
  host.classList.add(HOST_CLASS);
1557
- if (layer.parentNode !== host) host.insertBefore(layer, host.firstChild);
1558
- if (stage.parentNode !== host) host.insertBefore(stage, layer.nextSibling);
1559
- updateShift();
1560
- applyPresetClass();
785
+ if (stage.parentNode !== host) host.insertBefore(stage, host.firstChild);
1561
786
  if (previous !== canvas) resetForSwitch(previous !== null);
1562
787
  }
1563
788
 
1564
- /* ---------- 跟踪工具行 ---------- */
789
+ /**
790
+ * 切会话:上一轮的工具行可能还挂着,历史里的失败行也会被当成新事件。
791
+ * 清空跟踪表、静默一小段时间,等新会话渲染稳定。
792
+ */
793
+ function resetForSwitch(quiet) {
794
+ tracked.clear();
795
+ busy = false;
796
+ if (idleTimer) {
797
+ window.clearTimeout(idleTimer);
798
+ idleTimer = 0;
799
+ }
800
+ quietUntil = quiet ? Date.now() + TUNE.switchQuietMs : 0;
801
+ sessionKey = host ? host.getAttribute('data-conversation-session') || '' : null;
802
+ syncStage();
803
+ }
804
+
805
+ /* ---------- 工具行跟踪 ---------- */
1565
806
 
1566
807
  function track(node) {
1567
808
  if (!node || node.nodeType !== 1) return;
@@ -1587,145 +828,136 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1587
828
  for (let i = from; i < all.length; i += 1) tracked.add(all[i]);
1588
829
  }
1589
830
 
1590
- /* ---------- 状态判定 ---------- */
831
+ /* ---------- 状态判定:只关心"在不在干活" ---------- */
1591
832
 
1592
- function detectError() {
1593
- let fresh = false;
833
+ function detectBusy() {
1594
834
  for (const el of tracked) {
1595
835
  if (!el.isConnected) continue;
1596
836
  const state = el.getAttribute('data-state');
1597
- if (state !== 'error' && state !== 'stopped') continue;
1598
- if (errorSeen.has(el)) continue;
1599
- errorSeen.add(el);
1600
- if (primed) fresh = true;
837
+ if (state === 'running' || state === 'preparing') return true;
1601
838
  }
1602
- return fresh;
839
+ return document.querySelector('[data-chat-running], [data-streaming]') !== null;
1603
840
  }
1604
841
 
1605
- function resolveMode() {
1606
- currentTool = '';
1607
- const rows = [];
1608
- for (const el of tracked) if (el.isConnected) rows.push(el);
1609
- for (let i = rows.length - 1; i >= 0; i -= 1) {
1610
- const el = rows[i];
1611
- const state = el.getAttribute('data-state');
1612
- if (state !== 'running' && state !== 'preparing') continue;
1613
- const tool = el.getAttribute('data-tool') || '';
1614
- currentTool = tool;
1615
- if (tool === 'ask_user_question') return 'wait';
1616
- return toolMode(tool, el.getAttribute('data-variant'));
1617
- }
1618
- if (document.querySelector('[data-chat-running], [data-streaming]')) return 'think';
1619
- return 'idle';
1620
- }
1621
-
1622
- function applyMode(next) {
1623
- if (disposed || !host) return;
1624
- const now = Date.now();
1625
- if (next === currentMode) return;
1626
- if (now - lastSwitch < TUNE.minDwellMs && MODE_PRIORITY[next] <= MODE_PRIORITY[currentMode]) {
1627
- // 被最短驻留挡住时,补一次定时重判,否则可能一直停在旧状态(没有后续 DOM 变更)
1628
- if (!pending) {
1629
- pending = window.setTimeout(scan, TUNE.minDwellMs - (now - lastSwitch) + 10);
842
+ function setBusy(next) {
843
+ if (disposed) return;
844
+ if (next) {
845
+ if (idleTimer) {
846
+ window.clearTimeout(idleTimer);
847
+ idleTimer = 0;
848
+ }
849
+ if (!busy) {
850
+ busy = true;
851
+ syncStage();
1630
852
  }
1631
853
  return;
1632
854
  }
855
+ if (!busy || idleTimer) return;
856
+ idleTimer = window.setTimeout(() => {
857
+ idleTimer = 0;
858
+ busy = false;
859
+ syncStage();
860
+ }, TUNE.holdIdleMs);
861
+ }
1633
862
 
1634
- currentMode = next;
1635
- lastSwitch = now;
1636
- layer.dataset.mode = next;
1637
- layer.dataset.primary = MODE_PRIMARY[next] || 'both';
1638
-
1639
- if (next === 'error') layer.dataset.tint = 'error';
1640
- else if (next !== 'idle') delete layer.dataset.tint;
863
+ /* ---------- 背景播放 ---------- */
1641
864
 
1642
- if (next !== 'idle') flash();
1643
- syncStage(next);
865
+ /**
866
+ * 每次开工重新挑一个主题(随机,且不和上一次重复)。
867
+ * 按 Alt+T 固定过主题时,就一直用它。
868
+ */
869
+ function pickTheme() {
870
+ const ids = [];
871
+ for (let i = 0; i < STAGE_THEMES.length; i += 1) ids.push(STAGE_THEMES[i].id);
872
+ if (forcedTheme && ids.indexOf(forcedTheme) >= 0) return forcedTheme;
873
+ let id = ids[(Math.random() * ids.length) | 0];
874
+ if (ids.length > 1 && id === activeTheme) id = ids[(ids.indexOf(id) + 1) % ids.length];
875
+ return id;
1644
876
  }
1645
877
 
1646
- /** 一次 130ms 的 glitch:光纤青/品红分离 + HUD 轻微错位。 */
1647
- function glitch() {
1648
- if (disposed || document.hidden || reduced() || layer.dataset.off === '1') return;
1649
- layer.dataset.glitch = '1';
1650
- if (glitchTimer) window.clearTimeout(glitchTimer);
1651
- glitchTimer = window.setTimeout(() => {
1652
- glitchTimer = 0;
1653
- delete layer.dataset.glitch;
1654
- }, 130);
878
+ function stopStage() {
879
+ if (stageRaf) window.cancelAnimationFrame(stageRaf);
880
+ stageRaf = 0;
881
+ stageTheme = null;
1655
882
  }
1656
883
 
1657
- /** 遥测里的十六进制读数:有动作时才跳,静止时一个定时器都不留。 */
1658
- function startHex() {
1659
- if (hexTimer || document.hidden) return;
1660
- const el = layer.querySelector('.' + NS + '__t-hex');
1661
- if (!el) return;
1662
- const paint = () => {
1663
- hexSeed = (hexSeed * 1103515245 + 12345) & 0x7fffffff;
1664
- el.textContent = '0x' + (hexSeed & 0xffff).toString(16).toUpperCase().padStart(4, '0');
1665
- hexTicks += 1;
1666
- if (hexTicks % 10 === 0) glitch();
1667
- };
1668
- paint();
1669
- hexTimer = window.setInterval(paint, 600);
1670
- }
884
+ function startStage() {
885
+ stopStage();
886
+ if (!host) return;
887
+ const canvas = stage.querySelector('canvas');
888
+ if (!canvas) return;
1671
889
 
1672
- function stopHex() {
1673
- if (!hexTimer) return;
1674
- window.clearInterval(hexTimer);
1675
- hexTimer = 0;
1676
- }
890
+ const rect = host.getBoundingClientRect();
891
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
892
+ const w = Math.max(1, Math.round(rect.width));
893
+ const h = Math.max(1, Math.round(rect.height));
894
+ canvas.width = Math.round(w * dpr);
895
+ canvas.height = Math.round(h * dpr);
896
+ const ctx = canvas.getContext('2d');
897
+ if (!ctx) return;
898
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1677
899
 
1678
- /** 同步竖排遥测文字:只在内容真的变了时才写 DOM。 */
1679
- function syncTelemetry(mode) {
1680
- try {
1681
- const tag = layer.querySelector('.' + NS + '__t-tag');
1682
- const toolEl = layer.querySelector('.' + NS + '__t-tool');
1683
- const tagText = MODE_TAG[mode] || '';
1684
- const toolText = mode === 'idle' || mode === 'wait' ? '' : String(currentTool || '').slice(0, 14).toUpperCase();
1685
- if (tag && tag.textContent !== tagText) tag.textContent = tagText;
1686
- if (toolEl && toolEl.textContent !== toolText) toolEl.textContent = toolText;
1687
- if (mode === 'idle') stopHex();
1688
- else startHex();
1689
- } catch {
1690
- /* 遥测只是装饰,失败不影响主效果 */
900
+ const pal = stagePalette();
901
+ ctx.globalCompositeOperation = pal.blend;
902
+
903
+ const id = activeTheme || pickTheme();
904
+ activeTheme = id;
905
+ let factory = STAGE_THEMES[0];
906
+ for (let i = 0; i < STAGE_THEMES.length; i += 1) {
907
+ if (STAGE_THEMES[i].id === id) factory = STAGE_THEMES[i];
1691
908
  }
1692
- }
909
+ stageTheme = factory.create({ ctx, w, h, pal });
1693
910
 
1694
- /** 状态切换时给光纤一次单次亮脉冲(WAAPI,不占常驻动画)。 */
1695
- function flash() {
1696
- if (!host || document.hidden || reduced() || layer.dataset.off === '1') return;
1697
- glitch();
1698
- const bases = layer.querySelectorAll('.' + NS + '__base');
1699
- for (let i = 0; i < bases.length; i += 1) {
1700
- const base = bases[i];
1701
- const from = Number.parseFloat(window.getComputedStyle(base).opacity) || 0;
1702
- const peak = Math.min(1, from + TUNE.pulseRise);
1703
- try {
1704
- const anim = base.animate(
1705
- [{ opacity: String(from) }, { opacity: String(peak) }, { opacity: String(from) }],
1706
- { duration: TUNE.pulseMs, easing: 'cubic-bezier(.2,.7,.2,1)' },
1707
- );
1708
- anim.addEventListener('finish', () => anim.cancel());
1709
- } catch {
1710
- /* 老引擎不支持 WAAPI 时不闪,不影响其它状态 */
911
+ // 预热若干帧:第一帧不空,减少动态效果时也有完整静止画面
912
+ for (let i = 0; i < 24; i += 1) stageTheme.step(16);
913
+ if (reduced()) return;
914
+
915
+ let last = 0;
916
+ const frame = (now) => {
917
+ if (!stageRaf) return;
918
+ stageRaf = window.requestAnimationFrame(frame);
919
+ if (document.hidden || !stageTheme) {
920
+ last = now;
921
+ return;
1711
922
  }
923
+ const dt = last ? Math.min(64, now - last) : 16;
924
+ last = now;
925
+ stageTheme.step(dt);
926
+ };
927
+ stageRaf = window.requestAnimationFrame(frame);
928
+ }
929
+
930
+ /** 只有"在干活 + 窗口可见"才播;其它情况彻底停下。 */
931
+ function syncStage() {
932
+ const want = busy && !document.hidden && !disposed && Date.now() >= quietUntil;
933
+ if (!want) {
934
+ delete stage.dataset.on;
935
+ stopStage();
936
+ return;
1712
937
  }
1713
- // 角标一次轻微位移:像镜头重新对上焦
1714
- const corners = layer.querySelectorAll('.' + NS + '__corner');
1715
- for (let i = 0; i < corners.length; i += 1) {
1716
- try {
1717
- const anim = corners[i].animate(
1718
- [{ transform: 'translateX(0)' }, { transform: 'translateX(2px)' }, { transform: 'translateX(0)' }],
1719
- { duration: 150, easing: 'ease-out' },
1720
- );
1721
- anim.addEventListener('finish', () => anim.cancel());
1722
- } catch {
1723
- /* 同上 */
1724
- }
938
+ stage.dataset.on = '1';
939
+ if (!stageRaf) {
940
+ activeTheme = pickTheme();
941
+ startStage();
1725
942
  }
1726
943
  }
1727
944
 
1728
- /* ---------- 每帧扫描 ---------- */
945
+ function cycleTheme() {
946
+ const ids = [];
947
+ for (let i = 0; i < STAGE_THEMES.length; i += 1) ids.push(STAGE_THEMES[i].id);
948
+ let at = ids.indexOf(activeTheme);
949
+ if (at < 0) at = ids.indexOf(forcedTheme);
950
+ if (at < 0) at = 0;
951
+ forcedTheme = ids[(at + 1) % ids.length];
952
+ writeStore(STORAGE.theme, forcedTheme);
953
+ activeTheme = forcedTheme;
954
+ if (stageRaf || busy) {
955
+ stopStage();
956
+ if (busy && !document.hidden) startStage();
957
+ }
958
+ }
959
+
960
+ /* ---------- 扫描 ---------- */
1729
961
 
1730
962
  function scan() {
1731
963
  pending = 0;
@@ -1734,44 +966,24 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1734
966
  ensureHost();
1735
967
  if (!host) return;
1736
968
 
1737
- // 切换会话:等新会话挂载稳定再判断,避免把上一轮/历史行当成"正在工作"
1738
969
  const key = host.getAttribute('data-conversation-session') || '';
1739
970
  if (sessionKey === null) sessionKey = key;
1740
971
  else if (key !== sessionKey) {
1741
972
  resetForSwitch(true);
1742
973
  return;
1743
974
  }
1744
- if (Date.now() < suppressUntil) {
1745
- applyMode('idle');
1746
- syncTelemetry('idle');
975
+ if (Date.now() < quietUntil) {
976
+ syncStage();
1747
977
  return;
1748
978
  }
1749
979
 
1750
980
  prune();
1751
- const fresh = detectError();
1752
- const now = Date.now();
1753
- if (fresh) {
1754
- errorUntil = now + TUNE.errorHoldMs;
1755
- // 如果没有后续变更,红光也必须自己退掉,否则会一直停在错误态。
1756
- if (errorTimer) window.clearTimeout(errorTimer);
1757
- errorTimer = window.setTimeout(() => {
1758
- errorTimer = 0;
1759
- requestScan();
1760
- }, TUNE.errorHoldMs + 40);
1761
- }
1762
- primed = true;
1763
- const mode = now < errorUntil ? 'error' : resolveMode();
1764
- applyMode(mode);
1765
- syncTelemetry(mode);
981
+ setBusy(detectBusy());
1766
982
  } catch {
1767
983
  /* 任何一步失败都只是不生效,不影响宿主 */
1768
984
  }
1769
985
  }
1770
986
 
1771
- /**
1772
- * 用短定时器而不是 rAF 合并高频变更:状态判定不需要帧精度,
1773
- * 而 rAF 在窗口被遮挡 / 无头环境里会被节流甚至停摆,导致状态卡住。
1774
- */
1775
987
  function requestScan() {
1776
988
  if (disposed || pending) return;
1777
989
  pending = window.setTimeout(scan, 48);
@@ -1804,37 +1016,24 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1804
1016
 
1805
1017
  function onVisibility() {
1806
1018
  if (document.hidden) {
1807
- layer.dataset.paused = '1';
1808
- stopHex();
1809
1019
  stopStage();
1810
1020
  } else {
1811
- delete layer.dataset.paused;
1021
+ syncStage();
1812
1022
  requestScan();
1813
- syncStage(currentMode);
1814
1023
  }
1815
1024
  }
1816
1025
 
1817
1026
  function onResize() {
1818
- updateShift();
1027
+ if (stageRaf) {
1028
+ stopStage();
1029
+ startStage();
1030
+ }
1819
1031
  }
1820
1032
 
1821
1033
  function onKeyDown(event) {
1822
1034
  if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
1823
- const key = String(event.key || '').toLowerCase();
1824
- if (key === 'm') {
1825
- togglePreset();
1826
- return;
1827
- }
1828
- if (key === 't') {
1829
- cycleTheme();
1830
- return;
1831
- }
1832
- if (key !== TUNE.keyCode) return;
1833
- intensity = (intensity + 1) % TUNE.intensity.length;
1834
- const value = TUNE.intensity[intensity];
1835
- layer.style.setProperty('--intensity', String(value));
1836
- if (value === 0) layer.dataset.off = '1';
1837
- else delete layer.dataset.off;
1035
+ if (String(event.key || '').toLowerCase() !== TUNE.themeKey) return;
1036
+ cycleTheme();
1838
1037
  }
1839
1038
 
1840
1039
  /* ---------- 启动 ---------- */
@@ -1843,11 +1042,10 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1843
1042
  if (disposed || booted) return;
1844
1043
  booted = true;
1845
1044
  ensureHost();
1846
- applyPresetClass();
1847
1045
  try {
1848
1046
  seed();
1849
1047
  } catch {
1850
- /* 会话还没渲染出来时没有可跟踪的行,正常 */
1048
+ /* 会话还没渲染出来时没有可跟踪的行 */
1851
1049
  }
1852
1050
  requestScan();
1853
1051
  observe();
@@ -1866,9 +1064,7 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1866
1064
  if (disposed) return;
1867
1065
  disposed = true;
1868
1066
  if (pending) window.clearTimeout(pending);
1869
- if (errorTimer) window.clearTimeout(errorTimer);
1870
- if (glitchTimer) window.clearTimeout(glitchTimer);
1871
- stopHex();
1067
+ if (idleTimer) window.clearTimeout(idleTimer);
1872
1068
  if (observer) observer.disconnect();
1873
1069
  document.removeEventListener('DOMContentLoaded', boot);
1874
1070
  document.removeEventListener('visibilitychange', onVisibility);
@@ -1876,7 +1072,6 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1876
1072
  window.removeEventListener('keydown', onKeyDown, true);
1877
1073
  stopStage();
1878
1074
  if (stage.parentNode) stage.parentNode.removeChild(stage);
1879
- if (layer.parentNode) layer.parentNode.removeChild(layer);
1880
1075
  if (host) host.classList.remove(HOST_CLASS);
1881
1076
  style.remove();
1882
1077
  };
@@ -1885,9 +1080,8 @@ body[data-ds-dark-theme] .dsh-stage__veil{
1885
1080
  /* ================= 插件入口 ================= */
1886
1081
 
1887
1082
  /**
1888
- * Alt+D 切换浅色/深色。
1889
- * 走宿主的 theme 服务(setTheme),不直接改 body 属性,所以和「设置 → 外观」始终一致。
1890
- * theme 服务不在时不激活这个键,插件其余部分不受影响。
1083
+ * Alt+T 换背景主题。
1084
+ * Alt+D 切浅色/深色:走宿主 theme 服务,不直接改 body,和「设置 → 外观」始终一致。
1891
1085
  */
1892
1086
  function bindThemeHotkey(ctx) {
1893
1087
  if (!ctx || typeof ctx.inject !== 'function') return;