dsh-mini-remote 0.1.8 → 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,12 @@
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
+ }
127
137
 
128
138
  /* ---------- 子智能体(2026-10-03) ----------
129
139
  整块**只新增两条外观**:列表里的一行、聊天记录里那张完工卡片。其余全部复用:
@@ -146,6 +156,67 @@
146
156
  font: inherit; font-size: 14px; cursor: pointer; padding: 0;
147
157
  }
148
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); }
149
220
  /* 行负着左右两边的 16px:让分隔线横贯整个抽屉,而不是在里面缩一截。 */
150
221
  .sa-list { margin: 0 -16px; }
151
222
  .sa-row {
@@ -226,7 +297,7 @@
226
297
  回答正文第一行之间贴得太紧」——10px 是「同一条消息内部」的距离,
227
298
  而这里跨的是「我说的话」和「它答的话」,是更大的一个断点,该给更多。 */
228
299
  margin-bottom: 18px; white-space: pre-wrap; word-break: break-word;
229
- font-size: 15.5px; line-height: 1.7;
300
+ font-size: var(--font-base); line-height: 1.7;
230
301
  }
231
302
  .bubble.user { margin-left: auto; background: var(--panel-2); border: 1px solid var(--line); }
232
303
  .bubble.assistant { margin-right: auto; background: var(--panel); border: 1px solid var(--line); }
@@ -242,7 +313,7 @@
242
313
  /* ---------- Markdown 正文 ---------- */
243
314
  /* 外层是 pre-wrap(用户消息和旧内容靠它),markdown 输出的是真块级元素,
244
315
  所以要在这里把 pre-wrap 收掉,否则段落之间会多出一堆空行。 */
245
- .md { white-space: normal; }
316
+ .md { white-space: normal; font-size: var(--font-base); line-height: 1.7; }
246
317
  /* 回复里的图。地址有两种写法,见下面 mdInline() 里那段注释。
247
318
  宽度只封顶不写死:窄屏上占满可用宽度、按比例缩,圆角沿用全站那 2 像素。 */
248
319
  /* 图片没加载出来时的那一圈虚线。用中性灰,不动暖金——那是唯一的强调色,一屏最多两次。 */
@@ -535,6 +606,11 @@
535
606
  }
536
607
  .stop:disabled { opacity: .5; }
537
608
 
609
+ /* 「完整」模式不放装饰(2026-10-03 用户点名):立绘、气泡、光流进度条都不出现——
610
+ 那一屏的过程由轨迹自己在逐条冒,再挂一只跑动的鲸鱼娘就是重复。只留
611
+ 「已 N 秒 / 停止」那一行:去掉它就中止不了任务了。 */
612
+ .work.bare .work-top, .work.bare .work-progress { display: none; }
613
+
538
614
  /* 排队中的指令。跑着任务时接着发的那几条排在这儿,等这一轮跑完再跑。
539
615
  单帧模式下你发出去的那句话在别处一个字都看不见(那一模式只显示回复),
540
616
  所以这块是唯一的反馈,不能省。 */
@@ -602,6 +678,8 @@
602
678
  .work-stage { animation: none; }
603
679
  .pose { animation: none; transition: none; }
604
680
  .work-progress i { width: 100%; opacity: .45; animation: none; }
681
+ /* 扫光是状态指示、不是装饰,但用户要「减少动效」时整条关掉——底色照旧。 */
682
+ .traj-shimmer { animation: none; background-image: none; color: var(--run); }
605
683
  .queue .q-node i { transition: none; }
606
684
  .dot { transition: none; }
607
685
  }
@@ -636,11 +714,40 @@
636
714
  border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
637
715
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
638
716
  }
639
- .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; }
640
723
  .attach:disabled { opacity: .4; }
641
- /* 上传中:图标转起来。这条路上没有进度条,至少要让人看出「它在动」。 */
642
- .attach.busy svg { animation: spin 1s linear infinite; }
643
- @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
+ }
644
751
 
645
752
  /* 附件小条:排在输入框上面,让你发之前看得见自己带了什么。 */
646
753
  .attach-bar {
@@ -676,24 +783,35 @@
676
783
  backdrop-filter: blur(14px) saturate(1.1);
677
784
  }
678
785
  .cmd-menu[hidden] { display: none; }
679
- .cmd-menu .cmd-row {
786
+ /* 面板里的指令列表(#opsCmds)和这只菜单**长得一样、也共用这份规则**:
787
+ 同一份数据、同一种行,没有第二套样式。 */
788
+ .cmd-menu .cmd-row,
789
+ .ops-cmds .cmd-row {
680
790
  display: flex; align-items: baseline; gap: 10px; width: 100%;
681
791
  padding: 9px 8px; border: none; border-radius: var(--r); background: none;
682
792
  color: var(--fg); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
683
793
  }
684
- .cmd-menu .cmd-row:active { background: var(--panel-2); }
685
- .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 {
686
798
  flex: 0 0 auto; color: var(--ice);
687
799
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
688
800
  }
689
801
  /* 说明收成一行:这一屏是「扫一眼挑一个」,不是读文档的地方。 */
690
- .cmd-menu .cmd-desc {
802
+ .cmd-menu .cmd-desc,
803
+ .ops-cmds .cmd-desc {
691
804
  flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 12.5px;
692
805
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
693
806
  }
694
807
  /* 取不到指令时说清原因,**不显示空列表**:空列表看着就像「这个会话没有指令」,
695
808
  而真实原因多半是「这个会话现在没在跑」——两件事,用户能做的事也不一样。 */
696
- .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; }
697
815
 
698
816
  /* ---------- 左侧导航栏 ---------- */
699
817
  #nav {
@@ -764,6 +882,22 @@
764
882
  .ws-new:active { background: var(--panel-2); }
765
883
  .ws-new[disabled] { color: var(--muted); cursor: default; }
766
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
+
767
901
  /* 导航栏最上面那条「新建工作区」。跟工作区条目之间用一条线隔开:
768
902
  它是这一屏的入口,不是一个工作区。 */
769
903
  .nav-cta {
@@ -837,6 +971,94 @@
837
971
  露和收之间就不会把下面的消息顶来顶去(那会是一次小跳)。 */
838
972
  .more-note.hide { visibility: hidden; }
839
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
+
840
1062
  /* ---------- 设置抽屉 ---------- */
841
1063
  #settings {
842
1064
  position: fixed; inset: 0; background: var(--scrim);
@@ -852,11 +1074,77 @@
852
1074
  backdrop-filter: blur(14px) saturate(1.1);
853
1075
  }
854
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
+ }
855
1120
  .row {
856
1121
  display: flex; align-items: center; justify-content: space-between;
857
1122
  gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line);
858
1123
  }
859
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; }
860
1148
  .row .label { font-size: 15px; }
861
1149
  .row .hint { font-size: 12px; color: var(--muted); margin-top: 2px; }
862
1150
  /* flex-shrink: 0 不能省。它是弹性项,左边的说明文字一长就会被压缩;
@@ -868,15 +1156,89 @@
868
1156
  font-size: 13px; cursor: pointer;
869
1157
  }
870
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; }
871
1169
  select, .btn {
872
1170
  padding: 8px 12px; border-radius: var(--r); border: 1px solid var(--line);
873
1171
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 14px;
874
1172
  }
875
1173
  .btn { cursor: pointer; }
876
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
+ }
877
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
+ }
878
1209
  .sheet .foot .btn { flex: 1 1 0; text-align: center; }
879
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
+
880
1242
  /* ---------- token 输入 ---------- */
881
1243
  #gate { position: fixed; inset: 0; background: var(--bg); z-index: 100;
882
1244
  display: none; flex-direction: column; justify-content: center;
@@ -961,11 +1323,14 @@
961
1323
  // 所以这一段留在 <head> 里、样式表后面,**别挪到页面底部那段脚本区去**。
962
1324
  (function () {
963
1325
  try {
964
- var pick = localStorage.getItem('dshMiniTheme') || 'dark';
1326
+ var pick = localStorage.getItem('dshMiniTheme') || 'light';
965
1327
  var dark = !window.matchMedia || window.matchMedia('(prefers-color-scheme: dark)').matches;
966
- document.documentElement.setAttribute('data-theme',
967
- pick === 'auto' ? (dark ? 'dark' : 'light') : pick);
968
- } 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 会抛;按默认浅色走,不影响页面 */ }
969
1334
  })();
970
1335
  </script>
971
1336
  </head>
@@ -1057,9 +1422,14 @@
1057
1422
  <div class="cmd-menu" id="cmdMenu" hidden></div>
1058
1423
 
1059
1424
  <footer>
1060
- <!-- 回形针。input 藏起来,按钮点它——iOS 上直接放一个 file input 样式很难统一。 -->
1061
- <button class="attach" id="btnAttach" type="button" aria-label="上传文件" title="上传文件">
1062
- <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>
1063
1433
  </button>
1064
1434
  <input type="file" id="filePick" hidden>
1065
1435
  <textarea id="input" rows="1" placeholder="说点什么…" enterkeyhint="send"></textarea>
@@ -1079,7 +1449,16 @@
1079
1449
 
1080
1450
  <div id="settings">
1081
1451
  <div class="sheet">
1082
- <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">
1083
1462
 
1084
1463
  <div class="row">
1085
1464
  <div>
@@ -1089,6 +1468,7 @@
1089
1468
  <div class="seg" id="segMode">
1090
1469
  <button data-mode="minimal">单帧</button>
1091
1470
  <button data-mode="chat">聊天</button>
1471
+ <button data-mode="full">完整</button>
1092
1472
  </div>
1093
1473
  </div>
1094
1474
 
@@ -1105,6 +1485,18 @@
1105
1485
  </div>
1106
1486
  </div>
1107
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
+
1108
1500
  <!-- 权限档位。整块由脚本填:这台电脑有哪些档位是**运行时读**的,一个都不写死。
1109
1501
  服务拿不到时整块不显示——它是附加能力,缺了遥控本身照常。 -->
1110
1502
  <div id="permBlock" hidden>
@@ -1117,6 +1509,23 @@
1117
1509
  <div class="seg" id="segPerm"></div>
1118
1510
  </div>
1119
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
+
1120
1529
  <!--
1121
1530
  这里原来有两行:「完成后提醒」和「语音输入」。
1122
1531
 
@@ -1181,10 +1590,51 @@
1181
1590
  <button class="btn" id="btnReconnect">重连</button>
1182
1591
  </div>
1183
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
+
1184
1601
  <div class="foot">
1185
1602
  <button class="btn" id="btnForget">清除 token</button>
1186
1603
  <button class="btn primary" id="btnClose">完成</button>
1187
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>
1188
1638
  </div>
1189
1639
  </div>
1190
1640
 
@@ -1210,6 +1660,20 @@
1210
1660
  </div>
1211
1661
  </div>
1212
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>
1674
+ </div>
1675
+ </div>
1676
+
1213
1677
  <div id="gate">
1214
1678
  <h1>需要访问令牌</h1>
1215
1679
  <p>在电脑上打开 DSH 的「设置 → 手机遥控」,用手机重新扫一次二维码就能进来。
@@ -1241,12 +1705,15 @@
1241
1705
  mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
1242
1706
  // 外观:'dark' | 'light' | 'auto'。默认深色——不给老用户换掉现在的样子;
1243
1707
  // 要浅色或跟着系统走,去设置页「外观」点一下。
1244
- theme: localStorage.getItem('dshMiniTheme') || 'dark',
1708
+ theme: localStorage.getItem('dshMiniTheme') || 'light',
1709
+ // 字体大小:'small' | 'normal' | 'large'。默认标准。
1710
+ fontSize: localStorage.getItem('dshMiniFont') || 'normal',
1245
1711
  sound: localStorage.getItem('dshMiniSound') !== 'off',
1246
1712
  // 锁屏也能提醒(系统通知)。默认关着——它要用户主动点一下才会申请权限,
1247
1713
  // 不能替他决定。明文连接下这一项永远是空转的,见下面「锁屏也能提醒」那一段。
1248
1714
  notify: localStorage.getItem('dshMiniNotify') === 'on',
1249
1715
  running: false,
1716
+ subagentsRunning: false,
1250
1717
  sessions: [],
1251
1718
  // 排队中的指令([{ id, text, placement }])。跑着任务时接着发的那几条排在这儿,
1252
1719
  // 由服务端每次取快照时从 agent 的收件箱现读,手机不自己记。
@@ -1272,6 +1739,24 @@
1272
1739
  // 「这份历史只到最近一段」(服务端读历史有上限时会给这一句)。
1273
1740
  historyTruncated: false,
1274
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: '', // 活片段:正在想的那一句(只有完整模式有值,落盘即清)
1275
1760
  // 斜杠指令的列表(在输入框里打 / 时弹出来的那个菜单)。
1276
1761
  // **按绑定的会话缓存**:指令表是每个会话一份的,而这个会话没在跑时服务端一条都
1277
1762
  // 读不到(回 ok:false + 一句话)。所以换了会话必须重取——否则用户点到的是上一个
@@ -1279,7 +1764,15 @@
1279
1764
  commands: null, // null = 还没取到
1280
1765
  commandsError: '', // 取不到时服务端给的那句话,原样显示给用户
1281
1766
  commandsFor: null, // 上面这份数据是哪个会话的
1282
- commandsLoading: false // 正在取(挡住重复请求,见 loadCommands)
1767
+ commandsLoading: false, // 正在取(挡住重复请求,见 loadCommands)
1768
+ /**
1769
+ * 「输入框里这一行曾经是一条指令」——点过面板/菜单里某一条之后,这里记着它的名字。
1770
+ *
1771
+ * 用来堵一个真缺口:点了一条要参数的指令,用户把 `/名字 ` 里的名字删掉、只留参数,
1772
+ * 那一行就不再以 `/` 开头,会被当成普通提问发给模型,白花一次额度。
1773
+ * 有这枚标记时,send() 会把这一行拦下来,让他补回名字或**清空输入框**(= 明确取消)。
1774
+ */
1775
+ pendingCommand: null
1283
1776
  };
1284
1777
  if (params.get('token')) {
1285
1778
  localStorage.setItem('dshMiniToken', params.get('token'));
@@ -1287,6 +1780,232 @@
1287
1780
  history.replaceState(null, '', location.pathname);
1288
1781
  }
1289
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
+
1290
2009
  var $ = function (id) { return document.getElementById(id); };
1291
2010
  var mainEl = $('main'), replyEl = $('reply'), inputEl = $('input');
1292
2011
  var dotEl = $('dot'), statusEl = $('status');
@@ -2408,8 +3127,50 @@
2408
3127
  el.classList.toggle('hide', !chatNoteVisible());
2409
3128
  }
2410
3129
 
2411
- function renderChat() {
2412
- 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) {
2413
3174
  // 换了会话就把窗口收回首屏:上一个会话铺到多少条都不该跟过来——那正是这次要修的慢。
2414
3175
  // 判据用会话 id 而不是 history 的引用:服务端每次推的都是整份新数组,比引用永远「变了」。
2415
3176
  if (chatWindowFor !== state.boundSessionId) {
@@ -2440,61 +3201,47 @@
2440
3201
  // 「正在读」和「确实没有」是两件事,分开说。点开一个插件没看过的会话时,
2441
3202
  // 服务端要去 DSH 的会话记录里读它那一段(最大的那条 20 MB、约半秒),
2442
3203
  // 这期间说「还没有对话记录」会让人以为这个会话本来就是空的。
2443
- replyEl.innerHTML = state.historyLoading
2444
- ? '<div class="placeholder">正在读取这个会话的记录…</div>'
2445
- : '<div class="placeholder">还没有对话记录。</div>';
2446
- return;
3204
+ return {
3205
+ empty: state.historyLoading
3206
+ ? '<div class="placeholder">正在读取这个会话的记录…</div>'
3207
+ : '<div class="placeholder">还没有对话记录。</div>',
3208
+ moreNote: '', rows: [], list: [], all: [],
3209
+ };
2447
3210
  }
2448
3211
  // **只铺最近那几十条**,剩下的留在手里等用户往上滑(来由见 CHAT_PAGE 那段)。
2449
3212
  // 切在**滤掉排队那条之后**:不然被滤掉的那条会白占一个名额。
2450
3213
  var older = Math.max(0, chatLoaded - chatWindow);
2451
- if (older) list = list.slice(older);
2452
- var html = list.map(function (m) {
2453
- // 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
2454
- // 不套气泡、不给复制按钮(见 commandBlock 的说明)。
2455
- if (m.role === 'command') return commandBlock(m);
2456
- // 子智能体完工的通知:同一类「会话做的事」,画成一张可展开的卡片
2457
- // (见 noticeBlock 的说明)。它没有复制按钮,也没有气泡。
2458
- if (m.role === 'notice') return noticeBlock(m);
2459
- // 被按停的那一轮:标一句,别让人以为模型答到一半断了。
2460
- var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
2461
- // 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
2462
- // 原来只给 AI 气泡,用户反馈自己的话复制不了。
2463
- var copyBtn = '<button class="copy" type="button" aria-label="复制" title="复制"'
2464
- + ' data-copy="' + escapeHtml(m.text) + '">' + ICON_COPY + '</button>';
2465
- if (m.role === 'user') {
2466
- // 自己的指令:仍然是气泡,靠右。自己敲的是纯文本,不走 markdown——
2467
- // 不然打几个星号就变样了。
2468
- return '<div class="bubble user">' + linkify(m.text)
2469
- + '<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
+ }
2470
3222
  }
2471
- // AI 的回答:**不套气泡**(用户要求「参考单帧模式那样」)。
2472
- // 于是一左一右、一个有框一个没框,正好分得清谁说的。
2473
- return '<div class="said">'
2474
- + (stopped ? '<div class="reply-bar">' + stopped + '</div>' : '')
2475
- + '<div class="md">' + mdToHtml(m.text) + '</div>'
2476
- + '<div class="reply-foot">' + copyBtn
2477
- + '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
2478
- }).join('');
3223
+ }
3224
+ var all = list;
3225
+ if (older) list = list.slice(older);
2479
3226
  // 「上面还有,往上滑」要贴在**看得到的顶上**,而且**离顶远了就收起来**——
2480
3227
  // 两处讲究见 .more-note 的样式注释。露 / 收在这里先按当前位置定一次,
2481
3228
  // 之后由滚动里的 paintChatNote 接着跟。
2482
3229
  var moreNote = older
2483
3230
  ? '<div class="more-note' + (chatNoteVisible() ? '' : ' hide') + '">往上滑看更早的</div>'
2484
3231
  : '';
2485
- // 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
2486
- // 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
2487
- if (state.historyTruncated) {
2488
- html = '<div class="history-note">'
2489
- + escapeHtml(state.historyNote || '这个会话很大,只显示了最近一段。')
2490
- + '</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;
2491
3241
  }
2492
- // 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
2493
- // 两种模式共用同一块,改的时候两边都走一遍。
2494
- if (state.live) html += liveBlock(state.live);
2495
3242
  // 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
2496
3243
  var stick = atBottom();
2497
- replyEl.innerHTML = moreNote + html;
3244
+ replyEl.innerHTML = chatFrame(built, built.rows.join(''), state.live ? liveBlock(state.live) : '');
2498
3245
  scrollChat(stick);
2499
3246
  }
2500
3247
 
@@ -2507,8 +3254,8 @@
2507
3254
  */
2508
3255
  function maybeGrowChat() {
2509
3256
  // 单帧模式那边也在往 main 上滚(回答落定要跳到页面顶端),别把那一下当成「他在往上翻」;
2510
- // 本地都放完了也没什么可放的。
2511
- if (state.mode !== 'chat' || chatLoaded <= 0) return;
3257
+ // 本地都放完了也没什么可放的。完整模式画的也是这串聊天记录,翻页照旧要能放。
3258
+ if (state.mode === 'minimal' || chatLoaded <= 0) return;
2512
3259
  if (chatWindow >= chatLoaded) return;
2513
3260
  if (mainEl.scrollTop > chatEdge()) {
2514
3261
  // 滑下来了,这一轮重新装填:**一次靠近只放一屏**。
@@ -2541,7 +3288,10 @@
2541
3288
  function growChat() {
2542
3289
  var fromBottom = mainEl.scrollHeight - mainEl.scrollTop;
2543
3290
  chatWindow += CHAT_PAGE;
2544
- renderChat();
3291
+ // 完整模式要走 render() 总分发:直画 renderChat 会把后面那块轨迹区抹掉。
3292
+ // (位置补偿和聊天同一套:轨迹区每次重画高度不变,底部那一截没动。)
3293
+ if (state.mode === 'full') render();
3294
+ else renderChat();
2545
3295
  mainEl.scrollTop = mainEl.scrollHeight - fromBottom;
2546
3296
  }
2547
3297
 
@@ -2611,9 +3361,550 @@
2611
3361
  if (stick && !liveNow) mainEl.scrollTop = mainEl.scrollHeight;
2612
3362
  }
2613
3363
 
3364
+ /**
3365
+ * 三种模式各自的提示语。「完整」= 聊天那一串 **+ 每一轮的过程组**,所以三句话
3366
+ * 必须跟着模式一起改——收在一处是防两处走样(渲染处和点击处各写一遍的旧写法,
3367
+ * 加第三种模式时漏一处,用户看到的就是「点了完整、说明还写着聊天」)。
3368
+ */
3369
+ function modeHint() {
3370
+ if (state.mode === 'chat') return '聊天:常规问答对话';
3371
+ if (state.mode === 'full') return '完整:附加每一步过程';
3372
+ return '单帧:只留最新一条回复';
3373
+ }
3374
+
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 = {};
3392
+ }
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();
3417
+ }
3418
+
3419
+ /** 轨迹区的点击走一次容器委托就够(重绘都是全新 HTML,逐个绑必漏)。 */
3420
+ function bindTraj() {
3421
+ if (trajBound) return;
3422
+ trajBound = true;
3423
+ replyEl.addEventListener('click', onTrajectoryClick);
3424
+ }
3425
+
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
+
2614
3901
  function render() {
2615
- $('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
2616
- if (state.mode === 'chat') renderChat(); else renderMinimal();
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' : '');
2617
3908
  }
2618
3909
 
2619
3910
  function applySnapshot(snap) {
@@ -2637,14 +3928,34 @@
2637
3928
  if ('boundSessionId' in snap) {
2638
3929
  var wasBound = state.boundSessionId;
2639
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
+ }
2640
3943
  // 换了会话,指令菜单那份缓存就作废了(指令表是每个会话一份的,这个会话没在跑
2641
3944
  // 时一条都读不到,见「斜杠指令」那一段)。重画一次,它自己会发现数据不属于
2642
3945
  // 这个会话、去重取——否则用户点到的是上一个会话的指令,点下去必然被拒。
2643
3946
  if (state.boundSessionId !== wasBound) paintCmdMenu();
3947
+ // 权限档位也是每个会话各一份的(宿主的 currentValue 读的是这个会话那份投影)。
3948
+ // 不重读的话,抽屉里显示的是上一个会话那一档——用户看到的就是一句假话。
3949
+ if (state.boundSessionId !== wasBound) loadPermissions(state.boundSessionId);
2644
3950
  }
2645
3951
  // running 也在快照里,而且算的是**当前绑定的那个会话**。不接的话,
2646
3952
  // 在导航栏切到别的会话时,那个会话会继承上一个会话的「正在执行」。
2647
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
+ }
2648
3959
  paintNavCurrent();
2649
3960
  render();
2650
3961
  }
@@ -2656,6 +3967,10 @@
2656
3967
  var navSessions = {}; // workspaceId -> { sessions, total, truncated }
2657
3968
  var navFollowTimers = {}; // workspaceId -> 当前标题补齐轮询定时器
2658
3969
  var navExpanded = {}; // workspaceId -> 是否展开
3970
+ var navWsFetching = {}; // workspaceId -> 会话列表请求在途(同一工作区只发一趟)
3971
+ var navPresets = null; // Agent 模式清单(2026-10-04:新建会话时手机上也要能选模式)
3972
+ var navPresetsLoaded = false;
3973
+ var navPickerWs = ''; // 正在挑模式的那个工作区(清单行内展开在它的「新建会话」下)
2659
3974
 
2660
3975
  function openNav() {
2661
3976
  $('nav').classList.add('open');
@@ -2694,6 +4009,8 @@
2694
4009
  // 趁页面空闲先取一次;真正打开菜单时若已完成就直接画,不再把等待放在点击之后。
2695
4010
  var navPrefetchPromise = null;
2696
4011
  function prefetchWorkspaces() {
4012
+ // Agent 模式清单也是导航栏要用的、也几乎不变——顺手一起预取(见 prefetchPresets)。
4013
+ prefetchPresets();
2697
4014
  if (navList || navPrefetchPromise || navView !== 'tree' || !state.token) return;
2698
4015
  var run = function () {
2699
4016
  navPrefetchPromise = api('/mini/api/workspaces').then(function (body) {
@@ -2712,6 +4029,22 @@
2712
4029
  }
2713
4030
  }
2714
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
+
2715
4048
  function dayLabel(ts) {
2716
4049
  if (!ts) return '';
2717
4050
  var d = new Date(ts);
@@ -2724,6 +4057,11 @@
2724
4057
  function renderNav() {
2725
4058
  // 目录浏览那一屏接管整个面板。它自己画返回按钮,所以不走下面这套。
2726
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); });
2727
4065
  var list = navList || [];
2728
4066
  // 「新建工作区」常驻在最上面:它是这一屏的入口,不该等有了会话才出现——
2729
4067
  // 一个工作区都还没有的人,恰恰最需要它。
@@ -2754,6 +4092,22 @@
2754
4092
  // 这一行都在。放在列表末尾的话,一个 452 条会话的工作区要划到底才看得到。
2755
4093
  var html = '<button class="ws-new" type="button" data-newws="' + escapeHtml(wsId) + '">'
2756
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
+ }
2757
4111
  var got = navSessions[wsId];
2758
4112
  if (!got) return html + '<div class="placeholder">正在读取…</div>';
2759
4113
  if (!got.sessions.length) return html + '<div class="placeholder">这个工作区还没有会话。</div>';
@@ -2995,7 +4349,17 @@
2995
4349
  }
2996
4350
  navExpanded[wsId] = true;
2997
4351
  renderNav();
2998
- 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);
2999
4363
  /**
3000
4364
  * 接着问,直到服务端说「一条都不剩」。
3001
4365
  *
@@ -3025,11 +4389,15 @@
3025
4389
  }
3026
4390
  api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
3027
4391
  .then(function (body) {
4392
+ clearTimeout(bail);
3028
4393
  navSessions[wsId] = body;
4394
+ delete navWsFetching[wsId];
3029
4395
  renderNav();
3030
4396
  followTitles(body, Date.now() + TITLE_FOLLOW_LIMIT_MS);
3031
4397
  })
3032
4398
  .catch(function (err) {
4399
+ clearTimeout(bail);
4400
+ delete navWsFetching[wsId];
3033
4401
  navSessions[wsId] = { sessions: [], total: 0 };
3034
4402
  renderNav();
3035
4403
  toast(err.message);
@@ -3041,7 +4409,17 @@
3041
4409
  // 服务端那道闸门拦不住这个:两个请求都是合法的。
3042
4410
  var creatingSession = false;
3043
4411
 
3044
- 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 = '';
3045
4423
  if (creatingSession) return;
3046
4424
  creatingSession = true;
3047
4425
  Array.prototype.forEach.call($('navBody').querySelectorAll('.ws-new'), function (el) {
@@ -3061,7 +4439,7 @@
3061
4439
  // 不返回的话外面拿到的是 undefined,接不上任何后续。
3062
4440
  return api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions', {
3063
4441
  method: 'POST',
3064
- body: '{}',
4442
+ body: agentPreset ? JSON.stringify({ agentPreset: agentPreset }) : '{}',
3065
4443
  }).then(function (res) {
3066
4444
  done();
3067
4445
  // 接口把绑定后的快照一起给了,直接渲染——不这样的话,切过去的一瞬间
@@ -3132,6 +4510,12 @@
3132
4510
  if (at('[data-mkdir-cancel]')) { browseMkdir = false; renderNav(); return; }
3133
4511
  if (at('[data-mkdir-ok]')) { makeFolder(); return; }
3134
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
+ }
3135
4519
  // 「新建会话」要排在 ws-head 之前判:它在 ws-body 里面、不是 ws-head,
3136
4520
  // 顺序上无所谓,但放在前面更清楚——这一支是动作,那一支是展开/收起。
3137
4521
  var fresh = at('.ws-new');
@@ -3142,24 +4526,91 @@
3142
4526
  });
3143
4527
 
3144
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 = '';
3145
4567
  var es = null;
3146
4568
  function connect() {
3147
4569
  if (es) { try { es.close(); } catch (e) {} es = null; }
3148
4570
  if (!state.token) { showGate(); return; }
3149
4571
  setStatus('', '连接中…');
3150
- 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' : ''));
3151
4578
 
3152
4579
  es.addEventListener('open', function () {
3153
4580
  state.connected = true;
3154
4581
  updateStatusUI();
3155
4582
  $('connHint').textContent = '已连接';
3156
4583
  prefetchWorkspaces();
4584
+ prefetchSubagents();
4585
+ if (state.mode === 'full') {
4586
+ // 首连与重连同一处:full=1 已让服务端订上这条连接,拿住身份,再对齐一次——
4587
+ // 断线那一段路上可能丢过帧,用这份 GET 把 epoch/seq 校正(换代就整个换新)。
4588
+ subscribedClientId = clientId;
4589
+ requestTrajectory();
4590
+ }
3157
4591
  });
3158
4592
  es.addEventListener('state', function (e) {
3159
4593
  state.connected = true;
3160
4594
  updateStatusUI();
3161
4595
  try { applySnapshot(JSON.parse(e.data)); } catch (err) {}
3162
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();
3163
4614
  });
3164
4615
  es.addEventListener('navigation-title', function (e) {
3165
4616
  var payload;
@@ -3177,6 +4628,37 @@
3177
4628
  }
3178
4629
  if (navExpanded[payload.workspaceId] && navView === 'tree') renderNav();
3179
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();
4661
+ });
3180
4662
  es.addEventListener('reply', function (e) {
3181
4663
  var payload = JSON.parse(e.data);
3182
4664
  // interrupted 必须带上:这条是「实时推过来的那一条」,走的是和刷新时
@@ -3187,11 +4669,16 @@
3187
4669
  text: payload.text, timestamp: payload.timestamp,
3188
4670
  sessionId: payload.sessionId, interrupted: cut,
3189
4671
  };
3190
- if (state.mode === 'chat') {
4672
+ // 完整模式画的也是这串聊天记录(renderFull 复用 renderChat),回复落定
4673
+ // 同样要追加进去,否则要等下一份快照才补上;单帧不画记录,照旧不进。
4674
+ if (state.mode !== 'minimal') {
3191
4675
  state.history = (state.history || []).concat([
3192
4676
  {
3193
4677
  role: 'assistant', text: payload.text, timestamp: payload.timestamp,
3194
4678
  sessionId: payload.sessionId, interrupted: cut,
4679
+ // 完整模式把轨迹块插回对话原位要用:从轨迹中取当前轮号。
4680
+ // 手机本地回显时轮还没开,先留空;服务端快照补上来时会带上真轮号。
4681
+ turn: currentTrajectoryTurn(),
3195
4682
  }
3196
4683
  ]);
3197
4684
  }
@@ -3463,17 +4950,29 @@
3463
4950
  // **不能显示成空列表**:那等于说「这个会话没有指令」,是另一回事。
3464
4951
  state.commandsError = (res && res.error) || '读不到指令列表';
3465
4952
  }
3466
- paintCmdMenu();
4953
+ paintCommandViews();
3467
4954
  }).catch(function (err) {
3468
4955
  state.commandsLoading = false;
3469
4956
  if (forSession !== state.boundSessionId) return;
3470
4957
  state.commandsFor = forSession;
3471
4958
  state.commands = null;
3472
4959
  state.commandsError = err.message || '读不到指令列表';
3473
- paintCmdMenu();
4960
+ paintCommandViews();
3474
4961
  });
3475
4962
  }
3476
4963
 
4964
+ /**
4965
+ * 指令表换了一份之后,**所有正在看着它的地方**都要重画。
4966
+ *
4967
+ * 现在有两处:打 / 弹出来的菜单,和统一操作面板里的那个列表。回调那一个由面板
4968
+ * 那一段自己挂上——因为它在这一段下面,而这一段会被测试单独抠出来跑(那时面板还不存在)。
4969
+ */
4970
+ var onCommandsReady = null;
4971
+ function paintCommandViews() {
4972
+ paintCmdMenu();
4973
+ if (onCommandsReady) onCommandsReady();
4974
+ }
4975
+
3477
4976
  /**
3478
4977
  * 画(或收起)那只菜单。
3479
4978
  *
@@ -3484,6 +4983,9 @@
3484
4983
  function paintCmdMenu() {
3485
4984
  var el = $('cmdMenu');
3486
4985
  if (!el) return;
4986
+ // 输入框被清空了 = 用户把刚才那条指令整个删了,这是「明确取消」那一步。
4987
+ // 放在这儿而不是 send() 里:清空之后他往往还要改写成普通提问,那次 send 不该再被拦。
4988
+ if (!inputEl.value.trim()) state.pendingCommand = null;
3487
4989
  var prefix = slashQuery();
3488
4990
  if (prefix === null) { hideCmdMenu(); return; }
3489
4991
  var wasOpen = !el.hidden;
@@ -3516,16 +5018,22 @@
3516
5018
  }
3517
5019
 
3518
5020
  /**
3519
- * 菜单里点了一条。
5021
+ * 菜单/面板里点了一条。
3520
5022
  *
3521
- * 分两路,判据是服务端给的 hint(这条指令要不要参数):
5023
+ * 分两路,判据是服务端给的 hint(这条指令要不要参数)。两路都**不执行**,
5024
+ * 除非是打了 / 之后从菜单里点了一条不要参数的(那种当场跑,见 deferRun 的说明):
3522
5025
  * · 不要参数 —— 直接执行。让用户再按一次发送是白白多一步。
3523
5026
  * · 要参数 —— 把 `/名字 ` 填进输入框,末尾留一个空格、光标停在后面,并把
3524
5027
  * hint 那句话挂到输入框的 placeholder 上。
3525
5028
  * **不自己编一句「请输入参数」**:只有服务端知道这条指令要什么,
3526
5029
  * 编一句就等于让用户照着一个错的提示去填。
5030
+ *
5031
+ * @param {string} name
5032
+ * @param {boolean} [deferRun] 面板那条路传 true:**一次点击不执行**,只把这一行放进
5033
+ * 输入框,由「发送」那一下确认(没有参数提示的指令也照此办理,宁可多问一句)。
5034
+ * 打 / 弹出来的那个菜单不传——它本来就是「扫一眼挑一个」,多一步反而碍事。
3527
5035
  */
3528
- function pickCommand(name) {
5036
+ function pickCommand(name, deferRun) {
3529
5037
  var list = state.commands || [];
3530
5038
  var cmd = null;
3531
5039
  for (var i = 0; i < list.length; i++) { if (list[i].name === name) cmd = list[i]; }
@@ -3537,12 +5045,25 @@
3537
5045
  if (cmd.hint) {
3538
5046
  inputEl.value = '/' + cmd.name + ' ';
3539
5047
  inputEl.placeholder = cmd.hint;
5048
+ // 这一行「曾经是一条指令」:名字要是被删掉,send 不许拿它当普通提问发出去。
5049
+ state.pendingCommand = cmd.name;
3540
5050
  inputEl.focus();
3541
5051
  // 光标停在最后:停在名字中间的话,用户接着打的字会插进名字里去。
3542
5052
  inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
3543
5053
  autoGrow();
3544
5054
  return;
3545
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;
3546
5067
  inputEl.value = '';
3547
5068
  autoGrow();
3548
5069
  runCommand('/' + cmd.name);
@@ -3605,10 +5126,13 @@
3605
5126
  // 本地上限和服务端是同一个数(由服务端注入),先拦一道省得白传一场。
3606
5127
  if (MAX_UPLOAD > 0 && file.size > MAX_UPLOAD) {
3607
5128
  toast('「' + name + '」有 ' + humanSize(file.size) + ',超过上限 ' + humanSize(MAX_UPLOAD));
3608
- return;
5129
+ // 多选时是排队传的(见 filePick 的 change),所以这里也要回一个「承诺」——
5130
+ // 否则这一份被拦下会让整条链断掉,后面几张就再也传不出去了。
5131
+ return Promise.resolve();
3609
5132
  }
3610
5133
  setAttachBusy(true);
3611
- api('/mini/api/upload?name=' + encodeURIComponent(name) + '&size=' + file.size,
5134
+ // 返回这条链:多选时调用方靠它排队(一张传完再传下一张)。
5135
+ return api('/mini/api/upload?name=' + encodeURIComponent(name) + '&size=' + file.size,
3612
5136
  { method: 'POST', body: file })
3613
5137
  .then(function (res) {
3614
5138
  state.uploads.push({ uploadId: res.uploadId, name: res.name, bytes: res.bytes });
@@ -3620,17 +5144,38 @@
3620
5144
  .then(function () { setAttachBusy(false); });
3621
5145
  }
3622
5146
 
3623
- /** 附件小条。有东西才出现,没有就整块收起来——不占地方。 */
5147
+ /**
5148
+ * 附件小条。有东西才出现,没有就整块收起来——不占地方。
5149
+ *
5150
+ * 统一操作面板里那几行「已选文件」是**同一份数据、同一份 HTML**:都由这里画,
5151
+ * 都读 state.uploads。别在面板里另存一个附件数组——那样两处迟早对不上
5152
+ * (面板里删了一条,小条还挂着,用户就不知道该信哪个)。
5153
+ */
3624
5154
  function renderAttach() {
3625
5155
  var bar = $('attachBar');
3626
5156
  if (!bar) return;
3627
5157
  var list = state.uploads || [];
3628
- bar.hidden = !list.length;
3629
- bar.innerHTML = list.map(function (f, i) {
5158
+ var chips = list.map(function (f, i) {
3630
5159
  return '<span class="chip"><span class="nm">' + escapeHtml(f.name) + '</span>'
3631
5160
  + '<span class="sz">' + humanSize(f.bytes) + '</span>'
3632
5161
  + '<button class="x" type="button" data-drop="' + i + '" aria-label="去掉">×</button></span>';
3633
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();
3634
5179
  }
3635
5180
 
3636
5181
  function send() {
@@ -3643,6 +5188,21 @@
3643
5188
  // 只带文件、一个字不写也是合法的:「这份文件你看一下」本来就是常见用法。
3644
5189
  // 两样都没有才算空。
3645
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
+ }
3646
5206
  unlockAudio();
3647
5207
  // **发指令这个动作,无条件拉回底部。**
3648
5208
  //
@@ -3677,6 +5237,8 @@
3677
5237
  // 这里**不动 state.uploads**——清掉就等于把用户刚传上来的文件悄悄扔了,
3678
5238
  // 而他还以为跟着发出去了。留在小条上看得见,想发就配一句话再发一次。
3679
5239
  if (files.length) toast('指令带不了附件,附件还留在下面');
5240
+ // 跑出去了,那枚「曾是指令」的标记就该撤掉:留着会拦住下一条普通提问。
5241
+ state.pendingCommand = null;
3680
5242
  runCommand(text);
3681
5243
  return;
3682
5244
  }
@@ -3717,7 +5279,10 @@
3717
5279
  // 顺带也解决了发送失败时屏幕上留一条假气泡的问题。
3718
5280
  if (state.mode !== 'minimal') {
3719
5281
  state.history = (state.history || []).concat([
3720
- { role: 'user', text: shown, timestamp: Date.now() }
5282
+ { role: 'user', text: shown, timestamp: Date.now(),
5283
+ // 完整模式把轨迹块插回对话原位要用:从轨迹中取当前轮号。
5284
+ // 手机本地回显时轮还没开,先留空;服务端快照补上来时会带上真轮号。
5285
+ turn: currentTrajectoryTurn() }
3721
5286
  ]);
3722
5287
  render();
3723
5288
  }
@@ -3735,22 +5300,160 @@
3735
5300
  });
3736
5301
  }
3737
5302
 
3738
- $('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(); });
3739
5429
  $('filePick').addEventListener('change', function (e) {
3740
- 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 || []);
3741
5435
  // 清掉 value:不清的话,连着选同一个文件第二次不会触发 change,
3742
5436
  // 用户会觉得「点了没反应」。
3743
5437
  e.target.value = '';
3744
- 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());
3745
5448
  });
3746
5449
  $('attachBar').addEventListener('click', function (e) {
3747
5450
  var btn = e.target.closest ? e.target.closest('[data-drop]') : null;
3748
5451
  if (!btn) return;
3749
- state.uploads.splice(Number(btn.getAttribute('data-drop')), 1);
3750
- renderAttach();
5452
+ dropUpload(btn);
3751
5453
  });
3752
5454
 
3753
5455
  $('btnSend').addEventListener('click', send);
5456
+
3754
5457
  inputEl.addEventListener('input', autoGrow);
3755
5458
  // 每敲一个字重算一次菜单。它自己会先看「是不是一个没打完的 / 名字」再决定动不动,
3756
5459
  // 所以这里不用加判据。
@@ -3781,8 +5484,18 @@
3781
5484
  $('btnSettings').addEventListener('click', function () {
3782
5485
  sheet.classList.add('open');
3783
5486
  $('connHint').textContent = state.connected ? '已连接' : '未连接';
5487
+ // 权限档位那块也在这儿重读一次:它显示的是「现在在哪一档」的实时状态(电脑端
5488
+ // 随时能改),而且启动时那一次要是没读成,不重读就整块再也不出现。
5489
+ loadPermissions(state.boundSessionId);
5490
+ // 版本号那行也现读一次:它只在设置页里看得见,没必要开机就为它跑一趟。
5491
+ // (watchBuild 是同一个 script 块里的函数声明,这里调得到。)
5492
+ watchBuild();
5493
+ // 繁忙时的发送行为也现读一次:**电脑上随时能改**,开机读那一次会过期。
5494
+ loadBusyEnter();
3784
5495
  });
3785
5496
  $('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
5497
+ // 顶部吸顶那个关闭口(2026-10-05 设置页改整屏时加的)。
5498
+ $('btnSettingsClose').addEventListener('click', function () { sheet.classList.remove('open'); });
3786
5499
  sheet.addEventListener('click', function (e) { if (e.target === sheet) sheet.classList.remove('open'); });
3787
5500
 
3788
5501
  // ---------------- 子智能体 ----------------
@@ -3796,6 +5509,11 @@
3796
5509
  var saPanel = $('subagents');
3797
5510
  var saRows = []; // 最近一次读到的清单,点行时按 id 找回来
3798
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;
3799
5517
 
3800
5518
  function saToList() {
3801
5519
  saCurrent = null;
@@ -3857,14 +5575,17 @@
3857
5575
 
3858
5576
  function saRenderList(body) {
3859
5577
  var listEl = $('subagentsList');
5578
+ // 这两句空态都落在 .sa-list 里,而那个容器用负 16px 把内容拽到贴屏边
5579
+ // (为了让分隔线横贯整屏)——所以得补上 sa-none-indent 把 16px 加回来,
5580
+ // 否则这行字会顶到屏幕最左边。这个类是早就写好的,之前没给空态用上。
3860
5581
  if (!body || !body.ok) {
3861
- listEl.innerHTML = '<div class="sa-none">'
5582
+ listEl.innerHTML = '<div class="sa-none sa-none-indent">'
3862
5583
  + escapeHtml((body && body.error) || '读不到子智能体。') + '</div>';
3863
5584
  return;
3864
5585
  }
3865
5586
  saRows = body.subagents || [];
3866
5587
  if (!saRows.length) {
3867
- listEl.innerHTML = '<div class="sa-none">这个会话还没有派出过子智能体。</div>';
5588
+ listEl.innerHTML = '<div class="sa-none sa-none-indent">这个会话还没有派出过子智能体。</div>';
3868
5589
  return;
3869
5590
  }
3870
5591
  listEl.innerHTML = saRows.map(saRowHtml).join('');
@@ -3876,11 +5597,48 @@
3876
5597
  }
3877
5598
  }
3878
5599
 
3879
- function saLoad() {
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;
3880
5607
  $('subagentsList').innerHTML = '<div class="sa-none sa-none-indent">正在读取…</div>';
3881
- api('/mini/api/subagents').then(saRenderList).catch(function (err) {
3882
- $('subagentsList').innerHTML = '<div class="sa-none">'
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">'
3883
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;
3884
5642
  });
3885
5643
  }
3886
5644
 
@@ -4005,6 +5763,167 @@
4005
5763
  saLoad();
4006
5764
  });
4007
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
+ }
4008
5927
  $('subagentsBack').addEventListener('click', saToList);
4009
5928
  saPanel.addEventListener('click', function (e) {
4010
5929
  if (e.target === saPanel) saPanel.classList.remove('open');
@@ -4027,17 +5946,53 @@
4027
5946
  // 映射不到时会退回标识符本身,所以这里拿到的一定是个真名字,不会是空格。
4028
5947
  // 页面只管显示拿到的东西。
4029
5948
  var permOptions = null; // null = 还没读到(或这台电脑没这个能力)
5949
+ var permFor = null; // 手上这一份 currentValue 是**哪个会话**的
4030
5950
  var permArmed = ''; // 哪一档正在等「再点一次」
4031
5951
 
4032
- 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
+ }
4033
5983
  api('/mini/api/permissions').then(function (body) {
4034
- 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
+ }
4035
5990
  permOptions = body;
4036
5991
  renderPerms();
4037
5992
  }).catch(function () {
4038
- // 读不到就整块不显示。附加能力缺了,不该影响发指令。
4039
- permOptions = null;
4040
- $('permBlock').hidden = true;
5993
+ if (session !== permFor) return;
5994
+ // 附加能力缺了不该影响发指令:一个档位都没读到过时,整块不显示。
5995
+ if (!permOptions) $('permBlock').hidden = true;
4041
5996
  });
4042
5997
  }
4043
5998
 
@@ -4104,7 +6059,7 @@
4104
6059
  });
4105
6060
  }
4106
6061
 
4107
- loadPermissions();
6062
+ loadPermissions(state.boundSessionId);
4108
6063
 
4109
6064
  // 档位按钮的点击。
4110
6065
  //
@@ -4125,10 +6080,12 @@
4125
6080
  Array.prototype.forEach.call($('segMode').children, function (btn) {
4126
6081
  btn.addEventListener('click', function () {
4127
6082
  state.mode = btn.dataset.mode;
4128
- $('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
6083
+ $('hintMode').textContent = modeHint();
4129
6084
  localStorage.setItem('dshMiniMode', state.mode);
4130
6085
  syncSeg();
4131
6086
  render();
6087
+ // 切到「完整」:订上并把已有的过程拉下来;切走:退订、停推。
6088
+ syncTrajectorySubscription();
4132
6089
  });
4133
6090
  });
4134
6091
  Array.prototype.forEach.call($('segSound').children, function (btn) {
@@ -4172,6 +6129,21 @@
4172
6129
  }
4173
6130
  applyTheme(); // 补上首屏那次没做的一件事:把状态栏颜色对齐
4174
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
+
4175
6147
  function syncSeg() {
4176
6148
  Array.prototype.forEach.call($('segMode').children, function (b) {
4177
6149
  b.classList.toggle('active', b.dataset.mode === state.mode);
@@ -4182,6 +6154,9 @@
4182
6154
  Array.prototype.forEach.call($('segTheme').children, function (b) {
4183
6155
  b.classList.toggle('active', b.dataset.theme === state.theme);
4184
6156
  });
6157
+ Array.prototype.forEach.call($('segFont').children, function (b) {
6158
+ b.classList.toggle('active', b.dataset.font === state.fontSize);
6159
+ });
4185
6160
  }
4186
6161
 
4187
6162
  // 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
@@ -4230,6 +6205,71 @@
4230
6205
  });
4231
6206
  }
4232
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
+
4233
6273
  if (NOTIFY_OK) {
4234
6274
  // 先注册 service worker。**这一步不申请权限**——权限要用户主动点一下才给,
4235
6275
  // 页面一加载就弹窗会被浏览器直接拒掉,而且很讨厌。
@@ -4405,6 +6445,14 @@
4405
6445
  function watchBuild() {
4406
6446
  if (!state.token) return;
4407
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
+ }
4408
6456
  if (!r || !r.build || r.build === BUILD) return;
4409
6457
  if (inputEl.value && inputEl.value.trim()) return;
4410
6458
  location.reload();