dsh-mini-remote 0.1.7 → 0.1.9

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
@@ -21,13 +21,15 @@
21
21
  需要"显眼一点"的地方一律用鲸鱼娘的冰蓝 --ice,别动这两个。
22
22
  · --ok/--run/--err 是状态点的语义色,占的是 8px 一个小点,不算强调色。
23
23
  · 配色以鲸鱼娘为准心:深海墨蓝打底,她的冰蓝作次级色。
24
- · 两套主题:这套(默认,深色)和 [data-theme="light"],色调跟宣传图
24
+ · 两套主题:[data-theme="light"](默认,浅色)和深色,色调跟宣传图
25
25
  (docs/promo.png)统一。**颜色令牌两边必须一一对应**——缺一个,就有元素
26
26
  在某个主题下"隐身",而且只在那个主题下才看得出来。守卫在 test/ui.test.mjs。 */
27
27
  :root {
28
28
  color-scheme: dark;
29
29
  --bg: #0a0f16;
30
30
  --bg-top: #101a27;
31
+ /* 基准字号:设置页「字体大小」三档控制,默认 15.5px */
32
+ --font-base: 15.5px;
31
33
  --bg-deep: #070b11;
32
34
  --panel: #121a25;
33
35
  --panel-2: #18222f;
@@ -40,6 +42,7 @@
40
42
  --act: #c9a55c; /* 强调色当**底**用:一屏一个的主按钮 */
41
43
  --ok: #4fb477;
42
44
  --run: #a9c8e8;
45
+ --shimmer: #eaf4ff; /* 跑着的行那道扫光的亮色(照 PC 的 label-shimmer,2026-10-04) */
43
46
  --err: #d4614f;
44
47
  --r: 2px;
45
48
  --sh-1: 0 1px 2px rgba(0, 0, 0, .28);
@@ -71,6 +74,7 @@
71
74
  --act: #3b6fc4; /* 主按钮底:用宣传图那个宝蓝(用户 2026-09-27 裁决) */
72
75
  --ok: #2e9a5f;
73
76
  --run: #3b6fc4;
77
+ --shimmer: #7ea6e6; /* 浅色下的扫光要比 --run 亮、又不能白到看不见 */
74
78
  --err: #c0392b;
75
79
  --sh-1: 0 1px 2px rgba(28, 60, 110, .07);
76
80
  --sh-2: 0 2px 8px rgba(28, 60, 110, .12);
@@ -124,6 +128,146 @@
124
128
  font-size: 16px; display: grid; place-items: center; cursor: pointer;
125
129
  }
126
130
  .icon-btn:active { transform: scale(.94); }
131
+ #btnSubagents.subagent-running svg { animation: subagent-spin 1.6s linear infinite; }
132
+ #btnSubagents.subagent-running { border-color: var(--run); color: var(--run); }
133
+ @keyframes subagent-spin { to { transform: rotate(360deg); } }
134
+ @media (prefers-reduced-motion: reduce) {
135
+ #btnSubagents.subagent-running svg { animation: none; }
136
+ }
137
+
138
+ /* ---------- 子智能体(2026-10-03) ----------
139
+ 整块**只新增两条外观**:列表里的一行、聊天记录里那张完工卡片。其余全部复用:
140
+ 抽屉外壳用设置那一套(#subagents 的骨架和 .sheet 完全一样),状态点用既有 .dot,
141
+ 主按钮用既有 .btn.primary,气泡用既有 .bubble / .said。
142
+
143
+ **一个颜色令牌都没新加**:全用主屏已有的那几个,浅色主题下跟着变就行——
144
+ 令牌是两套主题各写一份的,新造一个就等于在浅色下多挖一个坑。
145
+
146
+ 也没有新的关键帧:卡片能不能展开交给原生 <details>,不写一行脚本去演它。 */
147
+ #subagents {
148
+ position: fixed; inset: 0; background: var(--scrim);
149
+ display: none; align-items: flex-end; z-index: 50;
150
+ }
151
+ #subagents.open { display: flex; }
152
+ .sa-head { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }
153
+ .sa-head h2 { margin: 0; font-size: 16px; flex: 1 1 auto; }
154
+ .sa-back {
155
+ flex: 0 0 auto; border: none; background: transparent; color: var(--muted);
156
+ font: inherit; font-size: 14px; cursor: pointer; padding: 0;
157
+ }
158
+ .sa-none { color: var(--muted); font-size: 14px; padding: 6px 0 2px; }
159
+
160
+ /* ---------- 插件页面(2026-10-04) ----------
161
+ 外壳和设置抽屉 / 子智能体**同一套**(#xxx.open + .sheet),不新建样式体系。
162
+ 层级比设置抽屉高一档(60 > 50):它从设置里点开,得盖在设置**上面**,
163
+ 而不是掉到输入框下面去(2026-10-04 用户真机报的正是这一条)。 */
164
+ #plugins {
165
+ position: fixed; inset: 0; background: var(--scrim);
166
+ display: none; align-items: flex-end; z-index: 60;
167
+ }
168
+ #plugins.open { display: flex; }
169
+ /* .sa-list 把两边拽到贴屏边(负 16px,让分隔线横贯),所以这里的左右缩进
170
+ 得自己补回同一个 16px——少这一笔,文字就贴着屏幕边(用户报的「两侧没有留白」)。
171
+
172
+ ---------- 分组可折可展(2026-10-05 用户要「分组做成可以折叠展开,默认折叠」)----------
173
+ 用浏览器原生的 <details>/<summary>:不写一行脚本去演它、不加关键帧,键盘和读屏
174
+ 也都认(和子智能体那张完工卡片同一个做法,见 .sa-card)。默认收起:HTML 里
175
+ 不写 open 属性就自然是收着的,所以「默认折叠」不需要额外的代码去保证。
176
+
177
+ 小三角**用 CSS 画**(两条边框拼一个实心三角),不写字形——字形依赖字体,
178
+ 换个机型就可能变成豆腐块;颜色用 currentColor,深浅两套主题自动都对。 */
179
+ .plugin-fold { margin: 16px 0 0; }
180
+ .plugin-fold > summary {
181
+ display: flex; align-items: center; gap: 8px; list-style: none;
182
+ margin: 0 16px 6px; padding: 4px 0;
183
+ font-size: 13px; color: var(--muted); cursor: pointer;
184
+ }
185
+ .plugin-fold > summary::-webkit-details-marker { display: none; }
186
+ /* 收起时朝右,展开时朝下——和 PC 那页的分组箭头一个意思。 */
187
+ .plugin-fold > summary::before {
188
+ content: ""; flex: 0 0 auto; width: 0; height: 0;
189
+ border-left: 5px solid currentColor;
190
+ border-top: 4px solid transparent;
191
+ border-bottom: 4px solid transparent;
192
+ }
193
+ .plugin-fold[open] > summary::before {
194
+ border-left: 5px solid transparent;
195
+ border-top: 5px solid currentColor;
196
+ border-right: 5px solid transparent;
197
+ }
198
+ /* 展开的那一组,标题点亮一点,免得收/开两态看着一样。 */
199
+ .plugin-fold[open] > summary { color: var(--fg); }
200
+ /* 一级分组(官方 / 已安装)比二级(全局插件 / 会话插件 / 预设名)重一点。 */
201
+ .plugin-fold.depth-1 > summary { font-size: 14px; margin-top: 4px; }
202
+ .plugin-fold.depth-2 { margin-top: 10px; }
203
+ .plugin-fold.depth-2 > summary { font-size: 12px; color: var(--dim); }
204
+ .plugin-fold.depth-2[open] > summary { color: var(--muted); }
205
+ .plugin-note { font-size: 12px; color: var(--err); margin: 6px 16px; }
206
+ .plugin-row {
207
+ display: flex; align-items: center; gap: 10px;
208
+ padding: 12px 16px; border-bottom: 1px solid var(--line);
209
+ }
210
+ .plugin-row:last-child { border-bottom: none; }
211
+ .plugin-info { flex: 1 1 auto; min-width: 0; }
212
+ .plugin-name { display: block; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
213
+ .plugin-desc { display: block; font-size: 12px; color: var(--muted); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
214
+ /* 插件报错/装不上:那一行说人话,颜色用状态语义色 --err(不是强调色)。 */
215
+ .plugin-problem { color: var(--err); }
216
+ .plugin-badge { flex: 0 0 auto; font-size: 10px; padding: 2px 6px; border-radius: var(--r); }
217
+ .plugin-badge.on { color: var(--ok); border: 1px solid var(--ok); }
218
+ .plugin-badge.off { color: var(--dim); border: 1px solid var(--line); }
219
+ .plugin-badge.err { color: var(--err); border: 1px solid var(--err); }
220
+ /* 行负着左右两边的 16px:让分隔线横贯整个抽屉,而不是在里面缩一截。 */
221
+ .sa-list { margin: 0 -16px; }
222
+ .sa-row {
223
+ display: flex; align-items: center; gap: 10px; width: 100%;
224
+ padding: 12px 16px; border: none; border-bottom: 1px solid var(--line);
225
+ background: transparent; color: var(--fg); font: inherit; text-align: left; cursor: pointer;
226
+ }
227
+ .sa-row:last-child { border-bottom: none; }
228
+ /* 清单还在读时那一行也在 .sa-list 里,会被那 -16px 拽到贴屏边,
229
+ 和行的文字差着一档。补回 .sa-row 同一个 16px 才对齐。 */
230
+ .sa-none.sa-none-indent { padding-left: 16px; }
231
+ /* 读不动的那一支只有说明、点不动,所以没有手指光标。 */
232
+ .sa-plain { cursor: default; }
233
+ .sa-main { flex: 1 1 auto; min-width: 0; }
234
+ .sa-name { display: block; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
235
+ .sa-sub { display: block; font-size: 12px; color: var(--muted); margin-top: 3px; }
236
+ /* 数字用等宽数位:几行并排时小数点对齐,扫一眼就能比大小。 */
237
+ .sa-num {
238
+ flex: 0 0 auto; text-align: right; font-size: 12px; color: var(--muted);
239
+ font-variant-numeric: tabular-nums; line-height: 1.5;
240
+ }
241
+ .sa-acts { display: flex; gap: 10px; margin-top: 16px; }
242
+ .sa-acts .btn { flex: 1 1 0; text-align: center; }
243
+ .sa-ask { display: flex; gap: 10px; margin-top: 16px; align-items: flex-end; }
244
+ .sa-ask input {
245
+ flex: 1 1 auto; min-width: 0; padding: 10px 12px; border-radius: var(--r);
246
+ border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
247
+ font: inherit; font-size: 16px; line-height: 1.5; outline: none;
248
+ }
249
+ .sa-ask input:focus { border-color: var(--ice); }
250
+ .sa-ask .btn { flex: 0 0 auto; }
251
+ .sa-doc { max-height: 52vh; overflow-y: auto; margin: 0 -16px; padding: 0 16px; }
252
+ .sa-doc .bubble, .sa-doc .said { max-width: 100%; }
253
+
254
+ /* 完工卡片。展开/收起用原生 <details>:不写脚本、不加关键帧,键盘和读屏也都认。
255
+ 样子和主会话里那张卡片同一个思路——单薄一层边框,不抢回答的气泡感。 */
256
+ .sa-card {
257
+ margin: 10px 0; border: 1px solid var(--line); border-radius: var(--r);
258
+ background: var(--panel-2); overflow: hidden;
259
+ }
260
+ .sa-card > summary {
261
+ display: flex; align-items: center; gap: 8px; list-style: none;
262
+ padding: 10px 12px; font-size: 13px; color: var(--muted); cursor: pointer;
263
+ }
264
+ .sa-card > summary::-webkit-details-marker { display: none; }
265
+ .sa-card > summary b { color: var(--fg); font-weight: 600; }
266
+ .sa-card[open] > summary { border-bottom: 1px solid var(--line); }
267
+ .sa-card .sa-body {
268
+ padding: 10px 12px; font-size: 14px; white-space: pre-wrap;
269
+ word-break: break-word; max-height: 240px; overflow: auto;
270
+ }
127
271
 
128
272
  /* ---------- 显示区 ---------- */
129
273
  main { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 16px 14px; }
@@ -153,7 +297,7 @@
153
297
  回答正文第一行之间贴得太紧」——10px 是「同一条消息内部」的距离,
154
298
  而这里跨的是「我说的话」和「它答的话」,是更大的一个断点,该给更多。 */
155
299
  margin-bottom: 18px; white-space: pre-wrap; word-break: break-word;
156
- font-size: 15.5px; line-height: 1.7;
300
+ font-size: var(--font-base); line-height: 1.7;
157
301
  }
158
302
  .bubble.user { margin-left: auto; background: var(--panel-2); border: 1px solid var(--line); }
159
303
  .bubble.assistant { margin-right: auto; background: var(--panel); border: 1px solid var(--line); }
@@ -169,7 +313,7 @@
169
313
  /* ---------- Markdown 正文 ---------- */
170
314
  /* 外层是 pre-wrap(用户消息和旧内容靠它),markdown 输出的是真块级元素,
171
315
  所以要在这里把 pre-wrap 收掉,否则段落之间会多出一堆空行。 */
172
- .md { white-space: normal; }
316
+ .md { white-space: normal; font-size: var(--font-base); line-height: 1.7; }
173
317
  /* 回复里的图。地址有两种写法,见下面 mdInline() 里那段注释。
174
318
  宽度只封顶不写死:窄屏上占满可用宽度、按比例缩,圆角沿用全站那 2 像素。 */
175
319
  /* 图片没加载出来时的那一圈虚线。用中性灰,不动暖金——那是唯一的强调色,一屏最多两次。 */
@@ -261,13 +405,25 @@
261
405
  border-left: 2px solid var(--line); border-radius: 0 var(--r) var(--r) 0;
262
406
  background: var(--panel-2); font-size: 13px; line-height: 1.5;
263
407
  }
264
- /* 名字加参数排一行,太长就省略——它是「做了哪件事」的标签,不是正文。 */
408
+ /* 名字加参数排一行,太长就省略——它是「做了哪件事」的标签,不是正文。
409
+ 收缩权重给到 10:位置要让给下面那条结果。挪不开的时候它先让到 0
410
+ (2026-09-30 之前就是这样:长结果把标签挤没了),这个观感这次不动它。 */
265
411
  .cmd .cmd-line {
412
+ flex: 0 10 auto;
266
413
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
267
414
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
268
415
  color: var(--fg);
269
416
  }
270
- .cmd .cmd-res { flex: 0 0 auto; color: var(--muted); }
417
+ /* 结果是**正文**,得能整段读全。原来是 `flex: 0 0 auto`——那等于「这一格不许缩,
418
+ 一行放完」,于是长结果(2026-09-30 用户报的 /doctor 那条)直接顶出 .cmd 的框,
419
+ 顶出去的半截连底色都没有;.cmd 不裁剪,溢出再顶到 main,而 main 的 overflow-y:auto
420
+ 会把横向一起算成滚动区,整页于是能左右拉、右边那截得滑一下才看得见。
421
+ 改成可以缩(min-width:0 拿掉「不许窄于内容」的下限)+ 允许从任意处断开,
422
+ 长结果就在框里正常折行。不做横向滚动、也不做省略号截断:用户要的是读全。 */
423
+ .cmd .cmd-res {
424
+ flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere;
425
+ color: var(--muted);
426
+ }
271
427
  .cmd.success { border-left-color: var(--ok); }
272
428
  .cmd.success .cmd-res { color: var(--ok); }
273
429
  /* 出错的那一种必须是红的:整串历史里只有它需要用户抬眼看一下。 */
@@ -450,6 +606,11 @@
450
606
  }
451
607
  .stop:disabled { opacity: .5; }
452
608
 
609
+ /* 「完整」模式不放装饰(2026-10-03 用户点名):立绘、气泡、光流进度条都不出现——
610
+ 那一屏的过程由轨迹自己在逐条冒,再挂一只跑动的鲸鱼娘就是重复。只留
611
+ 「已 N 秒 / 停止」那一行:去掉它就中止不了任务了。 */
612
+ .work.bare .work-top, .work.bare .work-progress { display: none; }
613
+
453
614
  /* 排队中的指令。跑着任务时接着发的那几条排在这儿,等这一轮跑完再跑。
454
615
  单帧模式下你发出去的那句话在别处一个字都看不见(那一模式只显示回复),
455
616
  所以这块是唯一的反馈,不能省。 */
@@ -517,6 +678,8 @@
517
678
  .work-stage { animation: none; }
518
679
  .pose { animation: none; transition: none; }
519
680
  .work-progress i { width: 100%; opacity: .45; animation: none; }
681
+ /* 扫光是状态指示、不是装饰,但用户要「减少动效」时整条关掉——底色照旧。 */
682
+ .traj-shimmer { animation: none; background-image: none; color: var(--run); }
520
683
  .queue .q-node i { transition: none; }
521
684
  .dot { transition: none; }
522
685
  }
@@ -551,11 +714,40 @@
551
714
  border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
552
715
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
553
716
  }
554
- .attach svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
717
+ /* 图标是 DSH 桌面端那个鲸鱼(见 HTML 里那段注释)。
718
+ 和原来的回形针有两点不一样,两条都得改:
719
+ · 它是**实心**路径(fill),不是描边的——留着 stroke 会把整个轮廓再描一圈;
720
+ · 它是横长条(23.16 : 17.04),不是正方形——照原来 19×19 会被拉扁。
721
+ 颜色走 currentColor(跟着按钮的 var(--muted)),所以深浅两套主题各自适配。 */
722
+ .attach svg { width: 22px; height: 16.2px; fill: currentColor; stroke: none; }
555
723
  .attach:disabled { opacity: .4; }
556
- /* 上传中:图标转起来。这条路上没有进度条,至少要让人看出「它在动」。 */
557
- .attach.busy svg { animation: spin 1s linear infinite; }
558
- @keyframes spin { to { transform: rotate(360deg); } }
724
+ /* 上传中:鲸鱼**原地游动**(2026-10-05 用户要求换掉转圈)。
725
+ 为什么不用转圈:转圈是「物件在转」的语汇(齿轮、加载器),用在活物身上
726
+ 读起来像标本被转着看。鲸鱼该有的动作是**游**。
727
+
728
+ 这一组值改过两轮:用户先审了 1.5px / ±4 度,看过之后说「幅度可以再大一些」,
729
+ 于是放到 **2.5px / ±7 度**(上下各大了 67%、俯仰大了 75%)。
730
+ 一个来回仍是 1.8 秒(用户只提了幅度,速度没动)。
731
+ 幅度占图标高度的 15%(16.2px 里的 2.5px);连同俯仰撑开的量,
732
+ 离 42px 按钮的边还有 8px 以上余量,跑不出去。
733
+
734
+ **俯仰的方向别写反**(第一版就写反了,靠放大渲染才发现):
735
+ 鲸鱼是**朝左**的(鼻头在左、尾巴在右上),而 CSS 里正角度是顺时针
736
+ ——顺时针会把**左边**抬起来(拿 rotate(90deg) 想:左边的点会转到上方)。
737
+ 所以「鼻尖抬起」是**正角度**。要的效果是「朝着前进的方向点头」:
738
+ 上浮时鼻尖朝上、下潜时鼻尖朝下。于是 0%/100%(最高点)用 +7deg,
739
+ 50%(最低点)用 -7deg。写反了看起来就像它在倒着游。 */
740
+ .attach.busy svg { animation: whaleSwim 1.8s ease-in-out infinite; }
741
+ @keyframes whaleSwim {
742
+ 0%, 100% { transform: translateY(-2.5px) rotate(7deg); }
743
+ 50% { transform: translateY(2.5px) rotate(-7deg); }
744
+ }
745
+ /* 用户要「减少动效」时它就不动——但**忙碌的表示没丢**:
746
+ 按钮此时是禁用态(opacity .4),本来就是「正在进行」的样子。
747
+ (这一条同时补掉一个遗留缺口:原来那个转圈压根没管减少动效。) */
748
+ @media (prefers-reduced-motion: reduce) {
749
+ .attach.busy svg { animation: none; }
750
+ }
559
751
 
560
752
  /* 附件小条:排在输入框上面,让你发之前看得见自己带了什么。 */
561
753
  .attach-bar {
@@ -591,24 +783,35 @@
591
783
  backdrop-filter: blur(14px) saturate(1.1);
592
784
  }
593
785
  .cmd-menu[hidden] { display: none; }
594
- .cmd-menu .cmd-row {
786
+ /* 面板里的指令列表(#opsCmds)和这只菜单**长得一样、也共用这份规则**:
787
+ 同一份数据、同一种行,没有第二套样式。 */
788
+ .cmd-menu .cmd-row,
789
+ .ops-cmds .cmd-row {
595
790
  display: flex; align-items: baseline; gap: 10px; width: 100%;
596
791
  padding: 9px 8px; border: none; border-radius: var(--r); background: none;
597
792
  color: var(--fg); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
598
793
  }
599
- .cmd-menu .cmd-row:active { background: var(--panel-2); }
600
- .cmd-menu .cmd-name {
794
+ .cmd-menu .cmd-row:active,
795
+ .ops-cmds .cmd-row:active { background: var(--panel-2); }
796
+ .cmd-menu .cmd-name,
797
+ .ops-cmds .cmd-name {
601
798
  flex: 0 0 auto; color: var(--ice);
602
799
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
603
800
  }
604
801
  /* 说明收成一行:这一屏是「扫一眼挑一个」,不是读文档的地方。 */
605
- .cmd-menu .cmd-desc {
802
+ .cmd-menu .cmd-desc,
803
+ .ops-cmds .cmd-desc {
606
804
  flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 12.5px;
607
805
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
608
806
  }
609
807
  /* 取不到指令时说清原因,**不显示空列表**:空列表看着就像「这个会话没有指令」,
610
808
  而真实原因多半是「这个会话现在没在跑」——两件事,用户能做的事也不一样。 */
611
- .cmd-menu .cmd-note { padding: 10px 8px; color: var(--muted); font-size: 13px; }
809
+ .cmd-menu .cmd-note,
810
+ .ops-cmds .cmd-note { padding: 10px 8px; color: var(--muted); font-size: 13px; }
811
+ /* 面板那一行多两样:一层包住名字和说明(好让右边那面小旗挨着它排),以及那面小旗本身
812
+ (声明了收附件的指令,手机这一版带不了)。名字和说明的省略规则照旧用上面那一套。 */
813
+ .ops-cmds .cmd-top { display: flex; align-items: baseline; gap: 10px; flex: 1 1 auto; min-width: 0; }
814
+ .ops-cmds .cmd-flag { flex: none; color: var(--muted); font-size: 12px; white-space: nowrap; }
612
815
 
613
816
  /* ---------- 左侧导航栏 ---------- */
614
817
  #nav {
@@ -679,6 +882,22 @@
679
882
  .ws-new:active { background: var(--panel-2); }
680
883
  .ws-new[disabled] { color: var(--muted); cursor: default; }
681
884
 
885
+ /* 「新建会话」下展开的模式清单(2026-10-04)。行内展开、点谁建谁——照
886
+ 「新建文件夹」的先例不弹窗;左侧一条细线表明它是那一行展开的内容;
887
+ 坏档灰字不可点。 */
888
+ .ws-modes { margin: 2px 0 8px; padding-left: 10px; border-left: 1px solid var(--line); }
889
+ .ws-mode {
890
+ display: block; width: 100%; text-align: left; cursor: pointer;
891
+ font: inherit; font-size: 12.5px; line-height: 1.5;
892
+ padding: 7px 8px; color: var(--fg);
893
+ background: none; border: none;
894
+ }
895
+ .ws-mode:active { background: var(--panel-2); }
896
+ .ws-mode b { font-weight: 650; }
897
+ .ws-mode-desc { display: block; color: var(--muted); font-size: 11.5px; }
898
+ .ws-mode-def { margin-left: 8px; color: var(--dim); font-size: 11px; }
899
+ .ws-mode-broken { color: var(--muted); cursor: default; }
900
+
682
901
  /* 导航栏最上面那条「新建工作区」。跟工作区条目之间用一条线隔开:
683
902
  它是这一屏的入口,不是一个工作区。 */
684
903
  .nav-cta {
@@ -752,6 +971,94 @@
752
971
  露和收之间就不会把下面的消息顶来顶去(那会是一次小跳)。 */
753
972
  .more-note.hide { visibility: hidden; }
754
973
 
974
+ /* 「完整」模式:过程组穿插在对话原位,长相对齐 PC 的 turn-process(tasks/todo.md 11):
975
+ 行首一枚细线小图标 + 灰色状态词/标题(叫法照抄 DSH,一个字不自创),点开才见原文。
976
+ 只用现成令牌、不加动效不加新颜色:它是补充信息,不是这一屏的主角。 */
977
+ .traj-group { margin-bottom: 10px; }
978
+ .traj-head {
979
+ display: flex; align-items: baseline; gap: 6px; width: 100%; text-align: left; cursor: pointer;
980
+ font: inherit; font-size: 12.5px; line-height: 1.6;
981
+ padding: 6px 4px; color: var(--muted);
982
+ background: none; border: none; border-top: 1px dashed var(--line);
983
+ }
984
+ .traj-entries { margin: 2px 0 0 4px; padding-left: 10px; border-left: 1px solid var(--line); }
985
+ .traj-row {
986
+ display: flex; align-items: baseline; gap: 6px; width: 100%; text-align: left; cursor: pointer;
987
+ font: inherit; font-size: 13px; line-height: 1.6;
988
+ padding: 5px 4px; color: var(--fg);
989
+ background: none; border: none;
990
+ }
991
+ .traj-row:active { background: var(--panel-2); }
992
+ .traj-ico { flex: none; width: 14px; height: 14px; color: var(--muted); }
993
+ .traj-ico svg { display: block; width: 14px; height: 14px; }
994
+ .traj-name { flex: none; color: var(--muted); }
995
+ /* 折叠行右侧的内容一行截断(照 PC):折叠行是给人扫的,不是给人读的——
996
+ 具体东西在展开里。组头标题同款处理(活片段再长也不许换行刷屏)。 */
997
+ .traj-detail {
998
+ flex: 1 1 auto; min-width: 0;
999
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1000
+ color: var(--fg);
1001
+ }
1002
+ .traj-head .traj-title, .traj-head .traj-run {
1003
+ flex: 1 1 auto; min-width: 0;
1004
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1005
+ }
1006
+ .traj-say {
1007
+ margin: 4px 0 8px; padding: 0 2px;
1008
+ word-break: break-word;
1009
+ color: var(--fg); font-size: 13px; line-height: 1.7;
1010
+ }
1011
+ .traj-say p { margin: 0 0 6px; }
1012
+ .traj-say p:last-child { margin-bottom: 0; }
1013
+ /* 思考正文(照 PC 的 compact Markdown):普通段落、三级灰、左缩进 22px,不套框。 */
1014
+ .traj-think {
1015
+ margin: 2px 0 8px; padding: 4px 0 4px 22px;
1016
+ color: var(--muted); font-size: 13px; line-height: 20px;
1017
+ word-break: break-word;
1018
+ }
1019
+ .traj-think p { margin: 0 0 6px; }
1020
+ .traj-think p:last-child { margin-bottom: 0; }
1021
+ /* 工具展开卡(照 PC 的通用 IN/OUT 卡):标签小字灰、正文等宽、细线分隔、出错整段红。 */
1022
+ .traj-io {
1023
+ margin: 2px 0 8px;
1024
+ background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r);
1025
+ }
1026
+ .traj-io-row { display: flex; gap: 10px; padding: 8px 10px; }
1027
+ .traj-io-row + .traj-io-row { border-top: 1px solid var(--line); }
1028
+ .traj-io-label { flex: none; width: 34px; color: var(--muted); font-size: 11px; line-height: 1.7; }
1029
+ .traj-io-body { flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 12.5px; line-height: 1.6; }
1030
+ .traj-io-body pre {
1031
+ margin: 0; white-space: pre-wrap; word-break: break-word;
1032
+ font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px;
1033
+ }
1034
+ .traj-io-err pre { color: var(--err); }
1035
+ .traj-io-note { margin-top: 4px; color: var(--dim); font-size: 11px; }
1036
+ /* 四类状态的字样:只上色、零动效(动效守卫只认那五种关键帧),颜色全取现成令牌。 */
1037
+ .traj-err { color: var(--err); } /* 失败首行:折叠处一眼可见 */
1038
+ .traj-run { color: var(--run); } /* 进行中:有标识;2026-10-04 起再加一道扫光(用户点名) */
1039
+ /* 跑着的行有一道渐变光扫过去(照 DSH 的 TextShimmer,2026-10-04 用户点名复现,
1040
+ 要适配主题)。机制照 PC:底色还是语义色(--run),一道亮色(--shimmer)扫过;
1041
+ 每秒 48 格、1.5 秒一轮、走前 2/3 停后 1/3,与 PC 同参。颜色全走现成令牌,
1042
+ 深浅两套主题各看各的。行上带一个负 delay(trajShimStyle):页面整块重画会把
1043
+ 动画打回开头,接上全局时钟的相位才不会变成频闪。 */
1044
+ .traj-shimmer {
1045
+ background-image: linear-gradient(105deg,
1046
+ var(--run) 38%, var(--shimmer) 50%, var(--run) 62%);
1047
+ background-size: 260% 100%;
1048
+ -webkit-background-clip: text; background-clip: text;
1049
+ color: transparent;
1050
+ animation: shimSweep 1.5s steps(48, end) infinite;
1051
+ }
1052
+ @keyframes shimSweep {
1053
+ 0% { background-position: 150% 0; }
1054
+ 66.6667%, 100% { background-position: -50% 0; }
1055
+ }
1056
+ .traj-note {
1057
+ margin: 0 0 10px; padding: 6px 8px;
1058
+ background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r);
1059
+ color: var(--muted); font-size: 12.5px; line-height: 1.6;
1060
+ }
1061
+
755
1062
  /* ---------- 设置抽屉 ---------- */
756
1063
  #settings {
757
1064
  position: fixed; inset: 0; background: var(--scrim);
@@ -767,11 +1074,77 @@
767
1074
  backdrop-filter: blur(14px) saturate(1.1);
768
1075
  }
769
1076
  .sheet h2 { margin: 0 0 14px; font-size: 16px; }
1077
+
1078
+ /* ---------- 设置页:整屏(2026-10-05 用户要求) ----------
1079
+ 内容越来越多,贴底那张最高 86vh 的抽屉不够用了。
1080
+
1081
+ **只改设置这一处**,不动 .sheet 本身——设置 / 统一操作面板 / 子智能体 / 插件页
1082
+ 共用那一套(✓ 毛玻璃底色照旧,一个字没改),另外三处本来就短,照旧贴底。
1083
+
1084
+ 布局改成一列:页眉(.sheet-head)待在**滚动区外面**,内容(.sheet-body)自己滚。
1085
+ 为什么要这样而不是把页眉做成吸顶的:吸顶的页眉得自己铺一层底色去挡滚上来的内容,
1086
+ 而它铺在抽屉的毛玻璃之上会**再叠一层**、叠出更深的色,顶上就一道色差。
1087
+ 页眉不进滚动区,底下就还是抽屉本来的那层毛玻璃,什么都不用铺。
1088
+
1089
+ 圆角和上边框去掉:它们本来是「贴着底边只露一截」才需要的,整屏之后那两条边
1090
+ 就是屏幕边。 */
1091
+ #settings .sheet {
1092
+ display: flex; flex-direction: column;
1093
+ overflow: hidden;
1094
+ max-height: none; height: 100%;
1095
+ border-radius: 0; border-top: none;
1096
+ padding: 0;
1097
+ }
1098
+ /* 页眉。**整屏之后必须有一个一直看得见的关闭口**:原来「点抽屉外面」那条路没了
1099
+ (没有露在外面的遮罩可点),光靠底部那个「完成」得先滚到底才行。
1100
+
1101
+ 高度是省出来的(2026-10-05 用户说太高):原来上下留 16/10px,而真正把这条页眉
1102
+ 顶高的是右边那个按钮——.btn 默认上下各 8px 内边距、14px 字,光它就 39px,
1103
+ 加起来 65px,比页面自己那条顶栏还高。
1104
+ · 上内边距收到 10px,和页面顶栏(header)**同一条竖向节奏**;
1105
+ · 页眉里这一个按钮单独紧凑一档(见下),别处照旧。
1106
+ 两处合起来约 50px。 */
1107
+ .sheet-head {
1108
+ flex: 0 0 auto;
1109
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
1110
+ padding: calc(env(safe-area-inset-top) + 10px) 16px 10px;
1111
+ border-bottom: 1px solid var(--line);
1112
+ }
1113
+ .sheet-head h2 { margin: 0; }
1114
+ /* 设置页真正滚的那一块。左右 16px 和底部安全区都是从 .sheet 挪过来的
1115
+ (那边 padding 已归零,账不能丢)。 */
1116
+ #settings .sheet-body {
1117
+ flex: 1 1 auto; overflow-y: auto;
1118
+ padding: 14px 16px calc(env(safe-area-inset-bottom) + 18px);
1119
+ }
770
1120
  .row {
771
1121
  display: flex; align-items: center; justify-content: space-between;
772
1122
  gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line);
773
1123
  }
774
1124
  .row:last-of-type { border-bottom: none; }
1125
+ /* 权限档位那一块外面套了一层(#permBlock,服务拿不到时整块要藏起来)。
1126
+ 它下面**没有分割线**,而别的板块之间都有——2026-10-05 用户截图点名。
1127
+ 原因:别的板块的分割线都是「那一行自己的下边线」(.row 的 border-bottom),
1128
+ 而权限档位那层的最后一个元素是**那排按钮**(.seg,它自带边框),
1129
+ 那层自己没有下边线,所以两块之间是空的。
1130
+
1131
+ 修法就照其他线的做法,一模一样:**让这一块自己带一条下边线,
1132
+ 并且先留出 12px**——线因此落在「按钮 + 12px」处,
1133
+ 和别处的线一样居中在留白里(下一行也有 12px 上边距)。
1134
+ 为什么不给「繁忙」那行加 border-top:那会让线紧贴着按钮的边框,
1135
+ 看起来只是按钮框变粗,不像一条分割线。
1136
+ 藏起来时(hidden = display:none)这条线自然不画,
1137
+ 上面「字体大小」那条线正好接上,不会两条挤在一起。 */
1138
+ #permBlock { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
1139
+ /* 权限档位里面那行标签**不要自己的下边线**(2026-10-05 用户截图点名:
1140
+ 「权限档位格子的上缘有一条粗线」)。
1141
+ 原因:那行标签按 .row 的规矩带着一条下边线,而它下面紧挨着的就是**按钮框**
1142
+ (.seg 自带整圈边框)——两条 1px 的线贴在一起,看起来就是一条粗线。
1143
+ 实测:上缘 2 个 CSS 像素、下缘 1 个,粗细不一样。
1144
+ 为什么那行没被「最后一个不画下边线」那条规则管到:权限档位这一层里,
1145
+ 按钮框排在标签**后面**,所以标签那行不算「最后一个」。
1146
+ 它本来也不该有线——它是下面那排按钮的标签,中间画一条分割线没有道理。 */
1147
+ #permBlock .row { border-bottom: none; }
775
1148
  .row .label { font-size: 15px; }
776
1149
  .row .hint { font-size: 12px; color: var(--muted); margin-top: 2px; }
777
1150
  /* flex-shrink: 0 不能省。它是弹性项,左边的说明文字一长就会被压缩;
@@ -783,15 +1156,89 @@
783
1156
  font-size: 13px; cursor: pointer;
784
1157
  }
785
1158
  .seg button.active { background: var(--ice); color: var(--on-act); }
1159
+ /* 权限档位那排按钮要**撑满**它的框(2026-10-05 用户截图点名:「自动审查那一格
1160
+ 背景颜色没填满」)。
1161
+ 根因:别的 .seg 都套在 .row 里(那是弹性容器),所以框会缩到内容那么宽,
1162
+ 按钮正好填满;而权限档位这一排是 #permBlock 的直接子元素,**在块级上下文里**
1163
+ → 框被拉成整行宽,而四个按钮只占自己文字那么宽,右边就空出一条
1164
+ (实测约 10 个 CSS 像素),露出来的是框自己的底色,看着像那一格没填满。
1165
+ 修法:让它们按内容分配、再把多出来的空间摊平(1 1 auto)。
1166
+ 不用 flex: 1 1 0(四等分)——「工作区内修改」六个字比别的宽,
1167
+ 强行等分会把它挤到换行。 */
1168
+ #segPerm button { flex: 1 1 auto; }
786
1169
  select, .btn {
787
1170
  padding: 8px 12px; border-radius: var(--r); border: 1px solid var(--line);
788
1171
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 14px;
789
1172
  }
790
1173
  .btn { cursor: pointer; }
791
1174
  .btn.primary { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
1175
+ /* 页眉里那个动作按钮(关闭)比通用 .btn 紧凑一档。
1176
+
1177
+ 通用 .btn 是给面板正身用的:上下各 8px 内边距 + 14px 字,约 39px 高。
1178
+ 摆进页眉就比标题高出一大截——**整条页眉的高度其实是它顶出来的**,而且那个
1179
+ 方框比标题那行字高出一圈,看着又大又不像跟标题对齐。
1180
+ 2026-10-05 用户两次报到这里:先说设置页「页眉高度太大」,又说插件页
1181
+ 「关闭按钮太大了,没跟插件两个字对齐」。
1182
+
1183
+ **四个面板页眉共用这一档**(统一操作面板 / 子智能体 / 插件页 / 设置页),
1184
+ 免得一处收了一处没收、几个面板长得不一样。
1185
+ 通用 .btn 的内边距一个字没动——它别处到处在用,动它等于整页跟着变。 */
1186
+ .ops-head .btn, .sa-head .btn, .sheet-head .btn {
1187
+ padding: 5px 11px; font-size: 13px;
1188
+ }
792
1189
  .sheet .foot { margin-top: 18px; display: flex; gap: 10px; }
1190
+ /* 设置抽屉最底下那行 DSH 版本:做成一条**收尾的分隔线**——两侧各一条细线、
1191
+ 版本号居中,把这一屏收住,免得孤零零一行小字吊在最后(2026-10-05 用户要的
1192
+ 「加一点设计感,比如两侧加横线」)。
1193
+ 线用现成的 --line(细线色),不新造令牌、不加关键帧、不碰圆角——这一屏的
1194
+ 层次本来就靠这条 1px 细线建立,别为了一行版本号另起一套。
1195
+
1196
+ `[hidden]` 那一句**不能省**:下面写了 display: flex,而作者样式表里的
1197
+ display 会盖掉 HTML `hidden` 自带的 display: none——少了它,问不到版本时
1198
+ 这一行照样显示,变成一条空的分隔条。 */
1199
+ .sheet .version {
1200
+ display: flex; align-items: center; gap: 10px;
1201
+ padding: 18px 0 6px; font-size: 12px; color: var(--muted);
1202
+ letter-spacing: 0.04em;
1203
+ }
1204
+ .sheet .version[hidden] { display: none; }
1205
+ .sheet .version::before,
1206
+ .sheet .version::after {
1207
+ content: ""; flex: 1 1 0; height: 1px; background: var(--line);
1208
+ }
793
1209
  .sheet .foot .btn { flex: 1 1 0; text-align: center; }
794
1210
 
1211
+ /* ---------- 统一操作面板(输入框旁边那一个入口点开的东西) ----------
1212
+ 骨架和设置抽屉**是同一套**:同一个 `#xxx.open` 即时显隐、同一个贴底的 `.sheet`
1213
+ (它自带 max-height / overflow-y / safe-area 那三样,窄屏顶不出去),不新建样式体系。
1214
+ 这里独有的只有上面两段小节头和那个筛选框;指令行沿用 .cmd-menu 那一套。 */
1215
+ #ops {
1216
+ position: fixed; inset: 0; background: var(--scrim);
1217
+ display: none; align-items: flex-end; z-index: 50;
1218
+ }
1219
+ #ops.open { display: flex; }
1220
+ .ops-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
1221
+ .ops-head h2 { flex: 1 1 auto; margin: 0; }
1222
+ /* 两个动作平分一行。都是既有 .btn(细边框 + --r 圆角 + --panel-2 底),
1223
+ 没有主按钮——这一屏不放强调色(#settings 那一屏已经有一处「完成」)。 */
1224
+ .ops-acts { display: flex; gap: 10px; }
1225
+ .ops-acts .btn { flex: 1 1 0; text-align: center; }
1226
+ .ops-files { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
1227
+ .ops-files[hidden] { display: none; }
1228
+ /* 「调用指令」这个小标题**要带上前面的类**,不能只写 .ops-sub。
1229
+ 2026-10-05 用户截图点名:「调用指令和上方选文件拍照那一行要有点留白」。
1230
+ 根因:这里本来写着 margin: 20px 0 10px,但上面有条 `.sheet h2 { margin: 0 0 14px }`
1231
+ ——它是「一个类 + 一个元素」(0,1,1),比只有「一个类」的 .ops-sub (0,1,0) 优先级高,
1232
+ 于是 margin-top 被盖成 0,那 20px 从来没生效过,标题就贴着按钮。
1233
+ 写成 .sheet .ops-sub (0,2,0) 才压得住那条规则。 */
1234
+ .sheet .ops-sub { margin: 20px 0 10px; font-size: 16px; }
1235
+ .ops-filter {
1236
+ width: 100%; padding: 9px 12px; border-radius: var(--r); border: 1px solid var(--line);
1237
+ background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
1238
+ }
1239
+ /* 列表自己滚,别把整张抽屉撑出屏幕。 */
1240
+ .ops-cmds { margin-top: 8px; max-height: 40vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
1241
+
795
1242
  /* ---------- token 输入 ---------- */
796
1243
  #gate { position: fixed; inset: 0; background: var(--bg); z-index: 100;
797
1244
  display: none; flex-direction: column; justify-content: center;
@@ -876,11 +1323,14 @@
876
1323
  // 所以这一段留在 <head> 里、样式表后面,**别挪到页面底部那段脚本区去**。
877
1324
  (function () {
878
1325
  try {
879
- var pick = localStorage.getItem('dshMiniTheme') || 'dark';
1326
+ var pick = localStorage.getItem('dshMiniTheme') || 'light';
880
1327
  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 会抛;按默认深色走,不影响页面 */ }
1328
+ var use = pick === 'auto' ? (dark ? 'dark' : 'light') : pick;
1329
+ document.documentElement.setAttribute('data-theme', use);
1330
+ // 首屏时也更新状态栏颜色,否则浅色页配深色状态栏会闪一下
1331
+ var meta = document.querySelector('meta[name="theme-color"]');
1332
+ if (meta) meta.setAttribute('content', use === 'light' ? '#eaf3fc' : '#0a0f16');
1333
+ } catch (e) { /* 无痕模式下 localStorage 会抛;按默认浅色走,不影响页面 */ }
884
1334
  })();
885
1335
  </script>
886
1336
  </head>
@@ -920,6 +1370,7 @@
920
1370
  <span class="dot" id="dot"></span>
921
1371
  <span class="brand">极简遥控器</span>
922
1372
  <span class="status" id="status">连接中…</span>
1373
+ <button class="icon-btn" id="btnSubagents" aria-label="子智能体"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.2 12H11M11 6v12M11 6h4.8M11 18h4.8"/><circle cx="6" cy="12" r="2.2"/><circle cx="18" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/></svg></button>
923
1374
  <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>
924
1375
  </header>
925
1376
 
@@ -971,9 +1422,14 @@
971
1422
  <div class="cmd-menu" id="cmdMenu" hidden></div>
972
1423
 
973
1424
  <footer>
974
- <!-- 回形针。input 藏起来,按钮点它——iOS 上直接放一个 file input 样式很难统一。 -->
975
- <button class="attach" id="btnAttach" type="button" aria-label="上传文件" title="上传文件">
976
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
1425
+ <!-- 统一入口。位置和尺寸一个像素都没动(还是原来回形针那个 42×42),改的只是点了之后
1426
+ 干什么:原来直接开文件选择器,现在开下面那张统一操作面板(文件 + 指令都在里面)。
1427
+ input 藏起来,按钮点它——iOS 上直接放一个 file input 样式很难统一。 -->
1428
+ <button class="attach" id="btnAttach" type="button" aria-label="添加文件或调用指令" title="添加文件或调用指令">
1429
+ <!-- 图标是 DSH 桌面端那个鲸鱼(dsh-client-ui-primitives 的 FishLogo,
1430
+ viewBox 0 0 23.16 17.04)。路径**原样抄自桌面端**,没有自己重画。
1431
+ 填色走 currentColor,所以跟着按钮的 var(--muted) 走,深浅两套主题各自适配。 -->
1432
+ <svg viewBox="0 0 23.16 17.04" aria-hidden="true"><path d="M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018ZM11.1749 14.4736C9.15936 12.889 8.18184 12.3675 7.77832 12.39C7.40081 12.4125 7.46881 12.8445 7.55182 13.126C7.63882 13.404 7.75182 13.5955 7.91033 13.8396C8.01983 14.0011 8.09533 14.2411 7.80083 14.4216C7.15181 14.8231 6.02327 14.2866 5.97027 14.2601C4.65673 13.4865 3.5587 12.4655 2.78467 11.069C2.03715 9.72493 1.60314 8.28289 1.53164 6.74384C1.51264 6.37233 1.62214 6.24082 1.99215 6.17332C2.47916 6.08332 2.98118 6.06432 3.46769 6.13582C5.52476 6.43633 7.27581 7.35586 8.74385 8.8129C9.58188 9.64243 10.2159 10.634 10.8689 11.6025C11.5634 12.631 12.3105 13.611 13.262 14.4146C13.598 14.6961 13.866 14.9101 14.1225 15.0681C13.349 15.1546 12.058 15.1731 11.1749 14.4746L11.1749 14.4736ZM12.141 8.25988C12.141 8.09488 12.273 7.96338 12.439 7.96338C12.4765 7.96338 12.5105 7.97088 12.541 7.98188C12.5825 7.99688 12.6205 8.01938 12.6505 8.05338C12.7035 8.10588 12.7335 8.18088 12.7335 8.25988C12.7335 8.42489 12.6015 8.55639 12.4355 8.55639C12.2695 8.55639 12.141 8.42489 12.141 8.25988ZM15.1415 9.79893C14.949 9.87793 14.7565 9.94544 14.5715 9.95294C14.2845 9.96794 13.9715 9.85143 13.8015 9.70893C13.5375 9.48742 13.3485 9.36342 13.2695 8.97691C13.2355 8.8119 13.2545 8.55639 13.2845 8.40989C13.3525 8.09438 13.277 7.89187 13.0545 7.70787C12.8735 7.55786 12.643 7.51636 12.39 7.51636C12.2955 7.51636 12.209 7.47486 12.1445 7.44136C12.039 7.38886 11.9519 7.25735 12.035 7.09585C12.0615 7.04335 12.19 6.91584 12.22 6.89334C12.5635 6.69784 12.9595 6.76184 13.326 6.90834C13.6655 7.04735 13.9225 7.30236 14.292 7.66287C14.6695 8.09838 14.7375 8.21838 14.9525 8.54539C15.1225 8.8009 15.277 9.06341 15.3831 9.36392C15.4471 9.55142 15.3641 9.70493 15.1415 9.79893Z" fill="currentColor"/></svg>
977
1433
  </button>
978
1434
  <input type="file" id="filePick" hidden>
979
1435
  <textarea id="input" rows="1" placeholder="说点什么…" enterkeyhint="send"></textarea>
@@ -993,7 +1449,16 @@
993
1449
 
994
1450
  <div id="settings">
995
1451
  <div class="sheet">
996
- <h2>设置</h2>
1452
+ <!-- 页眉。整屏之后「点外面关掉」没了,所以这里必须有一个一直看得见的关闭口
1453
+ (滚到哪儿都在);底部那个「完成」照样留着。
1454
+
1455
+ 它在**滚动区外面**(里面那个 .sheet-body 才是滚动区):这样页眉底下就是抽屉
1456
+ 本来那层毛玻璃,不用为它另铺一层底色——铺了会叠出更深的色,顶部一道色差。 -->
1457
+ <div class="sheet-head">
1458
+ <h2>设置</h2>
1459
+ <button class="btn" id="btnSettingsClose">关闭</button>
1460
+ </div>
1461
+ <div class="sheet-body">
997
1462
 
998
1463
  <div class="row">
999
1464
  <div>
@@ -1003,6 +1468,7 @@
1003
1468
  <div class="seg" id="segMode">
1004
1469
  <button data-mode="minimal">单帧</button>
1005
1470
  <button data-mode="chat">聊天</button>
1471
+ <button data-mode="full">完整</button>
1006
1472
  </div>
1007
1473
  </div>
1008
1474
 
@@ -1019,6 +1485,18 @@
1019
1485
  </div>
1020
1486
  </div>
1021
1487
 
1488
+ <!-- 字体大小:三档,存 localStorage,改 CSS 变量 --font-base -->
1489
+ <div class="row">
1490
+ <div>
1491
+ <div class="label">字体大小</div>
1492
+ </div>
1493
+ <div class="seg" id="segFont">
1494
+ <button data-font="small">小</button>
1495
+ <button data-font="normal">标准</button>
1496
+ <button data-font="large">大</button>
1497
+ </div>
1498
+ </div>
1499
+
1022
1500
  <!-- 权限档位。整块由脚本填:这台电脑有哪些档位是**运行时读**的,一个都不写死。
1023
1501
  服务拿不到时整块不显示——它是附加能力,缺了遥控本身照常。 -->
1024
1502
  <div id="permBlock" hidden>
@@ -1031,6 +1509,23 @@
1031
1509
  <div class="seg" id="segPerm"></div>
1032
1510
  </div>
1033
1511
 
1512
+ <!-- 繁忙时的发送行为(2026-10-05 用户要求搬进手机设置)。
1513
+ PC 上「设置 → 通用 → 繁忙时的发送行为」就是它:智能体跑着的时候,
1514
+ 你发的那句话是排队等这一轮跑完,还是当场插进去让它调整方向。
1515
+ **两端共用宿主那一份设置**——手机上改,电脑上也是这个值。
1516
+ 文案照抄 PC 词条表(settings.enter.*),一个字不自创。
1517
+ 读不到时整条藏起来(见 loadBusyEnter),不摆一个点了没用的开关。 -->
1518
+ <div class="row" id="rowBusyEnter" hidden>
1519
+ <div>
1520
+ <div class="label">繁忙时的发送行为</div>
1521
+ <div class="hint">智能体运行时发送按钮的行为</div>
1522
+ </div>
1523
+ <div class="seg" id="segBusyEnter">
1524
+ <button data-busy="queue">排队发送</button>
1525
+ <button data-busy="steer">插话发送</button>
1526
+ </div>
1527
+ </div>
1528
+
1034
1529
  <!--
1035
1530
  这里原来有两行:「完成后提醒」和「语音输入」。
1036
1531
 
@@ -1095,10 +1590,87 @@
1095
1590
  <button class="btn" id="btnReconnect">重连</button>
1096
1591
  </div>
1097
1592
 
1593
+ <div class="row">
1594
+ <div>
1595
+ <div class="label">插件</div>
1596
+ <div class="hint">查看官方插件和已安装插件</div>
1597
+ </div>
1598
+ <button class="btn" id="btnPlugins">查看</button>
1599
+ </div>
1600
+
1098
1601
  <div class="foot">
1099
1602
  <button class="btn" id="btnForget">清除 token</button>
1100
1603
  <button class="btn primary" id="btnClose">完成</button>
1101
1604
  </div>
1605
+
1606
+ <div class="version" id="dshVersion" hidden></div>
1607
+ </div>
1608
+ </div>
1609
+ </div>
1610
+
1611
+ <!-- 统一操作面板:输入框旁边那一个入口点开的就是它,上面「添加文件」、下面「调用指令」。
1612
+ 原来这两件事各有各的门(回形针直接开文件选择器、打 `/` 才弹指令菜单),手机上没人
1613
+ 知道打一个斜杠还能调指令。
1614
+
1615
+ 外壳是设置抽屉那一套**一模一样**的写法(`#xxx.open` 即时显隐 + `.sheet` 贴底),
1616
+ 不新建样式体系;令牌、圆角、细边框也全是现成的。
1617
+
1618
+ 它盖住整屏、贴着底边(和设置抽屉同一层),所以不和底栏抢位置;打开时先把键盘收起来,
1619
+ 不然手机键盘会把这张抽屉顶到看不见的地方。 -->
1620
+ <div id="ops">
1621
+ <div class="sheet">
1622
+ <div class="ops-head">
1623
+ <h2>添加文件</h2>
1624
+ <button class="btn" id="opsClose">关闭</button>
1625
+ </div>
1626
+ <div class="ops-acts">
1627
+ <button class="btn" id="opsPick" type="button">选文件</button>
1628
+ <button class="btn" id="opsShot" type="button">拍照</button>
1629
+ </div>
1630
+ <!-- 已选文件的去处和附件小条**是同一份数据**(state.uploads,同一个 renderAttach)。
1631
+ 这里画一遍是为了让用户在面板里就看得到自己带了什么,不必先关掉面板。 -->
1632
+ <div class="ops-files" id="opsFiles" hidden></div>
1633
+
1634
+ <h2 class="ops-sub">调用指令</h2>
1635
+ <input class="ops-filter" id="opsFilter" type="text" placeholder="筛选指令…"
1636
+ autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
1637
+ <div class="ops-cmds" id="opsCmds"></div>
1638
+ </div>
1639
+ </div>
1640
+
1641
+ <!-- 子智能体(2026-10-03 用户要求:把电脑端「子智能体」搬到手机)。
1642
+
1643
+ 入口在顶栏,紧挨着设置齿轮的左边。面板外壳和设置抽屉**是同一套**(#subagents
1644
+ 的骨架和 .sheet 一个样),不新建样式体系;这一块独有的外观只有两条——列表里的
1645
+ 一行,和聊天记录里那张完工卡片(见 CSS 那段注释)。
1646
+
1647
+ **列表取的是手机当前绑定的那个会话**名下的子智能体,和 send / stop 同一套口径:
1648
+ 手机上遥控的是你正看着的那个会话,不该牵动你在电脑上另开的那个。
1649
+
1650
+ 一个面板两种视图:清单;点某一行换成它**自己的运行记录**(返回键换回来)。 -->
1651
+ <div id="subagents">
1652
+ <div class="sheet">
1653
+ <div class="sa-head">
1654
+ <button class="sa-back" id="subagentsBack" hidden>‹ 返回</button>
1655
+ <h2 id="subagentsTitle">子智能体</h2>
1656
+ <button class="btn" id="subagentsClose">关闭</button>
1657
+ </div>
1658
+ <div class="sa-list" id="subagentsList"></div>
1659
+ <div class="sa-doc" id="subagentsDoc" hidden></div>
1660
+ </div>
1661
+ </div>
1662
+
1663
+ <!-- 插件页面(2026-10-04 用户要求:移动端设置里加插件入口)。
1664
+ 外壳和子智能体面板同一套(.sheet),显示官方插件和已安装插件(内置+第三方)。 -->
1665
+ <div id="plugins">
1666
+ <div class="sheet">
1667
+ <div class="sa-head">
1668
+ <h2>插件</h2>
1669
+ <button class="btn" id="pluginsClose">关闭</button>
1670
+ </div>
1671
+ <div class="sa-list" id="pluginsList">
1672
+ <div class="sa-none sa-none-indent">正在读取插件列表…</div>
1673
+ </div>
1102
1674
  </div>
1103
1675
  </div>
1104
1676
 
@@ -1133,12 +1705,15 @@
1133
1705
  mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
1134
1706
  // 外观:'dark' | 'light' | 'auto'。默认深色——不给老用户换掉现在的样子;
1135
1707
  // 要浅色或跟着系统走,去设置页「外观」点一下。
1136
- theme: localStorage.getItem('dshMiniTheme') || 'dark',
1708
+ theme: localStorage.getItem('dshMiniTheme') || 'light',
1709
+ // 字体大小:'small' | 'normal' | 'large'。默认标准。
1710
+ fontSize: localStorage.getItem('dshMiniFont') || 'normal',
1137
1711
  sound: localStorage.getItem('dshMiniSound') !== 'off',
1138
1712
  // 锁屏也能提醒(系统通知)。默认关着——它要用户主动点一下才会申请权限,
1139
1713
  // 不能替他决定。明文连接下这一项永远是空转的,见下面「锁屏也能提醒」那一段。
1140
1714
  notify: localStorage.getItem('dshMiniNotify') === 'on',
1141
1715
  running: false,
1716
+ subagentsRunning: false,
1142
1717
  sessions: [],
1143
1718
  // 排队中的指令([{ id, text, placement }])。跑着任务时接着发的那几条排在这儿,
1144
1719
  // 由服务端每次取快照时从 agent 的收件箱现读,手机不自己记。
@@ -1164,6 +1739,24 @@
1164
1739
  // 「这份历史只到最近一段」(服务端读历史有上限时会给这一句)。
1165
1740
  historyTruncated: false,
1166
1741
  historyNote: '',
1742
+ /**
1743
+ * 执行轨迹(「完整」模式里的过程):一组一轮,形状与服务端
1744
+ * `GET /mini/api/trajectory` 的 `turns` 一字不差
1745
+ * (`[{ turn, state, reason, entries:[…] }]`)。
1746
+ *
1747
+ * **它和 latest / history 是两回事**:思考正文只活在 `entries[].output` 里,
1748
+ * 回答气泡那两条路(`state.latest` / `state.history`)一个字都不碰它——
1749
+ * 这是 2026-09-27 用户定的边界(思维链一个字都不上气泡)。
1750
+ *
1751
+ * 只在「完整」模式订阅、只在那里画;切回单帧/聊天就退订,不再收也不再画。
1752
+ */
1753
+ trajectory: [],
1754
+ trajectoryNote: '', // 「这份轨迹不是全部」时服务端给的那句话(展示下一棒接手)
1755
+ trajectoryTruncated: false,
1756
+ trajectoryLoading: false, // 服务端还在读会话记录(**不等于「没有过程」**)
1757
+ trajectoryEpoch: '', // 服务换了一代(重启)就不能再往旧的那份上面接
1758
+ trajectorySeq: 0, // 收到第几帧,用来认出「我漏了几帧」(只进不退)
1759
+ trajectoryLive: '', // 活片段:正在想的那一句(只有完整模式有值,落盘即清)
1167
1760
  // 斜杠指令的列表(在输入框里打 / 时弹出来的那个菜单)。
1168
1761
  // **按绑定的会话缓存**:指令表是每个会话一份的,而这个会话没在跑时服务端一条都
1169
1762
  // 读不到(回 ok:false + 一句话)。所以换了会话必须重取——否则用户点到的是上一个
@@ -1171,7 +1764,15 @@
1171
1764
  commands: null, // null = 还没取到
1172
1765
  commandsError: '', // 取不到时服务端给的那句话,原样显示给用户
1173
1766
  commandsFor: null, // 上面这份数据是哪个会话的
1174
- commandsLoading: false // 正在取(挡住重复请求,见 loadCommands)
1767
+ commandsLoading: false, // 正在取(挡住重复请求,见 loadCommands)
1768
+ /**
1769
+ * 「输入框里这一行曾经是一条指令」——点过面板/菜单里某一条之后,这里记着它的名字。
1770
+ *
1771
+ * 用来堵一个真缺口:点了一条要参数的指令,用户把 `/名字 ` 里的名字删掉、只留参数,
1772
+ * 那一行就不再以 `/` 开头,会被当成普通提问发给模型,白花一次额度。
1773
+ * 有这枚标记时,send() 会把这一行拦下来,让他补回名字或**清空输入框**(= 明确取消)。
1774
+ */
1775
+ pendingCommand: null
1175
1776
  };
1176
1777
  if (params.get('token')) {
1177
1778
  localStorage.setItem('dshMiniToken', params.get('token'));
@@ -1179,6 +1780,232 @@
1179
1780
  history.replaceState(null, '', location.pathname);
1180
1781
  }
1181
1782
 
1783
+ /**
1784
+ * ---------------- 执行轨迹:订阅与收帧(数据层) ----------------
1785
+ *
1786
+ * 轨迹**不走全量快照**:实测它的正文是回答正文的 90.7 倍(单轮最高约 946KB),
1787
+ * 而 `event: state` 是「每条广播都发整份」。所以它走单独的增量帧
1788
+ * (`event: trajectory`,每帧只带新加的几条和改了的几笔),且**只有「完整」模式的
1789
+ * 连接才订**——不看这块的人一个字节都不付。
1790
+ *
1791
+ * 三件事一起看才完整:切到「完整」时订上并 GET 一次当前状态;切回单帧/聊天就退订;
1792
+ * 断线重连把 clientId 和 `?full=1` 带在流地址上(服务端自动重新订上),重连后再
1793
+ * GET 一次对齐——帧里的 epoch 变了就整个重拉。
1794
+ *
1795
+ * 本片到「收进 state.trajectory」为止;画法(三层折叠)在 renderFull 后面那一片。
1796
+ * clientId / subscribedClientId 两个连接身份变量声明在下面 SSE 一节里——
1797
+ * 那一节被测试整段抠出来跑,身份必须跟流地址待在一起才找得到。
1798
+ */
1799
+
1800
+ /**
1801
+ * 这一帧该不该接、接到哪一组。三道门:
1802
+ * 不是「完整」模式不接(退订之后可能还有在路上的旧帧);
1803
+ * 不是当前这个会话不接(切会话了,旧会话的过程与新会话无关)。
1804
+ * 没有这一组就立一个(turn 号是帧给的,组里 entries 往后只增不减)。
1805
+ */
1806
+ function trajectoryKeyOf(frame) {
1807
+ if (state.mode !== 'full') return null;
1808
+ if (frame.sessionId && state.boundSessionId && frame.sessionId !== state.boundSessionId) return null;
1809
+ var turn = typeof frame.turn === 'number' ? frame.turn : null;
1810
+ for (var i = 0; i < state.trajectory.length; i++) {
1811
+ if (state.trajectory[i].turn === turn) return state.trajectory[i];
1812
+ }
1813
+ var group = { turn: turn, state: 'running', reason: null, entries: [] };
1814
+ state.trajectory.push(group);
1815
+ return group;
1816
+ }
1817
+
1818
+ /**
1819
+ * 把一条增量补丁贴到已有条目上。**只改补丁里真有的字段**——「停了」那种补丁不该
1820
+ * 顺手把输出清空;截断信息是合并而不是替换(一条工具步可能参数超长、结果也超长)。
1821
+ * 写法和 lib/trajectory.js 的 applyTrajectoryPatch 一致:两端对同一笔补丁的理解
1822
+ * 必须一样,否则手机上会出现「明明有结果却还写着正在跑」。
1823
+ */
1824
+ function patchTrajectoryEntry(entry, patch) {
1825
+ if (!entry || !patch) return;
1826
+ if (typeof patch.state === 'string') entry.state = patch.state;
1827
+ if (Object.prototype.hasOwnProperty.call(patch, 'output')) entry.output = patch.output;
1828
+ if (Object.prototype.hasOwnProperty.call(patch, 'error')) entry.error = patch.error;
1829
+ if (patch.truncated) {
1830
+ entry.truncated = Object.assign({}, entry.truncated || {}, patch.truncated);
1831
+ }
1832
+ }
1833
+
1834
+ /**
1835
+ * 一帧的落法,四步按顺序走:
1836
+ *
1837
+ * 1. **换代认新**:帧里 epoch 和手里记的不一样(服务重启过),旧的那份整份作废,
1838
+ * 用 GET 重拉——旧代的 seq 在新代里毫无意义,硬拼会出现「凭空多出半截过程」。
1839
+ * 2. **缺帧认补**:帧的 seq 比预期大出不止一格,中间丢过帧,GET 重拉;
1840
+ * seq 只进不退(重连后服务端可能从头再发一轮)。
1841
+ * 3. **add 幂等**:同一条目按 id 认——收过就改成补丁,没收过才追加。
1842
+ * 同一帧送两遍(重连重放)不会长出两份,这是「帧重复」的硬要求。
1843
+ * 4. **组态只进结论**:一组的 running 是过程中的默认值,收下任何结论态就不再
1844
+ * 被后面的 running 盖回去(结论到了之后又来一条旧的 running 帧,不算输)。
1845
+ */
1846
+ function applyTrajectoryFrame(frame) {
1847
+ if (!frame || typeof frame !== 'object') return false;
1848
+ var epoch = typeof frame.epoch === 'string' ? frame.epoch : '';
1849
+ if (state.trajectoryEpoch && epoch && epoch !== state.trajectoryEpoch) {
1850
+ state.trajectoryEpoch = epoch;
1851
+ state.trajectorySeq = 0;
1852
+ state.trajectory = [];
1853
+ if (state.mode === 'full') requestTrajectory();
1854
+ return false;
1855
+ }
1856
+ if (!state.trajectoryEpoch && epoch) state.trajectoryEpoch = epoch;
1857
+ var seq = typeof frame.seq === 'number' ? frame.seq : 0;
1858
+ if (seq > 0 && state.trajectorySeq > 0 && seq > state.trajectorySeq + 1) {
1859
+ state.trajectorySeq = seq;
1860
+ if (state.mode === 'full') requestTrajectory();
1861
+ return false;
1862
+ }
1863
+ if (seq > state.trajectorySeq) state.trajectorySeq = seq;
1864
+
1865
+ var group = trajectoryKeyOf(frame);
1866
+ if (!group) return false;
1867
+
1868
+ var add = Array.isArray(frame.add) ? frame.add : [];
1869
+ for (var i = 0; i < add.length; i++) {
1870
+ var raw = add[i];
1871
+ if (!raw || typeof raw.id !== 'string' || !raw.id) continue;
1872
+ var hit = null;
1873
+ for (var j = 0; j < group.entries.length; j++) {
1874
+ if (group.entries[j].id === raw.id) { hit = group.entries[j]; break; }
1875
+ }
1876
+ if (hit) {
1877
+ patchTrajectoryEntry(hit, raw);
1878
+ } else {
1879
+ group.entries.push({
1880
+ id: raw.id,
1881
+ turn: typeof raw.turn === 'number' ? raw.turn : group.turn,
1882
+ step: raw.step,
1883
+ // 三种条目照单全收(think / say / tool)——旁白(say)是 2026-10-04 加的,
1884
+ // 这里原来写死成两种,实时帧里的旁白会被画成「工具调用」空壳行,
1885
+ // 等下一次整份对齐才变回正文段落(真机上就是「展现不对」)。
1886
+ kind: raw.kind === 'think' || raw.kind === 'say' ? raw.kind : 'tool',
1887
+ name: raw.name,
1888
+ args: raw.args,
1889
+ summary: raw.summary,
1890
+ output: raw.output,
1891
+ state: typeof raw.state === 'string' ? raw.state : 'running',
1892
+ error: raw.error || null,
1893
+ truncated: raw.truncated || null,
1894
+ timestamp: raw.timestamp,
1895
+ });
1896
+ }
1897
+ }
1898
+ // 真条目进来了:活片段让位(服务端也会喊清,这里兜一道——影子不许盖住落定的字)。
1899
+ if (add.length) state.trajectoryLive = '';
1900
+ var upd = Array.isArray(frame.update) ? frame.update : [];
1901
+ for (var k = 0; k < upd.length; k++) {
1902
+ var patch = upd[k];
1903
+ if (!patch || typeof patch.id !== 'string') continue;
1904
+ for (var m = 0; m < group.entries.length; m++) {
1905
+ if (group.entries[m].id === patch.id) { patchTrajectoryEntry(group.entries[m], patch); break; }
1906
+ }
1907
+ }
1908
+ if (typeof frame.state === 'string' && frame.state !== 'running') {
1909
+ group.state = frame.state;
1910
+ group.reason = frame.reason || null;
1911
+ } else if (group.state === 'running') {
1912
+ if (frame.reason) group.reason = frame.reason;
1913
+ }
1914
+ return true;
1915
+ }
1916
+
1917
+ /** 一帧到了:落进手里那份,只在「完整」模式下重画。 */
1918
+ function receiveTrajectoryFrame(frame) {
1919
+ if (!applyTrajectoryFrame(frame)) return;
1920
+ render();
1921
+ }
1922
+
1923
+ /**
1924
+ * 用一份 GET 结果把手里那份**整个换掉**(不是往里合):GET 是服务端按当前记录
1925
+ * 重新汇总的全量视图,比任何增量拼接都新,换掉就不会「补丁叠在过期底稿上」。
1926
+ * `loading` 如实收下——它说的是「还在读会话记录」,不是「没有过程」。
1927
+ */
1928
+ function setTrajectoryFromGet(view) {
1929
+ if (!view || view.ok !== true) return;
1930
+ state.trajectoryEpoch = typeof view.epoch === 'string' ? view.epoch : state.trajectoryEpoch;
1931
+ state.trajectorySeq = typeof view.seq === 'number' ? view.seq : 0;
1932
+ state.trajectory = Array.isArray(view.turns) ? view.turns : [];
1933
+ state.trajectoryTruncated = view.truncated === true;
1934
+ state.trajectoryNote = typeof view.note === 'string' && view.note ? view.note : '';
1935
+ state.trajectoryLoading = view.loading === true;
1936
+ render();
1937
+ }
1938
+
1939
+ /**
1940
+ * 拉一次当前过程。只在「完整」模式发;不是完整模式宁可什么都不说——去要一份
1941
+ * 自己没订的数据,等于给服务端添一次白跑。读完只有两种岔路:是完整模式且还是
1942
+ * 同一个会话才收下(发请求后切了会话/切了模式,这份就是旧账,直接丢),
1943
+ * 读不到就把原因写出来,不说「没有过程」。
1944
+ */
1945
+ function requestTrajectory() {
1946
+ if (state.mode !== 'full' || !state.token) return;
1947
+ var sessionId = state.boundSessionId;
1948
+ if (!sessionId) {
1949
+ state.trajectory = [];
1950
+ state.trajectorySeq = 0;
1951
+ state.trajectoryLoading = false;
1952
+ return;
1953
+ }
1954
+ state.trajectoryLoading = true;
1955
+ api('/mini/api/trajectory?sessionId=' + encodeURIComponent(sessionId))
1956
+ .then(function (view) {
1957
+ if (state.mode !== 'full' || state.boundSessionId !== sessionId) return;
1958
+ setTrajectoryFromGet(view);
1959
+ })
1960
+ .catch(function () {
1961
+ if (state.mode !== 'full' || state.boundSessionId !== sessionId) return;
1962
+ state.trajectoryLoading = false;
1963
+ state.trajectoryNote = '读不到这个会话的过程。';
1964
+ render();
1965
+ });
1966
+ }
1967
+
1968
+ /**
1969
+ * 模式开关的订阅侧动作,跟着「显示模式」按钮走:
1970
+ *
1971
+ * - 切到「完整」:连流时没带 `?full=1`(连流那一刻不是完整模式)就得补一次
1972
+ * POST 订上,然后 GET 一次把手里的换成当前状态;已经订过就只 GET 对齐。
1973
+ * - 切走:POST 退订,同时把身份清掉——服务端那条连接已经不再推了,
1974
+ * 手里这个「已订」的记号必须同步作废,否则下次切回来会误以为还订着。
1975
+ * - POST 回 404:这条流已经不在了(服务重启或断流没重连),立刻重连一次;
1976
+ * 重连的地址自带 `?full=1`(当前就是完整模式),服务端会重新把这条连接订上。
1977
+ * 重连成功与否都不用在这里补救——`open` 里那一套会再走一遍。
1978
+ */
1979
+ function syncTrajectorySubscription() {
1980
+ if (!state.token) return;
1981
+ var want = state.mode === 'full';
1982
+ if (!want) {
1983
+ if (subscribedClientId) {
1984
+ var was = subscribedClientId;
1985
+ subscribedClientId = '';
1986
+ api('/mini/api/trajectory/subscribe', {
1987
+ method: 'POST',
1988
+ body: JSON.stringify({ clientId: was, on: false }),
1989
+ }).catch(function () { /* 断了就断了:服务端本来就会随连接清理 */ });
1990
+ }
1991
+ return;
1992
+ }
1993
+ if (subscribedClientId) {
1994
+ requestTrajectory();
1995
+ return;
1996
+ }
1997
+ api('/mini/api/trajectory/subscribe', {
1998
+ method: 'POST',
1999
+ body: JSON.stringify({ clientId: clientId, on: true }),
2000
+ }).then(function () {
2001
+ if (state.mode !== 'full') return; // 刚要订又切走了,回包当没看见
2002
+ subscribedClientId = clientId;
2003
+ requestTrajectory();
2004
+ }).catch(function (err) {
2005
+ if (err && err.status === 404) connect();
2006
+ });
2007
+ }
2008
+
1182
2009
  var $ = function (id) { return document.getElementById(id); };
1183
2010
  var mainEl = $('main'), replyEl = $('reply'), inputEl = $('input');
1184
2011
  var dotEl = $('dot'), statusEl = $('status');
@@ -2043,6 +2870,11 @@
2043
2870
  // 「正在执行」那块照旧:它是常驻的 #work,跟这里没关系。
2044
2871
  var tail = commandTail();
2045
2872
  if (tail) parts.push(commandBlock(tail));
2873
+ // 子智能体刚完工的那条通知同理(判据见 noticeTail)。它出现在这里的时机很窄——
2874
+ // 通知一到,父级通常会被唤醒作答,那条回答很快就会把它顶掉——但那一段空窗里
2875
+ // 屏幕上什么都不变的话,用户会以为子智能体那边没消息。
2876
+ var note = noticeTail();
2877
+ if (note) parts.push(noticeBlock(note));
2046
2878
  if (live) parts.push(liveBlock(live));
2047
2879
  if (parts.length) {
2048
2880
  var stick = atBottom();
@@ -2150,6 +2982,61 @@
2150
2982
  return last;
2151
2983
  }
2152
2984
 
2985
+ /**
2986
+ * 一个会话 id 的「能区分人的那一段」。
2987
+ *
2988
+ * **不能直接取 id 的开头几位**:本机的会话 id 长这样 `session-80fd8f3c-…`,
2989
+ * 开头八位永远是 `session-`,一排子智能体就会全叫同一个名字。导航栏那边踩过
2990
+ * 同一个坑,page.test.mjs 里有一条守卫专门钉着它。所以先把 `session-` 这段
2991
+ * 人人相同的前缀去掉,再取一段开头——留下的才是真能区分它们的字符。
2992
+ */
2993
+ function shortSessionId(id) {
2994
+ var s = String(id || '');
2995
+ if (s.slice(0, 8) === 'session-') s = s.slice(8);
2996
+ if (!s) s = String(id || '');
2997
+ return s.slice(0, 8);
2998
+ }
2999
+
3000
+ /**
3001
+ * 子智能体完工的那张卡片(聊天记录里的一种条目)。
3002
+ *
3003
+ * 它是**第三样东西**:不是用户说的话,也不是模型的回答,而是「这个会话派出去的
3004
+ * 一件事有结果了」。所以既不套用户气泡、也不画成回答正文——套上气泡就变成
3005
+ * 「我说过这句话」,混进正文又会被读成模型的话,两种都是假的。
3006
+ *
3007
+ * 展开/收起交给原生 `<details>`:不写一行脚本、不加一个关键帧,键盘和读屏也都认。
3008
+ *
3009
+ * 正文**原样贴出来**(`white-space: pre-wrap`,见 .sa-card .sa-body),不再解析:
3010
+ * 这段话是 DSH 生成的——它先说清「谁、为什么结束」,再把子智能体最后的原话接上。
3011
+ * 那是**转述**,不该由我们这边的渲染器再加工一遍。
3012
+ */
3013
+ function noticeBlock(m) {
3014
+ var who = m.senderSessionId ? shortSessionId(m.senderSessionId) : '';
3015
+ var when = m.timestamp ? timeLabel(m.timestamp) : '';
3016
+ return '<details class="sa-card"><summary><b>子智能体完工</b>'
3017
+ + (who ? '<span>' + escapeHtml(who) + '</span>' : '')
3018
+ + (when ? '<span>' + escapeHtml(when) + '</span>' : '')
3019
+ + '</summary><div class="sa-body">' + escapeHtml(m.text || '') + '</div></details>';
3020
+ }
3021
+
3022
+ /**
3023
+ * 单帧模式要跟着显示的那条通知:**历史里最后一条**,而且得真的是一条通知。
3024
+ *
3025
+ * 判据和 commandTail 完全一样,连那道时间判断都照抄,理由也一样:
3026
+ * - 用「最后一条」而不是「最近一条」:中间要是夹了别的消息(父级被唤醒后又答了
3027
+ * 一句),那条通知已经不是最新的事了,再贴在最下面就成了过期通知;
3028
+ * - 单帧模式的回答走 SSE 的 reply 那条路,**不往 history 里塞**,所以 history
3029
+ * 的尾巴会一直停在那条通知上——不挡一下,新回答到了之后底下还挂着它。
3030
+ */
3031
+ function noticeTail() {
3032
+ var h = state.history || [];
3033
+ var last = h.length ? h[h.length - 1] : null;
3034
+ if (!last || last.role !== 'notice') return null;
3035
+ var lt = (state.latest && state.latest.timestamp) || 0;
3036
+ if (lt && last.timestamp && last.timestamp < lt) return null;
3037
+ return last;
3038
+ }
3039
+
2153
3040
  /**
2154
3041
  * 该不该自动滚到底。
2155
3042
  *
@@ -2240,8 +3127,50 @@
2240
3127
  el.classList.toggle('hide', !chatNoteVisible());
2241
3128
  }
2242
3129
 
2243
- function renderChat() {
2244
- mainEl.classList.remove('center');
3130
+ /**
3131
+ * 一条消息的 HTML。从 renderChat 的逐条循环里提出来:完整模式要把每轮的过程组
3132
+ * **穿插进这串消息的原位**(renderFull → trajInterleave),聊天模式直接按顺序拼——
3133
+ * 两边画同一条消息必须是同一段代码,两处各写一遍迟早走样。
3134
+ */
3135
+ function chatMsgHtml(m) {
3136
+ // 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
3137
+ // 不套气泡、不给复制按钮(见 commandBlock 的说明)。
3138
+ if (m.role === 'command') return commandBlock(m);
3139
+ // 子智能体完工的通知:同一类「会话做的事」,画成一张可展开的卡片
3140
+ // (见 noticeBlock 的说明)。它没有复制按钮,也没有气泡。
3141
+ if (m.role === 'notice') return noticeBlock(m);
3142
+ // 被按停的那一轮:标一句,别让人以为模型答到一半断了。
3143
+ var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
3144
+ // 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
3145
+ // 原来只给 AI 气泡,用户反馈自己的话复制不了。
3146
+ var copyBtn = '<button class="copy" type="button" aria-label="复制" title="复制"'
3147
+ + ' data-copy="' + escapeHtml(m.text) + '">' + ICON_COPY + '</button>';
3148
+ if (m.role === 'user') {
3149
+ // 自己的指令:仍然是气泡,靠右。自己敲的是纯文本,不走 markdown——
3150
+ // 不然打几个星号就变样了。
3151
+ return '<div class="bubble user">' + linkify(m.text)
3152
+ + '<span class="meta">' + stopped + timeLabel(m.timestamp) + copyBtn + '</span></div>';
3153
+ }
3154
+ // AI 的回答:**不套气泡**(用户要求「参考单帧模式那样」)。
3155
+ // 于是一左一右、一个有框一个没框,正好分得清谁说的。
3156
+ return '<div class="said">'
3157
+ + (stopped ? '<div class="reply-bar">' + stopped + '</div>' : '')
3158
+ + '<div class="md">' + mdToHtml(m.text) + '</div>'
3159
+ + '<div class="reply-foot">' + copyBtn
3160
+ + '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
3161
+ }
3162
+
3163
+ /**
3164
+ * 聊天那一串的「料」:滤掉排队那条、按窗口裁好、逐条变行。
3165
+ *
3166
+ * 返回的是**结构**(行数组 + 对应消息 + 滤后全量),不是拼好的字符串——
3167
+ * renderChat 直接按顺序拼;renderFull 拼之前要把每轮的过程组插进 rows 的
3168
+ * 原位(见 trajInterleave),只有结构在手才插得动。
3169
+ *
3170
+ * searchTurns 是「搜索命中的轮号」(只在完整模式搜索时给):命中轮的消息
3171
+ * 哪怕在窗口外也要露出来(10.3-3,「命中组在原位强开」的原位得先在场)。
3172
+ */
3173
+ function buildChat(searchTurns) {
2245
3174
  // 换了会话就把窗口收回首屏:上一个会话铺到多少条都不该跟过来——那正是这次要修的慢。
2246
3175
  // 判据用会话 id 而不是 history 的引用:服务端每次推的都是整份新数组,比引用永远「变了」。
2247
3176
  if (chatWindowFor !== state.boundSessionId) {
@@ -2272,58 +3201,47 @@
2272
3201
  // 「正在读」和「确实没有」是两件事,分开说。点开一个插件没看过的会话时,
2273
3202
  // 服务端要去 DSH 的会话记录里读它那一段(最大的那条 20 MB、约半秒),
2274
3203
  // 这期间说「还没有对话记录」会让人以为这个会话本来就是空的。
2275
- replyEl.innerHTML = state.historyLoading
2276
- ? '<div class="placeholder">正在读取这个会话的记录…</div>'
2277
- : '<div class="placeholder">还没有对话记录。</div>';
2278
- return;
3204
+ return {
3205
+ empty: state.historyLoading
3206
+ ? '<div class="placeholder">正在读取这个会话的记录…</div>'
3207
+ : '<div class="placeholder">还没有对话记录。</div>',
3208
+ moreNote: '', rows: [], list: [], all: [],
3209
+ };
2279
3210
  }
2280
3211
  // **只铺最近那几十条**,剩下的留在手里等用户往上滑(来由见 CHAT_PAGE 那段)。
2281
3212
  // 切在**滤掉排队那条之后**:不然被滤掉的那条会白占一个名额。
2282
3213
  var older = Math.max(0, chatLoaded - chatWindow);
2283
- if (older) list = list.slice(older);
2284
- var html = list.map(function (m) {
2285
- // 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
2286
- // 不套气泡、不给复制按钮(见 commandBlock 的说明)。
2287
- if (m.role === 'command') return commandBlock(m);
2288
- // 被按停的那一轮:标一句,别让人以为模型答到一半断了。
2289
- var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
2290
- // 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
2291
- // 原来只给 AI 气泡,用户反馈自己的话复制不了。
2292
- var copyBtn = '<button class="copy" type="button" aria-label="复制" title="复制"'
2293
- + ' data-copy="' + escapeHtml(m.text) + '">' + ICON_COPY + '</button>';
2294
- if (m.role === 'user') {
2295
- // 自己的指令:仍然是气泡,靠右。自己敲的是纯文本,不走 markdown——
2296
- // 不然打几个星号就变样了。
2297
- return '<div class="bubble user">' + linkify(m.text)
2298
- + '<span class="meta">' + stopped + timeLabel(m.timestamp) + copyBtn + '</span></div>';
3214
+ // 搜索命中轮把窗口上沿抬到自己(10.3-3):不然命中的过程组按「组跟消息走」
3215
+ // 的规矩被裁在窗口外,搜得到、看不见。
3216
+ if (searchTurns && searchTurns.length) {
3217
+ for (var t = 0; t < list.length; t++) {
3218
+ if (list[t] && searchTurns.indexOf(String(list[t].turn)) >= 0 && t < older) {
3219
+ older = t;
3220
+ break;
3221
+ }
2299
3222
  }
2300
- // AI 的回答:**不套气泡**(用户要求「参考单帧模式那样」)。
2301
- // 于是一左一右、一个有框一个没框,正好分得清谁说的。
2302
- return '<div class="said">'
2303
- + (stopped ? '<div class="reply-bar">' + stopped + '</div>' : '')
2304
- + '<div class="md">' + mdToHtml(m.text) + '</div>'
2305
- + '<div class="reply-foot">' + copyBtn
2306
- + '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
2307
- }).join('');
3223
+ }
3224
+ var all = list;
3225
+ if (older) list = list.slice(older);
2308
3226
  // 「上面还有,往上滑」要贴在**看得到的顶上**,而且**离顶远了就收起来**——
2309
3227
  // 两处讲究见 .more-note 的样式注释。露 / 收在这里先按当前位置定一次,
2310
3228
  // 之后由滚动里的 paintChatNote 接着跟。
2311
3229
  var moreNote = older
2312
3230
  ? '<div class="more-note' + (chatNoteVisible() ? '' : ' hide') + '">往上滑看更早的</div>'
2313
3231
  : '';
2314
- // 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
2315
- // 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
2316
- if (state.historyTruncated) {
2317
- html = '<div class="history-note">'
2318
- + escapeHtml(state.historyNote || '这个会话很大,只显示了最近一段。')
2319
- + '</div>' + html;
3232
+ return { empty: null, moreNote: moreNote, rows: list.map(chatMsgHtml), list: list, all: all };
3233
+ }
3234
+
3235
+ function renderChat() {
3236
+ mainEl.classList.remove('center');
3237
+ var built = buildChat();
3238
+ if (built.empty) {
3239
+ replyEl.innerHTML = built.empty;
3240
+ return;
2320
3241
  }
2321
- // 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
2322
- // 两种模式共用同一块,改的时候两边都走一遍。
2323
- if (state.live) html += liveBlock(state.live);
2324
3242
  // 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
2325
3243
  var stick = atBottom();
2326
- replyEl.innerHTML = moreNote + html;
3244
+ replyEl.innerHTML = chatFrame(built, built.rows.join(''), state.live ? liveBlock(state.live) : '');
2327
3245
  scrollChat(stick);
2328
3246
  }
2329
3247
 
@@ -2336,8 +3254,8 @@
2336
3254
  */
2337
3255
  function maybeGrowChat() {
2338
3256
  // 单帧模式那边也在往 main 上滚(回答落定要跳到页面顶端),别把那一下当成「他在往上翻」;
2339
- // 本地都放完了也没什么可放的。
2340
- if (state.mode !== 'chat' || chatLoaded <= 0) return;
3257
+ // 本地都放完了也没什么可放的。完整模式画的也是这串聊天记录,翻页照旧要能放。
3258
+ if (state.mode === 'minimal' || chatLoaded <= 0) return;
2341
3259
  if (chatWindow >= chatLoaded) return;
2342
3260
  if (mainEl.scrollTop > chatEdge()) {
2343
3261
  // 滑下来了,这一轮重新装填:**一次靠近只放一屏**。
@@ -2370,7 +3288,10 @@
2370
3288
  function growChat() {
2371
3289
  var fromBottom = mainEl.scrollHeight - mainEl.scrollTop;
2372
3290
  chatWindow += CHAT_PAGE;
2373
- renderChat();
3291
+ // 完整模式要走 render() 总分发:直画 renderChat 会把后面那块轨迹区抹掉。
3292
+ // (位置补偿和聊天同一套:轨迹区每次重画高度不变,底部那一截没动。)
3293
+ if (state.mode === 'full') render();
3294
+ else renderChat();
2374
3295
  mainEl.scrollTop = mainEl.scrollHeight - fromBottom;
2375
3296
  }
2376
3297
 
@@ -2440,52 +3361,618 @@
2440
3361
  if (stick && !liveNow) mainEl.scrollTop = mainEl.scrollHeight;
2441
3362
  }
2442
3363
 
2443
- function render() {
2444
- $('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
2445
- if (state.mode === 'chat') renderChat(); else renderMinimal();
3364
+ /**
3365
+ * 三种模式各自的提示语。「完整」= 聊天那一串 **+ 每一轮的过程组**,所以三句话
3366
+ * 必须跟着模式一起改——收在一处是防两处走样(渲染处和点击处各写一遍的旧写法,
3367
+ * 加第三种模式时漏一处,用户看到的就是「点了完整、说明还写着聊天」)。
3368
+ */
3369
+ function modeHint() {
3370
+ if (state.mode === 'chat') return '聊天:常规问答对话';
3371
+ if (state.mode === 'full') return '完整:附加每一步过程';
3372
+ return '单帧:只留最新一条回复';
2446
3373
  }
2447
3374
 
2448
- function applySnapshot(snap) {
2449
- if (!snap) return;
2450
- if ('latest' in snap) state.latest = snap.latest;
2451
- // 正在流出来的那一段。服务端只在自己决定要显示之后才写它,所以这里是空字符串
2452
- // 不代表模型没在写,只代表「还不到显示的时候」。
2453
- if ('live' in snap) state.live = snap.live || '';
2454
- // 模型此刻的自言自语(鲸鱼娘的台词)。null = 这会儿没话说,气泡回到该姿势自己的词条。
2455
- if ('thought' in snap) state.thought = snap.thought || null;
2456
- if (snap.history) state.history = snap.history;
2457
- // 「服务端正去读这个会话的完整记录」。点开一个没看过的会话时,那一串要读一会儿
2458
- // 才补上来(最大的那条日志 20 MB、约半秒),这期间不能装作「这个会话是空的」。
2459
- state.historyLoading = snap.historyLoading === true;
2460
- // 「这份历史只到最近一段」。服务端读历史有上限(会话太大只读日志尾部),
2461
- // 所以内容可能是截断的——**这件事必须跟着内容一起传上来**,不能只在服务端知道。
2462
- state.historyTruncated = snap.historyTruncated === true;
2463
- state.historyNote = snap.historyNote || '';
2464
- if (snap.sessions) state.sessions = snap.sessions;
2465
- if (Array.isArray(snap.queued)) state.queued = snap.queued;
2466
- if ('boundSessionId' in snap) {
2467
- var wasBound = state.boundSessionId;
2468
- state.boundSessionId = snap.boundSessionId;
2469
- // 换了会话,指令菜单那份缓存就作废了(指令表是每个会话一份的,这个会话没在跑
2470
- // 时一条都读不到,见「斜杠指令」那一段)。重画一次,它自己会发现数据不属于
2471
- // 这个会话、去重取——否则用户点到的是上一个会话的指令,点下去必然被拒。
2472
- if (state.boundSessionId !== wasBound) paintCmdMenu();
3375
+ /**
3376
+ * 「完整」的底座:聊天那一串**全部照画**(分批、往上滑补页、滚动对齐都照旧),
3377
+ * 每一轮的过程组**穿插进它那轮对话的原位**——指令之后、回答之前(10.3-1,
3378
+ * 对齐 DSH 聊天视图的 turn-process 位置,2026-10-03 用户裁决「一定要让执行
3379
+ * 轨迹的逻辑符合DSH本身」)。三层折叠那套跟着组走,落点规则见 trajInterleave。
3380
+ * 但**「正在读取」和「没有过程」必须分清**:loading=true 是服务端还在读会话记录,
3381
+ * 不是这个会话没有过程。这期间什么都不说,人就会以为没有过程——所以那句注记
3382
+ * 照旧垫在最前面。没轨迹且不在 loading 时一个过程组都不画(这时完整模式和聊天
3383
+ * 长得一样,是预期)。
3384
+ */
3385
+ function renderFull() {
3386
+ // 换了会话,用户手动拨过的折叠开关就作废——和聊天窗 chatWindowFor 同一个道理:
3387
+ // 两场会话的轮号会撞在一起,留在手里的开关会去拨新会话的组。
3388
+ if (trajFor !== state.boundSessionId) {
3389
+ trajFor = state.boundSessionId;
3390
+ trajGroupOpen = {};
3391
+ trajEntryOpen = {};
2473
3392
  }
2474
- // running 也在快照里,而且算的是**当前绑定的那个会话**。不接的话,
2475
- // 在导航栏切到别的会话时,那个会话会继承上一个会话的「正在执行」。
2476
- if ('running' in snap) setRunning(snap.running);
2477
- paintNavCurrent();
2478
- render();
3393
+ mainEl.classList.remove('center');
3394
+ var built = buildChat();
3395
+ var html;
3396
+ if (built.empty) {
3397
+ // 一条消息都没有(正在读/确实没有):手里有过程组就兜底页尾(10.3-1 d)。
3398
+ // 这一路不滚动,和 renderChat 的空态同口径。
3399
+ html = built.empty + trajInterleave([], [], built.all).join('');
3400
+ } else {
3401
+ // 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
3402
+ var stick = atBottom();
3403
+ html = chatFrame(built, trajInterleave(built.rows, built.list, built.all).join(''),
3404
+ state.live ? liveBlock(state.live) : '');
3405
+ replyEl.innerHTML = state.trajectoryLoading
3406
+ ? '<div class="history-note">正在读取这个会话的过程…</div>' + html
3407
+ : html;
3408
+ scrollChat(stick);
3409
+ bindTraj();
3410
+ return;
3411
+ }
3412
+ if (state.trajectoryLoading) {
3413
+ html = '<div class="history-note">正在读取这个会话的过程…</div>' + html;
3414
+ }
3415
+ replyEl.innerHTML = html;
3416
+ bindTraj();
2479
3417
  }
2480
3418
 
2481
- // ---------------- 左侧导航栏 ----------------
2482
- // 数据分两层取:先列工作区(便宜),展开某个再去要它的会话(要读日志折标题,贵)。
2483
- // 本机实测 35 个工作区、最多的一个有 452 个会话,一次全拉会卡住手机。
2484
- var navList = null; // 工作区列表,开一次取一次
2485
- var navSessions = {}; // workspaceId -> { sessions, total, truncated }
2486
- var navExpanded = {}; // workspaceId -> 是否展开
3419
+ /** 轨迹区的点击走一次容器委托就够(重绘都是全新 HTML,逐个绑必漏)。 */
3420
+ function bindTraj() {
3421
+ if (trajBound) return;
3422
+ trajBound = true;
3423
+ replyEl.addEventListener('click', onTrajectoryClick);
3424
+ }
2487
3425
 
2488
- function openNav() {
3426
+ /**
3427
+ * 聊天那串的外框:截断说明排内容前面、流式那段接在最后、moreNote 贴顶。
3428
+ * 聊天与完整两个模式共用这一段——两处各写一遍的旧写法走样过(见 modeHint 的说明)。
3429
+ * liveHtml 由调用方给(完整模式的切片里没有 liveBlock,传空串就是「没有流式」)。
3430
+ *
3431
+ * 定义在 renderFull 之后是**测试切片的约束**:trajHarness 切的是
3432
+ * renderFull → applySnapshot,外框函数得在切片里才调得到;页面里函数提升,
3433
+ * 位置不影响 renderChat 调它。
3434
+ */
3435
+ function chatFrame(built, body, liveHtml) {
3436
+ var html = body;
3437
+ // 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
3438
+ // 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
3439
+ if (state.historyTruncated) {
3440
+ html = '<div class="history-note">'
3441
+ + escapeHtml(state.historyNote || '这个会话很大,只显示了最近一段。')
3442
+ + '</div>' + html;
3443
+ }
3444
+ // 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
3445
+ // 流式那段(live)接在所有行(含穿插的过程组)之后:过程在回答之前,
3446
+ // 正在流的半句就是「回答」那个位置(10.3-1)。
3447
+ if (liveHtml) html += liveHtml;
3448
+ return built.moreNote + html;
3449
+ }
3450
+
3451
+ // ---------------- 轨迹的折叠与长相(完整模式,对齐 PC) ----------------
3452
+ // 数据是上一棒收进 state.trajectory 的轮次组:组 {turn, state, entries[]},
3453
+ // 条目 {kind:'think'|'tool', name, args, summary, output, state, …},字段语义
3454
+ // 见 lib/trajectory.js 头上的说明(思考正文和工具结果都放 output)。
3455
+ // 长相对齐 DSH 桌面端的 turn-process(tasks/todo.md 11):组头一行 = 图标 +
3456
+ // 状态词/标题(跑着是「正在…」活动词,收官是「已读取文件并调用工具」这类组合标题),
3457
+ // 条目一行 = 图标 + 「工具显示名 · 参数摘要」(思考行是「思考 · 首行」),
3458
+ // 点开才见正文(工具给「参数+结果」、思考给想的内容)。
3459
+ // 折叠口径不变:组级把「已结束的多条组」收成一行(running 还在长、error 要给人看、
3460
+ // 单条收了没信息量,这三种默认摊开);条目级默认收。
3461
+ // 用户拨过的开关存在下面两个对象里(组按轮号、条目按 id),只在本会话内有效,
3462
+ // 换会话时在 renderFull 里清掉。
3463
+ var trajFor = null; // 上次渲染时的会话 id(变了就清开关)
3464
+ var trajGroupOpen = {}; // 组级手动覆写:{ 轮号字符串: true|false }
3465
+ var trajEntryOpen = {}; // 条目级手动覆写:{ 条目 id: true|false }
3466
+ var trajBound = false; // replyEl 上的委托绑过没有
3467
+
3468
+ /**
3469
+ * 组默认摊不摊。收的条件是**两条一起成立**:条目够多(≥2)、已经跑完
3470
+ * (running 还在长,收了就看不见新条目;error 出错的组自己摊到条目级给人看,
3471
+ * 不用点)。用户手动拨过的开关优先于默认值。
3472
+ */
3473
+ function trajGroupIsOpen(g) {
3474
+ var key = String(g.turn);
3475
+ if (Object.prototype.hasOwnProperty.call(trajGroupOpen, key)) return trajGroupOpen[key];
3476
+ if (!g.entries || g.entries.length < 2) return true;
3477
+ if (g.state === 'running' || g.state === 'error') return true;
3478
+ return false;
3479
+ }
3480
+ /** 一条摊不摊:用户开关优先;没有开关时默认收(点开才见正文)。 */
3481
+ function trajEntryIsOpen(e) {
3482
+ if (Object.prototype.hasOwnProperty.call(trajEntryOpen, e.id)) return trajEntryOpen[e.id];
3483
+ return false;
3484
+ }
3485
+
3486
+ /**
3487
+ * 组头的开关。key 是 data 属性上的字符串(getAttribute 回来一定是字符串,
3488
+ * 轮号是数字,两边统一按 String 比——有一边不转换就会拨空)。
3489
+ * 拨完立刻重画:开关唯一可见的效果就是下一次渲染,不重画等于点了没反应。
3490
+ */
3491
+ function toggleTrajGroup(turnKey) {
3492
+ var groups = state.trajectory || [];
3493
+ for (var i = 0; i < groups.length; i++) {
3494
+ if (String(groups[i].turn) !== String(turnKey)) continue;
3495
+ trajGroupOpen[turnKey] = !trajGroupIsOpen(groups[i]);
3496
+ break;
3497
+ }
3498
+ render();
3499
+ }
3500
+ // 点的当下算开还是收,要按「含搜索强制摊开」的实况来(不然搜索摊开的条目点了
3501
+ // 收不回去):先按 id 找到那条再取反;找不到时按手里原有的开关算,和原来一致。
3502
+ function toggleTrajEntry(id) {
3503
+ var open = trajEntryOpen[id] === true;
3504
+ var groups = state.trajectory || [];
3505
+ for (var i = 0; i < groups.length; i++) {
3506
+ var es = groups[i].entries || [];
3507
+ for (var j = 0; j < es.length; j++) {
3508
+ if (String(es[j].id) === String(id)) open = trajEntryIsOpen(es[j]);
3509
+ }
3510
+ }
3511
+ trajEntryOpen[id] = !open;
3512
+ render();
3513
+ }
3514
+
3515
+ /**
3516
+ * 失败首行。reason 的第一行;reason 取不到就用错误名/错误码,都没有就如实说
3517
+ * 「失败」——**不编原因**(和 lib/trajectory.js readError 同一条口径)。
3518
+ */
3519
+ function trajErrLine(e) {
3520
+ if (!e || !e.error) return '';
3521
+ var r = typeof e.error.reason === 'string' ? e.error.reason : '';
3522
+ if (!r && typeof e.error.name === 'string' && e.error.name) r = e.error.name;
3523
+ if (!r && typeof e.error.code === 'string' && e.error.code) r = e.error.code;
3524
+ if (!r) return '失败';
3525
+ return r.split('\n')[0];
3526
+ }
3527
+ /** 组头上的失败首行:组里第一条带失败身份的条目的那一行。 */
3528
+ function trajGroupErrLine(g) {
3529
+ if (!g.entries) return '';
3530
+ for (var i = 0; i < g.entries.length; i++) {
3531
+ var line = trajErrLine(g.entries[i]);
3532
+ if (line) return line;
3533
+ }
3534
+ return '';
3535
+ }
3536
+
3537
+ /**
3538
+ * 截断标注。N 是**原始总字符数**(truncated 里的 chars 才是留下的那段),措辞照
3539
+ * lib/trajectory.js clipForTrajectory 的原句。服务端在正文尾部写了同一句就不再
3540
+ * 重复;没写(truncated 字段在、句子不在)就地补上——绝不静默砍尾。
3541
+ */
3542
+ function trajTruncNote(t, text) {
3543
+ if (!t || typeof t.total !== 'number') return '';
3544
+ if (String(text).indexOf('已截断') >= 0) return '';
3545
+ return '\n… 已截断,共 ' + t.total + ' 字符';
3546
+ }
3547
+
3548
+ /**
3549
+ * 一条的正文(照 PC 的展开卡):
3550
+ * 思考 = 普通 Markdown 段落(PC 的 compact 变体:13px/20px、三级灰、左缩进 22px,
3551
+ * **不是等宽**——它是想的东西,不是机器吐的字节);
3552
+ * 工具 = 「输入 / 输出」两栏卡(标签小字灰、正文等宽、细线分隔)——输入是美化后
3553
+ * 的 JSON(两空格,解析不了就原样),输出是结果文本,出错整段红色。
3554
+ * 自动审查被拒(照 PC):参数不显示,只说清楚为什么没跑。
3555
+ * 截断标注照旧如实跟在后面。
3556
+ */
3557
+ function trajEntryBody(e) {
3558
+ var tr = e.truncated || {};
3559
+ if (e.kind === 'think') {
3560
+ if (!e.output) return '';
3561
+ return '<div class="traj-think">' + mdToHtml(e.output) + trajTruncLine(tr.think, e.output) + '</div>';
3562
+ }
3563
+ if (e.error && e.error.name === 'AutoReviewDeniedError' && e.error.code === 'AUTO_REVIEW_DENIED') {
3564
+ var reason = typeof e.error.reason === 'string' && e.error.reason
3565
+ ? e.error.reason : 'Auto review 未授权此次操作';
3566
+ return '<div class="traj-io"><div class="traj-io-row"><div class="traj-io-label">输出</div>'
3567
+ + '<div class="traj-io-body traj-io-err"><pre>工具未执行。原因:'
3568
+ + escapeHtml(reason) + '</pre></div></div></div>';
3569
+ }
3570
+ var rows = '';
3571
+ if (e.args) {
3572
+ rows += '<div class="traj-io-row"><div class="traj-io-label">输入</div>'
3573
+ + '<div class="traj-io-body"><pre>' + escapeHtml(trajPretty(e.args)) + '</pre>'
3574
+ + trajTruncLine(tr.args, e.args) + '</div></div>';
3575
+ }
3576
+ var outText = e.output;
3577
+ if (!outText && e.error) {
3578
+ // 照 PC 的 resultText 兜底:空内容且失败时,给一行「名字: 码」,不装作没有结果。
3579
+ outText = (e.error.name || 'Error') + (e.error.code ? ': ' + e.error.code : '');
3580
+ }
3581
+ if (outText) {
3582
+ rows += '<div class="traj-io-row"><div class="traj-io-label">输出</div>'
3583
+ + '<div class="traj-io-body' + (e.error ? ' traj-io-err' : '') + '"><pre>'
3584
+ + escapeHtml(outText) + '</pre>' + trajTruncLine(tr.output, e.output || '') + '</div></div>';
3585
+ }
3586
+ if (!rows) return '';
3587
+ return '<div class="traj-io">' + rows + '</div>';
3588
+ }
3589
+
3590
+ /** 截断标注(如实):单起一行小字。 */
3591
+ function trajTruncLine(t, text) {
3592
+ var note = trajTruncNote(t, text);
3593
+ return note ? '<div class="traj-io-note">' + escapeHtml(note.trim()) + '</div>' : '';
3594
+ }
3595
+
3596
+ /** 参数按 PC 的 formatToolBody:能解析就美化 JSON(两空格),解析不了就原样。 */
3597
+ function trajPretty(raw) {
3598
+ try {
3599
+ return JSON.stringify(JSON.parse(raw), null, 2);
3600
+ } catch (err) {
3601
+ return String(raw);
3602
+ }
3603
+ }
3604
+
3605
+ // —— PC 同款词典(照抄 DSH 桌面端 0.2.0-rc.1,一个字不自创)——
3606
+ // 动作分类:dsh-client-ui-chat 的 activity()(工具名 → 动作)。
3607
+ function trajActivity(name) {
3608
+ var n = String(name || '');
3609
+ if (n === 'read') return 'read';
3610
+ if (n === 'read_image') return 'readImage';
3611
+ if (n === 'grep' || n === 'glob' || /_inspect$/.test(n)) return 'search';
3612
+ if (n === 'write') return 'write';
3613
+ if (n === 'edit' || n === 'apply_patch') return 'edit';
3614
+ if (n === 'bash' || n === 'pwsh' || n === 'exec_command' || n === 'write_stdin'
3615
+ || /^terminal_/.test(n)) return 'commands';
3616
+ if (n === 'run_code') return 'code';
3617
+ if (n === 'web_search') return 'webSearch';
3618
+ if (n === 'web_fetch') return 'webFetch';
3619
+ if (n === 'subagent' || /^subagent_/.test(n)) return 'subagents';
3620
+ if (n === 'todo_write' || n === 'create_goal' || n === 'update_goal' || n === 'get_goal') {
3621
+ return 'plan';
3622
+ }
3623
+ if (n === 'ask_user_question' || n === 'request_user_input') return 'questions';
3624
+ return 'tools';
3625
+ }
3626
+ // 动作 → 完成词 / 活动词(dsh-client-ui-chat 的 message.stepProcess.done.* 与 .* 本体)。
3627
+ var TRAJ_DONE = {
3628
+ thinking: '已完成分析', read: '已读取文件', readImage: '已读取图片', write: '已写入文件',
3629
+ search: '已搜索代码', edit: '修改了文件', commands: '执行了命令', code: '运行了代码',
3630
+ webSearch: '已搜索网页', webFetch: '已访问网页', subagents: '已协调子智能体',
3631
+ plan: '更新了计划', questions: '向用户提出了问题', tools: '已调用工具',
3632
+ };
3633
+ var TRAJ_LIVE = {
3634
+ thinking: '正在分析请求', read: '正在读取文件', readImage: '正在读取图片', write: '正在写入文件',
3635
+ search: '正在搜索代码', edit: '正在编辑文件', commands: '正在运行命令', code: '正在运行代码',
3636
+ webSearch: '正在搜索网页', webFetch: '正在访问网页', subagents: '正在协调子智能体',
3637
+ plan: '正在更新计划', questions: '等待你的操作', tools: '正在调用工具',
3638
+ };
3639
+ // 工具显示名(dsh-client-ui-tool 的 tool.title.*)。认不出名字的走变体标题,
3640
+ // 变体也没有就落「工具调用」——和 PC 的 generic 同一口径。
3641
+ var TRAJ_TOOL_TITLE = {
3642
+ read: '读取', read_image: '读取图片', write: '写入', edit: '编辑', apply_patch: '编辑',
3643
+ bash: '运行命令', pwsh: '运行命令', exec_command: '运行命令', write_stdin: '运行命令',
3644
+ grep: '搜索文件内容', glob: '查找文件', run_code: '运行代码',
3645
+ web_search: '网页搜索', web_fetch: '网页获取',
3646
+ todo_write: '更新任务清单', ask_user_question: '提问', request_user_input: '提问',
3647
+ create_goal: '创建目标', get_goal: '查看目标', update_goal: '更新目标',
3648
+ subagent: '创建子智能体', list_agents: '查看子智能体', send_message: '发送消息',
3649
+ interrupt_agent: '中断智能体', wait_agent: '等待子智能体', spawn_teammate: '创建队友',
3650
+ };
3651
+ var TRAJ_VARIANT_TITLE = {
3652
+ read: '读取', readImage: '读取', webFetch: '读取', search: '搜索',
3653
+ write: '写入', edit: '编辑', commands: '运行命令', code: '代码',
3654
+ thinking: '工具调用', tools: '工具调用',
3655
+ };
3656
+ function trajToolTitle(name) {
3657
+ var n = String(name || '');
3658
+ if (TRAJ_TOOL_TITLE[n]) return TRAJ_TOOL_TITLE[n];
3659
+ return TRAJ_VARIANT_TITLE[trajActivity(n)] || '工具调用';
3660
+ }
3661
+ // 图标:手写细线 SVG(1.5px 描边、currentColor),和全页图标同一种画法;
3662
+ // 画的是 PC 那套图标的角色(思考/读/搜/改/命令/代码/网页/子代理/计划/提问/工具)。
3663
+ var TRAJ_ICO = {
3664
+ thinking: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="9" r="5"/><path d="M9.5 18h5M10.5 21h3"/></svg>',
3665
+ read: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>',
3666
+ search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="6"/><path d="M15.5 15.5L21 21"/></svg>',
3667
+ edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 20l1-4L16 5l3 3L8 19z"/><path d="M14 7l3 3"/></svg>',
3668
+ commands: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 7l5 5-5 5"/><path d="M12 17h7"/></svg>',
3669
+ code: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 8l-5 4 5 4"/><path d="M15 8l5 4-5 4"/></svg>',
3670
+ webSearch: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="8"/><path d="M4 12h16"/><path d="M12 4c3 3 3 13 0 16c-3-3-3-13 0-16z"/></svg>',
3671
+ subagents: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="3"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>',
3672
+ plan: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 7l2 2 3-3M4 17l2 2 3-3"/><path d="M12 8h8M12 18h8"/></svg>',
3673
+ questions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="8"/><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .8-1 1.7"/><path d="M12 16.5h.01"/></svg>',
3674
+ tools: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 4l1.5 4.5L18 10l-4.5 1.5L12 16l-1.5-4.5L6 10l4.5-1.5z"/></svg>',
3675
+ };
3676
+ function trajIco(kind) {
3677
+ return TRAJ_ICO[kind] || TRAJ_ICO.tools;
3678
+ }
3679
+ /** 折叠处给思考行的那一句:正文首行,压成一行、限 160 字(DSH 的行内摘要同款口径)。
3680
+ 星号去掉(PC 同款:摘录是给人扫的,星号只会碍眼——5854)。 */
3681
+ function trajFirstLine(text) {
3682
+ var s = String(text == null ? '' : text).split('\n')[0].replace(/\s+/g, ' ').trim();
3683
+ s = s.replace(/\*\*/g, '');
3684
+ return s.length > 160 ? s.slice(0, 159) + '…' : s;
3685
+ }
3686
+
3687
+ /**
3688
+ * 扫光的起点接「全局时钟」:页面每次整块重画都会把动画打回开头,每次都从头扫
3689
+ * 就成了频闪。带上当前时间在 1.5 秒周期里的位置(负 delay),重画之后接着
3690
+ * 上次的相位走,肉眼看不出断口。
3691
+ */
3692
+ function trajShimStyle() {
3693
+ return ' style="animation-delay:-' + ((Date.now() % 1500) / 1000).toFixed(3) + 's"';
3694
+ }
3695
+ /**
3696
+ * 收官标题:照抄 DSH 的 processTitle——工具条目按动作分类计数(多的在前,一样多按
3697
+ * 出现先后),取前三类拼完成词;两类共享「已」字时第二个去「已」用「并」连,
3698
+ * 三类用「,」连,超过三类加「等」;一条工具都没有(纯思考)落「已完成分析」。
3699
+ */
3700
+ function trajTitleOf(g) {
3701
+ var order = [];
3702
+ var count = {};
3703
+ var es = g.entries || [];
3704
+ for (var i = 0; i < es.length; i++) {
3705
+ if (!es[i] || es[i].kind !== 'tool') continue;
3706
+ var k = trajActivity(es[i].name || '');
3707
+ if (count[k] === undefined) { count[k] = 0; order.push(k); }
3708
+ count[k] += 1;
3709
+ }
3710
+ order.sort(function (a, b) { return count[b] - count[a]; });
3711
+ var labels = order.slice(0, 3).map(function (k) { return TRAJ_DONE[k]; });
3712
+ var first = labels[0];
3713
+ if (first === undefined) return { kind: 'thinking', text: TRAJ_DONE.thinking };
3714
+ if (labels[1] === undefined) return { kind: order[0], text: first };
3715
+ if (labels.length === 2) {
3716
+ var second = labels[1];
3717
+ if (first.indexOf('已') === 0 && second.indexOf('已') === 0) second = second.slice(1);
3718
+ return { kind: order[0], text: first + '并' + second };
3719
+ }
3720
+ return {
3721
+ kind: order[0],
3722
+ text: first + ',' + labels.slice(1).join(',') + (order.length > 3 ? '等' : ''),
3723
+ };
3724
+ }
3725
+ /** 组头的图标与标题:跑着是活动词(正在…),收官是组合标题。 */
3726
+ function trajGroupTitle(g) {
3727
+ if (g.state === 'running') {
3728
+ var es = g.entries || [];
3729
+ var last = null;
3730
+ for (var i = es.length - 1; i >= 0; i--) {
3731
+ if (es[i] && es[i].state === 'running') { last = es[i]; break; }
3732
+ }
3733
+ // 还没有工具步在跑(正在想)就落「正在分析请求」——和 PC 的兜底同一个词。
3734
+ var kind = last && last.kind === 'tool' ? trajActivity(last.name || '') : 'thinking';
3735
+ // 活片段优先(正在想的那一句,随打字刷新,PC 的 label · detail 同款);
3736
+ // 没有片段才退到条目自己的摘要。
3737
+ var detail = state.trajectoryLive
3738
+ || (last ? (last.kind === 'think' ? trajFirstLine(last.output) : (last.summary || '')) : '');
3739
+ return { kind: kind, live: true, text: TRAJ_LIVE[kind] + (detail ? ' · ' + detail : '') };
3740
+ }
3741
+ return trajTitleOf(g);
3742
+ }
3743
+
3744
+ /**
3745
+ * 旁白那一段(对用户说的话,别的模式由鲸鱼娘气泡念,2026-10-04 起完整模式塞回轨迹):
3746
+ * 照 PC 是过程里的一段正文——不带图标、不可点开,说完就摆在这儿。
3747
+ * 正文走 markdown 渲染(和回答区一个待遇:粗体、行内代码、列表都认),
3748
+ * 不是原样贴星号(同日用户顺手报的)。
3749
+ */
3750
+ function trajSayHtml(e) {
3751
+ var tr = e.truncated || {};
3752
+ return '<div class="traj-say">'
3753
+ + mdToHtml((e.output || '') + trajTruncNote(tr.output, e.output || '')) + '</div>';
3754
+ }
3755
+
3756
+ /** 一行一条(PC 的成员行):图标 +「显示名 · 摘要」,失败/进行中顶在这一行上。 */
3757
+ function trajEntryHtml(e) {
3758
+ if (e.kind === 'say') return trajSayHtml(e);
3759
+ var think = e.kind === 'think';
3760
+ var kind = think ? 'thinking' : trajActivity(e.name || '');
3761
+ var label = think ? '思考' : trajToolTitle(e.name || '');
3762
+ // 自动审查拒绝(照 PC):行上摘要直接换成「Auto review 已拒绝」,别的都不重要了。
3763
+ var denial = e.error && e.error.name === 'AutoReviewDeniedError'
3764
+ && e.error.code === 'AUTO_REVIEW_DENIED';
3765
+ // 思考行展开后行上只留「思考」(照 PC:摘录是折叠态的预览,展开了就退场);
3766
+ // 工具行不一样——展开后标题·摘要照旧留着(PC 的 keepContentWhenOpen)。
3767
+ var open = trajEntryIsOpen(e);
3768
+ var detail = denial ? 'Auto review 已拒绝'
3769
+ : think ? (open ? '' : trajFirstLine(e.output)) : (e.summary || '');
3770
+ // 跑着的行整行扫光(2026-10-04 用户点名:「某一行正在运行时会有一个渐变的效果
3771
+ // 闪过去」)。不跑的行一个字节不变——类名后缀为空时拼出来就是原样。
3772
+ var shim = e.state === 'running';
3773
+ var shimCls = shim ? ' traj-shimmer' : '';
3774
+ var shimSty = shim ? trajShimStyle() : '';
3775
+ // 内容段与状态段**分开**:内容段一行截断(照 PC 的折叠行——折叠行是给人扫的),
3776
+ // 出错/进行中的字样是独立的一段,不许被省略号吃掉(2026-10-04 真机返修)。
3777
+ var body = '<span class="traj-detail' + shimCls + '"' + shimSty
3778
+ + '>' + (detail ? ' · ' + escapeHtml(detail) : '') + '</span>';
3779
+ var chips = '';
3780
+ if (e.error) chips = ' · <span class="traj-err">' + escapeHtml(trajErrLine(e)) + '</span>';
3781
+ else if (e.state === 'running') chips = ' · <span class="traj-run">进行中</span>';
3782
+ var row = '<button type="button" class="traj-row" data-traj-entry="'
3783
+ + escapeHtml(e.id) + '"><span class="traj-ico">' + trajIco(kind) + '</span>'
3784
+ + '<span class="traj-name' + shimCls + '"' + shimSty + '>' + escapeHtml(label) + '</span>'
3785
+ + body + chips + '</button>';
3786
+ return '<div class="traj-entry">' + row
3787
+ + (trajEntryIsOpen(e) ? trajEntryBody(e) : '') + '</div>';
3788
+ }
3789
+
3790
+ /** 一个轮次组:PC 的组头一行(图标 + 状态词/标题),摊开时下面挂条目。 */
3791
+ function trajGroupHtml(g) {
3792
+ var open = trajGroupIsOpen(g);
3793
+ var t = trajGroupTitle(g);
3794
+ var title = t.live
3795
+ ? '<span class="traj-run traj-shimmer"' + trajShimStyle() + '>' + escapeHtml(t.text) + '</span>'
3796
+ : '<span class="traj-title">' + escapeHtml(t.text) + '</span>';
3797
+ // 组头只显示状态词/标题,失败信息在下方条目行里显示,不重复。
3798
+ var head = '<button type="button" class="traj-head" data-traj-group="'
3799
+ + escapeHtml(String(g.turn)) + '"><span class="traj-ico">' + trajIco(t.kind) + '</span>'
3800
+ + '<span class="traj-chev">' + (open ? '▾' : '▸') + '</span>'
3801
+ + title + '</button>';
3802
+ var entries = open && g.entries.length
3803
+ ? '<div class="traj-entries">' + g.entries.map(trajEntryHtml).join('') + '</div>'
3804
+ : '';
3805
+ return '<div class="traj-group">' + head + entries + '</div>';
3806
+ }
3807
+
3808
+ /**
3809
+ * 把每轮的过程组插回它那轮对话的原位(10.3-1,对齐 DSH 的 turn-process 位置)。
3810
+ * 落点四条:
3811
+ * a 该轮**第一个回答之前**——「指令 → 过程 → 回答」;
3812
+ * b 该轮没有回答(跑着/失败)落该轮**簇尾**(活轮即页尾,新步骤从这里冒出来);
3813
+ * c 该轮有消息但都不在窗口里 → **不画**(组跟消息走,翻页上来自然带进来);
3814
+ * d 该轮一条消息都没有(排队被滤掉、本地回显没号)→ 兜底**页尾**。
3815
+ * rows[i] 与 list[i] 一一对应(list=裁好窗的消息,all=滤掉排队后的全量)。
3816
+ * 返回插好之后的新数组,不动入参。
3817
+ */
3818
+
3819
+ function trajInterleave(rows, list, all) {
3820
+ var groups = (state.trajectory || []).filter(function (g) {
3821
+ // 空组也可能是「正在想」的那一轮:turn/start 刚到、条目要等这一步说完才落
3822
+ // (DSH 的事件就是这么给的)。它就是进展本身,不许因为还没条目就整组不画——
3823
+ // 真机上那正是「跑的时候一片安静、跑完一次性全冒出来」的观感
3824
+ // (2026-10-04 用户报的)。跑完还是空的才不画:那一组确实没有过程。
3825
+ return g && Array.isArray(g.entries) && (g.entries.length > 0 || g.state === 'running');
3826
+ });
3827
+ // 服务端那句「这不是全部」跟着轨迹内容走(11.1-C):排在第一个过程组前面;
3828
+ // 一个过程组都没有(含轨迹读不到那条路)就兜底页尾——话必须说,位置不抢戏。
3829
+ var noteHtml = trajNoticeHtml();
3830
+ if (!groups.length) return rows.slice().concat(noteHtml ? [noteHtml] : []);
3831
+ var slots = [];
3832
+ for (var i = 0; i < groups.length; i++) {
3833
+ var g = groups[i];
3834
+ var at = -1;
3835
+ // a:该轮第一个回答之前
3836
+ for (var j = 0; j < list.length && at < 0; j++) {
3837
+ if (list[j] && list[j].role === 'assistant' && sameTurn(list[j].turn, g.turn)) at = j;
3838
+ }
3839
+ // b:没有回答就落簇尾
3840
+ if (at < 0) {
3841
+ for (var k = list.length - 1; k >= 0; k--) {
3842
+ if (list[k] && sameTurn(list[k].turn, g.turn)) { at = k + 1; break; }
3843
+ }
3844
+ }
3845
+ if (at < 0) {
3846
+ // c/d:窗口里没有这一轮的消息——全量里有=出窗不画;没有=兜底页尾。
3847
+ var anywhere = false;
3848
+ for (var n = 0; n < (all || []).length; n++) {
3849
+ if (all[n] && sameTurn(all[n].turn, g.turn)) { anywhere = true; break; }
3850
+ }
3851
+ if (anywhere) continue;
3852
+ at = list.length;
3853
+ }
3854
+ slots.push({ at: at, turn: g.turn, html: trajGroupHtml(g) });
3855
+ }
3856
+ // 同一落点上的组按轮号排(兜底页尾时可能好几组),插的时候按落点从前往后放。
3857
+ slots.sort(function (x, y) { return x.at - y.at || x.turn - y.turn; });
3858
+ var out = [];
3859
+ var s = 0;
3860
+ var noted = false;
3861
+ for (var p = 0; p <= rows.length; p++) {
3862
+ while (s < slots.length && slots[s].at === p) {
3863
+ if (noteHtml && !noted) { out.push(noteHtml); noted = true; }
3864
+ out.push(slots[s++].html);
3865
+ }
3866
+ if (p < rows.length) out.push(rows[p]);
3867
+ }
3868
+ if (noteHtml && !noted) out.push(noteHtml);
3869
+ return out;
3870
+ }
3871
+ /** 轮号对得上吗:两边都要有号,再比字符串(号来自消息字段/组字段,类型不总一致)。 */
3872
+ function sameTurn(a, b) {
3873
+ return a != null && b != null && String(a) === String(b);
3874
+ }
3875
+
3876
+ /**
3877
+ * 服务端「这份轨迹不是全部」的说明(state.trajectoryNote / trajectoryTruncated)。
3878
+ * 给了原句就原样显示;只有截断标志没给句子时补一句大实话兜底。**存在才显示**——
3879
+ * 一个字都没有时不许占地方,完整模式的观感必须和聊天一致。轨迹空了但服务端留了
3880
+ * 话(读取失败那条路)也要说:「读不到」不等于「没有过程」。
3881
+ */
3882
+ function trajNoticeHtml() {
3883
+ if (state.trajectoryNote) {
3884
+ return '<div class="traj-note">' + escapeHtml(state.trajectoryNote) + '</div>';
3885
+ }
3886
+ if (state.trajectoryTruncated) return '<div class="traj-note">这份轨迹不是全部。</div>';
3887
+ return '';
3888
+ }
3889
+
3890
+ // 轨迹区的点击全走这一根委托。先认条目再认组头——行和头各带各的 data 属性,
3891
+ // 认错一个就拨错层。
3892
+ function onTrajectoryClick(e) {
3893
+ var t = e.target && e.target.closest ? e.target : null;
3894
+ if (!t) return;
3895
+ var row = t.closest('[data-traj-entry]');
3896
+ if (row) { toggleTrajEntry(row.getAttribute('data-traj-entry')); return; }
3897
+ var head = t.closest('[data-traj-group]');
3898
+ if (head) toggleTrajGroup(head.getAttribute('data-traj-group'));
3899
+ }
3900
+
3901
+ function render() {
3902
+ $('hintMode').textContent = modeHint();
3903
+ if (state.mode === 'full') renderFull(); else if (state.mode === 'chat') renderChat(); else renderMinimal();
3904
+ // 「完整」模式不放装饰(11.1-B):给常驻的 #work 挂 bare,立绘、气泡、光流
3905
+ // 进度条由 CSS 藏掉,只留「已 N 秒 / 停止」。#work 不跟着 #reply 重画,
3906
+ // 所以这一下挂在总分发这里,切模式时各归各位。
3907
+ $('work').className = 'work' + (state.mode === 'full' ? ' bare' : '');
3908
+ }
3909
+
3910
+ function applySnapshot(snap) {
3911
+ if (!snap) return;
3912
+ if ('latest' in snap) state.latest = snap.latest;
3913
+ // 正在流出来的那一段。服务端只在自己决定要显示之后才写它,所以这里是空字符串
3914
+ // 不代表模型没在写,只代表「还不到显示的时候」。
3915
+ if ('live' in snap) state.live = snap.live || '';
3916
+ // 模型此刻的自言自语(鲸鱼娘的台词)。null = 这会儿没话说,气泡回到该姿势自己的词条。
3917
+ if ('thought' in snap) state.thought = snap.thought || null;
3918
+ if (snap.history) state.history = snap.history;
3919
+ // 「服务端正去读这个会话的完整记录」。点开一个没看过的会话时,那一串要读一会儿
3920
+ // 才补上来(最大的那条日志 20 MB、约半秒),这期间不能装作「这个会话是空的」。
3921
+ state.historyLoading = snap.historyLoading === true;
3922
+ // 「这份历史只到最近一段」。服务端读历史有上限(会话太大只读日志尾部),
3923
+ // 所以内容可能是截断的——**这件事必须跟着内容一起传上来**,不能只在服务端知道。
3924
+ state.historyTruncated = snap.historyTruncated === true;
3925
+ state.historyNote = snap.historyNote || '';
3926
+ if (snap.sessions) state.sessions = snap.sessions;
3927
+ if (Array.isArray(snap.queued)) state.queued = snap.queued;
3928
+ if ('boundSessionId' in snap) {
3929
+ var wasBound = state.boundSessionId;
3930
+ state.boundSessionId = snap.boundSessionId;
3931
+ if (state.boundSessionId !== wasBound) {
3932
+ saCache = null;
3933
+ saCacheSession = null;
3934
+ saCacheAt = 0;
3935
+ }
3936
+ // 手里那份轨迹属于上一个会话——换会话立刻作废:两场会话的轮号会撞在一起,
3937
+ // 不清掉就会把新会话的过程贴到旧会话那一组上。完整模式下再拉一份新的。
3938
+ if (state.boundSessionId !== wasBound) {
3939
+ state.trajectory = [];
3940
+ state.trajectorySeq = 0;
3941
+ if (state.mode === 'full') requestTrajectory();
3942
+ }
3943
+ // 换了会话,指令菜单那份缓存就作废了(指令表是每个会话一份的,这个会话没在跑
3944
+ // 时一条都读不到,见「斜杠指令」那一段)。重画一次,它自己会发现数据不属于
3945
+ // 这个会话、去重取——否则用户点到的是上一个会话的指令,点下去必然被拒。
3946
+ if (state.boundSessionId !== wasBound) paintCmdMenu();
3947
+ // 权限档位也是每个会话各一份的(宿主的 currentValue 读的是这个会话那份投影)。
3948
+ // 不重读的话,抽屉里显示的是上一个会话那一档——用户看到的就是一句假话。
3949
+ if (state.boundSessionId !== wasBound) loadPermissions(state.boundSessionId);
3950
+ }
3951
+ // running 也在快照里,而且算的是**当前绑定的那个会话**。不接的话,
3952
+ // 在导航栏切到别的会话时,那个会话会继承上一个会话的「正在执行」。
3953
+ if ('running' in snap) setRunning(snap.running);
3954
+ if ('subagentsRunning' in snap) {
3955
+ state.subagentsRunning = snap.subagentsRunning === true;
3956
+ $('btnSubagents').classList.toggle('subagent-running', state.subagentsRunning);
3957
+ $('btnSubagents').setAttribute('aria-label', state.subagentsRunning ? '子智能体(运行中)' : '子智能体');
3958
+ }
3959
+ paintNavCurrent();
3960
+ render();
3961
+ }
3962
+
3963
+ // ---------------- 左侧导航栏 ----------------
3964
+ // 数据分两层取:先列工作区(便宜),展开某个再去要它的会话(要读日志折标题,贵)。
3965
+ // 本机实测 35 个工作区、最多的一个有 452 个会话,一次全拉会卡住手机。
3966
+ var navList = null; // 工作区列表,开一次取一次
3967
+ var navSessions = {}; // workspaceId -> { sessions, total, truncated }
3968
+ var navFollowTimers = {}; // workspaceId -> 当前标题补齐轮询定时器
3969
+ var navExpanded = {}; // workspaceId -> 是否展开
3970
+ var navWsFetching = {}; // workspaceId -> 会话列表请求在途(同一工作区只发一趟)
3971
+ var navPresets = null; // Agent 模式清单(2026-10-04:新建会话时手机上也要能选模式)
3972
+ var navPresetsLoaded = false;
3973
+ var navPickerWs = ''; // 正在挑模式的那个工作区(清单行内展开在它的「新建会话」下)
3974
+
3975
+ function openNav() {
2489
3976
  $('nav').classList.add('open');
2490
3977
  loadWorkspaces(false);
2491
3978
  }
@@ -2493,6 +3980,17 @@
2493
3980
 
2494
3981
  function loadWorkspaces(force) {
2495
3982
  if (navList && !force) { renderNav(); return; }
3983
+ if (!force && navPrefetchPromise) {
3984
+ $('navBody').innerHTML = '<div class="placeholder">正在读取…</div>';
3985
+ navPrefetchPromise.then(function () {
3986
+ if ($('nav').classList.contains('open')) renderNav();
3987
+ }).catch(function (err) {
3988
+ navList = null;
3989
+ $('navBody').innerHTML = '<div class="placeholder">读不到会话列表:'
3990
+ + escapeHtml(err.message) + '</div>';
3991
+ });
3992
+ return;
3993
+ }
2496
3994
  $('navBody').innerHTML = '<div class="placeholder">正在读取…</div>';
2497
3995
  // 带 refresh=1 是给 ⟳ 用的:绕过服务端的短时缓存,真去读一遍。
2498
3996
  // 不带的话服务端会把最近一次的结果直接给你,导航栏就不再「每次都要重新读取」。
@@ -2507,6 +4005,46 @@
2507
4005
  });
2508
4006
  }
2509
4007
 
4008
+ // 导航栏不是首屏内容,但用户通常会在连上手机后很快点左上角。
4009
+ // 趁页面空闲先取一次;真正打开菜单时若已完成就直接画,不再把等待放在点击之后。
4010
+ var navPrefetchPromise = null;
4011
+ function prefetchWorkspaces() {
4012
+ // Agent 模式清单也是导航栏要用的、也几乎不变——顺手一起预取(见 prefetchPresets)。
4013
+ prefetchPresets();
4014
+ if (navList || navPrefetchPromise || navView !== 'tree' || !state.token) return;
4015
+ var run = function () {
4016
+ navPrefetchPromise = api('/mini/api/workspaces').then(function (body) {
4017
+ navList = body.workspaces || [];
4018
+ navSessions = {};
4019
+ if ($('nav').classList.contains('open')) renderNav();
4020
+ }).finally(function () { navPrefetchPromise = null; });
4021
+ navPrefetchPromise.catch(function () {
4022
+ // 预取失败不改变导航栏原来的行为,用户打开时仍会再试一次。
4023
+ });
4024
+ };
4025
+ if (typeof window.requestIdleCallback === 'function') {
4026
+ window.requestIdleCallback(run, { timeout: 1200 });
4027
+ } else {
4028
+ setTimeout(run, 0);
4029
+ }
4030
+ }
4031
+
4032
+ /**
4033
+ * Agent 模式清单(2026-10-04 用户要求:新建会话时手机上也要能选模式,PC 一直可以)。
4034
+ * 只取一次;读不到就如实记「没有可选的」——建会话那条路会直接建默认,
4035
+ * **不摆一个只有一项的选择题**,也不假装自己知道 DSH 有哪些模式。
4036
+ */
4037
+ function prefetchPresets() {
4038
+ if (navPresetsLoaded || !state.token) return;
4039
+ navPresetsLoaded = true;
4040
+ api('/mini/api/agent-presets').then(function (body) {
4041
+ navPresets = body && body.ok ? body : { ok: false, reason: (body && body.reason) || 'failed' };
4042
+ if (navPickerWs && $('nav').classList.contains('open')) renderNav();
4043
+ }).catch(function () {
4044
+ navPresets = { ok: false, reason: 'failed' };
4045
+ });
4046
+ }
4047
+
2510
4048
  function dayLabel(ts) {
2511
4049
  if (!ts) return '';
2512
4050
  var d = new Date(ts);
@@ -2519,6 +4057,11 @@
2519
4057
  function renderNav() {
2520
4058
  // 目录浏览那一屏接管整个面板。它自己画返回按钮,所以不走下面这套。
2521
4059
  if (navView === 'browse') { renderBrowse(); return; }
4060
+ // 自愈:navigation-refresh(会话开跑/收工都会广播)清得掉数据、清不掉展开态,
4061
+ // 重画出来的就是永远停着的「正在读取…」——取列表的请求原来只在点击时发,
4062
+ // 没人补发就一直停着(真机报过:收起再展开才好,2026-10-03)。缺数据的补拉一次,
4063
+ // loadWsSessions 自带在途闩,重复调用不会发第二趟。
4064
+ Object.keys(navExpanded).forEach(function (id) { loadWsSessions(id); });
2522
4065
  var list = navList || [];
2523
4066
  // 「新建工作区」常驻在最上面:它是这一屏的入口,不该等有了会话才出现——
2524
4067
  // 一个工作区都还没有的人,恰恰最需要它。
@@ -2549,6 +4092,22 @@
2549
4092
  // 这一行都在。放在列表末尾的话,一个 452 条会话的工作区要划到底才看得到。
2550
4093
  var html = '<button class="ws-new" type="button" data-newws="' + escapeHtml(wsId) + '">'
2551
4094
  + '+ 新建会话</button>';
4095
+ // 模式清单(2026-10-04):多于一种模式时,「新建会话」先把清单行内展开,
4096
+ // 点谁建谁;坏档如实标「不可用」——不装作能点。
4097
+ if (navPickerWs === wsId && navPresets && navPresets.ok && navPresets.presets.length > 1) {
4098
+ html += '<div class="ws-modes">' + navPresets.presets.map(function (m) {
4099
+ var title = '<b>' + escapeHtml(m.name || m.id) + '</b>';
4100
+ var desc = m.description
4101
+ ? '<span class="ws-mode-desc">' + escapeHtml(m.description) + '</span>' : '';
4102
+ var def = navPresets.defaultId === m.id ? '<span class="ws-mode-def">默认</span>' : '';
4103
+ if (m.broken) {
4104
+ return '<div class="ws-mode ws-mode-broken">' + title
4105
+ + '<span class="ws-mode-desc">不可用:' + escapeHtml(m.broken) + '</span></div>';
4106
+ }
4107
+ return '<button class="ws-mode" type="button" data-mode-ws="' + escapeHtml(wsId)
4108
+ + '" data-mode-preset="' + escapeHtml(m.id) + '">' + title + desc + def + '</button>';
4109
+ }).join('') + '</div>';
4110
+ }
2552
4111
  var got = navSessions[wsId];
2553
4112
  if (!got) return html + '<div class="placeholder">正在读取…</div>';
2554
4113
  if (!got.sessions.length) return html + '<div class="placeholder">这个工作区还没有会话。</div>';
@@ -2790,7 +4349,17 @@
2790
4349
  }
2791
4350
  navExpanded[wsId] = true;
2792
4351
  renderNav();
2793
- if (navSessions[wsId]) return;
4352
+ loadWsSessions(wsId);
4353
+ }
4354
+
4355
+ // 取一个工作区的会话列表。点击和 renderNav 的自愈(见 renderNav 顶部)都走这里:
4356
+ // navWsFetching 这道闩让同一工作区同时只有一趟在飞,别重复付读日志折标题的钱。
4357
+ // 15 秒兜底解锁是给「请求永远不回来」留的——闩本身不能变成新的卡死点,
4358
+ // 超过 15 秒就放行,让下一次渲染或点击还能再试(服务端有缓存,重发很便宜)。
4359
+ function loadWsSessions(wsId) {
4360
+ if (navSessions[wsId] || navWsFetching[wsId]) return;
4361
+ navWsFetching[wsId] = true;
4362
+ var bail = setTimeout(function () { delete navWsFetching[wsId]; }, 15000);
2794
4363
  /**
2795
4364
  * 接着问,直到服务端说「一条都不剩」。
2796
4365
  *
@@ -2802,7 +4371,8 @@
2802
4371
  */
2803
4372
  function followTitles(cur, deadline) {
2804
4373
  if (!(pendingCount(cur) > 0) || Date.now() >= deadline) return;
2805
- setTimeout(function () {
4374
+ navFollowTimers[wsId] = setTimeout(function () {
4375
+ delete navFollowTimers[wsId];
2806
4376
  // 用户收起了、或者这份数据已经被刷新换掉,就放手:别跟人抢。
2807
4377
  if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
2808
4378
  api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
@@ -2819,11 +4389,15 @@
2819
4389
  }
2820
4390
  api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
2821
4391
  .then(function (body) {
4392
+ clearTimeout(bail);
2822
4393
  navSessions[wsId] = body;
4394
+ delete navWsFetching[wsId];
2823
4395
  renderNav();
2824
4396
  followTitles(body, Date.now() + TITLE_FOLLOW_LIMIT_MS);
2825
4397
  })
2826
4398
  .catch(function (err) {
4399
+ clearTimeout(bail);
4400
+ delete navWsFetching[wsId];
2827
4401
  navSessions[wsId] = { sessions: [], total: 0 };
2828
4402
  renderNav();
2829
4403
  toast(err.message);
@@ -2835,7 +4409,17 @@
2835
4409
  // 服务端那道闸门拦不住这个:两个请求都是合法的。
2836
4410
  var creatingSession = false;
2837
4411
 
2838
- function createSession(wsId) {
4412
+ function createSession(wsId, agentPreset) {
4413
+ // 2026-10-04 用户要求:新建会话时手机上也要能选 agent 模式(PC 一直可以)。
4414
+ // 多于一种模式、又没点名要哪种:先把清单行内展开让用户挑(照「新建文件夹」的
4415
+ // 先例,不弹窗);只有一种、或读不到清单:直接建 DSH 的默认模式——
4416
+ // **不摆只有一个选项的选择题**,也不替用户猜。
4417
+ if (agentPreset === undefined && navPresets && navPresets.ok && navPresets.presets.length > 1) {
4418
+ navPickerWs = navPickerWs === wsId ? '' : wsId;
4419
+ renderNav();
4420
+ return Promise.resolve();
4421
+ }
4422
+ navPickerWs = '';
2839
4423
  if (creatingSession) return;
2840
4424
  creatingSession = true;
2841
4425
  Array.prototype.forEach.call($('navBody').querySelectorAll('.ws-new'), function (el) {
@@ -2855,7 +4439,7 @@
2855
4439
  // 不返回的话外面拿到的是 undefined,接不上任何后续。
2856
4440
  return api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions', {
2857
4441
  method: 'POST',
2858
- body: '{}',
4442
+ body: agentPreset ? JSON.stringify({ agentPreset: agentPreset }) : '{}',
2859
4443
  }).then(function (res) {
2860
4444
  done();
2861
4445
  // 接口把绑定后的快照一起给了,直接渲染——不这样的话,切过去的一瞬间
@@ -2926,6 +4510,12 @@
2926
4510
  if (at('[data-mkdir-cancel]')) { browseMkdir = false; renderNav(); return; }
2927
4511
  if (at('[data-mkdir-ok]')) { makeFolder(); return; }
2928
4512
 
4513
+ // 模式清单里点一项:带着这个模式去建(2026-10-04,手机上也能选 agent 模式)。
4514
+ var mode = at('[data-mode-preset]');
4515
+ if (mode) {
4516
+ createSession(mode.getAttribute('data-mode-ws'), mode.getAttribute('data-mode-preset'));
4517
+ return;
4518
+ }
2929
4519
  // 「新建会话」要排在 ws-head 之前判:它在 ws-body 里面、不是 ws-head,
2930
4520
  // 顺序上无所谓,但放在前面更清楚——这一支是动作,那一支是展开/收起。
2931
4521
  var fresh = at('.ws-new');
@@ -2936,22 +4526,138 @@
2936
4526
  });
2937
4527
 
2938
4528
  // ---------------- SSE ----------------
4529
+ /** 从轨迹中取当前轮号:正在跑的那一组的轮号。没有就返回 null。 */
4530
+ function currentTrajectoryTurn() {
4531
+ var groups = state.trajectory || [];
4532
+ for (var i = groups.length - 1; i >= 0; i--) {
4533
+ var g = groups[i];
4534
+ if (g && g.state === 'running' && g.turn != null) return g.turn;
4535
+ }
4536
+ // 没有正在跑的,取最近一个有轮号的
4537
+ for (var j = groups.length - 1; j >= 0; j--) {
4538
+ var h = groups[j];
4539
+ if (h && h.turn != null) return h.turn;
4540
+ }
4541
+ return null;
4542
+ }
4543
+
4544
+ /**
4545
+ * 这条流是谁——轨迹订阅按连接记,就得先有身份。
4546
+ *
4547
+ * 服务端手上只有一条条 SSE 连接(`res`),一次普通的 POST 认不出「我是哪条流」。
4548
+ * 所以由客户端自己报一个随机串,两处都带上:连流时的 `?clientId=…`,和之后每次
4549
+ * 开关订阅的 POST。不报也能用,只是那种连接订不了轨迹。
4550
+ *
4551
+ * 存 `sessionStorage`(不是 localStorage):一个标签页一个身份,第二个标签页不会
4552
+ * 跟这一页抢同一条订阅;刷新后还在,免得每刷一次就多出一个「认不出」的连接。
4553
+ * 无痕模式下存取会抛,就地退回「本次运行内固定」——订不上,但不影响别的功能。
4554
+ */
4555
+ var clientId = '';
4556
+ try {
4557
+ clientId = sessionStorage.getItem('dshMiniClient') || '';
4558
+ if (!clientId) {
4559
+ clientId = 'c' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
4560
+ sessionStorage.setItem('dshMiniClient', clientId);
4561
+ }
4562
+ } catch (e) {
4563
+ clientId = 'c' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
4564
+ }
4565
+ /** 服务端此刻认作「订了轨迹」的那个身份;空串 = 没订(切换模式的开关拿它判断)。 */
4566
+ var subscribedClientId = '';
2939
4567
  var es = null;
2940
4568
  function connect() {
2941
4569
  if (es) { try { es.close(); } catch (e) {} es = null; }
2942
4570
  if (!state.token) { showGate(); return; }
2943
4571
  setStatus('', '连接中…');
2944
- es = new EventSource('/mini/api/stream?token=' + encodeURIComponent(state.token));
4572
+ // clientId 每条连接都带(订阅的 POST 靠它认出这条流);full=1 只在「完整」模式带,
4573
+ // 服务端见到就自动把这条连接订上轨迹——断线重连沿用同一个地址,重新订上由服务端
4574
+ // 自己完成,客户端不用再握手一次。
4575
+ es = new EventSource('/mini/api/stream?token=' + encodeURIComponent(state.token)
4576
+ + '&clientId=' + encodeURIComponent(clientId)
4577
+ + (state.mode === 'full' ? '&full=1' : ''));
2945
4578
 
2946
4579
  es.addEventListener('open', function () {
2947
4580
  state.connected = true;
2948
4581
  updateStatusUI();
2949
4582
  $('connHint').textContent = '已连接';
4583
+ prefetchWorkspaces();
4584
+ prefetchSubagents();
4585
+ if (state.mode === 'full') {
4586
+ // 首连与重连同一处:full=1 已让服务端订上这条连接,拿住身份,再对齐一次——
4587
+ // 断线那一段路上可能丢过帧,用这份 GET 把 epoch/seq 校正(换代就整个换新)。
4588
+ subscribedClientId = clientId;
4589
+ requestTrajectory();
4590
+ }
2950
4591
  });
2951
4592
  es.addEventListener('state', function (e) {
2952
4593
  state.connected = true;
2953
4594
  updateStatusUI();
2954
4595
  try { applySnapshot(JSON.parse(e.data)); } catch (err) {}
4596
+ prefetchWorkspaces();
4597
+ prefetchSubagents();
4598
+ });
4599
+ // 轨迹的增量帧。JSON 解析失败就丢——宁可等下一份 GET 对齐,也不拿半帧去拼。
4600
+ es.addEventListener('trajectory', function (e) {
4601
+ var frame;
4602
+ try { frame = JSON.parse(e.data); } catch (err) { return; }
4603
+ receiveTrajectoryFrame(frame);
4604
+ });
4605
+ // 活片段帧(2026-10-04 用户解禁:完整模式要「正在想的片段一点点顺出来」)。
4606
+ // 只进完整模式的轨迹组头;聊天/单帧、鲸鱼娘气泡一个字节都不碰。
4607
+ es.addEventListener('trajectory-live', function (e) {
4608
+ var frame;
4609
+ try { frame = JSON.parse(e.data); } catch (err) { return; }
4610
+ if (state.mode !== 'full') return;
4611
+ if (frame.sessionId && state.boundSessionId && frame.sessionId !== state.boundSessionId) return;
4612
+ state.trajectoryLive = typeof frame.detail === 'string' ? frame.detail : '';
4613
+ render();
4614
+ });
4615
+ es.addEventListener('navigation-title', function (e) {
4616
+ var payload;
4617
+ try { payload = JSON.parse(e.data); } catch (err) { return; }
4618
+ if (!payload || typeof payload.workspaceId !== 'string' || typeof payload.sessionId !== 'string') return;
4619
+ var got = navSessions[payload.workspaceId];
4620
+ if (!got || !Array.isArray(got.sessions)) return;
4621
+ var row = got.sessions.find(function (item) { return item.id === payload.sessionId; });
4622
+ if (!row) return;
4623
+ row.title = typeof payload.title === 'string' ? payload.title : '';
4624
+ if (typeof got.pending === 'number' && got.pending > 0) got.pending -= 1;
4625
+ if (got.pending <= 0 && navFollowTimers[payload.workspaceId]) {
4626
+ clearTimeout(navFollowTimers[payload.workspaceId]);
4627
+ delete navFollowTimers[payload.workspaceId];
4628
+ }
4629
+ if (navExpanded[payload.workspaceId] && navView === 'tree') renderNav();
4630
+ });
4631
+ es.addEventListener('subagent-metrics', function (e) {
4632
+ var payload;
4633
+ try { payload = JSON.parse(e.data); } catch (err) { return; }
4634
+ if (!payload || payload.parentSessionId !== state.boundSessionId) return;
4635
+ var row = null;
4636
+ for (var i = 0; i < saRows.length; i++) {
4637
+ if (saRows[i].id === payload.childId) { row = saRows[i]; break; }
4638
+ }
4639
+ if (!row) return;
4640
+ row.tokens = payload.tokens;
4641
+ row.durationMs = payload.durationMs;
4642
+ row.lastTurnCompleted = payload.lastTurnCompleted;
4643
+ if (saCache && saCacheSession === state.boundSessionId) {
4644
+ for (var k = 0; k < (saCache.subagents || []).length; k++) {
4645
+ if (saCache.subagents[k].id === payload.childId) {
4646
+ Object.assign(saCache.subagents[k], payload);
4647
+ break;
4648
+ }
4649
+ }
4650
+ }
4651
+ if (saPanel.classList.contains('open') && !saCurrent) saRenderList(saCache || { ok: true, subagents: saRows });
4652
+ });
4653
+ es.addEventListener('navigation-refresh', function () {
4654
+ navList = null;
4655
+ navSessions = {};
4656
+ saCache = null;
4657
+ saCacheSession = null;
4658
+ saCacheAt = 0;
4659
+ if ($('nav').classList.contains('open')) loadWorkspaces(false);
4660
+ else prefetchWorkspaces();
2955
4661
  });
2956
4662
  es.addEventListener('reply', function (e) {
2957
4663
  var payload = JSON.parse(e.data);
@@ -2963,11 +4669,16 @@
2963
4669
  text: payload.text, timestamp: payload.timestamp,
2964
4670
  sessionId: payload.sessionId, interrupted: cut,
2965
4671
  };
2966
- if (state.mode === 'chat') {
4672
+ // 完整模式画的也是这串聊天记录(renderFull 复用 renderChat),回复落定
4673
+ // 同样要追加进去,否则要等下一份快照才补上;单帧不画记录,照旧不进。
4674
+ if (state.mode !== 'minimal') {
2967
4675
  state.history = (state.history || []).concat([
2968
4676
  {
2969
4677
  role: 'assistant', text: payload.text, timestamp: payload.timestamp,
2970
4678
  sessionId: payload.sessionId, interrupted: cut,
4679
+ // 完整模式把轨迹块插回对话原位要用:从轨迹中取当前轮号。
4680
+ // 手机本地回显时轮还没开,先留空;服务端快照补上来时会带上真轮号。
4681
+ turn: currentTrajectoryTurn(),
2971
4682
  }
2972
4683
  ]);
2973
4684
  }
@@ -3133,6 +4844,31 @@
3133
4844
  ask = { id: payload.id, questions: payload.questions, i: 0, picked: {}, custom: false }
3134
4845
  askRender()
3135
4846
  });
4847
+ // 这道题在**别处**被答掉了(飞书那边回的),插件主动推一条状态过来。
4848
+ //
4849
+ // 没有这一条,卡片就干巴巴挂在屏幕上:用户在飞书里答完了,切回手机还看得见这道题,
4850
+ // 只能以为没生效、再答一次——那时候拿到的是「已经过期了」。所以这里就地收起来,
4851
+ // 并说一句它是怎么没的。**只在自己正显示着同一道题时动手**,别的题(编号对不上)
4852
+ // 一个字都不碰。
4853
+ es.addEventListener('question-done', function (e) {
4854
+ let payload = null
4855
+ try { payload = JSON.parse(e.data) } catch (err) { return }
4856
+ if (!payload || !payload.id) return
4857
+ if (!ask || ask.id !== payload.id) return
4858
+ ask = null
4859
+ $('askCard').classList.remove('on')
4860
+ // 谁答的由插件如实说('feishu')——猜不得,也不把「超时/断线」说成「已在别处答过」。
4861
+ if (payload.source === 'feishu') toast('这道题已经在飞书里答过了')
4862
+ });
4863
+ es.addEventListener('approval-done', function (e) {
4864
+ let payload = null
4865
+ try { payload = JSON.parse(e.data) } catch (err) { return }
4866
+ if (!payload || !payload.id) return
4867
+ if (!approve || approve.id !== payload.id) return
4868
+ approve = null
4869
+ $('approveCard').classList.remove('on')
4870
+ if (payload.source === 'feishu') toast('这条已经在飞书里处理过了')
4871
+ });
3136
4872
  es.addEventListener('status', function (e) {
3137
4873
  try { setRunning(JSON.parse(e.data).running); } catch (err) {}
3138
4874
  });
@@ -3214,17 +4950,29 @@
3214
4950
  // **不能显示成空列表**:那等于说「这个会话没有指令」,是另一回事。
3215
4951
  state.commandsError = (res && res.error) || '读不到指令列表';
3216
4952
  }
3217
- paintCmdMenu();
4953
+ paintCommandViews();
3218
4954
  }).catch(function (err) {
3219
4955
  state.commandsLoading = false;
3220
4956
  if (forSession !== state.boundSessionId) return;
3221
4957
  state.commandsFor = forSession;
3222
4958
  state.commands = null;
3223
4959
  state.commandsError = err.message || '读不到指令列表';
3224
- paintCmdMenu();
4960
+ paintCommandViews();
3225
4961
  });
3226
4962
  }
3227
4963
 
4964
+ /**
4965
+ * 指令表换了一份之后,**所有正在看着它的地方**都要重画。
4966
+ *
4967
+ * 现在有两处:打 / 弹出来的菜单,和统一操作面板里的那个列表。回调那一个由面板
4968
+ * 那一段自己挂上——因为它在这一段下面,而这一段会被测试单独抠出来跑(那时面板还不存在)。
4969
+ */
4970
+ var onCommandsReady = null;
4971
+ function paintCommandViews() {
4972
+ paintCmdMenu();
4973
+ if (onCommandsReady) onCommandsReady();
4974
+ }
4975
+
3228
4976
  /**
3229
4977
  * 画(或收起)那只菜单。
3230
4978
  *
@@ -3235,6 +4983,9 @@
3235
4983
  function paintCmdMenu() {
3236
4984
  var el = $('cmdMenu');
3237
4985
  if (!el) return;
4986
+ // 输入框被清空了 = 用户把刚才那条指令整个删了,这是「明确取消」那一步。
4987
+ // 放在这儿而不是 send() 里:清空之后他往往还要改写成普通提问,那次 send 不该再被拦。
4988
+ if (!inputEl.value.trim()) state.pendingCommand = null;
3238
4989
  var prefix = slashQuery();
3239
4990
  if (prefix === null) { hideCmdMenu(); return; }
3240
4991
  var wasOpen = !el.hidden;
@@ -3267,16 +5018,22 @@
3267
5018
  }
3268
5019
 
3269
5020
  /**
3270
- * 菜单里点了一条。
5021
+ * 菜单/面板里点了一条。
3271
5022
  *
3272
- * 分两路,判据是服务端给的 hint(这条指令要不要参数):
5023
+ * 分两路,判据是服务端给的 hint(这条指令要不要参数)。两路都**不执行**,
5024
+ * 除非是打了 / 之后从菜单里点了一条不要参数的(那种当场跑,见 deferRun 的说明):
3273
5025
  * · 不要参数 —— 直接执行。让用户再按一次发送是白白多一步。
3274
5026
  * · 要参数 —— 把 `/名字 ` 填进输入框,末尾留一个空格、光标停在后面,并把
3275
5027
  * hint 那句话挂到输入框的 placeholder 上。
3276
5028
  * **不自己编一句「请输入参数」**:只有服务端知道这条指令要什么,
3277
5029
  * 编一句就等于让用户照着一个错的提示去填。
5030
+ *
5031
+ * @param {string} name
5032
+ * @param {boolean} [deferRun] 面板那条路传 true:**一次点击不执行**,只把这一行放进
5033
+ * 输入框,由「发送」那一下确认(没有参数提示的指令也照此办理,宁可多问一句)。
5034
+ * 打 / 弹出来的那个菜单不传——它本来就是「扫一眼挑一个」,多一步反而碍事。
3278
5035
  */
3279
- function pickCommand(name) {
5036
+ function pickCommand(name, deferRun) {
3280
5037
  var list = state.commands || [];
3281
5038
  var cmd = null;
3282
5039
  for (var i = 0; i < list.length; i++) { if (list[i].name === name) cmd = list[i]; }
@@ -3288,12 +5045,25 @@
3288
5045
  if (cmd.hint) {
3289
5046
  inputEl.value = '/' + cmd.name + ' ';
3290
5047
  inputEl.placeholder = cmd.hint;
5048
+ // 这一行「曾经是一条指令」:名字要是被删掉,send 不许拿它当普通提问发出去。
5049
+ state.pendingCommand = cmd.name;
3291
5050
  inputEl.focus();
3292
5051
  // 光标停在最后:停在名字中间的话,用户接着打的字会插进名字里去。
3293
5052
  inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
3294
5053
  autoGrow();
3295
5054
  return;
3296
5055
  }
5056
+ if (deferRun) {
5057
+ // 面板那条路:只填不跑。等用户按「发送」——那就是明确的确认执行动作。
5058
+ inputEl.value = '/' + cmd.name;
5059
+ state.pendingCommand = cmd.name;
5060
+ inputEl.focus();
5061
+ inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
5062
+ autoGrow();
5063
+ return;
5064
+ }
5065
+ // 当场执行:框里不留东西,也就不存在「曾是指令」这回事。
5066
+ state.pendingCommand = null;
3297
5067
  inputEl.value = '';
3298
5068
  autoGrow();
3299
5069
  runCommand('/' + cmd.name);
@@ -3356,10 +5126,13 @@
3356
5126
  // 本地上限和服务端是同一个数(由服务端注入),先拦一道省得白传一场。
3357
5127
  if (MAX_UPLOAD > 0 && file.size > MAX_UPLOAD) {
3358
5128
  toast('「' + name + '」有 ' + humanSize(file.size) + ',超过上限 ' + humanSize(MAX_UPLOAD));
3359
- return;
5129
+ // 多选时是排队传的(见 filePick 的 change),所以这里也要回一个「承诺」——
5130
+ // 否则这一份被拦下会让整条链断掉,后面几张就再也传不出去了。
5131
+ return Promise.resolve();
3360
5132
  }
3361
5133
  setAttachBusy(true);
3362
- api('/mini/api/upload?name=' + encodeURIComponent(name) + '&size=' + file.size,
5134
+ // 返回这条链:多选时调用方靠它排队(一张传完再传下一张)。
5135
+ return api('/mini/api/upload?name=' + encodeURIComponent(name) + '&size=' + file.size,
3363
5136
  { method: 'POST', body: file })
3364
5137
  .then(function (res) {
3365
5138
  state.uploads.push({ uploadId: res.uploadId, name: res.name, bytes: res.bytes });
@@ -3371,17 +5144,38 @@
3371
5144
  .then(function () { setAttachBusy(false); });
3372
5145
  }
3373
5146
 
3374
- /** 附件小条。有东西才出现,没有就整块收起来——不占地方。 */
5147
+ /**
5148
+ * 附件小条。有东西才出现,没有就整块收起来——不占地方。
5149
+ *
5150
+ * 统一操作面板里那几行「已选文件」是**同一份数据、同一份 HTML**:都由这里画,
5151
+ * 都读 state.uploads。别在面板里另存一个附件数组——那样两处迟早对不上
5152
+ * (面板里删了一条,小条还挂着,用户就不知道该信哪个)。
5153
+ */
3375
5154
  function renderAttach() {
3376
5155
  var bar = $('attachBar');
3377
5156
  if (!bar) return;
3378
5157
  var list = state.uploads || [];
3379
- bar.hidden = !list.length;
3380
- bar.innerHTML = list.map(function (f, i) {
5158
+ var chips = list.map(function (f, i) {
3381
5159
  return '<span class="chip"><span class="nm">' + escapeHtml(f.name) + '</span>'
3382
5160
  + '<span class="sz">' + humanSize(f.bytes) + '</span>'
3383
5161
  + '<button class="x" type="button" data-drop="' + i + '" aria-label="去掉">×</button></span>';
3384
5162
  }).join('');
5163
+ bar.hidden = !list.length;
5164
+ bar.innerHTML = chips;
5165
+ var box = $('opsFiles');
5166
+ if (box) { box.hidden = !list.length; box.innerHTML = chips; }
5167
+ }
5168
+
5169
+ /**
5170
+ * 去掉一条已选文件。小条和面板共用这一处,别写两份。
5171
+ *
5172
+ * 编号必须从**被点的按钮**上读(`closest('[data-drop]')`),不能读 e.target:
5173
+ * ✕ 是按钮里那个图形,手指落上去 e.target 就是图形本身,读它只能拿到 null,
5174
+ * 于是静默什么都不做(2026-09-28 实机报过的那个 bug)。
5175
+ */
5176
+ function dropUpload(btn) {
5177
+ state.uploads.splice(Number(btn.getAttribute('data-drop')), 1);
5178
+ renderAttach();
3385
5179
  }
3386
5180
 
3387
5181
  function send() {
@@ -3394,6 +5188,21 @@
3394
5188
  // 只带文件、一个字不写也是合法的:「这份文件你看一下」本来就是常见用法。
3395
5189
  // 两样都没有才算空。
3396
5190
  if (!text && !files.length) return;
5191
+
5192
+ // **点过指令、又把名字删掉的那一行,不许当普通提问发出去。**
5193
+ //
5194
+ // 从面板/菜单点了一条「要参数」的指令之后,输入框里躺着 `/名字 参数`。用户要是把
5195
+ // 名字删了、只留参数,这一行就不再以 / 开头,原来会被当成一次普通提问发给模型——
5196
+ // 白花一次额度,而且他还以为自己跑的是那条指令。
5197
+ //
5198
+ // 拦在**清空输入框之前**:那一行字要留着,补回名字就能接着发。出路有两条,
5199
+ // 都在提示里说清楚了:补回开头的指令名,或者清空输入框(清空 = 明确取消这条指令,
5200
+ // 由 paintCmdMenu 在每次输入时把那枚标记撤掉)。
5201
+ if (text.charAt(0) !== '/' && state.pendingCommand) {
5202
+ toast('这是 /' + state.pendingCommand + ' 的参数,指令名被删掉了。'
5203
+ + '把 /' + state.pendingCommand + ' 补回开头,或者清空输入框改问别的。');
5204
+ return;
5205
+ }
3397
5206
  unlockAudio();
3398
5207
  // **发指令这个动作,无条件拉回底部。**
3399
5208
  //
@@ -3428,6 +5237,8 @@
3428
5237
  // 这里**不动 state.uploads**——清掉就等于把用户刚传上来的文件悄悄扔了,
3429
5238
  // 而他还以为跟着发出去了。留在小条上看得见,想发就配一句话再发一次。
3430
5239
  if (files.length) toast('指令带不了附件,附件还留在下面');
5240
+ // 跑出去了,那枚「曾是指令」的标记就该撤掉:留着会拦住下一条普通提问。
5241
+ state.pendingCommand = null;
3431
5242
  runCommand(text);
3432
5243
  return;
3433
5244
  }
@@ -3468,7 +5279,10 @@
3468
5279
  // 顺带也解决了发送失败时屏幕上留一条假气泡的问题。
3469
5280
  if (state.mode !== 'minimal') {
3470
5281
  state.history = (state.history || []).concat([
3471
- { role: 'user', text: shown, timestamp: Date.now() }
5282
+ { role: 'user', text: shown, timestamp: Date.now(),
5283
+ // 完整模式把轨迹块插回对话原位要用:从轨迹中取当前轮号。
5284
+ // 手机本地回显时轮还没开,先留空;服务端快照补上来时会带上真轮号。
5285
+ turn: currentTrajectoryTurn() }
3472
5286
  ]);
3473
5287
  render();
3474
5288
  }
@@ -3486,22 +5300,160 @@
3486
5300
  });
3487
5301
  }
3488
5302
 
3489
- $('btnAttach').addEventListener('click', function () { $('filePick').click(); });
5303
+ // ---------------- 统一操作面板(输入框旁边那一个入口) ----------------
5304
+ //
5305
+ // 形态是用户 2026-10-02 定的:输入框旁边**一个**入口,点开是一张底部抽屉,
5306
+ // 上面「添加文件」、下面「调用指令」。原来这两件事各有各的门——回形针直接开文件
5307
+ // 选择器、打 `/` 才弹指令菜单——手机上没人知道打一个斜杠还能调指令。
5308
+ //
5309
+ // 四条复用纪律(都不许另起一套):
5310
+ // · 外壳 = 设置抽屉那一套(#settings.open + .sheet),不新建样式体系;
5311
+ // · 指令数据 = state.commands 那份按会话缓存,与打 / 弹出来的菜单**同一份**,
5312
+ // 判据也共用 needCommands(),不写「面板专用」的第二套;
5313
+ // · 指令行 = 现成的 pickCommand(name, true),一次点击**不执行**;
5314
+ // · 附件 = state.uploads + renderAttach(),面板里那几行就是小条那几行。
5315
+ //
5316
+ // 「指令带附件一起发」这一轮**不做**(手机的上传小票 DSH 的指令接口不认,见 onCommand
5317
+ // 那段说明)——所以对声明了要附件的指令,面板上如实标一句「手机暂时带不了附件」,
5318
+ // 不假装支持。
5319
+ var ops = $('ops');
5320
+ // 面板是不是开着。指令表换一份时要按它决定要不要重画列表(见 paintCommandViews)。
5321
+ var opsOpen = false;
5322
+
5323
+ function openOps() {
5324
+ opsOpen = true;
5325
+ // 先把键盘收起来:这张抽屉贴着底边,键盘不收,它就被顶到看不见的地方。
5326
+ if (inputEl.blur) inputEl.blur();
5327
+ ops.classList.add('open');
5328
+ // 与打 / 时同一个判据:换了会话要重取,上次没取到过就再试一次。
5329
+ if (needCommands() || state.commandsError) loadCommands();
5330
+ paintOps();
5331
+ }
5332
+
5333
+ function closeOps() {
5334
+ opsOpen = false;
5335
+ ops.classList.remove('open');
5336
+ }
5337
+
5338
+ /**
5339
+ * 画面板里的指令列表。
5340
+ *
5341
+ * 和打 / 那个菜单同源(state.commands),但这里是「搜」不是「按前缀」:面板上那个框是
5342
+ * 一句话搜索框,名字和说明都匹配(桌面端也是名字和标题一起匹配)。顺手带个 `/c` 也算,
5343
+ * 那是用户平时打指令的手势。
5344
+ *
5345
+ * **取不到就说原因**,不显示成「一条都没有」——两件事,用户能做的也不同。
5346
+ */
5347
+ function paintOps() {
5348
+ var listEl = $('opsCmds');
5349
+ if (!listEl) return;
5350
+ if (state.commandsError) {
5351
+ listEl.innerHTML = '<div class="cmd-note">' + escapeHtml(state.commandsError) + '</div>';
5352
+ return;
5353
+ }
5354
+ if (!state.commands) {
5355
+ // 取回来之前先占个位——不然打开面板是一片空白,看着像坏了。
5356
+ listEl.innerHTML = '<div class="cmd-note">正在读取可用的指令…</div>';
5357
+ return;
5358
+ }
5359
+ var filterEl = $('opsFilter');
5360
+ var q = String((filterEl && filterEl.value) || '').trim().toLowerCase();
5361
+ if (q.charAt(0) === '/') q = q.slice(1);
5362
+ var rows = state.commands.filter(function (c) {
5363
+ if (!q) return true;
5364
+ return (String(c.name) + ' ' + String(c.description || '')).toLowerCase().indexOf(q) >= 0;
5365
+ });
5366
+ if (!rows.length) {
5367
+ listEl.innerHTML = '<div class="cmd-note">没有对得上的指令。</div>';
5368
+ return;
5369
+ }
5370
+ listEl.innerHTML = rows.map(function (c) {
5371
+ return '<button class="cmd-row" type="button" data-cmd="' + escapeHtml(c.name) + '">'
5372
+ + '<span class="cmd-top">'
5373
+ + '<span class="cmd-name">/' + escapeHtml(c.name) + '</span>'
5374
+ + '<span class="cmd-desc">' + escapeHtml(c.description || '') + '</span>'
5375
+ + '</span>'
5376
+ + (c.attachments ? '<span class="cmd-flag">手机暂时带不了附件</span>' : '')
5377
+ + '</button>';
5378
+ }).join('');
5379
+ }
5380
+
5381
+ // 面板里点一行:**一次点击不执行**(deferRun)。要参数的把 hint 挂成占位提示、把
5382
+ // `/名字 ` 填进输入框;不要参数的也只填进去,等「发送」那一下——宁可多问一句,
5383
+ // 也不能点错一下就真跑掉。
5384
+ $('opsCmds').addEventListener('click', function (e) {
5385
+ var btn = e.target && e.target.closest ? e.target.closest('[data-cmd]') : null;
5386
+ if (!btn) return;
5387
+ closeOps();
5388
+ pickCommand(btn.getAttribute('data-cmd'), true);
5389
+ });
5390
+
5391
+ $('opsFilter').addEventListener('input', paintOps);
5392
+ $('opsClose').addEventListener('click', closeOps);
5393
+ // 点抽屉外面那一层 = 关掉(和设置抽屉同一条规矩)。
5394
+ ops.addEventListener('click', function (e) { if (e.target === ops) closeOps(); });
5395
+
5396
+ // 「添加文件」两件事:选文件、拍照。都走原来那个藏起来的 #filePick——手机上这两件事
5397
+ // 本来就是同一个系统选择器,区别只在「要不要限定图片、要不要调后置摄像头」。
5398
+ //
5399
+ // **多选只给「选文件」**(2026-10-05 用户要求:图库和文件里只能单选,改成多选)。
5400
+ // 拍照那一条不加 multiple:相机一次就出一张,摆个多选没有意义。
5401
+ function openFilePicker(camera) {
5402
+ var pick = $('filePick');
5403
+ if (!pick) return;
5404
+ if (camera) {
5405
+ pick.setAttribute('accept', 'image/*');
5406
+ pick.setAttribute('capture', 'environment');
5407
+ pick.removeAttribute('multiple');
5408
+ } else {
5409
+ pick.removeAttribute('accept');
5410
+ pick.removeAttribute('capture');
5411
+ pick.setAttribute('multiple', '');
5412
+ }
5413
+ pick.click();
5414
+ }
5415
+ $('opsPick').addEventListener('click', function () { openFilePicker(false); });
5416
+ $('opsShot').addEventListener('click', function () { openFilePicker(true); });
5417
+
5418
+ // 面板里那行已选文件上的 ✕。走的是和小条同一个 dropUpload()。
5419
+ $('opsFiles').addEventListener('click', function (e) {
5420
+ var btn = e.target && e.target.closest ? e.target.closest('[data-drop]') : null;
5421
+ if (!btn) return;
5422
+ dropUpload(btn);
5423
+ });
5424
+
5425
+ // 指令表换了一份(换会话、第一次取到)时,面板开着就跟着重画。
5426
+ onCommandsReady = function () { if (opsOpen) paintOps(); };
5427
+
5428
+ $('btnAttach').addEventListener('click', function () { openOps(); });
3490
5429
  $('filePick').addEventListener('change', function (e) {
3491
- var f = e.target.files && e.target.files[0];
5430
+ // 多选:一次可能挑了好几张,**一个接一个传**(2026-10-05 用户要求多选)。
5431
+ // 为什么不用并发:上传的「忙碌」是同一个开关(setAttachBusy 控制那个回形针),
5432
+ // 并发传的话最后一个传完就把按钮放开了,其实还有在飞的——用户会以为传完了。
5433
+ // 排队传则从头到尾都是忙的,什么时候能再点一目了然。
5434
+ var files = Array.prototype.slice.call(e.target.files || []);
3492
5435
  // 清掉 value:不清的话,连着选同一个文件第二次不会触发 change,
3493
5436
  // 用户会觉得「点了没反应」。
3494
5437
  e.target.value = '';
3495
- if (f) uploadFile(f);
5438
+ if (!files.length) return;
5439
+ // **选完就回主页面**(2026-10-05 用户要求:选完文件点完成,直接回到主页面,
5440
+ // 不要停在这张「添加文件」面板上)。
5441
+ // 关在这里是安全的:文件是**后台在传**的,主页面上的附件小条会一条条冒出来;
5442
+ // 面板里那几行「已选文件」本来也只是同一份数据的另一处画法(见 renderAttach),
5443
+ // 关掉面板不会打断上传。
5444
+ closeOps();
5445
+ files.reduce(function (chain, f) {
5446
+ return chain.then(function () { return uploadFile(f); });
5447
+ }, Promise.resolve());
3496
5448
  });
3497
5449
  $('attachBar').addEventListener('click', function (e) {
3498
5450
  var btn = e.target.closest ? e.target.closest('[data-drop]') : null;
3499
5451
  if (!btn) return;
3500
- state.uploads.splice(Number(btn.getAttribute('data-drop')), 1);
3501
- renderAttach();
5452
+ dropUpload(btn);
3502
5453
  });
3503
5454
 
3504
5455
  $('btnSend').addEventListener('click', send);
5456
+
3505
5457
  inputEl.addEventListener('input', autoGrow);
3506
5458
  // 每敲一个字重算一次菜单。它自己会先看「是不是一个没打完的 / 名字」再决定动不动,
3507
5459
  // 所以这里不用加判据。
@@ -3532,10 +5484,451 @@
3532
5484
  $('btnSettings').addEventListener('click', function () {
3533
5485
  sheet.classList.add('open');
3534
5486
  $('connHint').textContent = state.connected ? '已连接' : '未连接';
5487
+ // 权限档位那块也在这儿重读一次:它显示的是「现在在哪一档」的实时状态(电脑端
5488
+ // 随时能改),而且启动时那一次要是没读成,不重读就整块再也不出现。
5489
+ loadPermissions(state.boundSessionId);
5490
+ // 版本号那行也现读一次:它只在设置页里看得见,没必要开机就为它跑一趟。
5491
+ // (watchBuild 是同一个 script 块里的函数声明,这里调得到。)
5492
+ watchBuild();
5493
+ // 繁忙时的发送行为也现读一次:**电脑上随时能改**,开机读那一次会过期。
5494
+ loadBusyEnter();
3535
5495
  });
3536
5496
  $('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
5497
+ // 顶部吸顶那个关闭口(2026-10-05 设置页改整屏时加的)。
5498
+ $('btnSettingsClose').addEventListener('click', function () { sheet.classList.remove('open'); });
3537
5499
  sheet.addEventListener('click', function (e) { if (e.target === sheet) sheet.classList.remove('open'); });
3538
5500
 
5501
+ // ---------------- 子智能体 ----------------
5502
+ //
5503
+ // 数据全从 /mini/api/subagents 那三条来(见 lib/server.js)。三条都在电脑上的
5504
+ // **同一个进程里直接问 DSH**,不经过 WebSocket——靠进程里这层关系拿到的东西
5505
+ // (清单、某一道自己的运行记录)反而比桌面前端更直接:桌面那边只能隔着一条
5506
+ // 只开了两个口子的线路,清单和记录得靠投影另推一份过来。
5507
+ //
5508
+ // 两条纪律,和主会话一模一样:**读不到就说读不到,不编**;**数字算不出来就空着**。
5509
+ var saPanel = $('subagents');
5510
+ var saRows = []; // 最近一次读到的清单,点行时按 id 找回来
5511
+ var saCurrent = null; // 正在看运行记录的那一道;null = 现在在看清单
5512
+ var saCache = null;
5513
+ var saCacheSession = null;
5514
+ var saCacheAt = 0;
5515
+ var saInflight = null;
5516
+ var SA_CACHE_TTL = 5000;
5517
+
5518
+ function saToList() {
5519
+ saCurrent = null;
5520
+ $('subagentsDoc').hidden = true;
5521
+ $('subagentsList').hidden = false;
5522
+ $('subagentsBack').hidden = true;
5523
+ $('subagentsTitle').textContent = '子智能体';
5524
+ }
5525
+ function saToDoc(row) {
5526
+ saCurrent = row;
5527
+ $('subagentsList').hidden = true;
5528
+ $('subagentsDoc').hidden = false;
5529
+ $('subagentsBack').hidden = false;
5530
+ $('subagentsTitle').textContent = row.label || shortSessionId(row.id);
5531
+ }
5532
+
5533
+ /** 用量(token 数)。读不到就是 null,页面上写「—」——写 0 会被读成「一次都没跑过」。 */
5534
+ function saTokens(n) {
5535
+ if (typeof n !== 'number' || !isFinite(n) || n < 0) return '—';
5536
+ if (n < 1000) return String(n);
5537
+ if (n < 1000000) return (n / 1000).toFixed(1) + 'k';
5538
+ return (n / 1000000).toFixed(1) + 'M';
5539
+ }
5540
+ /** 耗时。同样:读不到写「—」。 */
5541
+ function saDuration(ms) {
5542
+ if (typeof ms !== 'number' || !isFinite(ms) || ms < 0) return '—';
5543
+ var s = Math.round(ms / 1000);
5544
+ if (s < 60) return s + ' 秒';
5545
+ var m = Math.floor(s / 60);
5546
+ if (m < 60) return m + ' 分 ' + (s % 60) + ' 秒';
5547
+ return Math.floor(m / 60) + ' 时 ' + (m % 60) + ' 分';
5548
+ }
5549
+ /** 目录读不动的那三种原因,翻成人话。认不出来就照原样说,不编一个像样的理由。 */
5550
+ function saReason(r) {
5551
+ if (r === 'corrupt') return '那一支的目录坏了';
5552
+ if (r === 'unsupported') return '那条记录是更新的格式,这个版本读不了';
5553
+ return '那一支暂时读不到(' + r + ')';
5554
+ }
5555
+
5556
+ function saRowHtml(s) {
5557
+ // 有一支目录读不动:DSH 会以一行「诊断」如实说原因。这里也如实转达,
5558
+ // 不假装那一支里没有人——吞掉的表现是「明明有子代理却一个都不显示」。
5559
+ if (s.diagnostic) {
5560
+ return '<div class="sa-row sa-plain"><span class="dot err"></span>'
5561
+ + '<span class="sa-main"><span class="sa-name">有一支读不出来</span>'
5562
+ + '<span class="sa-sub">' + escapeHtml(saReason(s.diagnostic)) + '</span></span></div>';
5563
+ }
5564
+ // 状态点用既有 .dot,和顶栏那颗同一套:在跑 = run;没在跑但最近跑完过 = ok;
5565
+ // 其余 = 灰。**没有动画**——那圈呼吸闪动在规格里已经被砍掉了(见 .dot 的注释)。
5566
+ var dot = s.running ? 'dot run' : (s.lastTurnCompleted ? 'dot ok' : 'dot');
5567
+ var kind = s.mode === 'continuable' ? '可续接' : '一次性';
5568
+ return '<button class="sa-row" type="button" data-sa="' + escapeHtml(s.id) + '">'
5569
+ + '<span class="' + dot + '"></span>'
5570
+ + '<span class="sa-main"><span class="sa-name">' + escapeHtml(s.label || shortSessionId(s.id)) + '</span>'
5571
+ + '<span class="sa-sub">' + kind + ' · ' + (s.running ? '在跑' : '已结束') + '</span></span>'
5572
+ + '<span class="sa-num">' + saTokens(s.tokens) + '<br>' + saDuration(s.durationMs) + '</span>'
5573
+ + '</button>';
5574
+ }
5575
+
5576
+ function saRenderList(body) {
5577
+ var listEl = $('subagentsList');
5578
+ // 这两句空态都落在 .sa-list 里,而那个容器用负 16px 把内容拽到贴屏边
5579
+ // (为了让分隔线横贯整屏)——所以得补上 sa-none-indent 把 16px 加回来,
5580
+ // 否则这行字会顶到屏幕最左边。这个类是早就写好的,之前没给空态用上。
5581
+ if (!body || !body.ok) {
5582
+ listEl.innerHTML = '<div class="sa-none sa-none-indent">'
5583
+ + escapeHtml((body && body.error) || '读不到子智能体。') + '</div>';
5584
+ return;
5585
+ }
5586
+ saRows = body.subagents || [];
5587
+ if (!saRows.length) {
5588
+ listEl.innerHTML = '<div class="sa-none sa-none-indent">这个会话还没有派出过子智能体。</div>';
5589
+ return;
5590
+ }
5591
+ listEl.innerHTML = saRows.map(saRowHtml).join('');
5592
+ var btns = listEl.querySelectorAll('[data-sa]');
5593
+ for (var i = 0; i < btns.length; i++) {
5594
+ (function (btn) {
5595
+ btn.addEventListener('click', function () { saOpenDoc(btn.getAttribute('data-sa')); });
5596
+ })(btns[i]);
5597
+ }
5598
+ }
5599
+
5600
+ function saLoad(force) {
5601
+ var sessionId = state.boundSessionId || '';
5602
+ if (!force && saCache && saCacheSession === sessionId && Date.now() - saCacheAt < SA_CACHE_TTL) {
5603
+ saRenderList(saCache);
5604
+ return;
5605
+ }
5606
+ if (!force && saInflight && saInflight.sessionId === sessionId) return;
5607
+ $('subagentsList').innerHTML = '<div class="sa-none sa-none-indent">正在读取…</div>';
5608
+ var request = api('/mini/api/subagents');
5609
+ saInflight = { sessionId: sessionId, request: request };
5610
+ request.then(function (body) {
5611
+ if (state.boundSessionId === sessionId && body && body.ok) {
5612
+ saCache = body;
5613
+ saCacheSession = sessionId;
5614
+ saCacheAt = Date.now();
5615
+ }
5616
+ saRenderList(body);
5617
+ }).catch(function (err) {
5618
+ $('subagentsList').innerHTML = '<div class="sa-none sa-none-indent">'
5619
+ + escapeHtml(err && err.message || '没读出来。') + '</div>';
5620
+ }).finally(function () {
5621
+ if (saInflight && saInflight.request === request) saInflight = null;
5622
+ });
5623
+ }
5624
+
5625
+ // 连接后趁页面空闲预取当前会话的子代理清单。首次读取可能很慢,不能把这段等待
5626
+ // 放到用户点入口之后;入口点击时会复用 saInflight,和导航工作区同一套策略。
5627
+ function prefetchSubagents() {
5628
+ var sessionId = state.boundSessionId || '';
5629
+ if (!sessionId || saCacheSession === sessionId || saInflight) return;
5630
+ var request = api('/mini/api/subagents');
5631
+ saInflight = { sessionId: sessionId, request: request };
5632
+ request.then(function (body) {
5633
+ if (state.boundSessionId !== sessionId || !body || !body.ok) return;
5634
+ saCache = body;
5635
+ saCacheSession = sessionId;
5636
+ saCacheAt = Date.now();
5637
+ if (saPanel.classList.contains('open') && !saCurrent) saRenderList(body);
5638
+ }).catch(function () {
5639
+ // 预取失败不影响入口;用户点开时仍会重试。
5640
+ }).finally(function () {
5641
+ if (saInflight && saInflight.request === request) saInflight = null;
5642
+ });
5643
+ }
5644
+
5645
+ /** 点开某一道:换成它**自己的运行记录**(和主会话同一套重放,见 lib/history.js)。 */
5646
+ function saOpenDoc(childId) {
5647
+ var row = null;
5648
+ for (var i = 0; i < saRows.length; i++) if (saRows[i].id === childId) row = saRows[i];
5649
+ if (!row) return;
5650
+ saToDoc(row);
5651
+ $('subagentsDoc').innerHTML = '<div class="sa-none">正在读取它的记录…</div>';
5652
+ api('/mini/api/subagent?id=' + encodeURIComponent(childId)).then(function (body) {
5653
+ // 读的过程中用户可能已经返回、或点开了另一道——那这一份就别再往上贴了。
5654
+ if (saCurrent !== row) return;
5655
+ saRenderDoc(body);
5656
+ }).catch(function (err) {
5657
+ if (saCurrent !== row) return;
5658
+ $('subagentsDoc').innerHTML = '<div class="sa-none">'
5659
+ + escapeHtml(err && err.message || '没读出来。') + '</div>';
5660
+ });
5661
+ }
5662
+
5663
+ /** 运行记录里的一条。长相和主会话**完全一样**:气泡/正文/指令行/完工卡片共用同一批样式。 */
5664
+ function saEntryHtml(e) {
5665
+ if (e.role === 'notice') return noticeBlock(e);
5666
+ if (e.role === 'command') return commandBlock(e);
5667
+ if (e.role === 'user') {
5668
+ return '<div class="bubble user">' + escapeHtml(e.text || '')
5669
+ + '<span class="meta">' + timeLabel(e.timestamp) + '</span></div>';
5670
+ }
5671
+ return '<div class="said"><div class="md">' + mdToHtml(e.text || '') + '</div>'
5672
+ + '<div class="reply-foot"><span class="time">' + timeLabel(e.timestamp) + '</span></div></div>';
5673
+ }
5674
+
5675
+ function saRenderDoc(body) {
5676
+ var docEl = $('subagentsDoc');
5677
+ if (!body || !body.ok) {
5678
+ docEl.innerHTML = '<div class="sa-none">'
5679
+ + escapeHtml((body && body.error) || '没读出来。') + '</div>';
5680
+ return;
5681
+ }
5682
+ var html = '';
5683
+ if (body.reclaimed) {
5684
+ // 一次性子智能体跑完、记录已经被回收:**如实说没有记录**,不拿它冒充
5685
+ // 「它没干过活」——那两件事对用户含义不同。
5686
+ html += '<div class="sa-none">它的运行记录已经不在电脑上了,'
5687
+ + '只能看到它当时发回来的那条完工通知。</div>';
5688
+ } else if (!body.entries || !body.entries.length) {
5689
+ html += '<div class="sa-none">它还没有任何记录。</div>';
5690
+ } else {
5691
+ if (body.truncated) {
5692
+ html += '<div class="history-note">'
5693
+ + escapeHtml(body.note || '只显示最近一段。') + '</div>';
5694
+ }
5695
+ html += body.entries.map(saEntryHtml).join('');
5696
+ }
5697
+ // 底部:能做什么**由类型和状态决定**,不写死。三条失败理由都由服务端翻成人话
5698
+ // (见 lib/index.js 的 SUBAGENT_FAILURES),页面只负责把它们显示出来。
5699
+ html += body.mode === 'continuable' ? saActsHtml(body)
5700
+ : '<div class="sa-none">这是一次性子智能体:跑完就结束了,接不了话,'
5701
+ + '只能看它跑过什么。</div>';
5702
+ docEl.innerHTML = html;
5703
+ saBindActs(body);
5704
+ }
5705
+
5706
+ function saActsHtml(body) {
5707
+ var html = '';
5708
+ // 停:只对「正在跑的、可续接的」那一轮有意义。已经停稳的子智能体没有可停的东西。
5709
+ if (body.running) {
5710
+ html += '<div class="sa-acts"><button class="btn" id="saStop">停止这一轮</button></div>';
5711
+ }
5712
+ // 继续说话:只有「可续接」才有这一栏。父会话不在线、并发满了,DSH 都会明确拒绝,
5713
+ // 那时候服务端把理由翻成人话给回来,这里照原样弹出来。
5714
+ html += '<div class="sa-ask">'
5715
+ + '<input id="saAskText" type="text" placeholder="跟它说一句…" autocomplete="off"'
5716
+ + ' autocapitalize="off" autocorrect="off" spellcheck="false">'
5717
+ + '<button class="btn primary" id="saSend">发送</button></div>';
5718
+ return html;
5719
+ }
5720
+
5721
+ function saBindActs(body) {
5722
+ var stop = $('saStop');
5723
+ if (stop) stop.addEventListener('click', function () { saStop(body, stop); });
5724
+ var send = $('saSend');
5725
+ if (send) send.addEventListener('click', function () { saAsk(body, send); });
5726
+ }
5727
+
5728
+ function saStop(body, btn) {
5729
+ btn.disabled = true;
5730
+ api('/mini/api/subagent/stop', {
5731
+ method: 'POST',
5732
+ body: JSON.stringify({ id: body.id, mode: body.mode }),
5733
+ }).then(function () {
5734
+ // 递进去不等于停稳(和主会话那条一样):重读一次,等它的状态自己翻过来。
5735
+ toast('已经让它停下这一轮。');
5736
+ saOpenDoc(body.id);
5737
+ }).catch(function (err) {
5738
+ toast(err && err.message || '停不下来。');
5739
+ btn.disabled = false;
5740
+ });
5741
+ }
5742
+
5743
+ function saAsk(body, btn) {
5744
+ var input = $('saAskText');
5745
+ var text = input ? input.value.trim() : '';
5746
+ if (!text) { toast('先说点什么。'); return; }
5747
+ btn.disabled = true;
5748
+ api('/mini/api/subagent/ask', {
5749
+ method: 'POST',
5750
+ body: JSON.stringify({ id: body.id, mode: body.mode, text: text }),
5751
+ }).then(function () {
5752
+ toast('已经递过去了。');
5753
+ saOpenDoc(body.id);
5754
+ }).catch(function (err) {
5755
+ toast(err && err.message || '没送出去。');
5756
+ btn.disabled = false;
5757
+ });
5758
+ }
5759
+
5760
+ $('btnSubagents').addEventListener('click', function () {
5761
+ saToList();
5762
+ saPanel.classList.add('open');
5763
+ saLoad();
5764
+ });
5765
+ $('subagentsClose').addEventListener('click', function () { saPanel.classList.remove('open'); });
5766
+
5767
+ // ---------------- 插件页面(2026-10-04) ----------------
5768
+ // 从设置里点「插件」→ 立刻盖到设置上面(#plugins 的 z-index 比 #settings 高一档);
5769
+ // 关掉它就回到设置,不用退出设置再去找。分层和两条留白见上面 #plugins 那段 CSS。
5770
+ var pluginsPanel = $('plugins');
5771
+ $('btnPlugins').addEventListener('click', function () {
5772
+ pluginsPanel.classList.add('open');
5773
+ loadPlugins();
5774
+ });
5775
+ $('pluginsClose').addEventListener('click', function () { pluginsPanel.classList.remove('open'); });
5776
+ // 点浮层空白处也关掉(和子智能体面板同一套)。
5777
+ pluginsPanel.addEventListener('click', function (e) {
5778
+ if (e.target === pluginsPanel) pluginsPanel.classList.remove('open');
5779
+ });
5780
+
5781
+ function loadPlugins() {
5782
+ var listEl = $('pluginsList');
5783
+ listEl.innerHTML = pluginNote('正在读取插件列表…');
5784
+ api('/mini/api/plugins').then(function (r) {
5785
+ listEl.innerHTML = renderPlugins(r || {});
5786
+ }).catch(function (err) {
5787
+ listEl.innerHTML = pluginNote('读不到插件列表:' + escapeHtml(err.message || '出错了'), true);
5788
+ });
5789
+ }
5790
+
5791
+ /**
5792
+ * 三块,和 PC 的「插件」页 + 「内置插件」页对齐:
5793
+ *
5794
+ * 官方 —— 能装还没装的(npm 包那一层)
5795
+ * 已安装 · 内置 —— **Cordis Loader 的实时条目表**,分「全局插件」和「会话插件」。
5796
+ * 这才是 DSH 的本体粒度:一整套系统是几百个插件模块拼起来的。
5797
+ * (2026-10-04 用户指出:内置插件拿 npm 包充数是不对的,那只有几个。)
5798
+ * 已安装 · 第三方 —— 用户自己装的包。DSH 自家的包不在这里列——它们在上面
5799
+ * 以「模块」的身份出现,那才是「一切皆插件」该有的样子。
5800
+ */
5801
+ function renderPlugins(r) {
5802
+ var bundles = r.bundles || [];
5803
+ var official = bundles.filter(function (b) { return b.optional && !b.installed; });
5804
+ var third = bundles.filter(function (b) {
5805
+ return b.installed && b.name.indexOf('@deepseek-ai/') !== 0;
5806
+ });
5807
+ var entries = r.entries || [];
5808
+ var presets = r.presets || [];
5809
+ var sessionRows = 0;
5810
+ presets.forEach(function (p) { sessionRows += (p.rows || []).length; });
5811
+
5812
+ var html = '';
5813
+ if (r.bundlesError) html += pluginNote('包清单读不到:' + escapeHtml(r.bundlesError), true);
5814
+ if (r.officialError) html += pluginNote('官方卡片读不到:' + escapeHtml(r.officialError), true);
5815
+ // 官方那一栏是两样拼的(PC 也是这个次序):可开关的官方包在前,
5816
+ // 注册了设置卡片的官方插件(终端 / Agent 循环 / 子智能体 / 网页搜索)在后。
5817
+ html += pluginGroup('官方', official.concat(r.official || []), officialRow);
5818
+
5819
+ if (r.builtinError) {
5820
+ html += pluginNote('内置插件读不到:' + escapeHtml(r.builtinError), true);
5821
+ } else {
5822
+ // 内置这一层自己也是一个可折的分组,里面再套「全局插件」「会话插件」两层。
5823
+ var inner = '';
5824
+ if (entries.length) inner += pluginGroup('全局插件', entries, invRow, 2);
5825
+ if (sessionRows) {
5826
+ // 这一层的数目是**四个预设各自行数之和**:同一个插件被好几个预设都带上,
5827
+ // 所以会重复计(实测共 95 行、其实只有 32 个不重复的插件)。
5828
+ // 这里**不再拿括号去解释**(2026-10-05 用户要那句去掉)——下面每个预设
5829
+ // 各报各的数(29/30/6/30),加起来就是这个数,顺着看下去就能对上。
5830
+ var sessionInner = '';
5831
+ presets.forEach(function (p) {
5832
+ var rows = p.rows || [];
5833
+ if (!rows.length) return;
5834
+ if (p.broken) sessionInner += pluginNote('这个预设的组合读不出来:' + escapeHtml(p.broken), true);
5835
+ var body = rows.map(invRow).join('');
5836
+ // 只有一个预设就不必再套一层;多预设才把预设名露出来(PC 那边是个下拉)。
5837
+ // 「(默认)」照 PC 标上:它说的是「不指定模式时按它来」,用户得有这个参照。
5838
+ sessionInner += presets.length > 1
5839
+ ? pluginFold(p.name + (p.isDefault ? '(默认)' : ''), rows.length, body, 2)
5840
+ : body;
5841
+ });
5842
+ inner += pluginFold('会话插件', sessionRows, sessionInner, 2);
5843
+ }
5844
+ html += inner
5845
+ ? pluginFold('已安装 · 内置', entries.length + sessionRows, inner, 1)
5846
+ : pluginNote('这台电脑没报出内置插件。');
5847
+ }
5848
+
5849
+ html += pluginGroup('已安装 · 第三方', third, bundleRow);
5850
+ if (!html) html = pluginNote('没有插件。');
5851
+ return html;
5852
+ }
5853
+
5854
+ function pluginNote(text, isErr) {
5855
+ return '<div class="' + (isErr ? 'plugin-note' : 'sa-none sa-none-indent') + '">' + text + '</div>';
5856
+ }
5857
+
5858
+ /**
5859
+ * 一层**可折叠**的分组。默认收起——HTML 里不写 open 属性就自然是收着的,
5860
+ * 「默认折叠」不需要额外的代码去保证。没有正文的空层整个不画(点开是个空壳没意义)。
5861
+ *
5862
+ * `depth` 只影响字号轻重(1 = 官方 / 已安装那一级,2 = 里面那两级)。
5863
+ * `note` 是接在计数后面的小注(比如「32 个不重复,4 个预设各算各的」)。
5864
+ */
5865
+ function pluginFold(title, count, bodyHtml, depth, note) {
5866
+ if (!bodyHtml) return '';
5867
+ return '<details class="plugin-fold depth-' + (depth || 1) + '"><summary>'
5868
+ + escapeHtml(title) + ' ' + count + (note ? '(' + escapeHtml(note) + ')' : '')
5869
+ + '</summary>' + bodyHtml + '</details>';
5870
+ }
5871
+
5872
+ /** 一堆插件:可折的一层。空的那堆整个不画(不占地方、也不说假话)。 */
5873
+ function pluginGroup(title, rows, rowFn, depth) {
5874
+ if (!rows.length) return '';
5875
+ return pluginFold(title, rows.length, rows.map(rowFn).join(''), depth);
5876
+ }
5877
+
5878
+ function rowHtml(name, desc, badge, descCls) {
5879
+ return '<div class="plugin-row">'
5880
+ + '<div class="plugin-info">'
5881
+ + '<span class="plugin-name">' + escapeHtml(name) + '</span>'
5882
+ + (desc ? '<span class="plugin-desc' + (descCls || '') + '">' + escapeHtml(desc) + '</span>' : '')
5883
+ + '</div>'
5884
+ + (badge ? '<span class="plugin-badge ' + badge[0] + '">' + escapeHtml(badge[1]) + '</span>' : '')
5885
+ + '</div>';
5886
+ }
5887
+
5888
+ /**
5889
+ * 一行「包」(官方 / 第三方)。显示名用服务端给的 `title`(本地化标题),
5890
+ * 没有就是包名——官方插件在 PC 上显示的正是「智能体团队」这类中文名。
5891
+ */
5892
+ function bundleRow(b) {
5893
+ var desc = b.description || '';
5894
+ if (b.problem) desc = desc ? desc + ' · ' + b.problem : b.problem;
5895
+ return rowHtml(b.title || b.name, desc,
5896
+ [b.enabled ? 'on' : 'off', b.enabled ? '已启用' : '已停用'],
5897
+ b.problem ? ' plugin-problem' : '');
5898
+ }
5899
+
5900
+ /**
5901
+ * 官方那一栏的一行:两种东西都在这里。
5902
+ * · 包(bundles)——带启用状态,能开关;
5903
+ * · 设置卡片(cards)——不是开关,是一张设置页,所以没有状态徽标(PC 上也是这样)。
5904
+ */
5905
+ function officialRow(item) {
5906
+ if (item.kind === 'card') return rowHtml(item.title, item.description || '', null);
5907
+ return bundleRow(item);
5908
+ }
5909
+
5910
+ /**
5911
+ * 一行「内置插件」(Loader 条目 / 预设组合行)。`title` 是服务端压好的短名
5912
+ * (`@deepseek-ai/dsh-tool-fs` → `tool-fs`),照 PC 的内置插件面板。
5913
+ */
5914
+ function invRow(e) {
5915
+ return rowHtml(e.title || e.moduleName, e.description || '', invBadge(e));
5916
+ }
5917
+
5918
+ /** 运行时状态。**问不到就不编**——预设没挂载时那一行本来就没有运行状态。 */
5919
+ function invBadge(e) {
5920
+ if (e.phase === 'failed') return ['err', '失败'];
5921
+ if (e.conditional) return ['off', '按条件'];
5922
+ if (!e.enabled) return ['off', '已停用'];
5923
+ if (e.phase === 'active') return ['on', '运行中'];
5924
+ if (e.phase === 'loading' || e.phase === 'pending') return ['off', '加载中'];
5925
+ return null;
5926
+ }
5927
+ $('subagentsBack').addEventListener('click', saToList);
5928
+ saPanel.addEventListener('click', function (e) {
5929
+ if (e.target === saPanel) saPanel.classList.remove('open');
5930
+ });
5931
+
3539
5932
  // ---------------- 权限档位 ----------------
3540
5933
  //
3541
5934
  // 这是把 PC 端那道授权闸门搬到了手机上:**手机从此能指挥 Agent 把电脑提到
@@ -3553,17 +5946,53 @@
3553
5946
  // 映射不到时会退回标识符本身,所以这里拿到的一定是个真名字,不会是空格。
3554
5947
  // 页面只管显示拿到的东西。
3555
5948
  var permOptions = null; // null = 还没读到(或这台电脑没这个能力)
5949
+ var permFor = null; // 手上这一份 currentValue 是**哪个会话**的
3556
5950
  var permArmed = ''; // 哪一档正在等「再点一次」
3557
5951
 
3558
- function loadPermissions() {
5952
+ /**
5953
+ * 读一次档位盘。
5954
+ *
5955
+ * **这是个要反复读的动作,不是启动时读一次就够。** 三个理由:
5956
+ * · 答案会变——电脑端随时能改档位,而这一块的作用就是「标出现在是哪一档」;
5957
+ * · 一次读不到就会永远缺这一块——宿主刚重启、绑定会话还没装进 `ctx.agents`
5958
+ * 那段时间里,服务端如实回 `no-session`(见 lib/permissions.js 的 listPresets)。
5959
+ * 启动时那一次正好撞上,不重读就再也回不来,只有整页刷新能让它出现。
5960
+ * 所以设置抽屉每次打开都重读(见上面「设置」那一段);
5961
+ * · **`currentValue` 是每个会话各一份的**(宿主的 `permission/preset` 记在会话自己
5962
+ * 的日志里,`service.current(session)` 读的是这个会话那份投影)。换了会话就不能
5963
+ * 拿上一个会话那一档接着显示——那是在说一个假话。所以换会话也要重读
5964
+ * (见 applySnapshot 里换会话那一段)。
5965
+ *
5966
+ * 传参就是「按这个会话读」;不传是「还是这个会话,重读一次」。
5967
+ *
5968
+ * 读不到时**只在这块本来就空着的情况下才藏起来**:已经画出来的档位不许因为一次
5969
+ * 读失败当场消失——那是把「此刻读不到」错当成「这台电脑没有这个能力」。
5970
+ * 换了会话则相反:那一份高亮**必须**当场撤掉,因为它不是这个会话的值。
5971
+ * 档位列表本身留着不动——那是宿主的本子,所有会话共用一份。
5972
+ */
5973
+ function loadPermissions(forSession) {
5974
+ var session = forSession === undefined ? permFor : (forSession || null);
5975
+ if (session !== permFor) {
5976
+ permFor = session;
5977
+ permArmed = ''; // 「再点一次」那半次确认也是上一个会话的,不跟着走
5978
+ if (permOptions) {
5979
+ permOptions.currentValue = '';
5980
+ renderPerms();
5981
+ }
5982
+ }
3559
5983
  api('/mini/api/permissions').then(function (body) {
3560
- if (!body || !body.ok) { $('permBlock').hidden = true; return; }
5984
+ // 请求飞在路上时用户又切走了:这一份属于上一个会话,扔掉。
5985
+ if (session !== permFor) return;
5986
+ if (!body || !body.ok) {
5987
+ if (!permOptions) $('permBlock').hidden = true;
5988
+ return;
5989
+ }
3561
5990
  permOptions = body;
3562
5991
  renderPerms();
3563
5992
  }).catch(function () {
3564
- // 读不到就整块不显示。附加能力缺了,不该影响发指令。
3565
- permOptions = null;
3566
- $('permBlock').hidden = true;
5993
+ if (session !== permFor) return;
5994
+ // 附加能力缺了不该影响发指令:一个档位都没读到过时,整块不显示。
5995
+ if (!permOptions) $('permBlock').hidden = true;
3567
5996
  });
3568
5997
  }
3569
5998
 
@@ -3630,7 +6059,7 @@
3630
6059
  });
3631
6060
  }
3632
6061
 
3633
- loadPermissions();
6062
+ loadPermissions(state.boundSessionId);
3634
6063
 
3635
6064
  // 档位按钮的点击。
3636
6065
  //
@@ -3651,10 +6080,12 @@
3651
6080
  Array.prototype.forEach.call($('segMode').children, function (btn) {
3652
6081
  btn.addEventListener('click', function () {
3653
6082
  state.mode = btn.dataset.mode;
3654
- $('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
6083
+ $('hintMode').textContent = modeHint();
3655
6084
  localStorage.setItem('dshMiniMode', state.mode);
3656
6085
  syncSeg();
3657
6086
  render();
6087
+ // 切到「完整」:订上并把已有的过程拉下来;切走:退订、停推。
6088
+ syncTrajectorySubscription();
3658
6089
  });
3659
6090
  });
3660
6091
  Array.prototype.forEach.call($('segSound').children, function (btn) {
@@ -3698,6 +6129,21 @@
3698
6129
  }
3699
6130
  applyTheme(); // 补上首屏那次没做的一件事:把状态栏颜色对齐
3700
6131
 
6132
+ // ---------------- 字体大小(小 / 标准 / 大) ----------------
6133
+ function applyFontSize() {
6134
+ var sizes = { small: '13px', normal: '15.5px', large: '18px' };
6135
+ document.documentElement.style.setProperty('--font-base', sizes[state.fontSize] || sizes.normal);
6136
+ }
6137
+ Array.prototype.forEach.call($('segFont').children, function (btn) {
6138
+ btn.addEventListener('click', function () {
6139
+ state.fontSize = btn.dataset.font;
6140
+ localStorage.setItem('dshMiniFont', state.fontSize);
6141
+ applyFontSize();
6142
+ syncSeg();
6143
+ });
6144
+ });
6145
+ applyFontSize(); // 首屏应用
6146
+
3701
6147
  function syncSeg() {
3702
6148
  Array.prototype.forEach.call($('segMode').children, function (b) {
3703
6149
  b.classList.toggle('active', b.dataset.mode === state.mode);
@@ -3708,6 +6154,9 @@
3708
6154
  Array.prototype.forEach.call($('segTheme').children, function (b) {
3709
6155
  b.classList.toggle('active', b.dataset.theme === state.theme);
3710
6156
  });
6157
+ Array.prototype.forEach.call($('segFont').children, function (b) {
6158
+ b.classList.toggle('active', b.dataset.font === state.fontSize);
6159
+ });
3711
6160
  }
3712
6161
 
3713
6162
  // 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
@@ -3756,6 +6205,71 @@
3756
6205
  });
3757
6206
  }
3758
6207
 
6208
+ /**
6209
+ * 繁忙时的发送行为(2026-10-05 用户要求搬进手机设置)。
6210
+ *
6211
+ * 这是 PC「设置 → 通用」里那一条的同款:智能体跑着的时候,你发的话是
6212
+ * 排队等这一轮跑完,还是当场插进去让它调整方向。**两端共用宿主那一份设置**,
6213
+ * 所以这里改完,电脑上也是这个值——这是用户拍板要的(同一个开关,别两端各存一份)。
6214
+ *
6215
+ * **读不到就整条不画**:拿不到宿主设置时把那一行藏起来,不摆一个点了没用的开关。
6216
+ * 这和插件页「宁可少画,不凭空多画」是同一条口径。
6217
+ *
6218
+ * 写的时候带上服务端给的 revision(乐观锁):万一电脑上刚改过,这次写入会被挡下来,
6219
+ * 而不是把对方那次改动悄悄盖掉——那种覆盖用户看不见,最难查。
6220
+ */
6221
+ var busyEnter = { current: 'queue', revision: null, ready: false };
6222
+ function paintBusyEnter() {
6223
+ Array.prototype.forEach.call($('segBusyEnter').children, function (b) {
6224
+ b.classList.toggle('active', b.dataset.busy === busyEnter.current);
6225
+ });
6226
+ }
6227
+ function loadBusyEnter() {
6228
+ api('/mini/api/busy-enter').then(function (r) {
6229
+ if (!r || !r.ok) {
6230
+ // 这台电脑没提供这个设置(老版本 DSH、或设置文档里没这一项)。
6231
+ // 藏起来就好,不用报错——那不是「出错」,是「这里本来就没有」。
6232
+ $('rowBusyEnter').hidden = true;
6233
+ return;
6234
+ }
6235
+ busyEnter.current = r.current === 'steer' ? 'steer' : 'queue';
6236
+ busyEnter.revision = typeof r.revision === 'number' ? r.revision : null;
6237
+ busyEnter.ready = true;
6238
+ $('rowBusyEnter').hidden = false;
6239
+ paintBusyEnter();
6240
+ }).catch(function () {
6241
+ $('rowBusyEnter').hidden = true;
6242
+ });
6243
+ }
6244
+ $('segBusyEnter').addEventListener('click', function (e) {
6245
+ var btn = e.target.closest ? e.target.closest('button[data-busy]') : null;
6246
+ if (!btn || !busyEnter.ready) return;
6247
+ var next = btn.dataset.busy;
6248
+ if (next === busyEnter.current) return;
6249
+ // 先画再发:这一下是用户自己按的,立刻有反馈;发失败了再画回去并说清。
6250
+ var prev = busyEnter.current;
6251
+ busyEnter.current = next;
6252
+ paintBusyEnter();
6253
+ api('/mini/api/busy-enter', {
6254
+ method: 'POST',
6255
+ body: JSON.stringify({ value: next }),
6256
+ }).then(function (r) {
6257
+ if (r && r.ok) {
6258
+ toast(r.current === 'steer' ? '繁忙时改为插话发送' : '繁忙时改为排队发送');
6259
+ busyEnter.revision = typeof r.revision === 'number' ? r.revision : busyEnter.revision;
6260
+ return;
6261
+ }
6262
+ // 失败要画回去——留着那个没生效的选中态,用户会以为改成功了。
6263
+ busyEnter.current = prev;
6264
+ paintBusyEnter();
6265
+ toast(r && r.reason === 'unavailable' ? '这台电脑改不了这一项' : '没能改成,再试一次');
6266
+ }).catch(function () {
6267
+ busyEnter.current = prev;
6268
+ paintBusyEnter();
6269
+ toast('没能改成,检查一下连接');
6270
+ });
6271
+ });
6272
+
3759
6273
  if (NOTIFY_OK) {
3760
6274
  // 先注册 service worker。**这一步不申请权限**——权限要用户主动点一下才给,
3761
6275
  // 页面一加载就弹窗会被浏览器直接拒掉,而且很讨厌。
@@ -3931,6 +6445,14 @@
3931
6445
  function watchBuild() {
3932
6446
  if (!state.token) return;
3933
6447
  api('/mini/api/version').then(function (r) {
6448
+ // 设置页底部那行版本。**问到才显示**——问不到就整行不出现,
6449
+ // 不拿「unknown」糊一行上去(那个字对用户没意义,还像页面坏了)。
6450
+ // 产品名写全称(DeepSeek Harness):缩写 DSH 只有我们自己人认。
6451
+ // 大小写照 DSH 自己的界面串(实测 49 处全是这个写法)。
6452
+ if (r && r.dshVersion && r.dshVersion !== 'unknown') {
6453
+ var el = $('dshVersion');
6454
+ if (el) { el.textContent = 'DeepSeek Harness ' + r.dshVersion; el.hidden = false; }
6455
+ }
3934
6456
  if (!r || !r.build || r.build === BUILD) return;
3935
6457
  if (inputEl.value && inputEl.value.trim()) return;
3936
6458
  location.reload();