dsh-mini-remote 0.1.4 → 0.1.6

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/lib/page.html CHANGED
@@ -3,29 +3,90 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
- <meta name="theme-color" content="#0f1115">
6
+ <meta name="theme-color" content="#0a0f16">
7
7
  <meta name="mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-capable" content="yes">
9
9
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10
- <title>DSH mini remote</title>
10
+ <title>极简遥控器</title>
11
11
  <style>
12
+ /* ---------- 设计令牌 ----------
13
+ 规格来自用户 2026-09-27 定的六关键词翻译表,改之前先读这一段:
14
+ · 圆角只有两档:卡片一律 2px,状态点用 999px/50%。**没有中间档**。
15
+ · 阴影只有两档,且都极浅;层次主要靠 --line 这条 1px 细线和间距建立。
16
+ · 强调色分两个角色,加起来**一屏最多两处**:
17
+ --gold 是**当字/点**用的(刻度上"当前这一步");--act 是**当底**用的(主按钮)。
18
+ 深色下两个都是暖金 #c9a55c;浅色下 --gold 压深成 #8a6529(白底才读得清),
19
+ --act 换成宣传图的宝蓝 #3b6fc4——用户 2026-09-27 看过对照图后定的:浅色那套
20
+ 底色本来就是从宣传图取的,冷底上再压一块饱和暖金,比在深色下更像外来物。
21
+ 需要"显眼一点"的地方一律用鲸鱼娘的冰蓝 --ice,别动这两个。
22
+ · --ok/--run/--err 是状态点的语义色,占的是 8px 一个小点,不算强调色。
23
+ · 配色以鲸鱼娘为准心:深海墨蓝打底,她的冰蓝作次级色。
24
+ · 两套主题:这套(默认,深色)和 [data-theme="light"],色调跟宣传图
25
+ (docs/promo.png)统一。**颜色令牌两边必须一一对应**——缺一个,就有元素
26
+ 在某个主题下"隐身",而且只在那个主题下才看得出来。守卫在 test/ui.test.mjs。 */
12
27
  :root {
13
- --bg: #0f1115;
14
- --panel: #171a21;
15
- --panel-2: #1e222b;
16
- --line: #2a2f3a;
17
- --fg: #e8eaed;
18
- --muted: #8b93a1;
19
- --accent: #4c8dff;
20
- --ok: #35c46b;
21
- --warn: #e0a33a;
22
- --err: #e5604d;
23
- --radius: 14px;
28
+ color-scheme: dark;
29
+ --bg: #0a0f16;
30
+ --bg-top: #101a27;
31
+ --bg-deep: #070b11;
32
+ --panel: #121a25;
33
+ --panel-2: #18222f;
34
+ --line: #22303f;
35
+ --fg: #e3ebf4;
36
+ --muted: #7b8a9b;
37
+ --dim: #6c7a8b;
38
+ --ice: #a9c8e8;
39
+ --gold: #c9a55c; /* 强调色当**字/点**用:刻度上"当前这一步" */
40
+ --act: #c9a55c; /* 强调色当**底**用:一屏一个的主按钮 */
41
+ --ok: #4fb477;
42
+ --run: #a9c8e8;
43
+ --err: #d4614f;
44
+ --r: 2px;
45
+ --sh-1: 0 1px 2px rgba(0, 0, 0, .28);
46
+ --sh-2: 0 2px 6px rgba(0, 0, 0, .34);
47
+ --glass: rgba(18, 26, 37, .72);
48
+ --tint: rgba(24, 34, 47, .5); /* 队列那块比面板再深一点的底 */
49
+ --pop: rgba(24, 34, 47, .97); /* 浮起来的小面板(指令菜单等) */
50
+ --scrim: rgba(0, 0, 0, .55); /* 弹出层背后压暗的那层;浅色主题里它得轻得多 */
51
+ --on-act: #10161f; /* 实心主按钮上的字:金底配白字读不清 */
52
+ }
53
+ /* 浅色主题:色调跟宣传图(docs/promo.png)统一——近白的冰蓝底、深宝蓝的字与状态色、
54
+ 白色卡片、极浅的蓝投影。深色那边的规矩一条不改:圆角两档、层次靠 1px 细线。
55
+ 强调色在浅色下分了家:当字/点用的金压深到 #8a6529,当底用的主按钮换成宣传图的宝蓝
56
+ (#3b6fc4 配白字)——用户 2026-09-27 看过对照图后定的。
57
+ 切换入口在设置页「外观」;选择存在 localStorage,首屏之前就定好,不会闪一下。 */
58
+ [data-theme="light"] {
59
+ color-scheme: light;
60
+ --bg: #eaf3fc;
61
+ --bg-top: #f7fbff;
62
+ --bg-deep: #dbe9f8;
63
+ --panel: #ffffff;
64
+ --panel-2: #f2f8ff;
65
+ --line: #d3e2f3;
66
+ --fg: #1b2c49;
67
+ --muted: #6b7f9b;
68
+ --dim: #8496ae;
69
+ --ice: #3b6fc4;
70
+ --gold: #8a6529; /* 白底上当字用,得压深才读得清 */
71
+ --act: #3b6fc4; /* 主按钮底:用宣传图那个宝蓝(用户 2026-09-27 裁决) */
72
+ --ok: #2e9a5f;
73
+ --run: #3b6fc4;
74
+ --err: #c0392b;
75
+ --sh-1: 0 1px 2px rgba(28, 60, 110, .07);
76
+ --sh-2: 0 2px 8px rgba(28, 60, 110, .12);
77
+ --glass: rgba(255, 255, 255, .74);
78
+ --tint: rgba(59, 111, 196, .07);
79
+ --pop: rgba(255, 255, 255, .98);
80
+ --scrim: rgba(23, 52, 95, .30);
81
+ --on-act: #ffffff; /* 宝蓝底上的字 */
24
82
  }
25
83
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
26
84
  html, body {
27
85
  margin: 0; height: 100%; overflow: hidden;
28
- background: var(--bg); color: var(--fg);
86
+ /* 纵深:顶部略亮、底部更深,做出"空间"而不是一整块平色。
87
+ 设计稿参考的是沉浸式暗色流媒体那一套——界面浮在深色基底上,不是被切成几块。 */
88
+ background: linear-gradient(180deg, var(--bg-top) 0%, var(--bg) 55%, var(--bg-deep) 100%);
89
+ color: var(--fg);
29
90
  font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
30
91
  "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
31
92
  overscroll-behavior: none;
@@ -33,24 +94,32 @@
33
94
  body { display: flex; flex-direction: column; }
34
95
 
35
96
  /* ---------- 顶栏 ---------- */
97
+ /* 顶栏和底栏是两块**悬浮毛玻璃**:内容从它们底下淡出,界面像浮着,
98
+ 而不是上下两条实心色带把屏幕切成三块。 */
36
99
  header {
37
100
  flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
38
101
  padding: calc(env(safe-area-inset-top) + 10px) 14px 10px;
39
- background: var(--panel); border-bottom: 1px solid var(--line);
102
+ background: var(--glass); border-bottom: 1px solid var(--line);
103
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
104
+ backdrop-filter: blur(14px) saturate(1.1);
40
105
  }
41
106
  .brand { font-weight: 600; letter-spacing: .3px; }
42
107
  .dot {
43
108
  width: 8px; height: 8px; border-radius: 50%;
44
- background: var(--muted); flex: 0 0 auto; transition: background .2s;
109
+ background: var(--muted); flex: 0 0 auto;
110
+ /* 状态变化是**瞬时**的:数据在变,界面不表演。
111
+ 这里原来挂着一圈 1.2s 的呼吸闪动,2026-09-27 按规格删掉了——
112
+ "正在思考"的脉动圆点是那几年的视觉套话;而"它在动"这件事,下面那块正在执行区
113
+ 里她说的那句话和秒数已经说得更准,也更安静。160ms 是本页唯一保留的过渡节奏。 */
114
+ transition: background .16s;
45
115
  }
46
116
  .dot.ok { background: var(--ok); }
47
- .dot.run { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
117
+ .dot.run { background: var(--run); }
48
118
  .dot.err { background: var(--err); }
49
- @keyframes pulse { 50% { opacity: .25; } }
50
119
  .status { color: var(--muted); font-size: 13px; flex: 1 1 auto;
51
120
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
52
121
  .icon-btn {
53
- flex: 0 0 auto; width: 36px; height: 36px; border-radius: 10px;
122
+ flex: 0 0 auto; width: 36px; height: 36px; border-radius: var(--r);
54
123
  border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
55
124
  font-size: 16px; display: grid; place-items: center; cursor: pointer;
56
125
  }
@@ -64,11 +133,11 @@
64
133
  white-space: pre-wrap; word-break: break-word;
65
134
  font-size: 16px; line-height: 1.75;
66
135
  }
67
- #reply a { color: var(--accent); }
136
+ #reply a { color: var(--ice); }
68
137
  .placeholder { color: var(--muted); font-size: 15px; text-align: center; line-height: 1.9; }
69
138
  .placeholder kbd {
70
139
  background: var(--panel-2); border: 1px solid var(--line);
71
- border-radius: 6px; padding: 1px 6px; font-size: 13px; font-family: inherit;
140
+ border-radius: var(--r); padding: 1px 6px; font-size: 13px; font-family: inherit;
72
141
  }
73
142
 
74
143
  /* ---------- 聊天模式 ---------- */
@@ -79,7 +148,7 @@
79
148
  两种气泡都要,只改一边的话,短回复那边还是会露出大方块,看着像坏了。 */
80
149
  .bubble {
81
150
  width: fit-content;
82
- max-width: 86%; padding: 10px 13px; border-radius: var(--radius);
151
+ max-width: 86%; padding: 10px 13px; border-radius: var(--r);
83
152
  /* 18px,和 .said 的下边距一致。原来是 10px,用户 2026-09-22 实机提「指令气泡和
84
153
  回答正文第一行之间贴得太紧」——10px 是「同一条消息内部」的距离,
85
154
  而这里跨的是「我说的话」和「它答的话」,是更大的一个断点,该给更多。 */
@@ -88,12 +157,12 @@
88
157
  }
89
158
  .bubble.user { margin-left: auto; background: var(--panel-2); border: 1px solid var(--line); }
90
159
  .bubble.assistant { margin-right: auto; background: var(--panel); border: 1px solid var(--line); }
91
- .bubble a { color: var(--accent); }
160
+ .bubble a { color: var(--ice); }
92
161
  .bubble .meta { display: block; margin-top: 6px; font-size: 11px; color: var(--muted); }
93
162
  /* 「已停止」标签:被按停的那一轮,手机上收到的只是**已经吐出来的那半句**。
94
163
  不标出来的话,它看着就像模型答崩了。 */
95
164
  .stopped {
96
- display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px;
165
+ display: inline-block; padding: 1px 7px; border-radius: var(--r); font-size: 11px;
97
166
  color: var(--muted); border: 1px solid var(--line); vertical-align: middle;
98
167
  }
99
168
  .reply-bar .stopped { margin-right: auto; }
@@ -101,6 +170,18 @@
101
170
  /* 外层是 pre-wrap(用户消息和旧内容靠它),markdown 输出的是真块级元素,
102
171
  所以要在这里把 pre-wrap 收掉,否则段落之间会多出一堆空行。 */
103
172
  .md { white-space: normal; }
173
+ /* 回复里的图。地址有两种写法,见下面 mdInline() 里那段注释。
174
+ 宽度只封顶不写死:窄屏上占满可用宽度、按比例缩,圆角沿用全站那 2 像素。 */
175
+ /* 图片没加载出来时的那一圈虚线。用中性灰,不动暖金——那是唯一的强调色,一屏最多两次。 */
176
+ .md-img-bad {
177
+ outline: 1px dashed rgba(0, 0, 0, .35);
178
+ outline-offset: 2px;
179
+ }
180
+
181
+ .md-img {
182
+ display: block; max-width: 100%; height: auto; margin: 10px 0;
183
+ border-radius: var(--r); border: 1px solid var(--line); background: var(--panel-2);
184
+ }
104
185
  .md > :first-child { margin-top: 0; }
105
186
  .md > :last-child { margin-bottom: 0; }
106
187
  .md p { margin: 0 0 .7em; }
@@ -113,7 +194,7 @@
113
194
  .md h4, .md h5, .md h6 { font-size: 1em; }
114
195
  .md ul, .md ol { margin: 0 0 .7em; padding-left: 1.4em; }
115
196
  .md li { margin: .18em 0; }
116
- .md a { color: var(--accent); word-break: break-all; }
197
+ .md a { color: var(--ice); word-break: break-all; }
117
198
  .md strong { font-weight: 650; }
118
199
  .md hr { border: none; border-top: 1px solid var(--line); margin: 1em 0; }
119
200
  .md blockquote {
@@ -121,12 +202,12 @@
121
202
  border-left: 3px solid var(--line); color: var(--muted);
122
203
  }
123
204
  .md code {
124
- background: var(--panel-2); border: 1px solid var(--line); border-radius: 5px;
205
+ background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r);
125
206
  padding: .1em .35em; font-size: .88em;
126
207
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
127
208
  }
128
209
  .md .code {
129
- margin: 0 0 .7em; border: 1px solid var(--line); border-radius: 10px;
210
+ margin: 0 0 .7em; border: 1px solid var(--line); border-radius: var(--r);
130
211
  background: var(--panel-2); overflow: hidden;
131
212
  }
132
213
  .md .code-head {
@@ -150,7 +231,7 @@
150
231
  三个地方共用:单帧模式的整篇、聊天模式的气泡、代码块。 */
151
232
  .copy {
152
233
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
153
- width: 28px; height: 28px; padding: 0; border-radius: 8px; cursor: pointer;
234
+ width: 28px; height: 28px; padding: 0; border-radius: var(--r); cursor: pointer;
154
235
  border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
155
236
  }
156
237
  .copy svg {
@@ -158,7 +239,7 @@
158
239
  stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
159
240
  }
160
241
  .copy:active { transform: scale(.95); }
161
- .copy.done { color: var(--accent); border-color: var(--accent); }
242
+ .copy.done { color: var(--ice); border-color: var(--ice); }
162
243
  .bubble .meta .copy { margin-left: 8px; vertical-align: middle; }
163
244
  /* 上面那条只放「已停止」标签——它是给正文打的预防针,得在正文之前出现。
164
245
  复制按钮挪到正文下面去了(.reply-foot)。 */
@@ -177,7 +258,7 @@
177
258
  display: flex; align-items: baseline; gap: 8px;
178
259
  width: fit-content; max-width: 100%;
179
260
  margin: 0 0 14px; padding: 4px 10px 4px 9px;
180
- border-left: 2px solid var(--line); border-radius: 0 8px 8px 0;
261
+ border-left: 2px solid var(--line); border-radius: 0 var(--r) var(--r) 0;
181
262
  background: var(--panel-2); font-size: 13px; line-height: 1.5;
182
263
  }
183
264
  /* 名字加参数排一行,太长就省略——它是「做了哪件事」的标签,不是正文。 */
@@ -202,7 +283,7 @@
202
283
  .live .md > *:last-child::after {
203
284
  content: ''; display: inline-block; width: 2px; height: 1em;
204
285
  margin-left: 2px; vertical-align: -2px;
205
- background: var(--accent, #2563eb);
286
+ background: var(--ice);
206
287
  animation: caret 1s steps(2, start) infinite;
207
288
  }
208
289
  @keyframes caret { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
@@ -233,16 +314,16 @@
233
314
  margin: 0 auto;
234
315
  }
235
316
 
236
- /* 立绘:每个姿势是一张「两帧并排」的雪碧图(760×330),靠 background-position
237
- 在左右两帧之间硬切,手脚就真的在动——不是把一张静图晃一晃。
238
- 七层叠在一起,换姿势时交叉淡入。 */
317
+ /* 立绘:每个姿势是一张「并排若干帧」的雪碧图(每帧 380×330),靠 background-position
318
+ 在帧与帧之间硬切,手脚就真的在动——不是把一张静图晃一晃。
319
+ 七层叠在一起,换姿势时交叉淡入。
320
+ 帧数写在数据里(POSES[i].frames,缺省 2);下面默认那套就是 2 帧的写法。 */
239
321
  .work-stage {
240
322
  position: relative; flex: 0 0 auto;
241
323
  width: 122px; height: 106px;
242
- /* 整块再轻轻浮动一下。它的节奏和翻帧错开,两下叠起来才像活的。 */
243
- animation: workBob 2.4s ease-in-out infinite;
324
+ /* 这里原来还叠着一层 2.4s 的上下浮动(`workBob`),2026-09-27 删掉:
325
+ 她的翻帧本身就是"活着"的证据,再叠一层漂浮就变成界面在表演了。 */
244
326
  }
245
- @keyframes workBob { 50% { transform: translateY(-3px); } }
246
327
  .pose {
247
328
  position: absolute; inset: 0;
248
329
  background-repeat: no-repeat;
@@ -261,6 +342,23 @@
261
342
  0%, 49.9% { background-position: 0 0; }
262
343
  50%, 100% { background-position: -122px 0; }
263
344
  }
345
+ /* 6 帧的姿势(冲刺的头发是程序化生成的 6 帧,见 tools/whale-sway.py):
346
+ 显示宽度 122×6,关键帧每 1/6 走一格。
347
+ step-end 会把每一格的值保持到下一格,所以每格只写一个百分比就够。
348
+ **总时长不动**(还是 POSES[i].flip 秒):这样腿脚换帧的快慢和 2 帧时一模一样,
349
+ 多出来的帧只是把头发那一趟摆动切得更细。 */
350
+ .pose[data-frames="6"] {
351
+ background-size: 732px 106px;
352
+ animation-name: poseFlip6;
353
+ }
354
+ @keyframes poseFlip6 {
355
+ 0% { background-position: 0 0; }
356
+ 16.667% { background-position: -122px 0; }
357
+ 33.333% { background-position: -244px 0; }
358
+ 50% { background-position: -366px 0; }
359
+ 66.667% { background-position: -488px 0; }
360
+ 83.333% { background-position: -610px 0; }
361
+ }
264
362
 
265
363
  /* 气泡:左边一个小尖角指向立绘。
266
364
  宽度**贴着自己的词条**,多长就多宽,右边不留空。
@@ -278,7 +376,7 @@
278
376
  max-width: calc(100% - 128px);
279
377
  align-self: flex-start; margin-top: 6px;
280
378
  background: var(--panel-2); border: 1px solid var(--line);
281
- border-radius: 12px; padding: 9px 12px;
379
+ border-radius: var(--r); padding: 9px 12px;
282
380
  font-size: 14.5px; line-height: 1.5; color: var(--fg);
283
381
  max-height: 50vh; overflow-y: auto;
284
382
  /* 长英文串、URL 不认换行,会把气泡顶宽。中文本来就能任意断行,这条是给它们兜的。 */
@@ -299,28 +397,40 @@
299
397
  /* 气泡里的话走和回答区同一套行内渲染(`mdInline`):`**加粗**`、`` `代码` ``、链接会变成样式。
300
398
  代码的底色特意不用 --panel-2——那是气泡自己的底色,套上去等于看不见。 */
301
399
  .work-bubble strong { font-weight: 650; }
302
- .work-bubble a { color: var(--accent); word-break: break-all; }
400
+ .work-bubble a { color: var(--ice); word-break: break-all; }
303
401
  .work-bubble code {
304
- background: rgba(127, 127, 127, .18); border: 1px solid var(--line); border-radius: 5px;
402
+ background: rgba(127, 127, 127, .18); border: 1px solid var(--line); border-radius: var(--r);
305
403
  padding: .1em .35em; font-size: .88em;
306
404
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
307
405
  }
308
406
 
309
- /* 进度条故意做成**不确定进度**的流动条,不是百分比。
407
+ /* 进度条是**不确定进度**,不是百分比。
310
408
  插件按设计只收「指令 + 最终回复」,拿不到 Agent 此刻跑到哪一步,
311
- 所以画一个填到 80% 的假进度条等于撒谎。它只表达「在跑」,不表达「跑到哪了」。 */
409
+ 所以画一个填到 80% 的假进度条等于撒谎。它只表达「在跑」,不表达「跑到哪了」。
410
+
411
+ 这一段前后改过三次,方向来回,写清楚免得再兜圈子:
412
+ ① 最初是一条来回扫的流光(`workSweep`),按 UI 规格删掉——流光属于"看起来在动"
413
+ 的廉价套话;
414
+ ② 删完剩一根**定在 38% 的静态标记**。结果更糟:用户实机一看就问「底部的进度条
415
+ 好像不会动」,而一根停在三成八的线,比一个会动的它**更**像在谎报百分比;
416
+ ③ 现在:宽度写死 38% 永不改变,动的是**位置**——十六格一轮、每格 160ms,
417
+ 也就是全页那一个节奏。推进式(steps)而不是匀速流淌。
418
+ 它仍然不知道跑了多少;它只是不再假装停住。 */
312
419
  .work-progress {
313
- margin-top: 10px; height: 4px; border-radius: 2px;
314
- background: var(--panel-2); overflow: hidden;
420
+ margin-top: 10px; height: 1px;
421
+ background: var(--line); overflow: hidden;
315
422
  }
316
423
  .work-progress i {
317
- display: block; width: 38%; height: 100%; border-radius: 2px;
318
- background: linear-gradient(90deg, transparent, var(--accent) 45%, var(--accent) 55%, transparent);
319
- animation: workSweep 1.5s ease-in-out infinite;
424
+ display: block; width: 38%; height: 100%;
425
+ background: var(--ice);
426
+ animation: barStep 2.56s steps(16, end) infinite;
320
427
  }
321
- @keyframes workSweep {
322
- from { transform: translateX(-105%); }
323
- to { transform: translateX(265%); }
428
+ /* 从左外侧进来、从右外侧出去。38% 宽的块想走完一整条轨道,位移得是自身宽度的
429
+ 263%(100 ÷ 38 ≈ 2.63)。用 steps 而不是匀速:一格一格地推进,
430
+ 跟上面那把刻度尺是同一种秩序感。 */
431
+ @keyframes barStep {
432
+ from { transform: translateX(-100%); }
433
+ to { transform: translateX(263%); }
324
434
  }
325
435
  /* 秒数在左、停止按钮在右。秒数原来是自己一行右对齐,现在跟按钮同一行。 */
326
436
  .work-foot {
@@ -334,7 +444,7 @@
334
444
  按下去之后自己禁用并改文案——cancel() 只是递请求,真正停下要等状态翻,
335
445
  这中间必须让用户看见「已经按到了」,否则会连按好几下。 */
336
446
  .stop {
337
- padding: 5px 14px; border-radius: 999px; font-size: 13px;
447
+ padding: 5px 14px; border-radius: var(--r); font-size: 13px;
338
448
  border: 1px solid var(--line); background: transparent; color: var(--fg);
339
449
  transition: opacity .15s;
340
450
  }
@@ -343,11 +453,44 @@
343
453
  /* 排队中的指令。跑着任务时接着发的那几条排在这儿,等这一轮跑完再跑。
344
454
  单帧模式下你发出去的那句话在别处一个字都看不见(那一模式只显示回复),
345
455
  所以这块是唯一的反馈,不能省。 */
456
+ /* ---------- 工序刻度尺 ----------
457
+ **它代表的是"队列",不是 Agent 的工序。** 插件按设计只收「指令 + 最终回复」,
458
+ 根本看不到 Agent 此刻跑到哪一步(见上面进度条那一段)。所以刻度上每个点都对应
459
+ 一条真实存在的指令:第一个点是**正在跑的那条**(暖金),后面是排着队的(空心)。
460
+ 设计稿里画的是一条理想中的工序进度尺;落到真实实现,只有队列是插件真知道的事,
461
+ 就按队列画——界面上不出现编出来的数据。 */
346
462
  .queue {
347
- margin-top: 12px; padding: 8px 10px; border-radius: 10px;
348
- background: var(--panel-2); border: 1px solid var(--line);
349
- }
350
- .queue .q-head { font-size: 11.5px; color: var(--muted); margin-bottom: 6px; }
463
+ margin-top: 12px; padding: 12px;
464
+ border: 1px solid var(--line); border-radius: var(--r);
465
+ background: var(--tint);
466
+ }
467
+ .queue .q-head { font-size: 11.5px; color: var(--muted); margin-bottom: 10px; }
468
+ .queue .q-scale {
469
+ position: relative; display: flex; align-items: center; height: 24px; margin: 2px 0 10px;
470
+ }
471
+ /* 刻度之间的那条细线:垫在点底下,两端各留 3px,不顶到边。 */
472
+ .queue .q-scale::before {
473
+ content: ''; position: absolute; left: 3px; right: 3px; top: 6px;
474
+ height: 1px; background: var(--line);
475
+ }
476
+ .queue .q-node {
477
+ position: relative; flex: 1 1 0; display: flex; flex-direction: column;
478
+ align-items: center; gap: 5px;
479
+ }
480
+ .queue .q-node i {
481
+ display: block; width: 7px; height: 7px; border-radius: 50%;
482
+ box-sizing: content-box; border: 1px solid var(--line); background: var(--bg);
483
+ /* 全页唯一允许的动效:节点由"待"变"过"时 160ms 颜色过渡。 */
484
+ transition: background .16s ease, border-color .16s ease;
485
+ }
486
+ /* 刻度上"当前这一步"是**当字用**的那个强调色(--gold):浅色主题底下它要压深,
487
+ 所以这里是 --gold 而不是 --act——--act 是"当底用"的,浅色下已经换成宝蓝了。 */
488
+ .queue .q-node.cur i { background: var(--gold); border-color: var(--gold); }
489
+ .queue .q-node b {
490
+ font-weight: 400; font-size: 10.5px; line-height: 1;
491
+ color: var(--muted); font-variant-numeric: tabular-nums;
492
+ }
493
+ .queue .q-node.cur b { color: var(--gold); }
351
494
  .queue .q-row {
352
495
  display: flex; align-items: center; gap: 8px; padding: 3px 0;
353
496
  font-size: 13.5px; line-height: 1.45;
@@ -360,41 +503,51 @@
360
503
  }
361
504
  .queue .q-drop {
362
505
  flex: 0 0 auto; width: 24px; height: 24px; padding: 0; line-height: 1;
363
- border-radius: 999px; font-size: 12px;
506
+ border-radius: var(--r); font-size: 12px;
364
507
  border: 1px solid var(--line); background: transparent; color: var(--muted);
365
508
  }
509
+ .queue .q-drop svg {
510
+ width: 12px; height: 12px; fill: none; stroke: currentColor;
511
+ stroke-width: 1.6; stroke-linecap: round;
512
+ }
366
513
 
367
514
  /* 系统里设了「减少动态效果」的人:不浮动、不翻帧、不轮播、进度条常亮。
368
515
  秒数照常走——那是信息,不是装饰。 */
369
516
  @media (prefers-reduced-motion: reduce) {
370
517
  .work-stage { animation: none; }
371
518
  .pose { animation: none; transition: none; }
372
- .work-progress i { animation: none; width: 100%; opacity: .45; }
519
+ .work-progress i { width: 100%; opacity: .45; animation: none; }
520
+ .queue .q-node i { transition: none; }
521
+ .dot { transition: none; }
373
522
  }
374
523
 
375
524
  /* ---------- 底栏 ---------- */
376
525
  footer {
377
526
  flex: 0 0 auto; display: flex; align-items: flex-end; gap: 8px;
378
527
  padding: 10px 12px calc(env(safe-area-inset-bottom) + 10px);
379
- background: var(--panel); border-top: 1px solid var(--line);
528
+ background: var(--glass); border-top: 1px solid var(--line);
529
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
530
+ backdrop-filter: blur(14px) saturate(1.1);
380
531
  }
381
532
  #input {
382
533
  flex: 1 1 auto; resize: none; min-height: 42px; max-height: 140px;
383
- padding: 10px 12px; border-radius: 12px; border: 1px solid var(--line);
534
+ padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line);
384
535
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
385
536
  line-height: 1.5; outline: none;
386
537
  }
387
- #input:focus { border-color: var(--accent); }
538
+ #input:focus { border-color: var(--ice); }
539
+ /* 发送键是这一屏**唯一的暖金**(另一处是刻度上"正在跑的那一步")。
540
+ 金色底配白字读不清,所以字用近黑的 --on-act。(浅色下 --act 是宝蓝、--on-act 是白字。) */
388
541
  .send {
389
- flex: 0 0 auto; height: 42px; padding: 0 16px; border-radius: 12px;
390
- border: none; background: var(--accent); color: #fff;
542
+ flex: 0 0 auto; height: 42px; padding: 0 16px; border-radius: var(--r);
543
+ border: none; background: var(--act); color: var(--on-act);
391
544
  font-size: 15px; font-weight: 600; cursor: pointer;
392
545
  }
393
546
  .send:disabled { opacity: .4; }
394
547
 
395
548
  /* ---------- 上传附件 ---------- */
396
549
  .attach {
397
- flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border-radius: 12px;
550
+ flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border-radius: var(--r);
398
551
  border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
399
552
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
400
553
  }
@@ -407,12 +560,14 @@
407
560
  /* 附件小条:排在输入框上面,让你发之前看得见自己带了什么。 */
408
561
  .attach-bar {
409
562
  flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 6px;
410
- padding: 8px 12px 0; background: var(--panel); border-top: 1px solid var(--line);
563
+ padding: 8px 12px 0; background: var(--glass); border-top: 1px solid var(--line);
564
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
565
+ backdrop-filter: blur(14px) saturate(1.1);
411
566
  }
412
567
  .attach-bar[hidden] { display: none; }
413
568
  .chip {
414
569
  display: inline-flex; align-items: center; gap: 6px; max-width: 100%;
415
- padding: 5px 6px 5px 10px; border-radius: 999px;
570
+ padding: 5px 6px 5px 10px; border-radius: var(--r);
416
571
  border: 1px solid var(--line); background: var(--panel-2);
417
572
  font-size: 12.5px; color: var(--fg);
418
573
  }
@@ -431,17 +586,19 @@
431
586
  .cmd-menu {
432
587
  flex: 0 0 auto; max-height: 42vh; overflow-y: auto;
433
588
  -webkit-overflow-scrolling: touch;
434
- padding: 6px 8px; background: var(--panel); border-top: 1px solid var(--line);
589
+ padding: 6px 8px; background: var(--glass); border-top: 1px solid var(--line);
590
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
591
+ backdrop-filter: blur(14px) saturate(1.1);
435
592
  }
436
593
  .cmd-menu[hidden] { display: none; }
437
594
  .cmd-menu .cmd-row {
438
595
  display: flex; align-items: baseline; gap: 10px; width: 100%;
439
- padding: 9px 8px; border: none; border-radius: 8px; background: none;
596
+ padding: 9px 8px; border: none; border-radius: var(--r); background: none;
440
597
  color: var(--fg); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
441
598
  }
442
599
  .cmd-menu .cmd-row:active { background: var(--panel-2); }
443
600
  .cmd-menu .cmd-name {
444
- flex: 0 0 auto; color: var(--accent);
601
+ flex: 0 0 auto; color: var(--ice);
445
602
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
446
603
  }
447
604
  /* 说明收成一行:这一屏是「扫一眼挑一个」,不是读文档的地方。 */
@@ -456,18 +613,20 @@
456
613
  /* ---------- 左侧导航栏 ---------- */
457
614
  #nav {
458
615
  position: fixed; inset: 0; z-index: 60; display: none;
459
- background: rgba(0, 0, 0, .5);
616
+ background: rgba(4, 7, 11, .62);
460
617
  }
461
618
  #nav.open { display: block; }
462
619
  .nav-panel {
463
620
  position: absolute; top: 0; bottom: 0; left: 0; width: min(86vw, 350px);
464
621
  display: flex; flex-direction: column;
465
- background: var(--panel); border-right: 1px solid var(--line);
622
+ background: var(--glass); border-right: 1px solid var(--line);
466
623
  padding-top: env(safe-area-inset-top);
467
624
  padding-bottom: env(safe-area-inset-bottom);
468
- animation: navIn .18s ease-out;
625
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
626
+ backdrop-filter: blur(14px) saturate(1.1);
627
+ /* 原来抽出来时有一条 0.18s 的滑入(`navIn`),2026-09-27 删掉:
628
+ 状态变化是瞬时的,抽屉不该表演怎么出现。 */
469
629
  }
470
- @keyframes navIn { from { transform: translateX(-100%); } }
471
630
  .nav-head {
472
631
  flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
473
632
  padding: 12px 12px 12px 16px; border-bottom: 1px solid var(--line);
@@ -499,19 +658,23 @@
499
658
  font: inherit; font-size: 14px; text-align: left; cursor: pointer;
500
659
  }
501
660
  .sess:active { background: var(--panel-2); }
502
- .sess.cur { background: var(--panel-2); box-shadow: inset 3px 0 0 var(--accent); }
661
+ /* "当前会话"用左边一条 2px 冰蓝线,不用投影:层次靠细线,不靠阴影。 */
662
+ .sess.cur { background: var(--panel-2); border-left: 2px solid var(--ice); }
503
663
  .sess .s-title { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
504
664
  .sess .s-meta { flex: 0 0 auto; color: var(--muted); font-size: 11.5px; }
665
+ /* 「正在跑」这个点是**状态**,和主屏顶栏那颗是同一种意思,所以用同一个冰蓝。
666
+ 这里原来跟着暖金走,结果抽屉一屏能出现三处金(两个点 + 「就在这个工作区」),
667
+ 破了"一屏最多两处"。金只留给"该动手的地方"和"当前这一步"。 */
505
668
  .sess .s-dot, .ws-head .s-dot {
506
669
  flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
507
- background: var(--warn); animation: pulse 1.2s ease-in-out infinite;
670
+ background: var(--run);
508
671
  }
509
672
  /* 「新建会话」那一行。跟会话条目同宽、同缩进,但用强调色——
510
673
  它不是一个会话,是一个动作。长得跟会话一样会让人以为点进去就是那条会话。 */
511
674
  .ws-new {
512
675
  display: flex; align-items: center; gap: 6px; width: 100%;
513
676
  padding: 10px 14px 10px 30px; background: none; border: none;
514
- color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
677
+ color: var(--ice); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
515
678
  }
516
679
  .ws-new:active { background: var(--panel-2); }
517
680
  .ws-new[disabled] { color: var(--muted); cursor: default; }
@@ -521,7 +684,7 @@
521
684
  .nav-cta {
522
685
  display: flex; align-items: center; gap: 6px; width: 100%;
523
686
  padding: 12px 14px; background: none; border: none; border-bottom: 1px solid var(--line);
524
- color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
687
+ color: var(--ice); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
525
688
  }
526
689
  .nav-cta:active { background: var(--panel-2); }
527
690
 
@@ -531,8 +694,8 @@
531
694
  overflow-x: auto; white-space: nowrap; border-bottom: 1px solid var(--line);
532
695
  }
533
696
  .crumb button {
534
- background: none; border: none; color: var(--accent); font: inherit;
535
- font-size: 13px; padding: 4px 6px; cursor: pointer; border-radius: 6px;
697
+ background: none; border: none; color: var(--ice); font: inherit;
698
+ font-size: 13px; padding: 4px 6px; cursor: pointer; border-radius: var(--r);
536
699
  }
537
700
  .crumb button:active { background: var(--panel-2); }
538
701
  .crumb .sep { color: var(--muted); font-size: 12px; }
@@ -541,8 +704,8 @@
541
704
  /* 「就选这个目录」——这一屏唯一的主按钮,所以给它实心底。 */
542
705
  .pick-here {
543
706
  display: block; width: calc(100% - 24px); margin: 10px 12px;
544
- padding: 11px; border: none; border-radius: var(--radius);
545
- background: var(--accent); color: #fff; font: inherit; font-size: 14px; cursor: pointer;
707
+ padding: 11px; border: none; border-radius: var(--r);
708
+ background: var(--act); color: var(--on-act); font: inherit; font-size: 14px; cursor: pointer;
546
709
  }
547
710
  .pick-here:active { opacity: .85; }
548
711
  .pick-here[disabled] { background: var(--line); color: var(--muted); cursor: default; }
@@ -553,12 +716,12 @@
553
716
  .mkdir-row input {
554
717
  flex: 1 1 auto; min-width: 0; padding: 9px 10px; font: inherit; font-size: 14px;
555
718
  background: var(--panel-2); border: 1px solid var(--line);
556
- border-radius: 10px; color: var(--fg);
719
+ border-radius: var(--r); color: var(--fg);
557
720
  }
558
721
  .mkdir-row button {
559
722
  flex: 0 0 auto; padding: 9px 12px; font: inherit; font-size: 13px; cursor: pointer;
560
723
  background: var(--panel-2); border: 1px solid var(--line);
561
- border-radius: 10px; color: var(--fg);
724
+ border-radius: var(--r); color: var(--fg);
562
725
  }
563
726
  .mkdir-row button:active { background: var(--line); }
564
727
 
@@ -569,20 +732,39 @@
569
732
  /* 「这不是全部」:垫在聊天内容最上面,往上翻到顶才看得见,不抢内容。 */
570
733
  .history-note {
571
734
  margin: 0 0 14px; padding: 8px 12px; border: 1px dashed var(--line);
572
- border-radius: 10px; color: var(--muted); font-size: 12.5px; line-height: 1.6;
573
- }
735
+ border-radius: var(--r); color: var(--muted); font-size: 12.5px; line-height: 1.6;
736
+ }
737
+ /* 「上面还有更早的」:聊天记录是一屏一屏铺的(见 renderChat 里 CHAT_PAGE 那段)。
738
+ 两处讲究:
739
+ · **贴在看得到的顶上**(sticky),不随内容走——随内容走就只在滑到顶那一瞬间可见,
740
+ 而那一瞬间正好触发了自动再放一屏,那句话等于永远看不见。
741
+ · **离顶远了就收起来**(.hide)。用户 2026-09-30 真机提的:都滑到最下面了,
742
+ 它还挂在顶上。那时候上面确实还有没放出来的记录,这句话没说错;但它是催人
743
+ 往上滑的,人已经在底部读内容了,挂在顶上就只剩挡视线。
744
+ 长相照抄底栏的 .chip,不为它新造一种元素。 */
745
+ .more-note {
746
+ position: sticky; top: 0; z-index: 1;
747
+ width: fit-content; margin: 0 auto 14px; padding: 4px 12px;
748
+ border-radius: 999px; border: 1px solid var(--line); background: var(--panel-2);
749
+ color: var(--muted); font-size: 12px; line-height: 1.7;
750
+ }
751
+ /* 收起来用 visibility 而不是 display:这一条的高度始终留在内容里,
752
+ 露和收之间就不会把下面的消息顶来顶去(那会是一次小跳)。 */
753
+ .more-note.hide { visibility: hidden; }
574
754
 
575
755
  /* ---------- 设置抽屉 ---------- */
576
756
  #settings {
577
- position: fixed; inset: 0; background: rgba(0,0,0,.55);
757
+ position: fixed; inset: 0; background: var(--scrim);
578
758
  display: none; align-items: flex-end; z-index: 50;
579
759
  }
580
760
  #settings.open { display: flex; }
581
761
  .sheet {
582
762
  width: 100%; max-height: 86vh; overflow-y: auto;
583
- background: var(--panel); border-radius: 18px 18px 0 0;
763
+ background: var(--glass); border-radius: var(--r) var(--r) 0 0;
584
764
  padding: 18px 16px calc(env(safe-area-inset-bottom) + 18px);
585
765
  border-top: 1px solid var(--line);
766
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
767
+ backdrop-filter: blur(14px) saturate(1.1);
586
768
  }
587
769
  .sheet h2 { margin: 0 0 14px; font-size: 16px; }
588
770
  .row {
@@ -595,18 +777,18 @@
595
777
  /* flex-shrink: 0 不能省。它是弹性项,左边的说明文字一长就会被压缩;
596
778
  而它自己带着 overflow: hidden,所以被压掉的那一段不是跑到屏幕外,是**被裁掉**——
597
779
  2026-09-25 用户截图里那个「关」字就是这么没的。 */
598
- .seg { display: flex; flex-shrink: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
780
+ .seg { display: flex; flex-shrink: 0; border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }
599
781
  .seg button {
600
782
  padding: 7px 12px; border: none; background: var(--panel-2); color: var(--muted);
601
783
  font-size: 13px; cursor: pointer;
602
784
  }
603
- .seg button.active { background: var(--accent); color: #fff; }
785
+ .seg button.active { background: var(--ice); color: var(--on-act); }
604
786
  select, .btn {
605
- padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line);
787
+ padding: 8px 12px; border-radius: var(--r); border: 1px solid var(--line);
606
788
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 14px;
607
789
  }
608
790
  .btn { cursor: pointer; }
609
- .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
791
+ .btn.primary { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
610
792
  .sheet .foot { margin-top: 18px; display: flex; gap: 10px; }
611
793
  .sheet .foot .btn { flex: 1 1 0; text-align: center; }
612
794
 
@@ -618,19 +800,20 @@
618
800
  #gate h1 { font-size: 20px; margin: 0; }
619
801
  #gate p { color: var(--muted); font-size: 14px; margin: 0; }
620
802
  #gate input {
621
- padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line);
803
+ padding: 12px 14px; border-radius: var(--r); border: 1px solid var(--line);
622
804
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
623
805
  }
624
806
  #gate button {
625
- padding: 13px; border-radius: 12px; border: none; background: var(--accent);
626
- color: #fff; font-size: 16px; font-weight: 600;
807
+ padding: 13px; border-radius: var(--r); border: none; background: var(--act);
808
+ color: var(--on-act); font-size: 16px; font-weight: 600;
627
809
  }
628
810
  #gate .err { color: var(--err); font-size: 13px; min-height: 18px; }
629
811
 
630
812
  #toast {
631
813
  position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%) translateY(8px);
632
- background: rgba(40,44,54,.97); color: var(--fg); padding: 9px 16px;
633
- border-radius: 20px; font-size: 13.5px; opacity: 0; pointer-events: none;
814
+ background: var(--pop); color: var(--fg); padding: 9px 16px;
815
+ border-radius: var(--r); font-size: 13.5px; opacity: 0; pointer-events: none;
816
+ border: 1px solid var(--line);
634
817
  transition: opacity .2s, transform .2s; z-index: 200; max-width: 86vw; text-align: center;
635
818
  }
636
819
  #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@@ -639,25 +822,67 @@
639
822
  整屏遮住聊天区:手机上一次只专心答一题,答完自动消失。
640
823
  之所以遮全屏而不是弹个小条:非技术用户看到"电脑在等你回答"时,
641
824
  最怕的是不知道该点哪里;整屏只剩问题本身和按钮,没有第二个选择。 */
642
- #askCard { position: fixed; inset: 0; z-index: 300; background: #0e1116;
825
+ /* 命令确认(审批)卡。形状与理由都同上面那张答题卡:整屏遮住,只剩「同意 / 拒绝」
826
+ 两个选择。**主题交给令牌决定**——主按钮那一处用的就是「当底的强调色」令牌,
827
+ 面板、文字、描边全沿用现有那几档,所以深色浅色自动都对,不用为两套主题各写一份。
828
+ (注释里**不写令牌本身那串字**:下面的检查是按"哪一块里出现了它"取材的,
829
+ 注释里写上,这一块就会被当成一条规则,取自一个不存在的选择器。) */
830
+ #approveCard { position: fixed; inset: 0; z-index: 310; background: var(--bg);
831
+ display: none; flex-direction: column; padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); overflow-y: auto; }
832
+ #approveCard.on { display: flex; }
833
+ #approveCard .ahead { font-size: 13px; color: var(--muted); margin-bottom: 12px; }
834
+ #approveCard .atool { font-size: 18px; line-height: 1.6; margin-bottom: 10px; }
835
+ #approveCard .areason { font-size: 14px; line-height: 1.65; color: var(--muted); margin-bottom: 16px; white-space: pre-wrap; }
836
+ /* 命令原文:等宽感靠 1px 细线左边界和缩进,不引新字体;长命令照原样折行。 */
837
+ #approveCard .acmd { font-size: 13px; line-height: 1.55; color: var(--muted); margin-bottom: 16px;
838
+ white-space: pre-wrap; word-break: break-all; border-left: 2px solid var(--line); padding-left: 10px; }
839
+ #approveCard .afoot { margin-top: auto; display: flex; gap: 10px; padding-top: 16px; }
840
+ #approveCard .afoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: var(--r);
841
+ border: 1px solid var(--line); background: var(--panel-2); color: inherit; font-family: inherit; }
842
+ /* 这一屏唯一的主按钮走 --act:深色下暖金,浅色下宝蓝配白字。 */
843
+ #approveCard .afoot button#approveAllow { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
844
+ #askCard { position: fixed; inset: 0; z-index: 300; background: var(--bg);
643
845
  display: none; flex-direction: column; padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); overflow-y: auto; }
644
846
  #askCard.on { display: flex; }
645
- #askCard .qhead { font-size: 13px; opacity: .6; margin-bottom: 12px; }
847
+ #askCard .qhead { font-size: 13px; color: var(--muted); margin-bottom: 12px; }
646
848
  #askCard .qtext { font-size: 18px; line-height: 1.6; margin-bottom: 10px; }
647
- #askCard .qdetail { font-size: 14px; line-height: 1.65; opacity: .75; margin-bottom: 16px; white-space: pre-wrap; }
849
+ #askCard .qdetail { font-size: 14px; line-height: 1.65; color: var(--muted); margin-bottom: 16px; white-space: pre-wrap; }
648
850
  #askCard .opt { display: block; width: 100%; text-align: left; margin: 0 0 10px; padding: 15px 16px;
649
- font-size: 16px; line-height: 1.5; border-radius: 12px; border: 1px solid #2a3140;
650
- background: #161b23; color: inherit; font-family: inherit; }
651
- #askCard .opt.sel { border-color: #3b82f6; background: #17243a; }
652
- #askCard .opt small { display: block; opacity: .7; font-size: 13px; margin-top: 5px; }
851
+ font-size: 16px; line-height: 1.5; border-radius: var(--r); border: 1px solid var(--line);
852
+ background: var(--panel-2); color: inherit; font-family: inherit; }
853
+ /* 选中的那一条用冰蓝描边:它是"你选的",不是"你要按的主按钮"——主按钮是暖金。 */
854
+ #askCard .opt.sel { border-color: var(--ice); background: rgba(169, 200, 232, .1); }
855
+ #askCard .opt small { display: block; color: var(--muted); font-size: 13px; margin-top: 5px; }
653
856
  #askCard textarea { width: 100%; box-sizing: border-box; font-size: 16px; line-height: 1.6;
654
- border-radius: 12px; border: 1px solid #2a3140; background: #161b23; color: inherit;
857
+ border-radius: var(--r); border: 1px solid var(--line); background: var(--panel-2); color: inherit;
655
858
  padding: 12px; font-family: inherit; resize: vertical; }
656
859
  #askCard .qfoot { margin-top: auto; display: flex; gap: 10px; padding-top: 16px; }
657
- #askCard .qfoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: 12px;
658
- border: 1px solid #2a3140; background: #161b23; color: inherit; font-family: inherit; }
659
- #askCard .qfoot button#askNext { background: #2563eb; border-color: #2563eb; color: #fff; }
860
+ #askCard .qfoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: var(--r);
861
+ border: 1px solid var(--line); background: var(--panel-2); color: inherit; font-family: inherit; }
862
+ /* 这一屏唯一的主按钮,暖金。 */
863
+ #askCard .qfoot button#askNext { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
864
+ /* 没有毛玻璃的浏览器:退回实色面板。别让界面变成一片糊住的半透明。 */
865
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
866
+ header, footer, .attach-bar, .cmd-menu, .nav-panel, .sheet { background: var(--panel); }
867
+ }
868
+ /* 细线图标:和回形针、复制按钮那套同一副笔法(1.5px 描边、圆头圆角)。 */
869
+ .icon-btn svg {
870
+ width: 18px; height: 18px; fill: none; stroke: currentColor;
871
+ stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
872
+ }
660
873
  </style>
874
+ <script>
875
+ // 主题必须在**首屏画出来之前**定下来,否则浅色手机打开会先闪一下深色底。
876
+ // 所以这一段留在 <head> 里、样式表后面,**别挪到页面底部那段脚本区去**。
877
+ (function () {
878
+ try {
879
+ var pick = localStorage.getItem('dshMiniTheme') || 'dark';
880
+ var dark = !window.matchMedia || window.matchMedia('(prefers-color-scheme: dark)').matches;
881
+ document.documentElement.setAttribute('data-theme',
882
+ pick === 'auto' ? (dark ? 'dark' : 'light') : pick);
883
+ } catch (e) { /* 无痕模式下 localStorage 会抛;按默认深色走,不影响页面 */ }
884
+ })();
885
+ </script>
661
886
  </head>
662
887
  <body>
663
888
 
@@ -676,13 +901,26 @@
676
901
  <button id="askNext" type="button">下一题</button>
677
902
  </div>
678
903
  </div>
904
+ <!-- 电脑要做需要拍板的事时弹出来的审批卡。默认隐藏,收到 approval 事件才显示。
905
+ 命令原文来自会话事件 tool/call 的 arguments(模型原样产出的 JSON),
906
+ 插件按调用编号记着,审批来的时候一起推过来。 -->
907
+ <div id="approveCard">
908
+ <div class="ahead">电脑被拦下来了,等你拍板</div>
909
+ <div class="atool" id="approveTool"></div>
910
+ <div class="areason" id="approveReason"></div>
911
+ <div class="acmd" id="approveCmd"></div>
912
+ <div class="afoot">
913
+ <button id="approveDeny" type="button">拒绝</button>
914
+ <button id="approveAllow" type="button">同意</button>
915
+ </div>
916
+ </div>
679
917
 
680
918
  <header>
681
- <button class="icon-btn" id="btnNav" aria-label="工作区与会话">☰</button>
919
+ <button class="icon-btn" id="btnNav" aria-label="工作区与会话"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
682
920
  <span class="dot" id="dot"></span>
683
- <span class="brand">DSH mini remote</span>
921
+ <span class="brand">极简遥控器</span>
684
922
  <span class="status" id="status">连接中…</span>
685
- <button class="icon-btn" id="btnSettings" aria-label="设置">⚙</button>
923
+ <button class="icon-btn" id="btnSettings" aria-label="设置"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h8M16 7h4M4 17h4M12 17h8"/><circle cx="14" cy="7" r="2.2"/><circle cx="10" cy="17" r="2.2"/></svg></button>
686
924
  </header>
687
925
 
688
926
  <!-- 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
@@ -746,8 +984,8 @@
746
984
  <aside class="nav-panel">
747
985
  <div class="nav-head">
748
986
  <span>工作区与会话</span>
749
- <button class="icon-btn" id="btnNavRefresh" aria-label="刷新">⟳</button>
750
- <button class="icon-btn" id="btnNavClose" aria-label="关闭">✕</button>
987
+ <button class="icon-btn" id="btnNavRefresh" aria-label="刷新"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 5v6h-6"/></svg></button>
988
+ <button class="icon-btn" id="btnNavClose" aria-label="关闭"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
751
989
  </div>
752
990
  <div class="nav-body" id="navBody"></div>
753
991
  </aside>
@@ -760,7 +998,7 @@
760
998
  <div class="row">
761
999
  <div>
762
1000
  <div class="label">显示模式</div>
763
- <div class="hint">单帧:只留最新一条回复</div>
1001
+ <div class="hint" id="hintMode">单帧:只留最新一条回复</div>
764
1002
  </div>
765
1003
  <div class="seg" id="segMode">
766
1004
  <button data-mode="minimal">单帧</button>
@@ -768,6 +1006,19 @@
768
1006
  </div>
769
1007
  </div>
770
1008
 
1009
+ <!-- 外观。深色是原来的样子;浅色的色调跟宣传图(docs/promo.png)统一:
1010
+ 近白冰蓝的底、深宝蓝的字与状态色、白色卡片。选择记在这台设备上。 -->
1011
+ <div class="row">
1012
+ <div>
1013
+ <div class="label">外观</div>
1014
+ </div>
1015
+ <div class="seg" id="segTheme">
1016
+ <button data-theme="dark">深色</button>
1017
+ <button data-theme="light">浅色</button>
1018
+ <button data-theme="auto">跟随系统</button>
1019
+ </div>
1020
+ </div>
1021
+
771
1022
  <!-- 权限档位。整块由脚本填:这台电脑有哪些档位是**运行时读**的,一个都不写死。
772
1023
  服务拿不到时整块不显示——它是附加能力,缺了遥控本身照常。 -->
773
1024
  <div id="permBlock" hidden>
@@ -880,6 +1131,9 @@
880
1131
  var state = {
881
1132
  token: params.get('token') || localStorage.getItem('dshMiniToken') || '',
882
1133
  mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
1134
+ // 外观:'dark' | 'light' | 'auto'。默认深色——不给老用户换掉现在的样子;
1135
+ // 要浅色或跟着系统走,去设置页「外观」点一下。
1136
+ theme: localStorage.getItem('dshMiniTheme') || 'dark',
883
1137
  sound: localStorage.getItem('dshMiniSound') !== 'off',
884
1138
  // 锁屏也能提醒(系统通知)。默认关着——它要用户主动点一下才会申请权限,
885
1139
  // 不能替他决定。明文连接下这一项永远是空转的,见下面「锁屏也能提醒」那一段。
@@ -956,6 +1210,24 @@
956
1210
  // 认不出来的语法原样留着:宁可少渲染,不能吃掉内容。
957
1211
  var CODE_SLOT = '\u0000';
958
1212
 
1213
+ /**
1214
+ * 正文里的图片地址 → 真正去取的地址。三种写法见下面 mdInline 那段注释。
1215
+ *
1216
+ * 判据只有一条:**不是 http(s) 就走同源**。所以 `javascript:`、`data:` 这类
1217
+ * 永远不会出现在 src 里;认不出的地址最多是取回来 404,不会变成别的东西。
1218
+ *
1219
+ * 放在这里而不是立绘那一段(artUrl 旁边):页面测试是从 escapeHtml 抠到复制按钮
1220
+ * 那一小段的,放外面就测不到真代码了。
1221
+ */
1222
+ function imageSrc(src) {
1223
+ if (/^https?:\/\//i.test(src)) return src;
1224
+ var name = src.replace(/\\/g, '/').replace(/^\.\//, '');
1225
+ if (/^lib\/art\//i.test(name)) name = name.slice(8);
1226
+ if (/^[a-z0-9-]+(\.webp)?$/i.test(name)) return artUrl(name.replace(/(\.webp)?$/i, ''));
1227
+ return '/mini/art/?p=' + encodeURIComponent(src)
1228
+ + '&token=' + encodeURIComponent(state.token);
1229
+ }
1230
+
959
1231
  function mdInline(raw) {
960
1232
  // 行内代码先挖出来换成占位符。不这么做的话,`**x**` 这种字面量
961
1233
  // 会被后面的粗体规则吃掉,代码里写的东西就显示错了。
@@ -965,6 +1237,20 @@
965
1237
  return CODE_SLOT + (codes.length - 1) + CODE_SLOT;
966
1238
  });
967
1239
  out = out
1240
+ // 图片:![说明](地址)。地址三种写法,判断在 imageSrc 里——
1241
+ // ① 完整 http(s) 链接:原样用,手机直接去那个网站取图;
1242
+ // ② 一个名字(如 out-demo,`.webp` 可带可不带):走立绘通道 /mini/art/<名字>.webp,
1243
+ // 只认 lib/art/ 下「小写字母数字连字符 + .webp」的文件名;写成
1244
+ // `lib/art/out-demo.webp` 这样带前缀的也认(2026-09-28 助手这么写过,手机上成了破图标);
1245
+ // ③ 电脑上一张图的位置(绝对路径,或相对项目根):走 /mini/art/?p=…,PNG/JPG/WEBP/GIF 都行,
1246
+ // 不要求先拷进 lib/art。
1247
+ // 三者之外的一律按 ③ 拼成同源路径,所以 `javascript:`、`data:` 这类进不了 src。
1248
+ // 必须排在下面那条链接规则前面:否则 `![x](y)` 里的 `[x](y)` 会先被链接规则
1249
+ // 吃掉,只剩下一个光秃秃的感叹号。
1250
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (_, alt, src) {
1251
+ return '<img class="md-img" src="' + imageSrc(src) + '" alt="' + alt + '"'
1252
+ + ' loading="lazy" referrerpolicy="no-referrer">';
1253
+ })
968
1254
  .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
969
1255
  .replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>')
970
1256
  .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
@@ -1130,6 +1416,22 @@
1130
1416
  return p(d.getHours()) + ':' + p(d.getMinutes());
1131
1417
  }
1132
1418
  var toastTimer;
1419
+ /**
1420
+ * 图片没加载出来时要说一声。
1421
+ *
1422
+ * 手机页认三种图片地址:完整的 http(s) 网址、一个裸名字(页面拼成 /mini/art/<名字>.webp),
1423
+ * 或者一张图在电脑上的路径(绝对路径或相对项目根)。三种都认不出来、或者那个文件根本不在,
1424
+ * 浏览器只会画一个破图标——用户看到的是"图没了",却不知道原因(2026-09-28 他为此来过三次)。
1425
+ *
1426
+ * 为什么挂**捕获**阶段:图片的 error 事件不冒泡,挂冒泡阶段一个也收不到。
1427
+ */
1428
+ document.addEventListener('error', function (e) {
1429
+ var el = e.target
1430
+ if (!el || el.tagName !== 'IMG' || !el.classList || !el.classList.contains('md-img')) return
1431
+ el.classList.add('md-img-bad')
1432
+ toast('这张图手机看不到:那个位置没有这张图,或者它不是 PNG/JPG/WEBP/GIF')
1433
+ }, true)
1434
+
1133
1435
  function toast(msg) {
1134
1436
  var el = $('toast');
1135
1437
  el.textContent = msg;
@@ -1210,7 +1512,8 @@
1210
1512
  // 被用户指着问「这一句有问题还是什么」,已改掉。测试钉着这条:见 page.test.mjs
1211
1513
  // 的「鲸鱼娘说的话里不许出现让人以为出事的字眼」。
1212
1514
  //
1213
- // 每个姿势是一张「两帧并排」的雪碧图,flip 是翻帧的间隔(秒):
1515
+ // 每个姿势是一张「并排若干帧」的雪碧图,flip 是**走完一整圈翻帧**的秒数
1516
+ // (代码里它就是 animation-duration:两帧时每帧占 flip/2,六帧时每帧占 flip/6):
1214
1517
  // 敲键盘和小跑要快,端着茶等和托腮想事情要慢。节奏跟着动作走,才不像机器在闪。
1215
1518
  var POSES = [
1216
1519
  { file: 'work-1-ready', line: '收到,这就去办', flip: 0.5 },
@@ -1223,7 +1526,10 @@
1223
1526
  // 只说自己在看,不下判断。
1224
1527
  { file: 'work-4-checking', line: '让我检查检查', flip: 1.2 },
1225
1528
  { file: 'work-5-thinking', line: '嗯…让我想想', flip: 1.7 },
1226
- { file: 'work-6-running', line: '跑一遍看看', flip: 0.4 },
1529
+ // 跑从 2026-09-28 起是 6 帧:先把两帧画出来的头发摆动收小(甩幅 155.3 → 70.6),
1530
+ // 再用 tools/whale-sway.py 把这一趟切成六帧(甩幅 74.9),身体逐像素不动。
1531
+ // 时长仍是 flip 秒、不乘帧数,所以腿脚换帧的快慢和两帧时一模一样,六帧只是把头发那一趟切得更细。
1532
+ { file: 'work-6-running', line: '跑一遍看看', flip: 0.4, frames: 6 },
1227
1533
  // 用户 2026-09-22 要的「跑步的动作」。它**不是第 8 个姿势,而是姿势之间的过渡**:
1228
1534
  // 序列是「姿势1 → 冲刺 → 姿势2 → 冲刺 → …」,表达「她干完一件、跑去干下一件」。
1229
1535
  // 所以它带 gap: true——不进轮播循环,只在每个姿势后面闪一下。
@@ -1231,8 +1537,14 @@
1231
1537
  // 但用户两次要求拉长,现在 GAP_MS 和 POSE_MS 一样是 3.6 秒。理由见 GAP_MS 的注释。
1232
1538
  // 它和上面那张小跑**刻意画得不一样**:侧面视角、身体前倾、步子拉得更开。
1233
1539
  // **不带词条**(line 是空串)——用户指定的,冲刺是纯动作,硬配一句话反而弱。
1234
- // 文件名里的 8 只是个名字,**顺序由数组决定**,别拿它当序号。
1235
- { file: 'work-8-sprinting', line: '', flip: 0.28, gap: true },
1540
+ // 文件名里的 8 只是个名字,**顺序由数组决定**,别拿它当序号。 */
1541
+ // **帧数 6(2026-09-29)**:原来只有 2 帧,头发只能整个姿势硬切过去。2 帧的摆动
1542
+ // 没法"流畅"——两帧之间没有中间态。现在这 6 帧的头发是拿冻结基准帧当底、
1543
+ // 用位移场程序化算出来的(tools/whale-sway.py),身体逐像素不动:
1544
+ // 越靠发梢摆得越大、相位越滞后,采样一个周期得到 6 帧。摆幅默认 15 像素、
1545
+ // 相位滞后 300°,量出来的甩幅 128.9(用户给的目标区间 110~140)。
1546
+ // **它只解决头发**:身体和腿仍然是那两帧,跑起来腿脚还是瞬间换的。
1547
+ { file: 'work-8-sprinting', line: '', flip: 0.28, gap: true, frames: 6 },
1236
1548
  // 最后一个只在任务真的跑够久了才进轮播——到那时候说这句话才是实话。
1237
1549
  // **它必须留在最后**:posePool 用的是 POSES.length - 1,靠位置认它。
1238
1550
  { file: 'work-7-waiting', line: '还在忙,再等我一会儿', flip: 2.0 }
@@ -1415,6 +1727,9 @@
1415
1727
  for (var i = 0; i < POSES.length; i += 1) {
1416
1728
  var layer = document.createElement('div');
1417
1729
  layer.className = 'pose';
1730
+ // 帧数决定显示宽度和用哪套关键帧(CSS 里 2 帧是默认,6 帧挂在 [data-frames="6"])。
1731
+ // **时长不跟着帧数放大**:一圈还是 flip 秒,这样腿脚换帧的快慢跟以前一样。
1732
+ layer.setAttribute('data-frames', POSES[i].frames || 2);
1418
1733
  layer.style.backgroundImage = 'url("' + artUrl(POSES[i].file) + '")';
1419
1734
  layer.style.animationDuration = POSES[i].flip + 's';
1420
1735
  stage.appendChild(layer);
@@ -1427,6 +1742,7 @@
1427
1742
  // 说话那张一起建好、一起预热:等台词来了再新建图层,会说第一个字之前先闪一下空白。
1428
1743
  var speak = document.createElement('div');
1429
1744
  speak.className = 'pose';
1745
+ speak.setAttribute('data-frames', SPEAK_POSE.frames || 2);
1430
1746
  speak.style.backgroundImage = 'url("' + artUrl(SPEAK_POSE.file) + '")';
1431
1747
  speak.style.animationDuration = SPEAK_POSE.flip + 's';
1432
1748
  stage.appendChild(speak);
@@ -1560,6 +1876,18 @@
1560
1876
  });
1561
1877
  }
1562
1878
 
1879
+
1880
+ /** 手机把审批结论交回电脑。三种结果和上面那条一样:成了 / 晚了 / 没送出去。 */
1881
+ function sendApproval(id, allow) {
1882
+ return api('/mini/api/approval', {
1883
+ method: 'POST',
1884
+ body: JSON.stringify({ id: id, decision: allow ? 'allow' : 'deny' }),
1885
+ })
1886
+ .then(function () { return 'ok'; })
1887
+ .catch(function (err) {
1888
+ return err && err.status === 409 ? 'late' : 'failed';
1889
+ });
1890
+ }
1563
1891
  // ---------------- 排队中的指令 ----------------
1564
1892
  //
1565
1893
  // 跑着任务时接着发的那几条不会丢,而是排进 agent 的收件箱(`followup()` 的语义
@@ -1571,16 +1899,31 @@
1571
1899
  function paintQueue() {
1572
1900
  var el = $('queue');
1573
1901
  var list = state.queued || [];
1902
+ // 刻度尺上第一个点永远是**正在跑的那条**,后面每个点对应一条真排在队里的指令。
1903
+ //
1904
+ // 2026-09-27 用户裁决:**队列是空的就整块收起来,不显示。**
1905
+ // 上一版这里是「只要在跑就留着」,理由是一个金点表示"你这一轮正是第 1 步";
1906
+ // 用户实际看到的是「排队中 0 条」配一个孤零零的点,判定为噪音。撤销。
1907
+ // (上面那段注释里"哪怕只有一格,秩序也在"的说法同样作废。)
1574
1908
  if (!list.length) { el.hidden = true; el.innerHTML = ''; return; }
1575
1909
  el.hidden = false;
1910
+ // 最多画 12 个点:再长这条尺子就成噪点了,超出缀一个省略号。
1911
+ var nodes = '<div class="q-node cur"><i></i><b>1</b></div>';
1912
+ var shown = Math.min(list.length, 11);
1913
+ for (var n = 0; n < shown; n += 1) {
1914
+ nodes += '<div class="q-node"><i></i><b>' + (n + 2) + '</b></div>';
1915
+ }
1916
+ if (list.length > shown) nodes += '<div class="q-node"><i></i><b>…</b></div>';
1576
1917
  var rows = '';
1577
1918
  for (var i = 0; i < list.length; i += 1) {
1578
1919
  rows += '<div class="q-row">'
1579
1920
  + '<span class="q-text">' + escapeHtml(list[i].text) + '</span>'
1580
1921
  + '<button class="q-drop" type="button" data-drop="' + escapeHtml(list[i].id) + '"'
1581
- + ' aria-label="撤掉这条">✕</button></div>';
1922
+ + ' aria-label="撤掉这条"><svg viewBox="0 0 24 24" aria-hidden="true">'
1923
+ + '<path d="M6 6l12 12M18 6L6 18"/></svg></button></div>';
1582
1924
  }
1583
- el.innerHTML = '<div class="q-head">排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们</div>' + rows;
1925
+ el.innerHTML = '<div class="q-head">排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们</div>'
1926
+ + '<div class="q-scale">' + nodes + '</div>' + rows;
1584
1927
  }
1585
1928
 
1586
1929
  /**
@@ -1598,9 +1941,15 @@
1598
1941
  }
1599
1942
 
1600
1943
  $('queue').addEventListener('click', function (e) {
1601
- var t = e.target;
1602
- var id = t && t.getAttribute ? t.getAttribute('data-drop') : null;
1603
- if (id) requestUnqueue(id);
1944
+ // **必须从被点的元素往上找到那个真按钮。** ✗ 是画在按钮里面的 SVG 图形,手指落上去
1945
+ // 时 e.target 是图形本身,读不到按钮身上的编号。用户 2026-09-28 实机报「排队框的 ✗
1946
+ // 点了没反应」就是这么来的:旧写法 `e.target.getAttribute('data-drop')` 拿到 null,
1947
+ // 然后静默什么都不做。按钮只有 24×24、图形 12×12,所以旧写法几乎必然踩中。
1948
+ var hit = e.target && e.target.closest ? e.target.closest('.q-drop') : null;
1949
+ var id = hit ? hit.getAttribute('data-drop') : null;
1950
+ if (id) { requestUnqueue(id); return; }
1951
+ // 找到了按钮却没有编号:这是代码毛病。说一声,别静默——静默失败是最难查的那一类。
1952
+ if (hit) toast('这条撤不了:没带上编号');
1604
1953
  });
1605
1954
 
1606
1955
  /** 让「正在执行」那块跟着 state.running 显隐。 */
@@ -1833,11 +2182,72 @@
1833
2182
  if (!el || typeof el.getBoundingClientRect !== 'function') return;
1834
2183
  if (typeof mainEl.getBoundingClientRect !== 'function') return;
1835
2184
  var gap = el.getBoundingClientRect().top - mainEl.getBoundingClientRect().top;
2185
+ // 这里**不用**为顶上那颗「往上滑看更早的」让高度:对齐开头只在「他本来就在底部」
2186
+ // 的时候做(见 scrollChat),而那时候那颗提示是收着的(见 chatNoteVisible)——
2187
+ // 让高度反而会凭空多出一段空白。
1836
2188
  mainEl.scrollTop += gap - 12;
1837
2189
  }
1838
2190
 
2191
+ /**
2192
+ * 聊天记录**一次铺多少条**,以及这次已经铺到多少条。
2193
+ *
2194
+ * 用户 2026-09-30 报的:在导航栏点一个没选中的会话,切过去很慢。查下来不是网络、
2195
+ * 也不是服务端取数(本机实测那三个接口 168 / 36 / 107 毫秒),而是这一页**一次把
2196
+ * 推上来的 200 条记录全铺出来**。这 200 条本来就已经在页面手里了——所以「往上多放
2197
+ * 一点」是个纯本地动作,不用再问服务端,也该是瞬时的。
2198
+ *
2199
+ * 40 这个数:一屏通常放得下三到五段长回答,40 条够铺六七屏;首屏只干五分之一的活,
2200
+ * 而往上翻的人一次也就看一屏,翻不到边界。
2201
+ */
2202
+ var CHAT_PAGE = 40;
2203
+ var chatWindow = CHAT_PAGE; // 从最新那条往回数,这次铺了多少条
2204
+ var chatWindowFor = null; // 上面这个数是哪个会话的(换会话要收回首屏,见 renderChat)
2205
+ var chatLoaded = 0; // 本地一共有多少条可铺(滤掉排队那条之后),由 renderChat 记
2206
+ var chatGrowArmed = true; // 这一轮靠近顶部已经放过一屏没有(见 maybeGrowChat)
2207
+
2208
+ /** 触发线:离顶一屏的四分之一以内。放一屏和那颗提示共用这一条,别写成两个数。 */
2209
+ function chatEdge() {
2210
+ return Math.min(160, Math.max(60, (mainEl.clientHeight || 0) / 4));
2211
+ }
2212
+
2213
+ /** 顶上那颗「往上滑看更早的」的元素(没铺出来就没有)。 */
2214
+ function moreNoteEl() {
2215
+ return replyEl.querySelector ? replyEl.querySelector('.more-note') : null;
2216
+ }
2217
+
2218
+ /**
2219
+ * 那句提示现在该不该露脸:**近了顶部、又不在底部**。
2220
+ *
2221
+ * 两个条件缺一不可——
2222
+ * · 离顶远了它就没用:再往上滑一下就自动放一屏,用不着它催;
2223
+ * · 在底部时它必须收起来(用户 2026-09-30 真机提的那条):那句话是催人往上滑的,
2224
+ * 人已经在最底下读内容了,它挂在那儿只剩挡视线。
2225
+ */
2226
+ function chatNoteVisible() {
2227
+ if (atBottom()) return false;
2228
+ return mainEl.scrollTop <= chatEdge();
2229
+ }
2230
+
2231
+ /**
2232
+ * 把提示的露 / 收跟当前位置对齐。
2233
+ *
2234
+ * 这是**滚动时**的那一半。重画只在快照来的时候发生,而用户从中间一路滑到底
2235
+ * 是不会等快照的——不在滚动里收,那条提示就会一直挂在顶上(真机就是这么发现的)。
2236
+ */
2237
+ function paintChatNote() {
2238
+ var el = moreNoteEl();
2239
+ if (!el || !el.classList || typeof el.classList.toggle !== 'function') return;
2240
+ el.classList.toggle('hide', !chatNoteVisible());
2241
+ }
2242
+
1839
2243
  function renderChat() {
1840
2244
  mainEl.classList.remove('center');
2245
+ // 换了会话就把窗口收回首屏:上一个会话铺到多少条都不该跟过来——那正是这次要修的慢。
2246
+ // 判据用会话 id 而不是 history 的引用:服务端每次推的都是整份新数组,比引用永远「变了」。
2247
+ if (chatWindowFor !== state.boundSessionId) {
2248
+ chatWindowFor = state.boundSessionId;
2249
+ chatWindow = CHAT_PAGE;
2250
+ }
1841
2251
  // **还在排队的那条不算「已发出」,这里要把它从聊天记录里滤掉。**
1842
2252
  //
1843
2253
  // 用户 2026-09-22 实机提的:「排队的指令在轮到之前不该出现在指令气泡里,
@@ -1855,6 +2265,9 @@
1855
2265
  var list = (state.history || []).filter(function (m) {
1856
2266
  return !(m.role === 'user' && m.id && queuedIds[m.id]);
1857
2267
  });
2268
+ // 先记「本地一共有多少条」再分支:下面那条「还没有对话记录」的路也要把数记成 0,
2269
+ // 不然中途清空一次(服务端正在重读)会留着一个旧的条数,让 maybeGrowChat 白忙一场。
2270
+ chatLoaded = list.length;
1858
2271
  if (!list.length && !state.running && !state.live && !(state.queued || []).length) {
1859
2272
  // 「正在读」和「确实没有」是两件事,分开说。点开一个插件没看过的会话时,
1860
2273
  // 服务端要去 DSH 的会话记录里读它那一段(最大的那条 20 MB、约半秒),
@@ -1864,6 +2277,10 @@
1864
2277
  : '<div class="placeholder">还没有对话记录。</div>';
1865
2278
  return;
1866
2279
  }
2280
+ // **只铺最近那几十条**,剩下的留在手里等用户往上滑(来由见 CHAT_PAGE 那段)。
2281
+ // 切在**滤掉排队那条之后**:不然被滤掉的那条会白占一个名额。
2282
+ var older = Math.max(0, chatLoaded - chatWindow);
2283
+ if (older) list = list.slice(older);
1867
2284
  var html = list.map(function (m) {
1868
2285
  // 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
1869
2286
  // 不套气泡、不给复制按钮(见 commandBlock 的说明)。
@@ -1888,6 +2305,12 @@
1888
2305
  + '<div class="reply-foot">' + copyBtn
1889
2306
  + '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
1890
2307
  }).join('');
2308
+ // 「上面还有,往上滑」要贴在**看得到的顶上**,而且**离顶远了就收起来**——
2309
+ // 两处讲究见 .more-note 的样式注释。露 / 收在这里先按当前位置定一次,
2310
+ // 之后由滚动里的 paintChatNote 接着跟。
2311
+ var moreNote = older
2312
+ ? '<div class="more-note' + (chatNoteVisible() ? '' : ' hide') + '">往上滑看更早的</div>'
2313
+ : '';
1891
2314
  // 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
1892
2315
  // 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
1893
2316
  if (state.historyTruncated) {
@@ -1900,10 +2323,57 @@
1900
2323
  if (state.live) html += liveBlock(state.live);
1901
2324
  // 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
1902
2325
  var stick = atBottom();
1903
- replyEl.innerHTML = html;
2326
+ replyEl.innerHTML = moreNote + html;
1904
2327
  scrollChat(stick);
1905
2328
  }
1906
2329
 
2330
+ /**
2331
+ * 往上滑到接近顶部,就再放一屏出来。
2332
+ *
2333
+ * 判据是「离顶部一屏的四分之一以内」,不要求真滑到 0:手机上惯性滚动会滑过头,
2334
+ * 卡在 0 反而要等一次回弹才触发。60 是下限(矮屏上四分之一太小,拇指一带就滑过),
2335
+ * 160 是上限(高屏上别放得太早,太早等于提前干了下一屏的活)。
2336
+ */
2337
+ function maybeGrowChat() {
2338
+ // 单帧模式那边也在往 main 上滚(回答落定要跳到页面顶端),别把那一下当成「他在往上翻」;
2339
+ // 本地都放完了也没什么可放的。
2340
+ if (state.mode !== 'chat' || chatLoaded <= 0) return;
2341
+ if (chatWindow >= chatLoaded) return;
2342
+ if (mainEl.scrollTop > chatEdge()) {
2343
+ // 滑下来了,这一轮重新装填:**一次靠近只放一屏**。
2344
+ // 不装填的话,万一位置校正没顶住(iOS 顶上那截橡皮筋会把人拽回 0),
2345
+ // 一次手势就能把 200 条连着放完——那正好把这个改动白做了。
2346
+ chatGrowArmed = true;
2347
+ return;
2348
+ }
2349
+ if (!chatGrowArmed) return;
2350
+ chatGrowArmed = false;
2351
+ growChat();
2352
+ }
2353
+
2354
+ /** main 每一次滚动:先对齐那句提示的露 / 收,再看要不要再放一屏。 */
2355
+ function onMainScroll() {
2356
+ paintChatNote();
2357
+ maybeGrowChat();
2358
+ }
2359
+
2360
+ /**
2361
+ * 往前多放一屏。纯本地动作:那些记录本来就在页面手里,不再问服务端。
2362
+ *
2363
+ * **放完必须立刻把位置校回来**,这是整件事最容易做坏的一步:往前面插内容,
2364
+ * 浏览器不会替你补偿,用户正看着的那一屏会被整个推下去一屏。
2365
+ *
2366
+ * 补偿用「离底部多远」:这次加的东西全在顶上,底部那一截一个字没动,
2367
+ * 所以只要这个距离不变,他眼前那几行就一格都不会走。别去量「原来那条消息」的
2368
+ * 矩形来对齐——重画是整块换 innerHTML,那条消息已经不在文档里了,量不着。
2369
+ */
2370
+ function growChat() {
2371
+ var fromBottom = mainEl.scrollHeight - mainEl.scrollTop;
2372
+ chatWindow += CHAT_PAGE;
2373
+ renderChat();
2374
+ mainEl.scrollTop = mainEl.scrollHeight - fromBottom;
2375
+ }
2376
+
1907
2377
  /**
1908
2378
  * 聊天模式里「已经为哪一次回答把开头对齐到顶部」。
1909
2379
  *
@@ -1971,7 +2441,8 @@
1971
2441
  }
1972
2442
 
1973
2443
  function render() {
1974
- if (state.mode === 'chat') renderChat(); else renderMinimal();
2444
+ $('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
2445
+ if (state.mode === 'chat') renderChat(); else renderMinimal();
1975
2446
  }
1976
2447
 
1977
2448
  function applySnapshot(snap) {
@@ -2293,6 +2764,24 @@
2293
2764
  });
2294
2765
  }
2295
2766
 
2767
+ // 标题补齐的节奏。服务端只等 250 毫秒就把列表先给出来(超时的标题留给后台),
2768
+ // 所以这里要接着问。**问几次由服务端说了算**,见 toggleWorkspace 里的说明。
2769
+ var TITLE_FOLLOW_MS = 2500; // 每隔这么久问一次
2770
+ var TITLE_FOLLOW_LIMIT_MS = 30000; // 从展开算起,最多追这么久
2771
+
2772
+ /**
2773
+ * 这份列表「还剩几条标题没读出来」。
2774
+ *
2775
+ * 优先用服务端给的 pending:那是事实(它知道还有几条日志没读完)。
2776
+ * **服务端没给这个字段时才退回数空标题**——那是「改了要重启」的半新状态下
2777
+ * (新页面配旧进程)唯一能用的判据。旧页面就是靠它补拉三次的,
2778
+ * 退回去至少有原来的效果,不会因为服务端换了版本就反而不追了。
2779
+ */
2780
+ function pendingCount(body) {
2781
+ if (body && typeof body.pending === 'number') return body.pending;
2782
+ return ((body && body.sessions) || []).filter(function (s) { return !s.title; }).length;
2783
+ }
2784
+
2296
2785
  function toggleWorkspace(wsId) {
2297
2786
  if (navExpanded[wsId]) {
2298
2787
  delete navExpanded[wsId];
@@ -2302,10 +2791,37 @@
2302
2791
  navExpanded[wsId] = true;
2303
2792
  renderNav();
2304
2793
  if (navSessions[wsId]) return;
2794
+ /**
2795
+ * 接着问,直到服务端说「一条都不剩」。
2796
+ *
2797
+ * 原来的写法是**固定补三次**(1.5 秒 / 3 秒 / 3 秒,共 7.5 秒),而一个工作区里
2798
+ * 四五个冷会话实测要 19 秒才读完——三次补拉盖不住,标题就是长不出来,
2799
+ * 用户只能退出去再进来。现在按服务端给的事实问:pending 归零就停。
2800
+ *
2801
+ * 30 秒是总时限:万一有一条日志坏了、永远读不出来,也不该没完没了地问下去。
2802
+ */
2803
+ function followTitles(cur, deadline) {
2804
+ if (!(pendingCount(cur) > 0) || Date.now() >= deadline) return;
2805
+ setTimeout(function () {
2806
+ // 用户收起了、或者这份数据已经被刷新换掉,就放手:别跟人抢。
2807
+ if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
2808
+ api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
2809
+ .then(function (body) {
2810
+ if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
2811
+ navSessions[wsId] = body;
2812
+ renderNav();
2813
+ followTitles(body, deadline);
2814
+ })
2815
+ .catch(function () {
2816
+ // 追不到就算了:列表本身还在,日期也够用。
2817
+ });
2818
+ }, TITLE_FOLLOW_MS);
2819
+ }
2305
2820
  api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
2306
2821
  .then(function (body) {
2307
2822
  navSessions[wsId] = body;
2308
2823
  renderNav();
2824
+ followTitles(body, Date.now() + TITLE_FOLLOW_LIMIT_MS);
2309
2825
  })
2310
2826
  .catch(function (err) {
2311
2827
  navSessions[wsId] = { sessions: [], total: 0 };
@@ -2572,6 +3088,44 @@
2572
3088
  if (verdict === 'late') toast('这道题已经交回电脑了,去电脑上答')
2573
3089
  })
2574
3090
  })
3091
+ // ——— 命令确认(审批)———
3092
+ //
3093
+ // 电脑要做需要拍板的事时,插件推一条 `approval` 过来,点了卡上的按钮再 POST 回
3094
+ // /mini/api/approval。和答题卡同样的三条讲究:没送出去就不收卡片(收掉了就再也
3095
+ // 没地方说这句话,用户看到的只会是"点了没反应");三种结果说三句不同的话;
3096
+ // 只认有 id 的帧,别的形状一律不理。
3097
+ let approve = null
3098
+ function approveShow(payload) {
3099
+ approve = { id: payload.id }
3100
+ $('approveTool').textContent = '它要让「' + (payload.toolName || '某个工具') + '」动手'
3101
+ $('approveReason').textContent = payload.reason || ''
3102
+ $('approveReason').style.display = payload.reason ? '' : 'none'
3103
+ // 命令原文:有就照原样显示。**没有就明说"没取到",不静默藏起来**——
3104
+ // 用户要的就是看见命令,藏起来等于骗他;真没取到时他也一眼看得出是这条链没走通。
3105
+ $('approveCmd').textContent = payload.command || '(这条没取到命令原文)'
3106
+ $('approveCard').classList.add('on')
3107
+ }
3108
+ function approveDecide(allow) {
3109
+ if (!approve) return
3110
+ const id = approve.id
3111
+ sendApproval(id, allow).then(function (verdict) {
3112
+ if (verdict === 'failed') {
3113
+ toast('没送出去,看一眼和电脑的连接,再点一次')
3114
+ return
3115
+ }
3116
+ approve = null
3117
+ $('approveCard').classList.remove('on')
3118
+ if (verdict === 'late') toast('这条已经交回电脑了,去电脑上处理')
3119
+ })
3120
+ }
3121
+ $('approveAllow').addEventListener('click', function () { approveDecide(true) })
3122
+ $('approveDeny').addEventListener('click', function () { approveDecide(false) })
3123
+ es.addEventListener('approval', function (e) {
3124
+ let payload = null
3125
+ try { payload = JSON.parse(e.data) } catch (err) { return }
3126
+ if (!payload || !payload.id) return
3127
+ approveShow(payload)
3128
+ });
2575
3129
  es.addEventListener('question', function (e) {
2576
3130
  let payload = null
2577
3131
  try { payload = JSON.parse(e.data) } catch (err) { return }
@@ -3097,6 +3651,7 @@
3097
3651
  Array.prototype.forEach.call($('segMode').children, function (btn) {
3098
3652
  btn.addEventListener('click', function () {
3099
3653
  state.mode = btn.dataset.mode;
3654
+ $('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
3100
3655
  localStorage.setItem('dshMiniMode', state.mode);
3101
3656
  syncSeg();
3102
3657
  render();
@@ -3110,6 +3665,39 @@
3110
3665
  syncSeg();
3111
3666
  });
3112
3667
  });
3668
+
3669
+ // ---------------- 外观(深色 / 浅色 / 跟随系统) ----------------
3670
+ // 只改 <html> 上的 data-theme,样式表里两套令牌各管各的。这里不碰任何具体颜色,
3671
+ // 免得以后加第三套主题还要回来改这一段。
3672
+ // 首屏那次由 <head> 里那一小段负责(不然会闪);这一段管切换和"跟着系统变"。
3673
+ function resolvedTheme() {
3674
+ if (state.theme !== 'auto') return state.theme;
3675
+ var dark = !window.matchMedia || window.matchMedia('(prefers-color-scheme: dark)').matches;
3676
+ return dark ? 'dark' : 'light';
3677
+ }
3678
+ function applyTheme() {
3679
+ var use = resolvedTheme() === 'light' ? 'light' : 'dark';
3680
+ document.documentElement.setAttribute('data-theme', use);
3681
+ // 手机状态栏那块颜色也跟着走,否则浅色页配一条深色状态栏,像没画完。
3682
+ var meta = document.querySelector('meta[name="theme-color"]');
3683
+ if (meta) meta.setAttribute('content', use === 'light' ? '#eaf3fc' : '#0a0f16');
3684
+ }
3685
+ Array.prototype.forEach.call($('segTheme').children, function (btn) {
3686
+ btn.addEventListener('click', function () {
3687
+ state.theme = btn.dataset.theme;
3688
+ localStorage.setItem('dshMiniTheme', state.theme);
3689
+ applyTheme();
3690
+ syncSeg();
3691
+ });
3692
+ });
3693
+ if (window.matchMedia) {
3694
+ var themeMq = window.matchMedia('(prefers-color-scheme: dark)');
3695
+ var onThemeMq = function () { if (state.theme === 'auto') applyTheme(); };
3696
+ if (themeMq.addEventListener) themeMq.addEventListener('change', onThemeMq);
3697
+ else if (themeMq.addListener) themeMq.addListener(onThemeMq);
3698
+ }
3699
+ applyTheme(); // 补上首屏那次没做的一件事:把状态栏颜色对齐
3700
+
3113
3701
  function syncSeg() {
3114
3702
  Array.prototype.forEach.call($('segMode').children, function (b) {
3115
3703
  b.classList.toggle('active', b.dataset.mode === state.mode);
@@ -3117,6 +3705,9 @@
3117
3705
  Array.prototype.forEach.call($('segSound').children, function (b) {
3118
3706
  b.classList.toggle('active', (b.dataset.sound === 'on') === state.sound);
3119
3707
  });
3708
+ Array.prototype.forEach.call($('segTheme').children, function (b) {
3709
+ b.classList.toggle('active', b.dataset.theme === state.theme);
3710
+ });
3120
3711
  }
3121
3712
 
3122
3713
  // 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
@@ -3324,6 +3915,28 @@
3324
3915
  connect();
3325
3916
  }
3326
3917
 
3918
+ /**
3919
+ * 服务器换过代码就自己刷新一次。
3920
+ *
3921
+ * 为什么非要有:这个页面是**启动时读进内存**才发出去的(改了要重启 DSH),而立绘是
3922
+ * **每次请求现读盘**的(文件一改就生效)。于是"页面和图不是同一时刻换的"这件事真的
3923
+ * 会发生——2026-09-29 用户在手机上看到「原来的两条腿变成 4 条腿了」:手里那份页面还是
3924
+ * 旧的(写死按两格、244 像素宽去切),硬盘上的图已经是六格的了,122 像素的窗口里于是
3925
+ * 挤进三个压扁的小人,看着就是一堆腿。
3926
+ *
3927
+ * `/mini/api/version` 报的是**服务器现在这份代码**的构建号:和手里这份一样就什么都不做,
3928
+ * 不一样说明页面过期了,刷一次就好(刷新后拿到的新页面自带正确的切帧规则)。
3929
+ * **正在打字就先不刷**——用户没发出去的话不能被吞掉,等下一轮再说。
3930
+ */
3931
+ function watchBuild() {
3932
+ if (!state.token) return;
3933
+ api('/mini/api/version').then(function (r) {
3934
+ if (!r || !r.build || r.build === BUILD) return;
3935
+ if (inputEl.value && inputEl.value.trim()) return;
3936
+ location.reload();
3937
+ }).catch(function () { /* 读不到就当没这回事,别把页面弄崩 */ });
3938
+ }
3939
+
3327
3940
  // 复制按钮:整篇、单条气泡、代码块共用一套。
3328
3941
  // 用事件委托——回复区每次都是整块重绘的,逐个绑会漏。
3329
3942
  // (原来这里是「点回复区任意位置复制全文」,它直接用了 navigator.clipboard,
@@ -3336,6 +3949,13 @@
3336
3949
  copyText(btn.getAttribute('data-copy') || '', btn);
3337
3950
  });
3338
3951
 
3952
+ // main 的滚动:一句提示的露 / 收,和「再放一屏」,都挂在这一根线上(见 onMainScroll)。
3953
+ // **这根线不能省**:逻辑写得再对,没挂上去就是「一点反应都没有」,
3954
+ // 而这种坏光看代码是看不出来的。
3955
+ // 位置也有讲究:绑在这里,不绑在 atBottom 那片函数旁边——页面测试会把那一段
3956
+ // 整块抠出来求值,测试里的假 mainEl 没有 addEventListener,写在那边好几个测试当场报错。
3957
+ mainEl.addEventListener('scroll', onMainScroll);
3958
+
3339
3959
  // ---------------- 启动 ----------------
3340
3960
  syncSeg();
3341
3961
  autoGrow();
@@ -3369,6 +3989,8 @@
3369
3989
 
3370
3990
  var catalog = null // 服务端给的模型目录
3371
3991
  var current = null // 服务端记的当前选择 {provider, model, reasoningEffort}
3992
+ // 上下文窗口用量 {used, window},服务端给。取不到就是 null——那时整段不显示。
3993
+ var contextUsage = null
3372
3994
  // 刚改完、服务端还没确认的那个值。为什么要多这一个:服务端记的「当前模型」是在
3373
3995
  // **下一次请求**时才更新的,所以改完立刻回头去读,读到的还是旧名字。先信
3374
3996
  // set-model 的返回值,等服务端追上了再让它接管。
@@ -3381,6 +4003,21 @@
3381
4003
  for (var i = 0; i < gs.length; i++) { if (gs[i].id === id) return gs[i].name }
3382
4004
  return id || ''
3383
4005
  }
4006
+ /**
4007
+ * 上下文用量那一句人话。**不编**:分子分母缺任一个就返回空串,界面据此整段不显示
4008
+ * (和「模型:还没问到」同一条口径——不知道就说不知道,不拿别的数凑)。
4009
+ *
4010
+ * 超过 100% 时按 100% 显示:它是"下一次请求大概要花多少",压不压缩由电脑决定;
4011
+ * 把 105% 端给用户,只会让人以为哪里出错了。
4012
+ */
4013
+ function contextLabel(used, capacity) {
4014
+ if (typeof used !== 'number' || typeof capacity !== 'number') return ''
4015
+ if (!(used > 0) || !(capacity > 0)) return ''
4016
+ var pct = Math.round(used / capacity * 100)
4017
+ if (pct > 100) pct = 100
4018
+ return pct + '%'
4019
+ }
4020
+
3384
4021
  function sel() { return pending || current || null }
3385
4022
 
3386
4023
  function renderBar() {
@@ -3395,6 +4032,9 @@
3395
4032
  // 来源名要用 DSH 给的那个(「DeepSeek」「opencode go」「mimo」),不是内部 id
3396
4033
  var parts = [groupName(s.provider), s.model]
3397
4034
  if (s.reasoningEffort) parts.push('思考 ' + s.reasoningEffort)
4035
+ // 上下文用得怎么样。取不到就什么都不加——不显示 0%,也不写"未知"占位。
4036
+ var ctxText = contextLabel(contextUsage && contextUsage.used, contextUsage && contextUsage.window)
4037
+ if (ctxText) parts.push(ctxText)
3398
4038
  text.textContent = parts.join(' · ')
3399
4039
  bar.style.display = 'flex'
3400
4040
  }
@@ -3410,14 +4050,14 @@
3410
4050
  + 'color:inherit;padding:11px 2px;border-bottom:1px solid var(--line,#333)'
3411
4051
  var HEAD = 'padding:14px 2px 4px;font-size:12px;color:var(--dim,#888)'
3412
4052
  var CHIP = 'margin:0 6px 6px 0;padding:5px 13px;border:1px solid var(--line,#444);'
3413
- + 'border-radius:999px;background:none;font:inherit;color:inherit;font-size:13px'
4053
+ + 'border-radius:var(--r,2px);background:none;font:inherit;color:inherit;font-size:13px'
3414
4054
 
3415
4055
  // 面板用代码建出来、挂在 body 末尾,而不是写进页面结构里——这样这一整块的
3416
4056
  // 改动全部落在脚本内部,不用动 HTML,也就不会碰到别的部分。
3417
- var mask = el('div', 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:69;display:none')
4057
+ var mask = el('div', 'position:fixed;inset:0;background:var(--scrim);z-index:69;display:none')
3418
4058
  var sheet = el('div', 'position:fixed;left:0;right:0;bottom:0;z-index:70;display:none;'
3419
4059
  + 'max-height:72vh;overflow-y:auto;background:var(--bg,#1b1b1b);'
3420
- + 'border-top:1px solid var(--line,#444);border-radius:14px 14px 0 0;'
4060
+ + 'border-top:1px solid var(--line,#444);border-radius:var(--r,2px) var(--r,2px) 0 0;'
3421
4061
  + 'padding:10px 14px calc(16px + env(safe-area-inset-bottom))')
3422
4062
  document.body.appendChild(mask)
3423
4063
  document.body.appendChild(sheet)
@@ -3517,6 +4157,9 @@
3517
4157
  // 目录那个默认值等于「上一次选过的模型」,于是用户切到一个用别的模型的
3518
4158
  // 会话,顶栏显示的还是上一个模型——一个像模像样的错答案,比空着坏得多。
3519
4159
  current = (d.current && d.current.model) ? d.current : null
4160
+ // 用量跟着模型一起回来(同一条接口)。**不校验内容**:给它什么显示什么,
4161
+ // 拿不准的由下面那个纯函数挡掉。刷新频率就是这一条接口的频率(15 秒一次)。
4162
+ contextUsage = d.context || null
3520
4163
  // 服务端追上了就撤掉本地那个临时值,之后一律以服务端的为准
3521
4164
  if (pending && current && current.provider === pending.provider
3522
4165
  && current.model === pending.model) pending = null
@@ -3547,6 +4190,12 @@
3547
4190
  // 显示一个过期的模型名比不显示更糟——用户会以为自己改的没生效。
3548
4191
  setInterval(function () { if (!document.hidden) load() }, 15000)
3549
4192
  document.addEventListener('visibilitychange', function () { if (!document.hidden) load() })
4193
+ // 上面那个轮询管"显示的东西新不新",这个管"**手里这份页面**是不是过期了"——
4194
+ // 插件重启过就要刷一次,不然页面会拿着旧的切帧规则去切新的立绘(见 watchBuild 注释)。
4195
+ // 30 秒的间隔够用:它防的是"重启之后一直拿着旧页面",不是实时性。
4196
+ setInterval(watchBuild, 30000)
4197
+ // 手机把页面挂到后台时定时器会被压住,切回来那一刻补一次检查。
4198
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) watchBuild() })
3550
4199
  })();
3551
4200
  })();
3552
4201
  </script>