dsh-mini-remote 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.en.md +13 -7
- package/README.md +13 -7
- package/lib/commands.js +6 -0
- package/lib/events.js +8 -3
- package/lib/history.js +7 -2
- package/lib/index.js +818 -18
- package/lib/mirror.js +1422 -0
- package/lib/page.html +2818 -152
- package/lib/page.js +3 -1
- package/lib/permissions.js +1 -0
- package/lib/server.js +641 -7
- package/lib/session-meta-cache.js +100 -0
- package/lib/store.js +273 -3
- package/lib/subagent-cache.js +63 -0
- package/lib/trajectory.js +570 -0
- package/lib/tree.js +30 -8
- package/package.json +2 -2
package/lib/page.html
CHANGED
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
需要"显眼一点"的地方一律用鲸鱼娘的冰蓝 --ice,别动这两个。
|
|
22
22
|
· --ok/--run/--err 是状态点的语义色,占的是 8px 一个小点,不算强调色。
|
|
23
23
|
· 配色以鲸鱼娘为准心:深海墨蓝打底,她的冰蓝作次级色。
|
|
24
|
-
·
|
|
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:
|
|
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
|
所以这块是唯一的反馈,不能省。 */
|
|
@@ -580,6 +656,14 @@
|
|
|
580
656
|
display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
|
581
657
|
font-size: 13.5px; line-height: 1.45;
|
|
582
658
|
}
|
|
659
|
+
/* 「插话」那一行的标记(2026-10-06)。不标的话,插话和排队在列表里长得一模一样,
|
|
660
|
+
而两者的等待时间差一个数量级(插话几秒、排队可能几分钟)——用户根本分不出
|
|
661
|
+
自己那条到底插进去了没有。颜色用既有的状态色 --run(和轨迹里的「进行中」同色)。 */
|
|
662
|
+
.queue .q-tag {
|
|
663
|
+
flex: 0 0 auto; font-size: 11px; line-height: 1;
|
|
664
|
+
padding: 2px 5px; border-radius: var(--r);
|
|
665
|
+
color: var(--run); border: 1px solid var(--run);
|
|
666
|
+
}
|
|
583
667
|
/* 长指令折行,但最多两行——队列是「提醒还有几条」,不是给人重读全文的地方。 */
|
|
584
668
|
.queue .q-text {
|
|
585
669
|
flex: 1 1 auto; min-width: 0; color: var(--fg);
|
|
@@ -602,6 +686,8 @@
|
|
|
602
686
|
.work-stage { animation: none; }
|
|
603
687
|
.pose { animation: none; transition: none; }
|
|
604
688
|
.work-progress i { width: 100%; opacity: .45; animation: none; }
|
|
689
|
+
/* 扫光是状态指示、不是装饰,但用户要「减少动效」时整条关掉——底色照旧。 */
|
|
690
|
+
.traj-shimmer { animation: none; background-image: none; color: var(--run); }
|
|
605
691
|
.queue .q-node i { transition: none; }
|
|
606
692
|
.dot { transition: none; }
|
|
607
693
|
}
|
|
@@ -636,11 +722,40 @@
|
|
|
636
722
|
border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
|
|
637
723
|
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
|
638
724
|
}
|
|
639
|
-
|
|
725
|
+
/* 图标是 DSH 桌面端那个鲸鱼(见 HTML 里那段注释)。
|
|
726
|
+
和原来的回形针有两点不一样,两条都得改:
|
|
727
|
+
· 它是**实心**路径(fill),不是描边的——留着 stroke 会把整个轮廓再描一圈;
|
|
728
|
+
· 它是横长条(23.16 : 17.04),不是正方形——照原来 19×19 会被拉扁。
|
|
729
|
+
颜色走 currentColor(跟着按钮的 var(--muted)),所以深浅两套主题各自适配。 */
|
|
730
|
+
.attach svg { width: 22px; height: 16.2px; fill: currentColor; stroke: none; }
|
|
640
731
|
.attach:disabled { opacity: .4; }
|
|
641
|
-
/*
|
|
642
|
-
|
|
643
|
-
|
|
732
|
+
/* 上传中:鲸鱼**原地游动**(2026-10-05 用户要求换掉转圈)。
|
|
733
|
+
为什么不用转圈:转圈是「物件在转」的语汇(齿轮、加载器),用在活物身上
|
|
734
|
+
读起来像标本被转着看。鲸鱼该有的动作是**游**。
|
|
735
|
+
|
|
736
|
+
这一组值改过两轮:用户先审了 1.5px / ±4 度,看过之后说「幅度可以再大一些」,
|
|
737
|
+
于是放到 **2.5px / ±7 度**(上下各大了 67%、俯仰大了 75%)。
|
|
738
|
+
一个来回仍是 1.8 秒(用户只提了幅度,速度没动)。
|
|
739
|
+
幅度占图标高度的 15%(16.2px 里的 2.5px);连同俯仰撑开的量,
|
|
740
|
+
离 42px 按钮的边还有 8px 以上余量,跑不出去。
|
|
741
|
+
|
|
742
|
+
**俯仰的方向别写反**(第一版就写反了,靠放大渲染才发现):
|
|
743
|
+
鲸鱼是**朝左**的(鼻头在左、尾巴在右上),而 CSS 里正角度是顺时针
|
|
744
|
+
——顺时针会把**左边**抬起来(拿 rotate(90deg) 想:左边的点会转到上方)。
|
|
745
|
+
所以「鼻尖抬起」是**正角度**。要的效果是「朝着前进的方向点头」:
|
|
746
|
+
上浮时鼻尖朝上、下潜时鼻尖朝下。于是 0%/100%(最高点)用 +7deg,
|
|
747
|
+
50%(最低点)用 -7deg。写反了看起来就像它在倒着游。 */
|
|
748
|
+
.attach.busy svg { animation: whaleSwim 1.8s ease-in-out infinite; }
|
|
749
|
+
@keyframes whaleSwim {
|
|
750
|
+
0%, 100% { transform: translateY(-2.5px) rotate(7deg); }
|
|
751
|
+
50% { transform: translateY(2.5px) rotate(-7deg); }
|
|
752
|
+
}
|
|
753
|
+
/* 用户要「减少动效」时它就不动——但**忙碌的表示没丢**:
|
|
754
|
+
按钮此时是禁用态(opacity .4),本来就是「正在进行」的样子。
|
|
755
|
+
(这一条同时补掉一个遗留缺口:原来那个转圈压根没管减少动效。) */
|
|
756
|
+
@media (prefers-reduced-motion: reduce) {
|
|
757
|
+
.attach.busy svg { animation: none; }
|
|
758
|
+
}
|
|
644
759
|
|
|
645
760
|
/* 附件小条:排在输入框上面,让你发之前看得见自己带了什么。 */
|
|
646
761
|
.attach-bar {
|
|
@@ -676,24 +791,35 @@
|
|
|
676
791
|
backdrop-filter: blur(14px) saturate(1.1);
|
|
677
792
|
}
|
|
678
793
|
.cmd-menu[hidden] { display: none; }
|
|
679
|
-
|
|
794
|
+
/* 面板里的指令列表(#opsCmds)和这只菜单**长得一样、也共用这份规则**:
|
|
795
|
+
同一份数据、同一种行,没有第二套样式。 */
|
|
796
|
+
.cmd-menu .cmd-row,
|
|
797
|
+
.ops-cmds .cmd-row {
|
|
680
798
|
display: flex; align-items: baseline; gap: 10px; width: 100%;
|
|
681
799
|
padding: 9px 8px; border: none; border-radius: var(--r); background: none;
|
|
682
800
|
color: var(--fg); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
|
|
683
801
|
}
|
|
684
|
-
.cmd-menu .cmd-row:active
|
|
685
|
-
.
|
|
802
|
+
.cmd-menu .cmd-row:active,
|
|
803
|
+
.ops-cmds .cmd-row:active { background: var(--panel-2); }
|
|
804
|
+
.cmd-menu .cmd-name,
|
|
805
|
+
.ops-cmds .cmd-name {
|
|
686
806
|
flex: 0 0 auto; color: var(--ice);
|
|
687
807
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
688
808
|
}
|
|
689
809
|
/* 说明收成一行:这一屏是「扫一眼挑一个」,不是读文档的地方。 */
|
|
690
|
-
.cmd-menu .cmd-desc
|
|
810
|
+
.cmd-menu .cmd-desc,
|
|
811
|
+
.ops-cmds .cmd-desc {
|
|
691
812
|
flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 12.5px;
|
|
692
813
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
693
814
|
}
|
|
694
815
|
/* 取不到指令时说清原因,**不显示空列表**:空列表看着就像「这个会话没有指令」,
|
|
695
816
|
而真实原因多半是「这个会话现在没在跑」——两件事,用户能做的事也不一样。 */
|
|
696
|
-
.cmd-menu .cmd-note
|
|
817
|
+
.cmd-menu .cmd-note,
|
|
818
|
+
.ops-cmds .cmd-note { padding: 10px 8px; color: var(--muted); font-size: 13px; }
|
|
819
|
+
/* 面板那一行多两样:一层包住名字和说明(好让右边那面小旗挨着它排),以及那面小旗本身
|
|
820
|
+
(声明了收附件的指令,手机这一版带不了)。名字和说明的省略规则照旧用上面那一套。 */
|
|
821
|
+
.ops-cmds .cmd-top { display: flex; align-items: baseline; gap: 10px; flex: 1 1 auto; min-width: 0; }
|
|
822
|
+
.ops-cmds .cmd-flag { flex: none; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
697
823
|
|
|
698
824
|
/* ---------- 左侧导航栏 ---------- */
|
|
699
825
|
#nav {
|
|
@@ -764,6 +890,22 @@
|
|
|
764
890
|
.ws-new:active { background: var(--panel-2); }
|
|
765
891
|
.ws-new[disabled] { color: var(--muted); cursor: default; }
|
|
766
892
|
|
|
893
|
+
/* 「新建会话」下展开的模式清单(2026-10-04)。行内展开、点谁建谁——照
|
|
894
|
+
「新建文件夹」的先例不弹窗;左侧一条细线表明它是那一行展开的内容;
|
|
895
|
+
坏档灰字不可点。 */
|
|
896
|
+
.ws-modes { margin: 2px 0 8px; padding-left: 10px; border-left: 1px solid var(--line); }
|
|
897
|
+
.ws-mode {
|
|
898
|
+
display: block; width: 100%; text-align: left; cursor: pointer;
|
|
899
|
+
font: inherit; font-size: 12.5px; line-height: 1.5;
|
|
900
|
+
padding: 7px 8px; color: var(--fg);
|
|
901
|
+
background: none; border: none;
|
|
902
|
+
}
|
|
903
|
+
.ws-mode:active { background: var(--panel-2); }
|
|
904
|
+
.ws-mode b { font-weight: 650; }
|
|
905
|
+
.ws-mode-desc { display: block; color: var(--muted); font-size: 11.5px; }
|
|
906
|
+
.ws-mode-def { margin-left: 8px; color: var(--dim); font-size: 11px; }
|
|
907
|
+
.ws-mode-broken { color: var(--muted); cursor: default; }
|
|
908
|
+
|
|
767
909
|
/* 导航栏最上面那条「新建工作区」。跟工作区条目之间用一条线隔开:
|
|
768
910
|
它是这一屏的入口,不是一个工作区。 */
|
|
769
911
|
.nav-cta {
|
|
@@ -837,6 +979,106 @@
|
|
|
837
979
|
露和收之间就不会把下面的消息顶来顶去(那会是一次小跳)。 */
|
|
838
980
|
.more-note.hide { visibility: hidden; }
|
|
839
981
|
|
|
982
|
+
/* 「完整」模式:过程组穿插在对话原位,长相对齐 PC 的 turn-process(tasks/todo.md 11):
|
|
983
|
+
行首一枚细线小图标 + 灰色状态词/标题(叫法照抄 DSH,一个字不自创),点开才见原文。
|
|
984
|
+
只用现成令牌、不加动效不加新颜色:它是补充信息,不是这一屏的主角。 */
|
|
985
|
+
.traj-group { margin-bottom: 10px; }
|
|
986
|
+
.traj-head {
|
|
987
|
+
display: flex; align-items: baseline; gap: 6px; width: 100%; text-align: left; cursor: pointer;
|
|
988
|
+
font: inherit; font-size: 12.5px; line-height: 1.6;
|
|
989
|
+
padding: 6px 4px; color: var(--muted);
|
|
990
|
+
background: none; border: none; border-top: 1px dashed var(--line);
|
|
991
|
+
}
|
|
992
|
+
.traj-entries { margin: 2px 0 0 4px; padding-left: 10px; border-left: 1px solid var(--line); }
|
|
993
|
+
.traj-row {
|
|
994
|
+
display: flex; align-items: baseline; gap: 6px; width: 100%; text-align: left; cursor: pointer;
|
|
995
|
+
font: inherit; font-size: 13px; line-height: 1.6;
|
|
996
|
+
padding: 5px 4px; color: var(--fg);
|
|
997
|
+
background: none; border: none;
|
|
998
|
+
}
|
|
999
|
+
.traj-row:active { background: var(--panel-2); }
|
|
1000
|
+
.traj-ico { flex: none; width: 14px; height: 14px; color: var(--muted); }
|
|
1001
|
+
.traj-ico svg { display: block; width: 14px; height: 14px; }
|
|
1002
|
+
.traj-name { flex: none; color: var(--muted); }
|
|
1003
|
+
/* 折叠行右侧的内容一行截断(照 PC):折叠行是给人扫的,不是给人读的——
|
|
1004
|
+
具体东西在展开里。组头标题同款处理(活片段再长也不许换行刷屏)。 */
|
|
1005
|
+
.traj-detail {
|
|
1006
|
+
flex: 1 1 auto; min-width: 0;
|
|
1007
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
1008
|
+
color: var(--fg);
|
|
1009
|
+
}
|
|
1010
|
+
.traj-head .traj-title, .traj-head .traj-run {
|
|
1011
|
+
flex: 1 1 auto; min-width: 0;
|
|
1012
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
1013
|
+
}
|
|
1014
|
+
.traj-say {
|
|
1015
|
+
margin: 4px 0 8px; padding: 0 2px;
|
|
1016
|
+
word-break: break-word;
|
|
1017
|
+
color: var(--fg); font-size: 13px; line-height: 1.7;
|
|
1018
|
+
}
|
|
1019
|
+
.traj-say p { margin: 0 0 6px; }
|
|
1020
|
+
.traj-say p:last-child { margin-bottom: 0; }
|
|
1021
|
+
/* 思考正文(照 PC 的 compact Markdown):普通段落、三级灰、左缩进 22px,不套框。 */
|
|
1022
|
+
.traj-think {
|
|
1023
|
+
margin: 2px 0 8px; padding: 4px 0 4px 22px;
|
|
1024
|
+
color: var(--muted); font-size: 13px; line-height: 20px;
|
|
1025
|
+
word-break: break-word;
|
|
1026
|
+
}
|
|
1027
|
+
.traj-think p { margin: 0 0 6px; }
|
|
1028
|
+
.traj-think p:last-child { margin-bottom: 0; }
|
|
1029
|
+
/* 工具展开卡(照 PC 的通用 IN/OUT 卡):标签小字灰、正文等宽、细线分隔、出错整段红。 */
|
|
1030
|
+
.traj-io {
|
|
1031
|
+
margin: 2px 0 8px;
|
|
1032
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r);
|
|
1033
|
+
}
|
|
1034
|
+
.traj-io-row { display: flex; gap: 10px; padding: 8px 10px; }
|
|
1035
|
+
.traj-io-row + .traj-io-row { border-top: 1px solid var(--line); }
|
|
1036
|
+
.traj-io-label { flex: none; width: 34px; color: var(--muted); font-size: 11px; line-height: 1.7; }
|
|
1037
|
+
.traj-io-body { flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 12.5px; line-height: 1.6; }
|
|
1038
|
+
.traj-io-body pre {
|
|
1039
|
+
margin: 0; white-space: pre-wrap; word-break: break-word;
|
|
1040
|
+
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px;
|
|
1041
|
+
}
|
|
1042
|
+
.traj-io-err pre { color: var(--err); }
|
|
1043
|
+
.traj-io-note { margin-top: 4px; color: var(--dim); font-size: 11px; }
|
|
1044
|
+
/* 四类状态的字样:只上色、零动效(动效守卫只认那五种关键帧),颜色全取现成令牌。 */
|
|
1045
|
+
.traj-err { color: var(--err); } /* 失败首行:折叠处一眼可见 */
|
|
1046
|
+
.traj-run { color: var(--run); } /* 进行中:有标识;2026-10-04 起再加一道扫光(用户点名) */
|
|
1047
|
+
/* 条目行里那两个字样**不许被压扁、不许折行**(2026-10-06 用户真机反馈:
|
|
1048
|
+
「进行中」的「中」挂到下一行)。
|
|
1049
|
+
根因:它们是直接拼在 .traj-row 里的裸 <span>,而 .traj-row 是**弹性容器**
|
|
1050
|
+
——于是各自变成一个弹性项,默认 flex: 0 1 auto、min-width: auto。
|
|
1051
|
+
中文的「最小内容宽度」是**一个字**,空间一紧它就缩成一字宽,文字自己折行,
|
|
1052
|
+
变成「进 / 行 / 中」竖着排,还把整行撑高(真机复现过,长摘要那一行最明显)。
|
|
1053
|
+
修法:让它不参与压缩、且不许折行——**正好也是 2026-10-04 定下的口径**
|
|
1054
|
+
(「出错/进行中的字样是独立的一段,不许被省略号吃掉」):它保持完整宽度,
|
|
1055
|
+
让左边那段 .traj-detail 去缩、去打省略号。
|
|
1056
|
+
只挂在 .traj-row 下:组头那份 .traj-head .traj-run 自己有 nowrap + 省略号,
|
|
1057
|
+
不要动它。 */
|
|
1058
|
+
.traj-row .traj-err, .traj-row .traj-run { flex: none; white-space: nowrap; }
|
|
1059
|
+
/* 跑着的行有一道渐变光扫过去(照 DSH 的 TextShimmer,2026-10-04 用户点名复现,
|
|
1060
|
+
要适配主题)。机制照 PC:底色还是语义色(--run),一道亮色(--shimmer)扫过;
|
|
1061
|
+
每秒 48 格、1.5 秒一轮、走前 2/3 停后 1/3,与 PC 同参。颜色全走现成令牌,
|
|
1062
|
+
深浅两套主题各看各的。行上带一个负 delay(trajShimStyle):页面整块重画会把
|
|
1063
|
+
动画打回开头,接上全局时钟的相位才不会变成频闪。 */
|
|
1064
|
+
.traj-shimmer {
|
|
1065
|
+
background-image: linear-gradient(105deg,
|
|
1066
|
+
var(--run) 38%, var(--shimmer) 50%, var(--run) 62%);
|
|
1067
|
+
background-size: 260% 100%;
|
|
1068
|
+
-webkit-background-clip: text; background-clip: text;
|
|
1069
|
+
color: transparent;
|
|
1070
|
+
animation: shimSweep 1.5s steps(48, end) infinite;
|
|
1071
|
+
}
|
|
1072
|
+
@keyframes shimSweep {
|
|
1073
|
+
0% { background-position: 150% 0; }
|
|
1074
|
+
66.6667%, 100% { background-position: -50% 0; }
|
|
1075
|
+
}
|
|
1076
|
+
.traj-note {
|
|
1077
|
+
margin: 0 0 10px; padding: 6px 8px;
|
|
1078
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r);
|
|
1079
|
+
color: var(--muted); font-size: 12.5px; line-height: 1.6;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
840
1082
|
/* ---------- 设置抽屉 ---------- */
|
|
841
1083
|
#settings {
|
|
842
1084
|
position: fixed; inset: 0; background: var(--scrim);
|
|
@@ -852,11 +1094,87 @@
|
|
|
852
1094
|
backdrop-filter: blur(14px) saturate(1.1);
|
|
853
1095
|
}
|
|
854
1096
|
.sheet h2 { margin: 0 0 14px; font-size: 16px; }
|
|
1097
|
+
|
|
1098
|
+
/* ---------- 设置页:整屏(2026-10-05 用户要求) ----------
|
|
1099
|
+
内容越来越多,贴底那张最高 86vh 的抽屉不够用了。
|
|
1100
|
+
|
|
1101
|
+
**只改设置这一处**,不动 .sheet 本身——设置 / 统一操作面板 / 子智能体 / 插件页
|
|
1102
|
+
共用那一套(✓ 毛玻璃底色照旧,一个字没改),另外三处本来就短,照旧贴底。
|
|
1103
|
+
|
|
1104
|
+
布局改成一列:页眉(.sheet-head)待在**滚动区外面**,内容(.sheet-body)自己滚。
|
|
1105
|
+
为什么要这样而不是把页眉做成吸顶的:吸顶的页眉得自己铺一层底色去挡滚上来的内容,
|
|
1106
|
+
而它铺在抽屉的毛玻璃之上会**再叠一层**、叠出更深的色,顶上就一道色差。
|
|
1107
|
+
页眉不进滚动区,底下就还是抽屉本来的那层毛玻璃,什么都不用铺。
|
|
1108
|
+
|
|
1109
|
+
圆角和上边框去掉:它们本来是「贴着底边只露一截」才需要的,整屏之后那两条边
|
|
1110
|
+
就是屏幕边。 */
|
|
1111
|
+
#settings .sheet {
|
|
1112
|
+
display: flex; flex-direction: column;
|
|
1113
|
+
overflow: hidden;
|
|
1114
|
+
max-height: none; height: 100%;
|
|
1115
|
+
border-radius: 0; border-top: none;
|
|
1116
|
+
padding: 0;
|
|
1117
|
+
}
|
|
1118
|
+
/* 页眉。**整屏之后必须有一个一直看得见的关闭口**:原来「点抽屉外面」那条路没了
|
|
1119
|
+
(没有露在外面的遮罩可点),光靠底部那个「完成」得先滚到底才行。
|
|
1120
|
+
|
|
1121
|
+
高度是省出来的(2026-10-05 用户说太高):原来上下留 16/10px,而真正把这条页眉
|
|
1122
|
+
顶高的是右边那个按钮——.btn 默认上下各 8px 内边距、14px 字,光它就 39px,
|
|
1123
|
+
加起来 65px,比页面自己那条顶栏还高。
|
|
1124
|
+
· 上内边距收到 10px,和页面顶栏(header)**同一条竖向节奏**;
|
|
1125
|
+
· 页眉里这一个按钮单独紧凑一档(见下),别处照旧。
|
|
1126
|
+
两处合起来约 50px。 */
|
|
1127
|
+
.sheet-head {
|
|
1128
|
+
flex: 0 0 auto;
|
|
1129
|
+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
1130
|
+
padding: calc(env(safe-area-inset-top) + 10px) 16px 10px;
|
|
1131
|
+
border-bottom: 1px solid var(--line);
|
|
1132
|
+
}
|
|
1133
|
+
.sheet-head h2 { margin: 0; }
|
|
1134
|
+
/* 设置页真正滚的那一块。左右 16px 和底部安全区都是从 .sheet 挪过来的
|
|
1135
|
+
(那边 padding 已归零,账不能丢)。 */
|
|
1136
|
+
#settings .sheet-body {
|
|
1137
|
+
flex: 1 1 auto; overflow-y: auto;
|
|
1138
|
+
padding: 14px 16px calc(env(safe-area-inset-bottom) + 18px);
|
|
1139
|
+
}
|
|
855
1140
|
.row {
|
|
856
1141
|
display: flex; align-items: center; justify-content: space-between;
|
|
857
1142
|
gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line);
|
|
858
1143
|
}
|
|
859
1144
|
.row:last-of-type { border-bottom: none; }
|
|
1145
|
+
|
|
1146
|
+
/* **这一句不能省。** `hidden` 只是 UA 样式表里的一条 `display:none`,
|
|
1147
|
+
而上面 `.row` 写了 `display:flex`——作者样式表压得住 UA 那一句(同一个道理
|
|
1148
|
+
见 `#mirrorView[hidden]`、`.sheet .version[hidden]`)。
|
|
1149
|
+
少了它,`.row` 上的 `hidden` 等于没写:元素照样撑开一段高度(2026-10-08 实测
|
|
1150
|
+
358×70,`getComputedStyle` 回来还是 `flex`),于是设置里「电脑端界面」那一行
|
|
1151
|
+
在进阶设置关着时照样露着。**改脚本没用**——脚本一直是对的,它确实把 hidden
|
|
1152
|
+
设上了;漏的是这条兜底样式。 */
|
|
1153
|
+
.row[hidden] { display: none; }
|
|
1154
|
+
|
|
1155
|
+
/* 权限档位那一块外面套了一层(#permBlock,服务拿不到时整块要藏起来)。
|
|
1156
|
+
它下面**没有分割线**,而别的板块之间都有——2026-10-05 用户截图点名。
|
|
1157
|
+
原因:别的板块的分割线都是「那一行自己的下边线」(.row 的 border-bottom),
|
|
1158
|
+
而权限档位那层的最后一个元素是**那排按钮**(.seg,它自带边框),
|
|
1159
|
+
那层自己没有下边线,所以两块之间是空的。
|
|
1160
|
+
|
|
1161
|
+
修法就照其他线的做法,一模一样:**让这一块自己带一条下边线,
|
|
1162
|
+
并且先留出 12px**——线因此落在「按钮 + 12px」处,
|
|
1163
|
+
和别处的线一样居中在留白里(下一行也有 12px 上边距)。
|
|
1164
|
+
为什么不给「繁忙」那行加 border-top:那会让线紧贴着按钮的边框,
|
|
1165
|
+
看起来只是按钮框变粗,不像一条分割线。
|
|
1166
|
+
藏起来时(hidden = display:none)这条线自然不画,
|
|
1167
|
+
上面「字体大小」那条线正好接上,不会两条挤在一起。 */
|
|
1168
|
+
#permBlock { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
|
1169
|
+
/* 权限档位里面那行标签**不要自己的下边线**(2026-10-05 用户截图点名:
|
|
1170
|
+
「权限档位格子的上缘有一条粗线」)。
|
|
1171
|
+
原因:那行标签按 .row 的规矩带着一条下边线,而它下面紧挨着的就是**按钮框**
|
|
1172
|
+
(.seg 自带整圈边框)——两条 1px 的线贴在一起,看起来就是一条粗线。
|
|
1173
|
+
实测:上缘 2 个 CSS 像素、下缘 1 个,粗细不一样。
|
|
1174
|
+
为什么那行没被「最后一个不画下边线」那条规则管到:权限档位这一层里,
|
|
1175
|
+
按钮框排在标签**后面**,所以标签那行不算「最后一个」。
|
|
1176
|
+
它本来也不该有线——它是下面那排按钮的标签,中间画一条分割线没有道理。 */
|
|
1177
|
+
#permBlock .row { border-bottom: none; }
|
|
860
1178
|
.row .label { font-size: 15px; }
|
|
861
1179
|
.row .hint { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
862
1180
|
/* flex-shrink: 0 不能省。它是弹性项,左边的说明文字一长就会被压缩;
|
|
@@ -868,15 +1186,133 @@
|
|
|
868
1186
|
font-size: 13px; cursor: pointer;
|
|
869
1187
|
}
|
|
870
1188
|
.seg button.active { background: var(--ice); color: var(--on-act); }
|
|
1189
|
+
/* 权限档位那排按钮要**撑满**它的框(2026-10-05 用户截图点名:「自动审查那一格
|
|
1190
|
+
背景颜色没填满」)。
|
|
1191
|
+
根因:别的 .seg 都套在 .row 里(那是弹性容器),所以框会缩到内容那么宽,
|
|
1192
|
+
按钮正好填满;而权限档位这一排是 #permBlock 的直接子元素,**在块级上下文里**
|
|
1193
|
+
→ 框被拉成整行宽,而四个按钮只占自己文字那么宽,右边就空出一条
|
|
1194
|
+
(实测约 10 个 CSS 像素),露出来的是框自己的底色,看着像那一格没填满。
|
|
1195
|
+
修法:让它们按内容分配、再把多出来的空间摊平(1 1 auto)。
|
|
1196
|
+
不用 flex: 1 1 0(四等分)——「工作区内修改」六个字比别的宽,
|
|
1197
|
+
强行等分会把它挤到换行。 */
|
|
1198
|
+
#segPerm button { flex: 1 1 auto; }
|
|
871
1199
|
select, .btn {
|
|
872
1200
|
padding: 8px 12px; border-radius: var(--r); border: 1px solid var(--line);
|
|
873
1201
|
background: var(--panel-2); color: var(--fg); font: inherit; font-size: 14px;
|
|
874
1202
|
}
|
|
875
1203
|
.btn { cursor: pointer; }
|
|
876
1204
|
.btn.primary { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
|
|
1205
|
+
/* 页眉里那个动作按钮(关闭)比通用 .btn 紧凑一档。
|
|
1206
|
+
|
|
1207
|
+
通用 .btn 是给面板正身用的:上下各 8px 内边距 + 14px 字,约 39px 高。
|
|
1208
|
+
摆进页眉就比标题高出一大截——**整条页眉的高度其实是它顶出来的**,而且那个
|
|
1209
|
+
方框比标题那行字高出一圈,看着又大又不像跟标题对齐。
|
|
1210
|
+
2026-10-05 用户两次报到这里:先说设置页「页眉高度太大」,又说插件页
|
|
1211
|
+
「关闭按钮太大了,没跟插件两个字对齐」。
|
|
1212
|
+
|
|
1213
|
+
**四个面板页眉共用这一档**(统一操作面板 / 子智能体 / 插件页 / 设置页),
|
|
1214
|
+
免得一处收了一处没收、几个面板长得不一样。
|
|
1215
|
+
通用 .btn 的内边距一个字没动——它别处到处在用,动它等于整页跟着变。 */
|
|
1216
|
+
.ops-head .btn, .sa-head .btn, .sheet-head .btn {
|
|
1217
|
+
padding: 5px 11px; font-size: 13px;
|
|
1218
|
+
}
|
|
877
1219
|
.sheet .foot { margin-top: 18px; display: flex; gap: 10px; }
|
|
1220
|
+
/* 设置抽屉最底下那行 DSH 版本:做成一条**收尾的分隔线**——两侧各一条细线、
|
|
1221
|
+
版本号居中,把这一屏收住,免得孤零零一行小字吊在最后(2026-10-05 用户要的
|
|
1222
|
+
「加一点设计感,比如两侧加横线」)。
|
|
1223
|
+
线用现成的 --line(细线色),不新造令牌、不加关键帧、不碰圆角——这一屏的
|
|
1224
|
+
层次本来就靠这条 1px 细线建立,别为了一行版本号另起一套。
|
|
1225
|
+
|
|
1226
|
+
`[hidden]` 那一句**不能省**:下面写了 display: flex,而作者样式表里的
|
|
1227
|
+
display 会盖掉 HTML `hidden` 自带的 display: none——少了它,问不到版本时
|
|
1228
|
+
这一行照样显示,变成一条空的分隔条。 */
|
|
1229
|
+
.sheet .version {
|
|
1230
|
+
display: flex; align-items: center; gap: 10px;
|
|
1231
|
+
padding: 18px 0 6px; font-size: 12px; color: var(--muted);
|
|
1232
|
+
letter-spacing: 0.04em;
|
|
1233
|
+
}
|
|
1234
|
+
.sheet .version[hidden] { display: none; }
|
|
1235
|
+
.sheet .version::before,
|
|
1236
|
+
.sheet .version::after {
|
|
1237
|
+
content: ""; flex: 1 1 0; height: 1px; background: var(--line);
|
|
1238
|
+
}
|
|
878
1239
|
.sheet .foot .btn { flex: 1 1 0; text-align: center; }
|
|
879
1240
|
|
|
1241
|
+
/* ---------- 「电脑端设置界面」那一整屏(内嵌,不跳转) ----------
|
|
1242
|
+
原委写在页面里 `#mirrorView` 上面那段注释里:跳转那条路在用户手机上三次都没成,
|
|
1243
|
+
所以改成嵌在这一页里——点一下只是把这一层显示出来、给里面的框设个地址,
|
|
1244
|
+
**浏览器没有任何东西可以拦**。
|
|
1245
|
+
|
|
1246
|
+
层级 80:要盖住设置抽屉(50)和那些动态小面板(70),但**必须在**授权卡(300+)
|
|
1247
|
+
和门禁页(100)**下面**——那两样是打断性的,任何时候都该在最上面。
|
|
1248
|
+
|
|
1249
|
+
`[hidden]` 那一句不能省:下面写了 display: flex,作者样式表里的 [hidden] 会被压住
|
|
1250
|
+
(和 `.sheet .version[hidden]` 同一个道理,见上面那段注释)。 */
|
|
1251
|
+
#mirrorView {
|
|
1252
|
+
position: fixed; inset: 0; z-index: 80;
|
|
1253
|
+
/* 高度跟「看得见的那块」走,而不是跟「地址栏收起时的那块」走(2026-10-07)。
|
|
1254
|
+
`inset:0` 的高度是按**大视口**算的(地址栏收起时的高度),而手机地址栏拉出来之后,
|
|
1255
|
+
真正看得见的只有下面那一截——于是框比可视区高,里面那份界面在框里**垂直居中**,
|
|
1256
|
+
底边就落到屏幕外面去了(用户报过「面板底边被切掉」)。
|
|
1257
|
+
`100dvh` 跟可视区走。**这条是「从参考插件学来、但落点要换」的典型**:它给官方面板补
|
|
1258
|
+
`max-height:calc(100dvh - 32px)`,可那个 `dvh` 是**框里面**的,框里的可视区单位等于
|
|
1259
|
+
框自己的高,等于白写;要治这个病只能写在**外层**这一页——这里 `dvh` 才是真的。
|
|
1260
|
+
老浏览器不认 `dvh` 会丢掉这一条,退回现在的行为,不会崩。 */
|
|
1261
|
+
height: 100dvh;
|
|
1262
|
+
display: flex; flex-direction: column;
|
|
1263
|
+
background: var(--bg);
|
|
1264
|
+
}
|
|
1265
|
+
#mirrorView[hidden] { display: none; }
|
|
1266
|
+
.mv-bar {
|
|
1267
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
|
|
1268
|
+
padding: 10px 12px;
|
|
1269
|
+
padding-top: calc(10px + env(safe-area-inset-top, 0px));
|
|
1270
|
+
border-bottom: 1px solid var(--line);
|
|
1271
|
+
background: var(--bg-top);
|
|
1272
|
+
}
|
|
1273
|
+
.mv-title { flex: 1 1 auto; font-size: 14px; color: var(--fg); }
|
|
1274
|
+
#mirrorFrame {
|
|
1275
|
+
flex: 1 1 auto; width: 100%; border: 0; background: var(--bg);
|
|
1276
|
+
/* 底部让开手势条(2026-10-07 从「被镜像的那份页面」搬回来的)。
|
|
1277
|
+
这条原先是写在 lib/mirror.js 的 ADAPT_CSS 里,而那段样式是**注进框里那份文档**的
|
|
1278
|
+
——`#mirrorFrame` 是本文件(外层)的元素,两个不同的文档,所以那条规则一个像素
|
|
1279
|
+
都没生效:活服务上量过,框的 `padding-bottom` 是 0px,被镜像的那份 HTML 里反倒
|
|
1280
|
+
躺着一份没人用的 `#mirrorFrame` 规则(官方界面里根本没有这个 id)。
|
|
1281
|
+
顶部那条不归这里管:外层横条 .mv-bar 自己写了 padding-top。 */
|
|
1282
|
+
padding-bottom: env(safe-area-inset-bottom, 0px); box-sizing: border-box;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/* ---------- 统一操作面板(输入框旁边那一个入口点开的东西) ----------
|
|
1286
|
+
骨架和设置抽屉**是同一套**:同一个 `#xxx.open` 即时显隐、同一个贴底的 `.sheet`
|
|
1287
|
+
(它自带 max-height / overflow-y / safe-area 那三样,窄屏顶不出去),不新建样式体系。
|
|
1288
|
+
这里独有的只有上面两段小节头和那个筛选框;指令行沿用 .cmd-menu 那一套。 */
|
|
1289
|
+
#ops {
|
|
1290
|
+
position: fixed; inset: 0; background: var(--scrim);
|
|
1291
|
+
display: none; align-items: flex-end; z-index: 50;
|
|
1292
|
+
}
|
|
1293
|
+
#ops.open { display: flex; }
|
|
1294
|
+
.ops-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
|
1295
|
+
.ops-head h2 { flex: 1 1 auto; margin: 0; }
|
|
1296
|
+
/* 两个动作平分一行。都是既有 .btn(细边框 + --r 圆角 + --panel-2 底),
|
|
1297
|
+
没有主按钮——这一屏不放强调色(#settings 那一屏已经有一处「完成」)。 */
|
|
1298
|
+
.ops-acts { display: flex; gap: 10px; }
|
|
1299
|
+
.ops-acts .btn { flex: 1 1 0; text-align: center; }
|
|
1300
|
+
.ops-files { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
|
1301
|
+
.ops-files[hidden] { display: none; }
|
|
1302
|
+
/* 「调用指令」这个小标题**要带上前面的类**,不能只写 .ops-sub。
|
|
1303
|
+
2026-10-05 用户截图点名:「调用指令和上方选文件拍照那一行要有点留白」。
|
|
1304
|
+
根因:这里本来写着 margin: 20px 0 10px,但上面有条 `.sheet h2 { margin: 0 0 14px }`
|
|
1305
|
+
——它是「一个类 + 一个元素」(0,1,1),比只有「一个类」的 .ops-sub (0,1,0) 优先级高,
|
|
1306
|
+
于是 margin-top 被盖成 0,那 20px 从来没生效过,标题就贴着按钮。
|
|
1307
|
+
写成 .sheet .ops-sub (0,2,0) 才压得住那条规则。 */
|
|
1308
|
+
.sheet .ops-sub { margin: 20px 0 10px; font-size: 16px; }
|
|
1309
|
+
.ops-filter {
|
|
1310
|
+
width: 100%; padding: 9px 12px; border-radius: var(--r); border: 1px solid var(--line);
|
|
1311
|
+
background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
|
|
1312
|
+
}
|
|
1313
|
+
/* 列表自己滚,别把整张抽屉撑出屏幕。 */
|
|
1314
|
+
.ops-cmds { margin-top: 8px; max-height: 40vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
|
1315
|
+
|
|
880
1316
|
/* ---------- token 输入 ---------- */
|
|
881
1317
|
#gate { position: fixed; inset: 0; background: var(--bg); z-index: 100;
|
|
882
1318
|
display: none; flex-direction: column; justify-content: center;
|
|
@@ -955,17 +1391,222 @@
|
|
|
955
1391
|
width: 18px; height: 18px; fill: none; stroke: currentColor;
|
|
956
1392
|
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
|
|
957
1393
|
}
|
|
1394
|
+
|
|
1395
|
+
/* ---------- 「回到底部」浮标(2026-10-08) ----------
|
|
1396
|
+
用户要的是「瞬间回到底部的按钮,圈子里一个简约的往下箭头」,参考电脑端 DSH 那颗。
|
|
1397
|
+
|
|
1398
|
+
形状照电脑端那颗抄:圆底 + 一根下折线(矢量 svg,不是字形、不是 emoji——
|
|
1399
|
+
字形换个机型就可能变成豆腐块)。尺寸不一样:电脑端 34×34,这一颗 38×38
|
|
1400
|
+
(第四轮缩到 27、第五轮回到 31,见下面那两段)。
|
|
1401
|
+
圆角走 999px 那一档,不新造档位。
|
|
1402
|
+
|
|
1403
|
+
---- 第二轮:用户要「克制而高级的主题适配」,外观只动下面这三处 ----
|
|
1404
|
+
|
|
1405
|
+
① 边界从「画上去的一根 1px 线」改成「从面里长出来的一道 0.5px 环」:
|
|
1406
|
+
去掉 border,环改由 box-shadow 的第一层 `0 0 0 .5px` 来画。这不是自创——
|
|
1407
|
+
电脑端那套高度系统本来就长这样(dsh-client-ui-theme 里那道 elevation-stroke
|
|
1408
|
+
就是「0 0 0 .5px 加描边色」,官方那颗浮标挂的 elevation-panel 第一位正是它)。
|
|
1409
|
+
0.5px 在 2 倍屏上正好落成 1 个物理像素:比 1px 细一半,看得见轮廓,
|
|
1410
|
+
又找不到那根线。颜色仍走 --line(全页的细线都用它)。
|
|
1411
|
+
|
|
1412
|
+
② 投影换轻一档,深色单独处理:
|
|
1413
|
+
· 浅色用 --sh-1,不再用 --sh-2。--sh-2(0 2px 8px / 12%)是这两档里更重的
|
|
1414
|
+
一档,压在这颗 38px 的小圆上像贴了块膏药;--sh-1(0 1px 2px / 7%)
|
|
1415
|
+
同一个蓝调,更小更柔。官方那套的第二三层(0 3px 8px @3% 加 0 0 16px @2%)
|
|
1416
|
+
比这更浅也更宽——要贴到那个程度就得现调一组新数,与这一轮「克制」的调子
|
|
1417
|
+
相悖,所以取现成的那一档。
|
|
1418
|
+
· 深色把投影层整个去掉。实测它在深色下等于零:浮标落在页面底色渐变最暗的
|
|
1419
|
+
那一头(近黑的 #070b11),再压一层黑色投影,同一像素开关投影前后最多差
|
|
1420
|
+
5/255(整幅平均 0.07/255)。层次改由**底色本身**立——实测按钮的 --panel #121a25 比它脚下的
|
|
1421
|
+
背景 #070b11 亮 (+11,+15,+20),本身就已经高出一档,和官方那颗(深色下用
|
|
1422
|
+
比背景亮的浮层底色)是同一个道理。底线是静息色不能改成 --panel-2:
|
|
1423
|
+
那是这颗按钮**按下**时的底色,拿它当静息色,按下去就没有反馈了。
|
|
1424
|
+
|
|
1425
|
+
③ 看得见的圆缩到 38,手指的范围反而变大:原来 42×42 是「看得见多大就点得到
|
|
1426
|
+
多大」,而 42 其实低于常说的那条 44 下限;现在圆缩到 38,看不见的热区用
|
|
1427
|
+
::before 撑到 46×46。图标等比缩到 14.5px(16 × 38/42),箭头在圆里占的比例
|
|
1428
|
+
和原来一样,既不空也不挤。
|
|
1429
|
+
|
|
1430
|
+
颜色**一个令牌都不新造**,全用主屏现成的那几个:底 --panel、线 --line、
|
|
1431
|
+
图标 --fg、按下 --panel-2、投影 --sh-1。两套主题各自成对。
|
|
1432
|
+
|
|
1433
|
+
---- 第四轮(2026-10-08):看得见的面积减半,同时往左让开滚动条那一条 ----
|
|
1434
|
+
|
|
1435
|
+
用户两条原话:「这个按钮还可以再缩小一半的面积」「如果旁边滚动条显示的话,
|
|
1436
|
+
点不下去,是否可以触压位往左侧再拖拽一些」。两件事分开算:
|
|
1437
|
+
|
|
1438
|
+
① 面积减半 = 直径乘 √2 的一半(0.707),不是把直径砍一半。
|
|
1439
|
+
38 × 0.707 = 26.87 → **27**。(真把直径砍成 19,面积就只有四分之一,
|
|
1440
|
+
那不是「减半」,是「小得离谱」。)
|
|
1441
|
+
看得见的地方小一半,可点的地方一点没缩:热区仍 **≥44**(46 → 45,见 ::before),
|
|
1442
|
+
这是手指那条下限,不能跟着圆一起缩。
|
|
1443
|
+
图标跟着等比缩:16 × 27/38 = **10.3px**——占圆的比例和 38 那会儿一模一样,
|
|
1444
|
+
所以圆里既不空也不挤。
|
|
1445
|
+
|
|
1446
|
+
② 往左让开滚动条(这条更要紧,用户是点不下去才说的)。两件事都在**活页面**上量过:
|
|
1447
|
+
· 手机仿真(= 手机上的实际形态)下 main 的 `offsetWidth - clientWidth` **= 0**:
|
|
1448
|
+
滚动条是**覆盖式**的,版面上一个像素都不占,内容区照样通到最右边 390。
|
|
1449
|
+
所以按钮的右沿天生停在 390 − 14(main 的 padding-right)= **376**,
|
|
1450
|
+
右边只剩 14px——滚动条一冒出来就贴在那一条上,正好压住按钮右半边。
|
|
1451
|
+
· 换一个**经典式**(占宽度)的形态量,滚动条占的正是**最右边那条 15px**
|
|
1452
|
+
(x 375..390,盒模型与像素两边对得上,见 ui.test.mjs 里那条断言)。
|
|
1453
|
+
也就是:不管哪一种形态,那条带都从 375 起头。
|
|
1454
|
+
于是往左挪的量按**实测那条带**算,不拍脑袋:
|
|
1455
|
+
滚动条带左沿 375 —— 热区右沿要退到 372(留 3px 余量,免得取整蹭上去)
|
|
1456
|
+
—— 热区比圆右侧多出 9px(inset −9)—— 所以圆的右沿要退到 363。
|
|
1457
|
+
现在圆右沿是 376,**往左 13px** 正好落在 363。
|
|
1458
|
+
落法只有一处:`.to-bottom` 自己的 `margin-right: 13px`。槽是 flex-end,
|
|
1459
|
+
给它一个右外边距就等于把它从槽的右沿往里推 13px。
|
|
1460
|
+
sticky 那套机制、main 的布局、滚动条的样式,一行都没碰。
|
|
1461
|
+
(经典式形态下滚动条本来就把内容区挤窄 15px,那时圆右沿是 361,
|
|
1462
|
+
再让 13px 是 348——只会更宽裕,不会挤到别处。)
|
|
1463
|
+
|
|
1464
|
+
为什么不用脚本算滚动条有多宽:覆盖式的宽度脚本根本量不到(上面那个 0 就是证据),
|
|
1465
|
+
而且 sticky 的位置本来就不该由脚本碰。按实测到的那条**最宽**的带留出固定余量,
|
|
1466
|
+
两种形态都盖得住,这才是稳的写法。
|
|
1467
|
+
|
|
1468
|
+
---- 第五轮(2026-10-08):用户说「有点缩得太小了,增大 15%」 ----
|
|
1469
|
+
|
|
1470
|
+
① 「15%」按**看得见的直径**读:27 × 1.15 = 31.05 → **31**。
|
|
1471
|
+
(另一种读法是「面积增大 15%」,那只有 27 × √1.15 = 28.96 → 29。
|
|
1472
|
+
两个读法只差 2px;这颗圆刚被上一轮削到 27,取大的那个才叫「不再偏小」。)
|
|
1473
|
+
图标继续等比:10.3 × 31/27 = **11.8px**——箭头在圆里占的比例,和 38、27 那两轮
|
|
1474
|
+
一模一样(16/38 = 10.3/27 = 11.8/31),所以圆里既不空也不挤。
|
|
1475
|
+
`margin-top` 必须跟着等于 −31:它是负的自身高度,槽高 0,只有相等下沿才落在槽线上。
|
|
1476
|
+
|
|
1477
|
+
② 热区一个数都没动,它自己长大了:31 + 9×2 = **49**(44 那条手指下限照旧守住)。
|
|
1478
|
+
这正是 inset 不必跟着改的原因——圆缩时热区不许跟着缩,圆长时它跟着长是白赚的。
|
|
1479
|
+
|
|
1480
|
+
③ **第四轮那条「让开滚动条」没有被弄坏,因为要守的是右沿、不是宽度**:
|
|
1481
|
+
按钮走 flex-end,直径一变只往**左**边长;右沿的位置只由
|
|
1482
|
+
main 的 padding-right(14)加按钮自己的 margin-right(13)定,
|
|
1483
|
+
所以圆的右沿仍是 376 − 13 = **363**,热区再往右 9px 是 **372**,
|
|
1484
|
+
离滚动条那条带(左沿 375)仍是 3px。margin-right 一个字没动。
|
|
1485
|
+
|
|
1486
|
+
位置的关键在 .to-bottom-slot:它是 main 的最后一个孩子,靠 `position: sticky`
|
|
1487
|
+
贴在**滚动口下沿往上 16px** 的地方。这一手的好处是位置不用脚本算——
|
|
1488
|
+
main 的下沿天生就是输入区那一摞(附件条 / 指令菜单 / 底栏)的上沿,
|
|
1489
|
+
所以输入框长高、附件条冒出来、指令菜单弹开,它都跟着一起让位,永远不会盖住
|
|
1490
|
+
输入框和发送键;底部那条手势条也由 footer 自己的 safe-area 内边距让开了,
|
|
1491
|
+
它比输入区还高一截,离手势条更远。 */
|
|
1492
|
+
.to-bottom-slot {
|
|
1493
|
+
position: sticky; bottom: 16px; z-index: 5;
|
|
1494
|
+
height: 0; display: flex; justify-content: flex-end;
|
|
1495
|
+
/* 这一条不能省:槽是通栏的,不设 none 就会在底边上拦掉底下内容的点击。
|
|
1496
|
+
按钮自己再打开 auto,所以只有那一个圆圈可点。 */
|
|
1497
|
+
pointer-events: none;
|
|
1498
|
+
}
|
|
1499
|
+
.to-bottom {
|
|
1500
|
+
/* position 只是为了给下面那片透明热区一个定位原点;左右位置由 margin-right 让出来。 */
|
|
1501
|
+
position: relative;
|
|
1502
|
+
/* margin-top 是负的自身高度:按钮的下沿才会正好落在槽那条线上(槽高 0)。
|
|
1503
|
+
第五轮起圆是 31(第四轮 27 × 1.15),这个数就得跟着是 -31——
|
|
1504
|
+
留着 -27 会把它整圈**下**移 4px(下沿掉到槽线下面去)。
|
|
1505
|
+
margin-right: 13px 是第四轮那条「让开滚动条」的全部落法,数怎么来的见上面;
|
|
1506
|
+
它让的是**右沿**,而右沿只由 14 + 13 定,跟直径无关——所以这一条不用重算。 */
|
|
1507
|
+
width: 31px; height: 31px; margin-top: -31px; margin-right: 13px; padding: 0;
|
|
1508
|
+
border: 0; border-radius: 999px;
|
|
1509
|
+
background: var(--panel); color: var(--fg);
|
|
1510
|
+
/* ① 第一层就是那道 0.5px 环;深色到此为止(② 的深色那半:投影在那儿等于零)。 */
|
|
1511
|
+
box-shadow: 0 0 0 .5px var(--line);
|
|
1512
|
+
cursor: pointer; pointer-events: auto;
|
|
1513
|
+
display: flex; align-items: center; justify-content: center;
|
|
1514
|
+
/* ---- 第三轮(2026-10-08):出现 / 消失各来一段 130ms 的淡入淡出 ----
|
|
1515
|
+
用户原话「这个按键消失的时候应该有个渐出效果,官方 pc 端是做了的」。
|
|
1516
|
+
真去量了电脑端那份界面(/mini/mirror 上逐帧采 opacity + MutationObserver 看 DOM),
|
|
1517
|
+
结论是**两半都对一半**,两头都得记下来:
|
|
1518
|
+
|
|
1519
|
+
· 电脑端那颗按钮**身上确实挂着一条 `opacity .13s ease`**——但它不是 ChatView 自己的
|
|
1520
|
+
样式,是外形(skin)那层的基础按钮规则给的(选择器是
|
|
1521
|
+
`html[data-dsh-skin="claude"] button, …[role="button"], …`,实测这条很清楚地匹配到
|
|
1522
|
+
那颗浮标)。所以「官方做了渐出」这句话在**声明**上是对的。
|
|
1523
|
+
· 可它**画面上从来没淡过**:ChatView 是 `!followingTail && <div class=toBottomSlot>…`
|
|
1524
|
+
的条件挂载,一滚到底整块节点直接被 React 摘掉,那条 transition 根本没有机会跑。
|
|
1525
|
+
活服务上逐帧采样实测:消失前后只有两个状态(t≈1.5ms 时 opacity=1 且 isConnected=true,
|
|
1526
|
+
t≈18.6ms 时节点已 isConnected=false、computed 全空),中间**一帧过渡值都没有**;
|
|
1527
|
+
MutationObserver 同一时刻记下的是 toBottomSlot 被 childList 整块移除,没有任何
|
|
1528
|
+
「先加类、过一会儿再摘」的动作。也就是说:**设计语言里那一档是 130ms ease,
|
|
1529
|
+
但官方的手法是硬卸载**。
|
|
1530
|
+
|
|
1531
|
+
所以这里按用户的要求把它补上,并且**只补官方自己没接上的那一段**:时长与缓动
|
|
1532
|
+
直接取官方那颗按钮身上现成的那一档 `130ms / ease`,不另起一个数。
|
|
1533
|
+
方向对称:消失淡出、出现淡入,两边同一档,不做缩放、不做弹跳。
|
|
1534
|
+
|
|
1535
|
+
为什么不用 `display:none` 收:`display:none` 一挂上,元素就不在渲染树里了,
|
|
1536
|
+
过渡根本无从谈起——所以「用 display 收尾」和「要一段淡出」这两件事天生互斥。两条正路:
|
|
1537
|
+
(a) 脚本两端配合(先淡出,等过渡结束再置 hidden);
|
|
1538
|
+
(b) `transition-behavior: allow-discrete` + `@starting-style`。
|
|
1539
|
+
这里走的是第三条、也是同一件事的**更保险写法**:藏起来用
|
|
1540
|
+
`visibility: hidden` + `pointer-events: none` 代替 `display:none`。
|
|
1541
|
+
理由有两条,都是实打实的:
|
|
1542
|
+
· 兼容面。`allow-discrete` / `@starting-style` 要 Chrome 117 / Safari 17.4 /
|
|
1543
|
+
Firefox 129 往上;手机浏览器五花八门,老一档的机器上那两条会被整条丢掉,
|
|
1544
|
+
结果就是**一点淡出都没有**——正好回到用户抱怨的那个状态。而 `visibility`
|
|
1545
|
+
的过渡是十年以上的老写法,哪台机器都认。
|
|
1546
|
+
· 「过渡走完才真正藏起来」这件事由浏览器替我办:`visibility` 的插值规则很特别,
|
|
1547
|
+
只要两端有一端是 `visible`,整个过渡期间它就一直算 `visible`,
|
|
1548
|
+
到进度 1 才翻成 `hidden`。所以淡出那 130ms 里它照样画在屏幕上,
|
|
1549
|
+
走完就真的不可见——等于把 (a) 交给浏览器做,脚本一行都不用加,
|
|
1550
|
+
`paintToBottom` 仍然是「只改 hidden」那一个动作(页面测试也是这么钉的)。
|
|
1551
|
+
|
|
1552
|
+
`visibility` 那条也顺手把「出现」这半边解决了:元素在藏着的时候**仍然有计算样式**
|
|
1553
|
+
(因为它不是 display:none),opacity 老老实实是 0,所以一露出来就是 0→1 的淡入,
|
|
1554
|
+
不需要 `@starting-style` 去给一个起点。
|
|
1555
|
+
|
|
1556
|
+
最后一句:这里只有 `transition`,没有关键帧、没有 animation——
|
|
1557
|
+
它是一颗按钮,不是一段表演(`test/ui.test.mjs` 里那条护栏就是钉这个的)。 */
|
|
1558
|
+
transition: opacity .13s ease, visibility .13s;
|
|
1559
|
+
opacity: 1; visibility: visible;
|
|
1560
|
+
}
|
|
1561
|
+
/* ③ 手指要的那 44 由这片**看不见**的热区补上:圆 31,热区 49。
|
|
1562
|
+
绝对定位 + 负 inset 让它溢出去,不参与版式(槽的高度还是 0,位置照旧全交给
|
|
1563
|
+
CSS 的 sticky);它是按钮自己的伪元素,命中的点事件目标仍然是按钮,所以
|
|
1564
|
+
「只有那一个圆圈可点」这条规矩没破,只是那个圈比看得见的那个大 9px。
|
|
1565
|
+
inset 从 −4 放到 −9 是**跟着第四轮那个 27 一起算的**:27 + 9×2 = 45 ≥ 44。
|
|
1566
|
+
第五轮圆长到 31 之后一直是 31 + 9×2 = 49 ≥ 44,所以这个数**不用动**——
|
|
1567
|
+
热区跟着圆一起长是白赚的,而它绝不会跟着圆一起缩(那是手指的下限,不是外观)。 */
|
|
1568
|
+
.to-bottom::before {
|
|
1569
|
+
content: ''; position: absolute; inset: -9px; border-radius: 999px;
|
|
1570
|
+
}
|
|
1571
|
+
/* ② 浅色那边:环比深色**深一档**,再叠第二层更安静的那一档投影(深色两层都不动)。
|
|
1572
|
+
为什么不用现成变量:浅色这套色阶里,--line(相对亮度 0.747)往下一格是空的——
|
|
1573
|
+
再往下就只剩 --dim(0.298)和 --muted(0.207),一用就把「一道细线」变成「一圈描边」
|
|
1574
|
+
(实测它们相对底色要差 85 / 109 个灰阶、对比度 2.4 / 3.1:1,放在这颗小圆上太重)。
|
|
1575
|
+
所以在 --line 与 --fg(正文色,同一个蓝调家族)之间取一成的 --fg:
|
|
1576
|
+
实测环相对相邻底色由 11/255 变成 28/255(对比度 1.096 → 1.292:1),
|
|
1577
|
+
刚好从「几乎看不见」跨成「看得见的一道细线」,离 --dim 那一档还差着三倍。
|
|
1578
|
+
颜色仍全部来自现成令牌,不写死任何色值;深色那边一个数没动,还是 --line。 */
|
|
1579
|
+
[data-theme="light"] .to-bottom {
|
|
1580
|
+
box-shadow: 0 0 0 .5px color-mix(in srgb, var(--line) 90%, var(--fg)), var(--sh-1);
|
|
1581
|
+
}
|
|
1582
|
+
.to-bottom:active { background: var(--panel-2); }
|
|
1583
|
+
/* 藏起来的那个状态。**这一句不能省**:作者样式表里的 display:flex 会顶掉 hidden 自带的
|
|
1584
|
+
display:none,所以「藏」这件事必须由我们自己在这条规则里写实。
|
|
1585
|
+
原来写的是 display:none,现在换成下面这三条(为什么换见上面那段长注释):
|
|
1586
|
+
· opacity: 0 —— 淡出的终点,130ms 里从 1 走到 0;
|
|
1587
|
+
· visibility: hidden —— 过渡期间它一直是 visible(visibility 的插值规则),
|
|
1588
|
+
走完才翻成 hidden,所以真正不可见发生在淡出**结束之后**;
|
|
1589
|
+
· pointer-events: none —— 这一条**不进过渡**,属性一挂上就立刻生效,
|
|
1590
|
+
所以淡出那 130ms 里它已经点不到了;淡完之后它也不是一层透明的可点层。
|
|
1591
|
+
版式一个像素都没动:槽高是 0,按钮靠 margin-top 负的自身高度挂在槽线上方,
|
|
1592
|
+
这两个状态里它的盒子一样大,所以不产生任何位移。 */
|
|
1593
|
+
.to-bottom[hidden] { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
1594
|
+
/* 箭头等比缩:10.3 × 31/27 = 11.8。占圆的比例和 27、38 那两轮一样,不空也不挤。 */
|
|
1595
|
+
.to-bottom svg { width: 11.8px; height: 11.8px; }
|
|
958
1596
|
</style>
|
|
959
1597
|
<script>
|
|
960
1598
|
// 主题必须在**首屏画出来之前**定下来,否则浅色手机打开会先闪一下深色底。
|
|
961
1599
|
// 所以这一段留在 <head> 里、样式表后面,**别挪到页面底部那段脚本区去**。
|
|
962
1600
|
(function () {
|
|
963
1601
|
try {
|
|
964
|
-
var pick = localStorage.getItem('dshMiniTheme') || '
|
|
1602
|
+
var pick = localStorage.getItem('dshMiniTheme') || 'light';
|
|
965
1603
|
var dark = !window.matchMedia || window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1604
|
+
var use = pick === 'auto' ? (dark ? 'dark' : 'light') : pick;
|
|
1605
|
+
document.documentElement.setAttribute('data-theme', use);
|
|
1606
|
+
// 首屏时也更新状态栏颜色,否则浅色页配深色状态栏会闪一下
|
|
1607
|
+
var meta = document.querySelector('meta[name="theme-color"]');
|
|
1608
|
+
if (meta) meta.setAttribute('content', use === 'light' ? '#eaf3fc' : '#0a0f16');
|
|
1609
|
+
} catch (e) { /* 无痕模式下 localStorage 会抛;按默认浅色走,不影响页面 */ }
|
|
969
1610
|
})();
|
|
970
1611
|
</script>
|
|
971
1612
|
</head>
|
|
@@ -1048,6 +1689,22 @@
|
|
|
1048
1689
|
排队和鲸鱼娘是两件事——前者是「还有几条在等」,后者是「她正在忙」,
|
|
1049
1690
|
别因为后者要藏就把前者一起藏了。 -->
|
|
1050
1691
|
<div class="queue" id="queue" hidden></div>
|
|
1692
|
+
|
|
1693
|
+
<!-- 「回到底部」浮标(2026-10-08 用户要求)。样子参考电脑端 DSH 那颗
|
|
1694
|
+
(ChatView.module.css 的 .xz4KEq_toBottom:34×34 圆底 + 一根 14×14 的下折线 svg),
|
|
1695
|
+
位置、尺寸和配色按手机这页的规格重做,见样式表里那一段。
|
|
1696
|
+
|
|
1697
|
+
**它必须留在 #reply 外面**:跟 #work 同一个道理——拼进重绘里的话,每次状态广播
|
|
1698
|
+
都会把它重建一次,用户正按着它就没了。也**必须在 main 里面**:只有作为滚动口的孩子,
|
|
1699
|
+
CSS 的 sticky 才贴得住(位置由 CSS 算,脚本一个像素都不碰)。
|
|
1700
|
+
|
|
1701
|
+
箭头路径原样抄自电脑端那颗图标(dsh-client-ui-primitives 的 IconChevronDownOutline),
|
|
1702
|
+
没有自己重画:viewBox 0 0 16 16、1px 描边、fill:none、颜色走 currentColor。 -->
|
|
1703
|
+
<div class="to-bottom-slot" id="toBottomSlot">
|
|
1704
|
+
<button class="to-bottom" id="toBottom" type="button" aria-label="回到底部" hidden>
|
|
1705
|
+
<svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1"><path d="M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6"/></svg>
|
|
1706
|
+
</button>
|
|
1707
|
+
</div>
|
|
1051
1708
|
</main>
|
|
1052
1709
|
|
|
1053
1710
|
<div class="attach-bar" id="attachBar" hidden></div>
|
|
@@ -1057,9 +1714,14 @@
|
|
|
1057
1714
|
<div class="cmd-menu" id="cmdMenu" hidden></div>
|
|
1058
1715
|
|
|
1059
1716
|
<footer>
|
|
1060
|
-
<!--
|
|
1061
|
-
|
|
1062
|
-
|
|
1717
|
+
<!-- 统一入口。位置和尺寸一个像素都没动(还是原来回形针那个 42×42),改的只是点了之后
|
|
1718
|
+
干什么:原来直接开文件选择器,现在开下面那张统一操作面板(文件 + 指令都在里面)。
|
|
1719
|
+
input 藏起来,按钮点它——iOS 上直接放一个 file input 样式很难统一。 -->
|
|
1720
|
+
<button class="attach" id="btnAttach" type="button" aria-label="添加文件或调用指令" title="添加文件或调用指令">
|
|
1721
|
+
<!-- 图标是 DSH 桌面端那个鲸鱼(dsh-client-ui-primitives 的 FishLogo,
|
|
1722
|
+
viewBox 0 0 23.16 17.04)。路径**原样抄自桌面端**,没有自己重画。
|
|
1723
|
+
填色走 currentColor,所以跟着按钮的 var(--muted) 走,深浅两套主题各自适配。 -->
|
|
1724
|
+
<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
1725
|
</button>
|
|
1064
1726
|
<input type="file" id="filePick" hidden>
|
|
1065
1727
|
<textarea id="input" rows="1" placeholder="说点什么…" enterkeyhint="send"></textarea>
|
|
@@ -1079,7 +1741,16 @@
|
|
|
1079
1741
|
|
|
1080
1742
|
<div id="settings">
|
|
1081
1743
|
<div class="sheet">
|
|
1082
|
-
|
|
1744
|
+
<!-- 页眉。整屏之后「点外面关掉」没了,所以这里必须有一个一直看得见的关闭口
|
|
1745
|
+
(滚到哪儿都在);底部那个「完成」照样留着。
|
|
1746
|
+
|
|
1747
|
+
它在**滚动区外面**(里面那个 .sheet-body 才是滚动区):这样页眉底下就是抽屉
|
|
1748
|
+
本来那层毛玻璃,不用为它另铺一层底色——铺了会叠出更深的色,顶部一道色差。 -->
|
|
1749
|
+
<div class="sheet-head">
|
|
1750
|
+
<h2>设置</h2>
|
|
1751
|
+
<button class="btn" id="btnSettingsClose">关闭</button>
|
|
1752
|
+
</div>
|
|
1753
|
+
<div class="sheet-body">
|
|
1083
1754
|
|
|
1084
1755
|
<div class="row">
|
|
1085
1756
|
<div>
|
|
@@ -1089,6 +1760,7 @@
|
|
|
1089
1760
|
<div class="seg" id="segMode">
|
|
1090
1761
|
<button data-mode="minimal">单帧</button>
|
|
1091
1762
|
<button data-mode="chat">聊天</button>
|
|
1763
|
+
<button data-mode="full">完整</button>
|
|
1092
1764
|
</div>
|
|
1093
1765
|
</div>
|
|
1094
1766
|
|
|
@@ -1105,6 +1777,18 @@
|
|
|
1105
1777
|
</div>
|
|
1106
1778
|
</div>
|
|
1107
1779
|
|
|
1780
|
+
<!-- 字体大小:三档,存 localStorage,改 CSS 变量 --font-base -->
|
|
1781
|
+
<div class="row">
|
|
1782
|
+
<div>
|
|
1783
|
+
<div class="label">字体大小</div>
|
|
1784
|
+
</div>
|
|
1785
|
+
<div class="seg" id="segFont">
|
|
1786
|
+
<button data-font="small">小</button>
|
|
1787
|
+
<button data-font="normal">标准</button>
|
|
1788
|
+
<button data-font="large">大</button>
|
|
1789
|
+
</div>
|
|
1790
|
+
</div>
|
|
1791
|
+
|
|
1108
1792
|
<!-- 权限档位。整块由脚本填:这台电脑有哪些档位是**运行时读**的,一个都不写死。
|
|
1109
1793
|
服务拿不到时整块不显示——它是附加能力,缺了遥控本身照常。 -->
|
|
1110
1794
|
<div id="permBlock" hidden>
|
|
@@ -1117,6 +1801,23 @@
|
|
|
1117
1801
|
<div class="seg" id="segPerm"></div>
|
|
1118
1802
|
</div>
|
|
1119
1803
|
|
|
1804
|
+
<!-- 繁忙时的发送行为(2026-10-05 用户要求搬进手机设置)。
|
|
1805
|
+
PC 上「设置 → 通用 → 繁忙时的发送行为」就是它:智能体跑着的时候,
|
|
1806
|
+
你发的那句话是排队等这一轮跑完,还是当场插进去让它调整方向。
|
|
1807
|
+
**两端共用宿主那一份设置**——手机上改,电脑上也是这个值。
|
|
1808
|
+
文案照抄 PC 词条表(settings.enter.*),一个字不自创。
|
|
1809
|
+
读不到时整条藏起来(见 loadBusyEnter),不摆一个点了没用的开关。 -->
|
|
1810
|
+
<div class="row" id="rowBusyEnter" hidden>
|
|
1811
|
+
<div>
|
|
1812
|
+
<div class="label">繁忙时的发送行为</div>
|
|
1813
|
+
<div class="hint">智能体运行时发送按钮的行为</div>
|
|
1814
|
+
</div>
|
|
1815
|
+
<div class="seg" id="segBusyEnter">
|
|
1816
|
+
<button data-busy="queue">排队发送</button>
|
|
1817
|
+
<button data-busy="steer">插话发送</button>
|
|
1818
|
+
</div>
|
|
1819
|
+
</div>
|
|
1820
|
+
|
|
1120
1821
|
<!--
|
|
1121
1822
|
这里原来有两行:「完成后提醒」和「语音输入」。
|
|
1122
1823
|
|
|
@@ -1181,10 +1882,109 @@
|
|
|
1181
1882
|
<button class="btn" id="btnReconnect">重连</button>
|
|
1182
1883
|
</div>
|
|
1183
1884
|
|
|
1885
|
+
<div class="row">
|
|
1886
|
+
<div>
|
|
1887
|
+
<div class="label">插件</div>
|
|
1888
|
+
<div class="hint">查看官方插件和已安装插件</div>
|
|
1889
|
+
</div>
|
|
1890
|
+
<button class="btn" id="btnPlugins">查看</button>
|
|
1891
|
+
</div>
|
|
1892
|
+
|
|
1893
|
+
<!--
|
|
1894
|
+
「进阶设置(电脑端界面)」(2026-10-06 用户要求)。
|
|
1895
|
+
|
|
1896
|
+
手机上这张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
|
|
1897
|
+
这一项是退路:**把电脑端的界面整个搬过来看**——同一份界面,不迁移、不逐个适配。
|
|
1898
|
+
|
|
1899
|
+
**默认关,而且开着要付代价**:打开它 = 把这个令牌的权限从「我们那张手机页暴露的
|
|
1900
|
+
那点事」扩到**整个电脑端设置面**(模型配置、凭据页、插件装卸、会话记录……)。
|
|
1901
|
+
所以:默认关、后果写在提示里、开启要点两次(见下面 tapMirror)。
|
|
1902
|
+
|
|
1903
|
+
服务端没这个能力时(没拿到 DSH_WEB_URL)整行不显示——**不摆一个假的开关**,
|
|
1904
|
+
和「繁忙时的发送行为」同一条口径。 -->
|
|
1905
|
+
<div class="row" id="rowMirror" hidden>
|
|
1906
|
+
<div>
|
|
1907
|
+
<div class="label">进阶设置</div>
|
|
1908
|
+
<div class="hint" id="mirrorHint">打开电脑上的 DSH 界面,改那些手机上没有的设置</div>
|
|
1909
|
+
</div>
|
|
1910
|
+
<div class="seg" id="segMirror">
|
|
1911
|
+
<button data-mirror="on">开</button>
|
|
1912
|
+
<button data-mirror="off">关</button>
|
|
1913
|
+
</div>
|
|
1914
|
+
</div>
|
|
1915
|
+
|
|
1916
|
+
<div class="row" id="rowMirrorOpen" hidden>
|
|
1917
|
+
<div>
|
|
1918
|
+
<div class="label">电脑端界面</div>
|
|
1919
|
+
<div class="hint">电脑端dsh完整页面</div>
|
|
1920
|
+
</div>
|
|
1921
|
+
<button class="btn" id="btnMirrorOpen" type="button">打开</button>
|
|
1922
|
+
</div>
|
|
1923
|
+
|
|
1184
1924
|
<div class="foot">
|
|
1185
1925
|
<button class="btn" id="btnForget">清除 token</button>
|
|
1186
1926
|
<button class="btn primary" id="btnClose">完成</button>
|
|
1187
1927
|
</div>
|
|
1928
|
+
|
|
1929
|
+
<div class="version" id="dshVersion" hidden></div>
|
|
1930
|
+
</div>
|
|
1931
|
+
</div>
|
|
1932
|
+
</div>
|
|
1933
|
+
|
|
1934
|
+
<!--
|
|
1935
|
+
「电脑端设置界面」那一整屏:**嵌在这一页里,不跳转、不开新标签。**
|
|
1936
|
+
|
|
1937
|
+
## 为什么最后做成内嵌(2026-10-06 用户三次实机报「点击打开没反应」)
|
|
1938
|
+
|
|
1939
|
+
前两版都想让它「打开」出去,都栽了:
|
|
1940
|
+
· 第一版 `<a target="_blank">`:内嵌浏览器(微信这类)常把新标签直接拦掉;
|
|
1941
|
+
· 第二版加了 JS 兜底(`window.open` 失败就同标签跳):还是没反应——那些浏览器里
|
|
1942
|
+
`window.open` 会「成功」返回一个对象,代码以为开好了就不再跳,而新标签开在后台;
|
|
1943
|
+
· 第三版干脆用普通 `<a href>` 同标签跳(理论上拦不掉):**用户那边仍然没反应**。
|
|
1944
|
+
|
|
1945
|
+
三次都在赌「跳转」这个动作本身。**这一版不赌了**:点一下只是把这一层显示出来、
|
|
1946
|
+
给里面的框设个地址——没有跳转、没有新标签、没有弹窗,**浏览器没有任何东西可以拦**。
|
|
1947
|
+
已经用真浏览器验过:那个界面**愿意被嵌**(不设「不许被嵌」的响应头,实测里面
|
|
1948
|
+
标题、侧栏、欢迎页全都正常)。
|
|
1949
|
+
|
|
1950
|
+
另外整行都能点(不只右边那个按钮)——按钮只有 54×40,手指容易点偏。
|
|
1951
|
+
-->
|
|
1952
|
+
<div id="mirrorView" hidden>
|
|
1953
|
+
<div class="mv-bar">
|
|
1954
|
+
<span class="mv-title">电脑端界面</span>
|
|
1955
|
+
<button class="btn" id="mirrorClose" type="button">返回</button>
|
|
1956
|
+
</div>
|
|
1957
|
+
<!-- 地址是点开那一刻才设的:没点之前一个字节都不加载。 -->
|
|
1958
|
+
<iframe id="mirrorFrame" title="电脑端界面"></iframe>
|
|
1959
|
+
</div>
|
|
1960
|
+
|
|
1961
|
+
<!-- 统一操作面板:输入框旁边那一个入口点开的就是它,上面「添加文件」、下面「调用指令」。
|
|
1962
|
+
原来这两件事各有各的门(回形针直接开文件选择器、打 `/` 才弹指令菜单),手机上没人
|
|
1963
|
+
知道打一个斜杠还能调指令。
|
|
1964
|
+
|
|
1965
|
+
外壳是设置抽屉那一套**一模一样**的写法(`#xxx.open` 即时显隐 + `.sheet` 贴底),
|
|
1966
|
+
不新建样式体系;令牌、圆角、细边框也全是现成的。
|
|
1967
|
+
|
|
1968
|
+
它盖住整屏、贴着底边(和设置抽屉同一层),所以不和底栏抢位置;打开时先把键盘收起来,
|
|
1969
|
+
不然手机键盘会把这张抽屉顶到看不见的地方。 -->
|
|
1970
|
+
<div id="ops">
|
|
1971
|
+
<div class="sheet">
|
|
1972
|
+
<div class="ops-head">
|
|
1973
|
+
<h2>添加文件</h2>
|
|
1974
|
+
<button class="btn" id="opsClose">关闭</button>
|
|
1975
|
+
</div>
|
|
1976
|
+
<div class="ops-acts">
|
|
1977
|
+
<button class="btn" id="opsPick" type="button">选文件</button>
|
|
1978
|
+
<button class="btn" id="opsShot" type="button">拍照</button>
|
|
1979
|
+
</div>
|
|
1980
|
+
<!-- 已选文件的去处和附件小条**是同一份数据**(state.uploads,同一个 renderAttach)。
|
|
1981
|
+
这里画一遍是为了让用户在面板里就看得到自己带了什么,不必先关掉面板。 -->
|
|
1982
|
+
<div class="ops-files" id="opsFiles" hidden></div>
|
|
1983
|
+
|
|
1984
|
+
<h2 class="ops-sub">调用指令</h2>
|
|
1985
|
+
<input class="ops-filter" id="opsFilter" type="text" placeholder="筛选指令…"
|
|
1986
|
+
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
|
1987
|
+
<div class="ops-cmds" id="opsCmds"></div>
|
|
1188
1988
|
</div>
|
|
1189
1989
|
</div>
|
|
1190
1990
|
|
|
@@ -1210,6 +2010,20 @@
|
|
|
1210
2010
|
</div>
|
|
1211
2011
|
</div>
|
|
1212
2012
|
|
|
2013
|
+
<!-- 插件页面(2026-10-04 用户要求:移动端设置里加插件入口)。
|
|
2014
|
+
外壳和子智能体面板同一套(.sheet),显示官方插件和已安装插件(内置+第三方)。 -->
|
|
2015
|
+
<div id="plugins">
|
|
2016
|
+
<div class="sheet">
|
|
2017
|
+
<div class="sa-head">
|
|
2018
|
+
<h2>插件</h2>
|
|
2019
|
+
<button class="btn" id="pluginsClose">关闭</button>
|
|
2020
|
+
</div>
|
|
2021
|
+
<div class="sa-list" id="pluginsList">
|
|
2022
|
+
<div class="sa-none sa-none-indent">正在读取插件列表…</div>
|
|
2023
|
+
</div>
|
|
2024
|
+
</div>
|
|
2025
|
+
</div>
|
|
2026
|
+
|
|
1213
2027
|
<div id="gate">
|
|
1214
2028
|
<h1>需要访问令牌</h1>
|
|
1215
2029
|
<p>在电脑上打开 DSH 的「设置 → 手机遥控」,用手机重新扫一次二维码就能进来。
|
|
@@ -1241,12 +2055,15 @@
|
|
|
1241
2055
|
mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
|
|
1242
2056
|
// 外观:'dark' | 'light' | 'auto'。默认深色——不给老用户换掉现在的样子;
|
|
1243
2057
|
// 要浅色或跟着系统走,去设置页「外观」点一下。
|
|
1244
|
-
theme: localStorage.getItem('dshMiniTheme') || '
|
|
2058
|
+
theme: localStorage.getItem('dshMiniTheme') || 'light',
|
|
2059
|
+
// 字体大小:'small' | 'normal' | 'large'。默认标准。
|
|
2060
|
+
fontSize: localStorage.getItem('dshMiniFont') || 'normal',
|
|
1245
2061
|
sound: localStorage.getItem('dshMiniSound') !== 'off',
|
|
1246
2062
|
// 锁屏也能提醒(系统通知)。默认关着——它要用户主动点一下才会申请权限,
|
|
1247
2063
|
// 不能替他决定。明文连接下这一项永远是空转的,见下面「锁屏也能提醒」那一段。
|
|
1248
2064
|
notify: localStorage.getItem('dshMiniNotify') === 'on',
|
|
1249
2065
|
running: false,
|
|
2066
|
+
subagentsRunning: false,
|
|
1250
2067
|
sessions: [],
|
|
1251
2068
|
// 排队中的指令([{ id, text, placement }])。跑着任务时接着发的那几条排在这儿,
|
|
1252
2069
|
// 由服务端每次取快照时从 agent 的收件箱现读,手机不自己记。
|
|
@@ -1272,6 +2089,24 @@
|
|
|
1272
2089
|
// 「这份历史只到最近一段」(服务端读历史有上限时会给这一句)。
|
|
1273
2090
|
historyTruncated: false,
|
|
1274
2091
|
historyNote: '',
|
|
2092
|
+
/**
|
|
2093
|
+
* 执行轨迹(「完整」模式里的过程):一组一轮,形状与服务端
|
|
2094
|
+
* `GET /mini/api/trajectory` 的 `turns` 一字不差
|
|
2095
|
+
* (`[{ turn, state, reason, entries:[…] }]`)。
|
|
2096
|
+
*
|
|
2097
|
+
* **它和 latest / history 是两回事**:思考正文只活在 `entries[].output` 里,
|
|
2098
|
+
* 回答气泡那两条路(`state.latest` / `state.history`)一个字都不碰它——
|
|
2099
|
+
* 这是 2026-09-27 用户定的边界(思维链一个字都不上气泡)。
|
|
2100
|
+
*
|
|
2101
|
+
* 只在「完整」模式订阅、只在那里画;切回单帧/聊天就退订,不再收也不再画。
|
|
2102
|
+
*/
|
|
2103
|
+
trajectory: [],
|
|
2104
|
+
trajectoryNote: '', // 「这份轨迹不是全部」时服务端给的那句话(展示下一棒接手)
|
|
2105
|
+
trajectoryTruncated: false,
|
|
2106
|
+
trajectoryLoading: false, // 服务端还在读会话记录(**不等于「没有过程」**)
|
|
2107
|
+
trajectoryEpoch: '', // 服务换了一代(重启)就不能再往旧的那份上面接
|
|
2108
|
+
trajectorySeq: 0, // 收到第几帧,用来认出「我漏了几帧」(只进不退)
|
|
2109
|
+
trajectoryLive: '', // 活片段:正在想的那一句(只有完整模式有值,落盘即清)
|
|
1275
2110
|
// 斜杠指令的列表(在输入框里打 / 时弹出来的那个菜单)。
|
|
1276
2111
|
// **按绑定的会话缓存**:指令表是每个会话一份的,而这个会话没在跑时服务端一条都
|
|
1277
2112
|
// 读不到(回 ok:false + 一句话)。所以换了会话必须重取——否则用户点到的是上一个
|
|
@@ -1279,7 +2114,15 @@
|
|
|
1279
2114
|
commands: null, // null = 还没取到
|
|
1280
2115
|
commandsError: '', // 取不到时服务端给的那句话,原样显示给用户
|
|
1281
2116
|
commandsFor: null, // 上面这份数据是哪个会话的
|
|
1282
|
-
commandsLoading: false // 正在取(挡住重复请求,见 loadCommands)
|
|
2117
|
+
commandsLoading: false, // 正在取(挡住重复请求,见 loadCommands)
|
|
2118
|
+
/**
|
|
2119
|
+
* 「输入框里这一行曾经是一条指令」——点过面板/菜单里某一条之后,这里记着它的名字。
|
|
2120
|
+
*
|
|
2121
|
+
* 用来堵一个真缺口:点了一条要参数的指令,用户把 `/名字 ` 里的名字删掉、只留参数,
|
|
2122
|
+
* 那一行就不再以 `/` 开头,会被当成普通提问发给模型,白花一次额度。
|
|
2123
|
+
* 有这枚标记时,send() 会把这一行拦下来,让他补回名字或**清空输入框**(= 明确取消)。
|
|
2124
|
+
*/
|
|
2125
|
+
pendingCommand: null
|
|
1283
2126
|
};
|
|
1284
2127
|
if (params.get('token')) {
|
|
1285
2128
|
localStorage.setItem('dshMiniToken', params.get('token'));
|
|
@@ -1287,6 +2130,232 @@
|
|
|
1287
2130
|
history.replaceState(null, '', location.pathname);
|
|
1288
2131
|
}
|
|
1289
2132
|
|
|
2133
|
+
/**
|
|
2134
|
+
* ---------------- 执行轨迹:订阅与收帧(数据层) ----------------
|
|
2135
|
+
*
|
|
2136
|
+
* 轨迹**不走全量快照**:实测它的正文是回答正文的 90.7 倍(单轮最高约 946KB),
|
|
2137
|
+
* 而 `event: state` 是「每条广播都发整份」。所以它走单独的增量帧
|
|
2138
|
+
* (`event: trajectory`,每帧只带新加的几条和改了的几笔),且**只有「完整」模式的
|
|
2139
|
+
* 连接才订**——不看这块的人一个字节都不付。
|
|
2140
|
+
*
|
|
2141
|
+
* 三件事一起看才完整:切到「完整」时订上并 GET 一次当前状态;切回单帧/聊天就退订;
|
|
2142
|
+
* 断线重连把 clientId 和 `?full=1` 带在流地址上(服务端自动重新订上),重连后再
|
|
2143
|
+
* GET 一次对齐——帧里的 epoch 变了就整个重拉。
|
|
2144
|
+
*
|
|
2145
|
+
* 本片到「收进 state.trajectory」为止;画法(三层折叠)在 renderFull 后面那一片。
|
|
2146
|
+
* clientId / subscribedClientId 两个连接身份变量声明在下面 SSE 一节里——
|
|
2147
|
+
* 那一节被测试整段抠出来跑,身份必须跟流地址待在一起才找得到。
|
|
2148
|
+
*/
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* 这一帧该不该接、接到哪一组。三道门:
|
|
2152
|
+
* 不是「完整」模式不接(退订之后可能还有在路上的旧帧);
|
|
2153
|
+
* 不是当前这个会话不接(切会话了,旧会话的过程与新会话无关)。
|
|
2154
|
+
* 没有这一组就立一个(turn 号是帧给的,组里 entries 往后只增不减)。
|
|
2155
|
+
*/
|
|
2156
|
+
function trajectoryKeyOf(frame) {
|
|
2157
|
+
if (state.mode !== 'full') return null;
|
|
2158
|
+
if (frame.sessionId && state.boundSessionId && frame.sessionId !== state.boundSessionId) return null;
|
|
2159
|
+
var turn = typeof frame.turn === 'number' ? frame.turn : null;
|
|
2160
|
+
for (var i = 0; i < state.trajectory.length; i++) {
|
|
2161
|
+
if (state.trajectory[i].turn === turn) return state.trajectory[i];
|
|
2162
|
+
}
|
|
2163
|
+
var group = { turn: turn, state: 'running', reason: null, entries: [] };
|
|
2164
|
+
state.trajectory.push(group);
|
|
2165
|
+
return group;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* 把一条增量补丁贴到已有条目上。**只改补丁里真有的字段**——「停了」那种补丁不该
|
|
2170
|
+
* 顺手把输出清空;截断信息是合并而不是替换(一条工具步可能参数超长、结果也超长)。
|
|
2171
|
+
* 写法和 lib/trajectory.js 的 applyTrajectoryPatch 一致:两端对同一笔补丁的理解
|
|
2172
|
+
* 必须一样,否则手机上会出现「明明有结果却还写着正在跑」。
|
|
2173
|
+
*/
|
|
2174
|
+
function patchTrajectoryEntry(entry, patch) {
|
|
2175
|
+
if (!entry || !patch) return;
|
|
2176
|
+
if (typeof patch.state === 'string') entry.state = patch.state;
|
|
2177
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'output')) entry.output = patch.output;
|
|
2178
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'error')) entry.error = patch.error;
|
|
2179
|
+
if (patch.truncated) {
|
|
2180
|
+
entry.truncated = Object.assign({}, entry.truncated || {}, patch.truncated);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/**
|
|
2185
|
+
* 一帧的落法,四步按顺序走:
|
|
2186
|
+
*
|
|
2187
|
+
* 1. **换代认新**:帧里 epoch 和手里记的不一样(服务重启过),旧的那份整份作废,
|
|
2188
|
+
* 用 GET 重拉——旧代的 seq 在新代里毫无意义,硬拼会出现「凭空多出半截过程」。
|
|
2189
|
+
* 2. **缺帧认补**:帧的 seq 比预期大出不止一格,中间丢过帧,GET 重拉;
|
|
2190
|
+
* seq 只进不退(重连后服务端可能从头再发一轮)。
|
|
2191
|
+
* 3. **add 幂等**:同一条目按 id 认——收过就改成补丁,没收过才追加。
|
|
2192
|
+
* 同一帧送两遍(重连重放)不会长出两份,这是「帧重复」的硬要求。
|
|
2193
|
+
* 4. **组态只进结论**:一组的 running 是过程中的默认值,收下任何结论态就不再
|
|
2194
|
+
* 被后面的 running 盖回去(结论到了之后又来一条旧的 running 帧,不算输)。
|
|
2195
|
+
*/
|
|
2196
|
+
function applyTrajectoryFrame(frame) {
|
|
2197
|
+
if (!frame || typeof frame !== 'object') return false;
|
|
2198
|
+
var epoch = typeof frame.epoch === 'string' ? frame.epoch : '';
|
|
2199
|
+
if (state.trajectoryEpoch && epoch && epoch !== state.trajectoryEpoch) {
|
|
2200
|
+
state.trajectoryEpoch = epoch;
|
|
2201
|
+
state.trajectorySeq = 0;
|
|
2202
|
+
state.trajectory = [];
|
|
2203
|
+
if (state.mode === 'full') requestTrajectory();
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
if (!state.trajectoryEpoch && epoch) state.trajectoryEpoch = epoch;
|
|
2207
|
+
var seq = typeof frame.seq === 'number' ? frame.seq : 0;
|
|
2208
|
+
if (seq > 0 && state.trajectorySeq > 0 && seq > state.trajectorySeq + 1) {
|
|
2209
|
+
state.trajectorySeq = seq;
|
|
2210
|
+
if (state.mode === 'full') requestTrajectory();
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
2213
|
+
if (seq > state.trajectorySeq) state.trajectorySeq = seq;
|
|
2214
|
+
|
|
2215
|
+
var group = trajectoryKeyOf(frame);
|
|
2216
|
+
if (!group) return false;
|
|
2217
|
+
|
|
2218
|
+
var add = Array.isArray(frame.add) ? frame.add : [];
|
|
2219
|
+
for (var i = 0; i < add.length; i++) {
|
|
2220
|
+
var raw = add[i];
|
|
2221
|
+
if (!raw || typeof raw.id !== 'string' || !raw.id) continue;
|
|
2222
|
+
var hit = null;
|
|
2223
|
+
for (var j = 0; j < group.entries.length; j++) {
|
|
2224
|
+
if (group.entries[j].id === raw.id) { hit = group.entries[j]; break; }
|
|
2225
|
+
}
|
|
2226
|
+
if (hit) {
|
|
2227
|
+
patchTrajectoryEntry(hit, raw);
|
|
2228
|
+
} else {
|
|
2229
|
+
group.entries.push({
|
|
2230
|
+
id: raw.id,
|
|
2231
|
+
turn: typeof raw.turn === 'number' ? raw.turn : group.turn,
|
|
2232
|
+
step: raw.step,
|
|
2233
|
+
// 三种条目照单全收(think / say / tool)——旁白(say)是 2026-10-04 加的,
|
|
2234
|
+
// 这里原来写死成两种,实时帧里的旁白会被画成「工具调用」空壳行,
|
|
2235
|
+
// 等下一次整份对齐才变回正文段落(真机上就是「展现不对」)。
|
|
2236
|
+
kind: raw.kind === 'think' || raw.kind === 'say' ? raw.kind : 'tool',
|
|
2237
|
+
name: raw.name,
|
|
2238
|
+
args: raw.args,
|
|
2239
|
+
summary: raw.summary,
|
|
2240
|
+
output: raw.output,
|
|
2241
|
+
state: typeof raw.state === 'string' ? raw.state : 'running',
|
|
2242
|
+
error: raw.error || null,
|
|
2243
|
+
truncated: raw.truncated || null,
|
|
2244
|
+
timestamp: raw.timestamp,
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
// 真条目进来了:活片段让位(服务端也会喊清,这里兜一道——影子不许盖住落定的字)。
|
|
2249
|
+
if (add.length) state.trajectoryLive = '';
|
|
2250
|
+
var upd = Array.isArray(frame.update) ? frame.update : [];
|
|
2251
|
+
for (var k = 0; k < upd.length; k++) {
|
|
2252
|
+
var patch = upd[k];
|
|
2253
|
+
if (!patch || typeof patch.id !== 'string') continue;
|
|
2254
|
+
for (var m = 0; m < group.entries.length; m++) {
|
|
2255
|
+
if (group.entries[m].id === patch.id) { patchTrajectoryEntry(group.entries[m], patch); break; }
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
if (typeof frame.state === 'string' && frame.state !== 'running') {
|
|
2259
|
+
group.state = frame.state;
|
|
2260
|
+
group.reason = frame.reason || null;
|
|
2261
|
+
} else if (group.state === 'running') {
|
|
2262
|
+
if (frame.reason) group.reason = frame.reason;
|
|
2263
|
+
}
|
|
2264
|
+
return true;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/** 一帧到了:落进手里那份,只在「完整」模式下重画。 */
|
|
2268
|
+
function receiveTrajectoryFrame(frame) {
|
|
2269
|
+
if (!applyTrajectoryFrame(frame)) return;
|
|
2270
|
+
render();
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* 用一份 GET 结果把手里那份**整个换掉**(不是往里合):GET 是服务端按当前记录
|
|
2275
|
+
* 重新汇总的全量视图,比任何增量拼接都新,换掉就不会「补丁叠在过期底稿上」。
|
|
2276
|
+
* `loading` 如实收下——它说的是「还在读会话记录」,不是「没有过程」。
|
|
2277
|
+
*/
|
|
2278
|
+
function setTrajectoryFromGet(view) {
|
|
2279
|
+
if (!view || view.ok !== true) return;
|
|
2280
|
+
state.trajectoryEpoch = typeof view.epoch === 'string' ? view.epoch : state.trajectoryEpoch;
|
|
2281
|
+
state.trajectorySeq = typeof view.seq === 'number' ? view.seq : 0;
|
|
2282
|
+
state.trajectory = Array.isArray(view.turns) ? view.turns : [];
|
|
2283
|
+
state.trajectoryTruncated = view.truncated === true;
|
|
2284
|
+
state.trajectoryNote = typeof view.note === 'string' && view.note ? view.note : '';
|
|
2285
|
+
state.trajectoryLoading = view.loading === true;
|
|
2286
|
+
render();
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/**
|
|
2290
|
+
* 拉一次当前过程。只在「完整」模式发;不是完整模式宁可什么都不说——去要一份
|
|
2291
|
+
* 自己没订的数据,等于给服务端添一次白跑。读完只有两种岔路:是完整模式且还是
|
|
2292
|
+
* 同一个会话才收下(发请求后切了会话/切了模式,这份就是旧账,直接丢),
|
|
2293
|
+
* 读不到就把原因写出来,不说「没有过程」。
|
|
2294
|
+
*/
|
|
2295
|
+
function requestTrajectory() {
|
|
2296
|
+
if (state.mode !== 'full' || !state.token) return;
|
|
2297
|
+
var sessionId = state.boundSessionId;
|
|
2298
|
+
if (!sessionId) {
|
|
2299
|
+
state.trajectory = [];
|
|
2300
|
+
state.trajectorySeq = 0;
|
|
2301
|
+
state.trajectoryLoading = false;
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
state.trajectoryLoading = true;
|
|
2305
|
+
api('/mini/api/trajectory?sessionId=' + encodeURIComponent(sessionId))
|
|
2306
|
+
.then(function (view) {
|
|
2307
|
+
if (state.mode !== 'full' || state.boundSessionId !== sessionId) return;
|
|
2308
|
+
setTrajectoryFromGet(view);
|
|
2309
|
+
})
|
|
2310
|
+
.catch(function () {
|
|
2311
|
+
if (state.mode !== 'full' || state.boundSessionId !== sessionId) return;
|
|
2312
|
+
state.trajectoryLoading = false;
|
|
2313
|
+
state.trajectoryNote = '读不到这个会话的过程。';
|
|
2314
|
+
render();
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/**
|
|
2319
|
+
* 模式开关的订阅侧动作,跟着「显示模式」按钮走:
|
|
2320
|
+
*
|
|
2321
|
+
* - 切到「完整」:连流时没带 `?full=1`(连流那一刻不是完整模式)就得补一次
|
|
2322
|
+
* POST 订上,然后 GET 一次把手里的换成当前状态;已经订过就只 GET 对齐。
|
|
2323
|
+
* - 切走:POST 退订,同时把身份清掉——服务端那条连接已经不再推了,
|
|
2324
|
+
* 手里这个「已订」的记号必须同步作废,否则下次切回来会误以为还订着。
|
|
2325
|
+
* - POST 回 404:这条流已经不在了(服务重启或断流没重连),立刻重连一次;
|
|
2326
|
+
* 重连的地址自带 `?full=1`(当前就是完整模式),服务端会重新把这条连接订上。
|
|
2327
|
+
* 重连成功与否都不用在这里补救——`open` 里那一套会再走一遍。
|
|
2328
|
+
*/
|
|
2329
|
+
function syncTrajectorySubscription() {
|
|
2330
|
+
if (!state.token) return;
|
|
2331
|
+
var want = state.mode === 'full';
|
|
2332
|
+
if (!want) {
|
|
2333
|
+
if (subscribedClientId) {
|
|
2334
|
+
var was = subscribedClientId;
|
|
2335
|
+
subscribedClientId = '';
|
|
2336
|
+
api('/mini/api/trajectory/subscribe', {
|
|
2337
|
+
method: 'POST',
|
|
2338
|
+
body: JSON.stringify({ clientId: was, on: false }),
|
|
2339
|
+
}).catch(function () { /* 断了就断了:服务端本来就会随连接清理 */ });
|
|
2340
|
+
}
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
if (subscribedClientId) {
|
|
2344
|
+
requestTrajectory();
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
api('/mini/api/trajectory/subscribe', {
|
|
2348
|
+
method: 'POST',
|
|
2349
|
+
body: JSON.stringify({ clientId: clientId, on: true }),
|
|
2350
|
+
}).then(function () {
|
|
2351
|
+
if (state.mode !== 'full') return; // 刚要订又切走了,回包当没看见
|
|
2352
|
+
subscribedClientId = clientId;
|
|
2353
|
+
requestTrajectory();
|
|
2354
|
+
}).catch(function (err) {
|
|
2355
|
+
if (err && err.status === 404) connect();
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
|
|
1290
2359
|
var $ = function (id) { return document.getElementById(id); };
|
|
1291
2360
|
var mainEl = $('main'), replyEl = $('reply'), inputEl = $('input');
|
|
1292
2361
|
var dotEl = $('dot'), statusEl = $('status');
|
|
@@ -2024,13 +3093,30 @@
|
|
|
2024
3093
|
if (list.length > shown) nodes += '<div class="q-node"><i></i><b>…</b></div>';
|
|
2025
3094
|
var rows = '';
|
|
2026
3095
|
for (var i = 0; i < list.length; i += 1) {
|
|
3096
|
+
// 插话和排队**要分得出来**(2026-10-06):服务端每条都带 placement,
|
|
3097
|
+
// `next-step` 是插话(下一个步骤边界就取走,通常几秒),
|
|
3098
|
+
// `next-turn` 是排队(等这一轮跑完,可能几分钟)。原来两条画得一模一样。
|
|
3099
|
+
var isSteer = list[i].placement === 'next-step';
|
|
2027
3100
|
rows += '<div class="q-row">'
|
|
3101
|
+
+ (isSteer ? '<span class="q-tag">插话</span>' : '')
|
|
2028
3102
|
+ '<span class="q-text">' + escapeHtml(list[i].text) + '</span>'
|
|
2029
3103
|
+ '<button class="q-drop" type="button" data-drop="' + escapeHtml(list[i].id) + '"'
|
|
2030
3104
|
+ ' aria-label="撤掉这条"><svg viewBox="0 0 24 24" aria-hidden="true">'
|
|
2031
3105
|
+ '<path d="M6 6l12 12M18 6L6 18"/></svg></button></div>';
|
|
2032
3106
|
}
|
|
2033
|
-
|
|
3107
|
+
// 标题得跟着实际内容说:**只要有一条插话,「跑完这轮就轮到它们」就是假话**。
|
|
3108
|
+
// 三种情况各说各的,不糊一句通用的。
|
|
3109
|
+
var steerCount = 0;
|
|
3110
|
+
for (var k = 0; k < list.length; k += 1) {
|
|
3111
|
+
if (list[k].placement === 'next-step') steerCount += 1;
|
|
3112
|
+
}
|
|
3113
|
+
var waitCount = list.length - steerCount;
|
|
3114
|
+
var head = steerCount && waitCount
|
|
3115
|
+
? '插话 ' + steerCount + ' 条(下一步就看到) · 排队 ' + waitCount + ' 条'
|
|
3116
|
+
: steerCount
|
|
3117
|
+
? '插话 ' + steerCount + ' 条 · 它下一步就看到'
|
|
3118
|
+
: '排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们';
|
|
3119
|
+
el.innerHTML = '<div class="q-head">' + head + '</div>'
|
|
2034
3120
|
+ '<div class="q-scale">' + nodes + '</div>' + rows;
|
|
2035
3121
|
}
|
|
2036
3122
|
|
|
@@ -2408,8 +3494,50 @@
|
|
|
2408
3494
|
el.classList.toggle('hide', !chatNoteVisible());
|
|
2409
3495
|
}
|
|
2410
3496
|
|
|
2411
|
-
|
|
2412
|
-
|
|
3497
|
+
/**
|
|
3498
|
+
* 一条消息的 HTML。从 renderChat 的逐条循环里提出来:完整模式要把每轮的过程组
|
|
3499
|
+
* **穿插进这串消息的原位**(renderFull → trajInterleave),聊天模式直接按顺序拼——
|
|
3500
|
+
* 两边画同一条消息必须是同一段代码,两处各写一遍迟早走样。
|
|
3501
|
+
*/
|
|
3502
|
+
function chatMsgHtml(m) {
|
|
3503
|
+
// 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
|
|
3504
|
+
// 不套气泡、不给复制按钮(见 commandBlock 的说明)。
|
|
3505
|
+
if (m.role === 'command') return commandBlock(m);
|
|
3506
|
+
// 子智能体完工的通知:同一类「会话做的事」,画成一张可展开的卡片
|
|
3507
|
+
// (见 noticeBlock 的说明)。它没有复制按钮,也没有气泡。
|
|
3508
|
+
if (m.role === 'notice') return noticeBlock(m);
|
|
3509
|
+
// 被按停的那一轮:标一句,别让人以为模型答到一半断了。
|
|
3510
|
+
var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
|
|
3511
|
+
// 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
|
|
3512
|
+
// 原来只给 AI 气泡,用户反馈自己的话复制不了。
|
|
3513
|
+
var copyBtn = '<button class="copy" type="button" aria-label="复制" title="复制"'
|
|
3514
|
+
+ ' data-copy="' + escapeHtml(m.text) + '">' + ICON_COPY + '</button>';
|
|
3515
|
+
if (m.role === 'user') {
|
|
3516
|
+
// 自己的指令:仍然是气泡,靠右。自己敲的是纯文本,不走 markdown——
|
|
3517
|
+
// 不然打几个星号就变样了。
|
|
3518
|
+
return '<div class="bubble user">' + linkify(m.text)
|
|
3519
|
+
+ '<span class="meta">' + stopped + timeLabel(m.timestamp) + copyBtn + '</span></div>';
|
|
3520
|
+
}
|
|
3521
|
+
// AI 的回答:**不套气泡**(用户要求「参考单帧模式那样」)。
|
|
3522
|
+
// 于是一左一右、一个有框一个没框,正好分得清谁说的。
|
|
3523
|
+
return '<div class="said">'
|
|
3524
|
+
+ (stopped ? '<div class="reply-bar">' + stopped + '</div>' : '')
|
|
3525
|
+
+ '<div class="md">' + mdToHtml(m.text) + '</div>'
|
|
3526
|
+
+ '<div class="reply-foot">' + copyBtn
|
|
3527
|
+
+ '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
/**
|
|
3531
|
+
* 聊天那一串的「料」:滤掉排队那条、按窗口裁好、逐条变行。
|
|
3532
|
+
*
|
|
3533
|
+
* 返回的是**结构**(行数组 + 对应消息 + 滤后全量),不是拼好的字符串——
|
|
3534
|
+
* renderChat 直接按顺序拼;renderFull 拼之前要把每轮的过程组插进 rows 的
|
|
3535
|
+
* 原位(见 trajInterleave),只有结构在手才插得动。
|
|
3536
|
+
*
|
|
3537
|
+
* searchTurns 是「搜索命中的轮号」(只在完整模式搜索时给):命中轮的消息
|
|
3538
|
+
* 哪怕在窗口外也要露出来(10.3-3,「命中组在原位强开」的原位得先在场)。
|
|
3539
|
+
*/
|
|
3540
|
+
function buildChat(searchTurns) {
|
|
2413
3541
|
// 换了会话就把窗口收回首屏:上一个会话铺到多少条都不该跟过来——那正是这次要修的慢。
|
|
2414
3542
|
// 判据用会话 id 而不是 history 的引用:服务端每次推的都是整份新数组,比引用永远「变了」。
|
|
2415
3543
|
if (chatWindowFor !== state.boundSessionId) {
|
|
@@ -2440,61 +3568,47 @@
|
|
|
2440
3568
|
// 「正在读」和「确实没有」是两件事,分开说。点开一个插件没看过的会话时,
|
|
2441
3569
|
// 服务端要去 DSH 的会话记录里读它那一段(最大的那条 20 MB、约半秒),
|
|
2442
3570
|
// 这期间说「还没有对话记录」会让人以为这个会话本来就是空的。
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
3571
|
+
return {
|
|
3572
|
+
empty: state.historyLoading
|
|
3573
|
+
? '<div class="placeholder">正在读取这个会话的记录…</div>'
|
|
3574
|
+
: '<div class="placeholder">还没有对话记录。</div>',
|
|
3575
|
+
moreNote: '', rows: [], list: [], all: [],
|
|
3576
|
+
};
|
|
2447
3577
|
}
|
|
2448
3578
|
// **只铺最近那几十条**,剩下的留在手里等用户往上滑(来由见 CHAT_PAGE 那段)。
|
|
2449
3579
|
// 切在**滤掉排队那条之后**:不然被滤掉的那条会白占一个名额。
|
|
2450
3580
|
var older = Math.max(0, chatLoaded - chatWindow);
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
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>';
|
|
3581
|
+
// 搜索命中轮把窗口上沿抬到自己(10.3-3):不然命中的过程组按「组跟消息走」
|
|
3582
|
+
// 的规矩被裁在窗口外,搜得到、看不见。
|
|
3583
|
+
if (searchTurns && searchTurns.length) {
|
|
3584
|
+
for (var t = 0; t < list.length; t++) {
|
|
3585
|
+
if (list[t] && searchTurns.indexOf(String(list[t].turn)) >= 0 && t < older) {
|
|
3586
|
+
older = t;
|
|
3587
|
+
break;
|
|
3588
|
+
}
|
|
2470
3589
|
}
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
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('');
|
|
3590
|
+
}
|
|
3591
|
+
var all = list;
|
|
3592
|
+
if (older) list = list.slice(older);
|
|
2479
3593
|
// 「上面还有,往上滑」要贴在**看得到的顶上**,而且**离顶远了就收起来**——
|
|
2480
3594
|
// 两处讲究见 .more-note 的样式注释。露 / 收在这里先按当前位置定一次,
|
|
2481
3595
|
// 之后由滚动里的 paintChatNote 接着跟。
|
|
2482
3596
|
var moreNote = older
|
|
2483
3597
|
? '<div class="more-note' + (chatNoteVisible() ? '' : ' hide') + '">往上滑看更早的</div>'
|
|
2484
3598
|
: '';
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
3599
|
+
return { empty: null, moreNote: moreNote, rows: list.map(chatMsgHtml), list: list, all: all };
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
function renderChat() {
|
|
3603
|
+
mainEl.classList.remove('center');
|
|
3604
|
+
var built = buildChat();
|
|
3605
|
+
if (built.empty) {
|
|
3606
|
+
replyEl.innerHTML = built.empty;
|
|
3607
|
+
return;
|
|
2491
3608
|
}
|
|
2492
|
-
// 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
|
|
2493
|
-
// 两种模式共用同一块,改的时候两边都走一遍。
|
|
2494
|
-
if (state.live) html += liveBlock(state.live);
|
|
2495
3609
|
// 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
|
|
2496
3610
|
var stick = atBottom();
|
|
2497
|
-
replyEl.innerHTML =
|
|
3611
|
+
replyEl.innerHTML = chatFrame(built, built.rows.join(''), state.live ? liveBlock(state.live) : '');
|
|
2498
3612
|
scrollChat(stick);
|
|
2499
3613
|
}
|
|
2500
3614
|
|
|
@@ -2507,8 +3621,8 @@
|
|
|
2507
3621
|
*/
|
|
2508
3622
|
function maybeGrowChat() {
|
|
2509
3623
|
// 单帧模式那边也在往 main 上滚(回答落定要跳到页面顶端),别把那一下当成「他在往上翻」;
|
|
2510
|
-
//
|
|
2511
|
-
if (state.mode
|
|
3624
|
+
// 本地都放完了也没什么可放的。完整模式画的也是这串聊天记录,翻页照旧要能放。
|
|
3625
|
+
if (state.mode === 'minimal' || chatLoaded <= 0) return;
|
|
2512
3626
|
if (chatWindow >= chatLoaded) return;
|
|
2513
3627
|
if (mainEl.scrollTop > chatEdge()) {
|
|
2514
3628
|
// 滑下来了,这一轮重新装填:**一次靠近只放一屏**。
|
|
@@ -2541,7 +3655,10 @@
|
|
|
2541
3655
|
function growChat() {
|
|
2542
3656
|
var fromBottom = mainEl.scrollHeight - mainEl.scrollTop;
|
|
2543
3657
|
chatWindow += CHAT_PAGE;
|
|
2544
|
-
|
|
3658
|
+
// 完整模式要走 render() 总分发:直画 renderChat 会把后面那块轨迹区抹掉。
|
|
3659
|
+
// (位置补偿和聊天同一套:轨迹区每次重画高度不变,底部那一截没动。)
|
|
3660
|
+
if (state.mode === 'full') render();
|
|
3661
|
+
else renderChat();
|
|
2545
3662
|
mainEl.scrollTop = mainEl.scrollHeight - fromBottom;
|
|
2546
3663
|
}
|
|
2547
3664
|
|
|
@@ -2611,62 +3728,666 @@
|
|
|
2611
3728
|
if (stick && !liveNow) mainEl.scrollTop = mainEl.scrollHeight;
|
|
2612
3729
|
}
|
|
2613
3730
|
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
3731
|
+
/**
|
|
3732
|
+
* 三种模式各自的提示语。「完整」= 聊天那一串 **+ 每一轮的过程组**,所以三句话
|
|
3733
|
+
* 必须跟着模式一起改——收在一处是防两处走样(渲染处和点击处各写一遍的旧写法,
|
|
3734
|
+
* 加第三种模式时漏一处,用户看到的就是「点了完整、说明还写着聊天」)。
|
|
3735
|
+
*/
|
|
3736
|
+
function modeHint() {
|
|
3737
|
+
if (state.mode === 'chat') return '聊天:常规问答对话';
|
|
3738
|
+
if (state.mode === 'full') return '完整:附加每一步过程';
|
|
3739
|
+
return '单帧:只留最新一条回复';
|
|
2617
3740
|
}
|
|
2618
3741
|
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
//
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
if (Array.isArray(snap.queued)) state.queued = snap.queued;
|
|
2637
|
-
if ('boundSessionId' in snap) {
|
|
2638
|
-
var wasBound = state.boundSessionId;
|
|
2639
|
-
state.boundSessionId = snap.boundSessionId;
|
|
2640
|
-
// 换了会话,指令菜单那份缓存就作废了(指令表是每个会话一份的,这个会话没在跑
|
|
2641
|
-
// 时一条都读不到,见「斜杠指令」那一段)。重画一次,它自己会发现数据不属于
|
|
2642
|
-
// 这个会话、去重取——否则用户点到的是上一个会话的指令,点下去必然被拒。
|
|
2643
|
-
if (state.boundSessionId !== wasBound) paintCmdMenu();
|
|
3742
|
+
/**
|
|
3743
|
+
* 「完整」的底座:聊天那一串**全部照画**(分批、往上滑补页、滚动对齐都照旧),
|
|
3744
|
+
* 每一轮的过程组**穿插进它那轮对话的原位**——指令之后、回答之前(10.3-1,
|
|
3745
|
+
* 对齐 DSH 聊天视图的 turn-process 位置,2026-10-03 用户裁决「一定要让执行
|
|
3746
|
+
* 轨迹的逻辑符合DSH本身」)。三层折叠那套跟着组走,落点规则见 trajInterleave。
|
|
3747
|
+
* 但**「正在读取」和「没有过程」必须分清**:loading=true 是服务端还在读会话记录,
|
|
3748
|
+
* 不是这个会话没有过程。这期间什么都不说,人就会以为没有过程——所以那句注记
|
|
3749
|
+
* 照旧垫在最前面。没轨迹且不在 loading 时一个过程组都不画(这时完整模式和聊天
|
|
3750
|
+
* 长得一样,是预期)。
|
|
3751
|
+
*/
|
|
3752
|
+
function renderFull() {
|
|
3753
|
+
// 换了会话,用户手动拨过的折叠开关就作废——和聊天窗 chatWindowFor 同一个道理:
|
|
3754
|
+
// 两场会话的轮号会撞在一起,留在手里的开关会去拨新会话的组。
|
|
3755
|
+
if (trajFor !== state.boundSessionId) {
|
|
3756
|
+
trajFor = state.boundSessionId;
|
|
3757
|
+
trajGroupOpen = {};
|
|
3758
|
+
trajEntryOpen = {};
|
|
2644
3759
|
}
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3760
|
+
mainEl.classList.remove('center');
|
|
3761
|
+
var built = buildChat();
|
|
3762
|
+
var html;
|
|
3763
|
+
if (built.empty) {
|
|
3764
|
+
// 一条消息都没有(正在读/确实没有):手里有过程组就兜底页尾(10.3-1 d)。
|
|
3765
|
+
// 这一路不滚动,和 renderChat 的空态同口径。
|
|
3766
|
+
html = built.empty + trajInterleave([], [], built.all).join('');
|
|
3767
|
+
} else {
|
|
3768
|
+
// 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
|
|
3769
|
+
var stick = atBottom();
|
|
3770
|
+
html = chatFrame(built, trajInterleave(built.rows, built.list, built.all).join(''),
|
|
3771
|
+
state.live ? liveBlock(state.live) : '');
|
|
3772
|
+
replyEl.innerHTML = state.trajectoryLoading
|
|
3773
|
+
? '<div class="history-note">正在读取这个会话的过程…</div>' + html
|
|
3774
|
+
: html;
|
|
3775
|
+
scrollChat(stick);
|
|
3776
|
+
bindTraj();
|
|
3777
|
+
return;
|
|
3778
|
+
}
|
|
3779
|
+
if (state.trajectoryLoading) {
|
|
3780
|
+
html = '<div class="history-note">正在读取这个会话的过程…</div>' + html;
|
|
3781
|
+
}
|
|
3782
|
+
replyEl.innerHTML = html;
|
|
3783
|
+
bindTraj();
|
|
2650
3784
|
}
|
|
2651
3785
|
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
var navFollowTimers = {}; // workspaceId -> 当前标题补齐轮询定时器
|
|
2658
|
-
var navExpanded = {}; // workspaceId -> 是否展开
|
|
2659
|
-
|
|
2660
|
-
function openNav() {
|
|
2661
|
-
$('nav').classList.add('open');
|
|
2662
|
-
loadWorkspaces(false);
|
|
3786
|
+
/** 轨迹区的点击走一次容器委托就够(重绘都是全新 HTML,逐个绑必漏)。 */
|
|
3787
|
+
function bindTraj() {
|
|
3788
|
+
if (trajBound) return;
|
|
3789
|
+
trajBound = true;
|
|
3790
|
+
replyEl.addEventListener('click', onTrajectoryClick);
|
|
2663
3791
|
}
|
|
2664
|
-
function closeNav() { $('nav').classList.remove('open'); }
|
|
2665
3792
|
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
3793
|
+
/**
|
|
3794
|
+
* 聊天那串的外框:截断说明排内容前面、流式那段接在最后、moreNote 贴顶。
|
|
3795
|
+
* 聊天与完整两个模式共用这一段——两处各写一遍的旧写法走样过(见 modeHint 的说明)。
|
|
3796
|
+
* liveHtml 由调用方给(完整模式的切片里没有 liveBlock,传空串就是「没有流式」)。
|
|
3797
|
+
*
|
|
3798
|
+
* 定义在 renderFull 之后是**测试切片的约束**:trajHarness 切的是
|
|
3799
|
+
* renderFull → applySnapshot,外框函数得在切片里才调得到;页面里函数提升,
|
|
3800
|
+
* 位置不影响 renderChat 调它。
|
|
3801
|
+
*/
|
|
3802
|
+
function chatFrame(built, body, liveHtml) {
|
|
3803
|
+
var html = body;
|
|
3804
|
+
// 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
|
|
3805
|
+
// 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
|
|
3806
|
+
if (state.historyTruncated) {
|
|
3807
|
+
html = '<div class="history-note">'
|
|
3808
|
+
+ escapeHtml(state.historyNote || '这个会话很大,只显示了最近一段。')
|
|
3809
|
+
+ '</div>' + html;
|
|
3810
|
+
}
|
|
3811
|
+
// 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
|
|
3812
|
+
// 流式那段(live)接在所有行(含穿插的过程组)之后:过程在回答之前,
|
|
3813
|
+
// 正在流的半句就是「回答」那个位置(10.3-1)。
|
|
3814
|
+
if (liveHtml) html += liveHtml;
|
|
3815
|
+
return built.moreNote + html;
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
// ---------------- 轨迹的折叠与长相(完整模式,对齐 PC) ----------------
|
|
3819
|
+
// 数据是上一棒收进 state.trajectory 的轮次组:组 {turn, state, entries[]},
|
|
3820
|
+
// 条目 {kind:'think'|'tool', name, args, summary, output, state, …},字段语义
|
|
3821
|
+
// 见 lib/trajectory.js 头上的说明(思考正文和工具结果都放 output)。
|
|
3822
|
+
// 长相对齐 DSH 桌面端的 turn-process(tasks/todo.md 11):组头一行 = 图标 +
|
|
3823
|
+
// 状态词/标题(跑着是「正在…」活动词,收官是「已读取文件并调用工具」这类组合标题),
|
|
3824
|
+
// 条目一行 = 图标 + 「工具显示名 · 参数摘要」(思考行是「思考 · 首行」),
|
|
3825
|
+
// 点开才见正文(工具给「参数+结果」、思考给想的内容)。
|
|
3826
|
+
// 折叠口径不变:组级把「已结束的多条组」收成一行(running 还在长、error 要给人看、
|
|
3827
|
+
// 单条收了没信息量,这三种默认摊开);条目级默认收。
|
|
3828
|
+
// 用户拨过的开关存在下面两个对象里(组按轮号、条目按 id),只在本会话内有效,
|
|
3829
|
+
// 换会话时在 renderFull 里清掉。
|
|
3830
|
+
var trajFor = null; // 上次渲染时的会话 id(变了就清开关)
|
|
3831
|
+
var trajGroupOpen = {}; // 组级手动覆写:{ 轮号字符串: true|false }
|
|
3832
|
+
var trajEntryOpen = {}; // 条目级手动覆写:{ 条目 id: true|false }
|
|
3833
|
+
var trajBound = false; // replyEl 上的委托绑过没有
|
|
3834
|
+
|
|
3835
|
+
/**
|
|
3836
|
+
* 组默认摊不摊。收的条件是**两条一起成立**:条目够多(≥2)、已经跑完
|
|
3837
|
+
* (running 还在长,收了就看不见新条目;error 出错的组自己摊到条目级给人看,
|
|
3838
|
+
* 不用点)。用户手动拨过的开关优先于默认值。
|
|
3839
|
+
*/
|
|
3840
|
+
function trajGroupIsOpen(g) {
|
|
3841
|
+
var key = String(g.turn);
|
|
3842
|
+
if (Object.prototype.hasOwnProperty.call(trajGroupOpen, key)) return trajGroupOpen[key];
|
|
3843
|
+
if (!g.entries || g.entries.length < 2) return true;
|
|
3844
|
+
if (g.state === 'running' || g.state === 'error') return true;
|
|
3845
|
+
return false;
|
|
3846
|
+
}
|
|
3847
|
+
/** 一条摊不摊:用户开关优先;没有开关时默认收(点开才见正文)。 */
|
|
3848
|
+
function trajEntryIsOpen(e) {
|
|
3849
|
+
if (Object.prototype.hasOwnProperty.call(trajEntryOpen, e.id)) return trajEntryOpen[e.id];
|
|
3850
|
+
return false;
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
/**
|
|
3854
|
+
* 组头的开关。key 是 data 属性上的字符串(getAttribute 回来一定是字符串,
|
|
3855
|
+
* 轮号是数字,两边统一按 String 比——有一边不转换就会拨空)。
|
|
3856
|
+
* 拨完立刻重画:开关唯一可见的效果就是下一次渲染,不重画等于点了没反应。
|
|
3857
|
+
*/
|
|
3858
|
+
function toggleTrajGroup(turnKey) {
|
|
3859
|
+
var groups = state.trajectory || [];
|
|
3860
|
+
for (var i = 0; i < groups.length; i++) {
|
|
3861
|
+
if (String(groups[i].turn) !== String(turnKey)) continue;
|
|
3862
|
+
trajGroupOpen[turnKey] = !trajGroupIsOpen(groups[i]);
|
|
3863
|
+
break;
|
|
3864
|
+
}
|
|
3865
|
+
render();
|
|
3866
|
+
}
|
|
3867
|
+
// 点的当下算开还是收,要按「含搜索强制摊开」的实况来(不然搜索摊开的条目点了
|
|
3868
|
+
// 收不回去):先按 id 找到那条再取反;找不到时按手里原有的开关算,和原来一致。
|
|
3869
|
+
function toggleTrajEntry(id) {
|
|
3870
|
+
var open = trajEntryOpen[id] === true;
|
|
3871
|
+
var groups = state.trajectory || [];
|
|
3872
|
+
for (var i = 0; i < groups.length; i++) {
|
|
3873
|
+
var es = groups[i].entries || [];
|
|
3874
|
+
for (var j = 0; j < es.length; j++) {
|
|
3875
|
+
if (String(es[j].id) === String(id)) open = trajEntryIsOpen(es[j]);
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
trajEntryOpen[id] = !open;
|
|
3879
|
+
render();
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
/**
|
|
3883
|
+
* 失败首行。reason 的第一行;reason 取不到就用错误名/错误码,都没有就如实说
|
|
3884
|
+
* 「失败」——**不编原因**(和 lib/trajectory.js readError 同一条口径)。
|
|
3885
|
+
*/
|
|
3886
|
+
function trajErrLine(e) {
|
|
3887
|
+
if (!e || !e.error) return '';
|
|
3888
|
+
var r = typeof e.error.reason === 'string' ? e.error.reason : '';
|
|
3889
|
+
if (!r && typeof e.error.name === 'string' && e.error.name) r = e.error.name;
|
|
3890
|
+
if (!r && typeof e.error.code === 'string' && e.error.code) r = e.error.code;
|
|
3891
|
+
if (!r) return '失败';
|
|
3892
|
+
return r.split('\n')[0];
|
|
3893
|
+
}
|
|
3894
|
+
/** 组头上的失败首行:组里第一条带失败身份的条目的那一行。 */
|
|
3895
|
+
function trajGroupErrLine(g) {
|
|
3896
|
+
if (!g.entries) return '';
|
|
3897
|
+
for (var i = 0; i < g.entries.length; i++) {
|
|
3898
|
+
var line = trajErrLine(g.entries[i]);
|
|
3899
|
+
if (line) return line;
|
|
3900
|
+
}
|
|
3901
|
+
return '';
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
/**
|
|
3905
|
+
* 截断标注。N 是**原始总字符数**(truncated 里的 chars 才是留下的那段),措辞照
|
|
3906
|
+
* lib/trajectory.js clipForTrajectory 的原句。服务端在正文尾部写了同一句就不再
|
|
3907
|
+
* 重复;没写(truncated 字段在、句子不在)就地补上——绝不静默砍尾。
|
|
3908
|
+
*/
|
|
3909
|
+
function trajTruncNote(t, text) {
|
|
3910
|
+
if (!t || typeof t.total !== 'number') return '';
|
|
3911
|
+
if (String(text).indexOf('已截断') >= 0) return '';
|
|
3912
|
+
return '\n… 已截断,共 ' + t.total + ' 字符';
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
/**
|
|
3916
|
+
* 一条的正文(照 PC 的展开卡):
|
|
3917
|
+
* 思考 = 普通 Markdown 段落(PC 的 compact 变体:13px/20px、三级灰、左缩进 22px,
|
|
3918
|
+
* **不是等宽**——它是想的东西,不是机器吐的字节);
|
|
3919
|
+
* 工具 = 「输入 / 输出」两栏卡(标签小字灰、正文等宽、细线分隔)——输入是美化后
|
|
3920
|
+
* 的 JSON(两空格,解析不了就原样),输出是结果文本,出错整段红色。
|
|
3921
|
+
* 自动审查被拒(照 PC):参数不显示,只说清楚为什么没跑。
|
|
3922
|
+
* 截断标注照旧如实跟在后面。
|
|
3923
|
+
*/
|
|
3924
|
+
function trajEntryBody(e) {
|
|
3925
|
+
var tr = e.truncated || {};
|
|
3926
|
+
if (e.kind === 'think') {
|
|
3927
|
+
if (!e.output) return '';
|
|
3928
|
+
return '<div class="traj-think">' + mdToHtml(e.output) + trajTruncLine(tr.think, e.output) + '</div>';
|
|
3929
|
+
}
|
|
3930
|
+
if (e.error && e.error.name === 'AutoReviewDeniedError' && e.error.code === 'AUTO_REVIEW_DENIED') {
|
|
3931
|
+
var reason = typeof e.error.reason === 'string' && e.error.reason
|
|
3932
|
+
? e.error.reason : 'Auto review 未授权此次操作';
|
|
3933
|
+
return '<div class="traj-io"><div class="traj-io-row"><div class="traj-io-label">输出</div>'
|
|
3934
|
+
+ '<div class="traj-io-body traj-io-err"><pre>工具未执行。原因:'
|
|
3935
|
+
+ escapeHtml(reason) + '</pre></div></div></div>';
|
|
3936
|
+
}
|
|
3937
|
+
var rows = '';
|
|
3938
|
+
if (e.args) {
|
|
3939
|
+
rows += '<div class="traj-io-row"><div class="traj-io-label">输入</div>'
|
|
3940
|
+
+ '<div class="traj-io-body"><pre>' + escapeHtml(trajPretty(e.args)) + '</pre>'
|
|
3941
|
+
+ trajTruncLine(tr.args, e.args) + '</div></div>';
|
|
3942
|
+
}
|
|
3943
|
+
var outText = e.output;
|
|
3944
|
+
if (!outText && e.error) {
|
|
3945
|
+
// 照 PC 的 resultText 兜底:空内容且失败时,给一行「名字: 码」,不装作没有结果。
|
|
3946
|
+
outText = (e.error.name || 'Error') + (e.error.code ? ': ' + e.error.code : '');
|
|
3947
|
+
}
|
|
3948
|
+
if (outText) {
|
|
3949
|
+
rows += '<div class="traj-io-row"><div class="traj-io-label">输出</div>'
|
|
3950
|
+
+ '<div class="traj-io-body' + (e.error ? ' traj-io-err' : '') + '"><pre>'
|
|
3951
|
+
+ escapeHtml(outText) + '</pre>' + trajTruncLine(tr.output, e.output || '') + '</div></div>';
|
|
3952
|
+
}
|
|
3953
|
+
if (!rows) return '';
|
|
3954
|
+
return '<div class="traj-io">' + rows + '</div>';
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
/** 截断标注(如实):单起一行小字。 */
|
|
3958
|
+
function trajTruncLine(t, text) {
|
|
3959
|
+
var note = trajTruncNote(t, text);
|
|
3960
|
+
return note ? '<div class="traj-io-note">' + escapeHtml(note.trim()) + '</div>' : '';
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
/** 参数按 PC 的 formatToolBody:能解析就美化 JSON(两空格),解析不了就原样。 */
|
|
3964
|
+
function trajPretty(raw) {
|
|
3965
|
+
try {
|
|
3966
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
3967
|
+
} catch (err) {
|
|
3968
|
+
return String(raw);
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
// —— PC 同款词典(照抄 DSH 桌面端 0.2.0-rc.1,一个字不自创)——
|
|
3973
|
+
// 动作分类:dsh-client-ui-chat 的 activity()(工具名 → 动作)。
|
|
3974
|
+
function trajActivity(name) {
|
|
3975
|
+
var n = String(name || '');
|
|
3976
|
+
if (n === 'read') return 'read';
|
|
3977
|
+
if (n === 'read_image') return 'readImage';
|
|
3978
|
+
if (n === 'grep' || n === 'glob' || /_inspect$/.test(n)) return 'search';
|
|
3979
|
+
if (n === 'write') return 'write';
|
|
3980
|
+
if (n === 'edit' || n === 'apply_patch') return 'edit';
|
|
3981
|
+
if (n === 'bash' || n === 'pwsh' || n === 'exec_command' || n === 'write_stdin'
|
|
3982
|
+
|| /^terminal_/.test(n)) return 'commands';
|
|
3983
|
+
if (n === 'run_code') return 'code';
|
|
3984
|
+
if (n === 'web_search') return 'webSearch';
|
|
3985
|
+
if (n === 'web_fetch') return 'webFetch';
|
|
3986
|
+
if (n === 'subagent' || /^subagent_/.test(n)) return 'subagents';
|
|
3987
|
+
if (n === 'todo_write' || n === 'create_goal' || n === 'update_goal' || n === 'get_goal') {
|
|
3988
|
+
return 'plan';
|
|
3989
|
+
}
|
|
3990
|
+
if (n === 'ask_user_question' || n === 'request_user_input') return 'questions';
|
|
3991
|
+
return 'tools';
|
|
3992
|
+
}
|
|
3993
|
+
// 动作 → 完成词 / 活动词(dsh-client-ui-chat 的 message.stepProcess.done.* 与 .* 本体)。
|
|
3994
|
+
var TRAJ_DONE = {
|
|
3995
|
+
thinking: '已完成分析', read: '已读取文件', readImage: '已读取图片', write: '已写入文件',
|
|
3996
|
+
search: '已搜索代码', edit: '修改了文件', commands: '执行了命令', code: '运行了代码',
|
|
3997
|
+
webSearch: '已搜索网页', webFetch: '已访问网页', subagents: '已协调子智能体',
|
|
3998
|
+
plan: '更新了计划', questions: '向用户提出了问题', tools: '已调用工具',
|
|
3999
|
+
};
|
|
4000
|
+
var TRAJ_LIVE = {
|
|
4001
|
+
thinking: '正在分析请求', read: '正在读取文件', readImage: '正在读取图片', write: '正在写入文件',
|
|
4002
|
+
search: '正在搜索代码', edit: '正在编辑文件', commands: '正在运行命令', code: '正在运行代码',
|
|
4003
|
+
webSearch: '正在搜索网页', webFetch: '正在访问网页', subagents: '正在协调子智能体',
|
|
4004
|
+
plan: '正在更新计划', questions: '等待你的操作', tools: '正在调用工具',
|
|
4005
|
+
};
|
|
4006
|
+
// 工具显示名(dsh-client-ui-tool 的 tool.title.*)。认不出名字的走变体标题,
|
|
4007
|
+
// 变体也没有就落「工具调用」——和 PC 的 generic 同一口径。
|
|
4008
|
+
var TRAJ_TOOL_TITLE = {
|
|
4009
|
+
read: '读取', read_image: '读取图片', write: '写入', edit: '编辑', apply_patch: '编辑',
|
|
4010
|
+
bash: '运行命令', pwsh: '运行命令', exec_command: '运行命令', write_stdin: '运行命令',
|
|
4011
|
+
grep: '搜索文件内容', glob: '查找文件', run_code: '运行代码',
|
|
4012
|
+
web_search: '网页搜索', web_fetch: '网页获取',
|
|
4013
|
+
todo_write: '更新任务清单', ask_user_question: '提问', request_user_input: '提问',
|
|
4014
|
+
create_goal: '创建目标', get_goal: '查看目标', update_goal: '更新目标',
|
|
4015
|
+
subagent: '创建子智能体', list_agents: '查看子智能体', send_message: '发送消息',
|
|
4016
|
+
interrupt_agent: '中断智能体', wait_agent: '等待子智能体', spawn_teammate: '创建队友',
|
|
4017
|
+
};
|
|
4018
|
+
var TRAJ_VARIANT_TITLE = {
|
|
4019
|
+
read: '读取', readImage: '读取', webFetch: '读取', search: '搜索',
|
|
4020
|
+
write: '写入', edit: '编辑', commands: '运行命令', code: '代码',
|
|
4021
|
+
thinking: '工具调用', tools: '工具调用',
|
|
4022
|
+
};
|
|
4023
|
+
function trajToolTitle(name) {
|
|
4024
|
+
var n = String(name || '');
|
|
4025
|
+
if (TRAJ_TOOL_TITLE[n]) return TRAJ_TOOL_TITLE[n];
|
|
4026
|
+
return TRAJ_VARIANT_TITLE[trajActivity(n)] || '工具调用';
|
|
4027
|
+
}
|
|
4028
|
+
// 图标:手写细线 SVG(1.5px 描边、currentColor),和全页图标同一种画法;
|
|
4029
|
+
// 画的是 PC 那套图标的角色(思考/读/搜/改/命令/代码/网页/子代理/计划/提问/工具)。
|
|
4030
|
+
var TRAJ_ICO = {
|
|
4031
|
+
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>',
|
|
4032
|
+
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>',
|
|
4033
|
+
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>',
|
|
4034
|
+
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>',
|
|
4035
|
+
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>',
|
|
4036
|
+
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>',
|
|
4037
|
+
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>',
|
|
4038
|
+
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>',
|
|
4039
|
+
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>',
|
|
4040
|
+
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>',
|
|
4041
|
+
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>',
|
|
4042
|
+
};
|
|
4043
|
+
function trajIco(kind) {
|
|
4044
|
+
return TRAJ_ICO[kind] || TRAJ_ICO.tools;
|
|
4045
|
+
}
|
|
4046
|
+
/** 折叠处给思考行的那一句:正文首行,压成一行、限 160 字(DSH 的行内摘要同款口径)。
|
|
4047
|
+
星号去掉(PC 同款:摘录是给人扫的,星号只会碍眼——5854)。 */
|
|
4048
|
+
function trajFirstLine(text) {
|
|
4049
|
+
var s = String(text == null ? '' : text).split('\n')[0].replace(/\s+/g, ' ').trim();
|
|
4050
|
+
s = s.replace(/\*\*/g, '');
|
|
4051
|
+
return s.length > 160 ? s.slice(0, 159) + '…' : s;
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
/**
|
|
4055
|
+
* 扫光的起点接「全局时钟」:页面每次整块重画都会把动画打回开头,每次都从头扫
|
|
4056
|
+
* 就成了频闪。带上当前时间在 1.5 秒周期里的位置(负 delay),重画之后接着
|
|
4057
|
+
* 上次的相位走,肉眼看不出断口。
|
|
4058
|
+
*/
|
|
4059
|
+
function trajShimStyle() {
|
|
4060
|
+
return ' style="animation-delay:-' + ((Date.now() % 1500) / 1000).toFixed(3) + 's"';
|
|
4061
|
+
}
|
|
4062
|
+
/**
|
|
4063
|
+
* 收官标题:照抄 DSH 的 processTitle——工具条目按动作分类计数(多的在前,一样多按
|
|
4064
|
+
* 出现先后),取前三类拼完成词;两类共享「已」字时第二个去「已」用「并」连,
|
|
4065
|
+
* 三类用「,」连,超过三类加「等」;一条工具都没有(纯思考)落「已完成分析」。
|
|
4066
|
+
*/
|
|
4067
|
+
function trajTitleOf(g) {
|
|
4068
|
+
var order = [];
|
|
4069
|
+
var count = {};
|
|
4070
|
+
var es = g.entries || [];
|
|
4071
|
+
for (var i = 0; i < es.length; i++) {
|
|
4072
|
+
if (!es[i] || es[i].kind !== 'tool') continue;
|
|
4073
|
+
var k = trajActivity(es[i].name || '');
|
|
4074
|
+
if (count[k] === undefined) { count[k] = 0; order.push(k); }
|
|
4075
|
+
count[k] += 1;
|
|
4076
|
+
}
|
|
4077
|
+
order.sort(function (a, b) { return count[b] - count[a]; });
|
|
4078
|
+
var labels = order.slice(0, 3).map(function (k) { return TRAJ_DONE[k]; });
|
|
4079
|
+
var first = labels[0];
|
|
4080
|
+
if (first === undefined) return { kind: 'thinking', text: TRAJ_DONE.thinking };
|
|
4081
|
+
if (labels[1] === undefined) return { kind: order[0], text: first };
|
|
4082
|
+
if (labels.length === 2) {
|
|
4083
|
+
var second = labels[1];
|
|
4084
|
+
if (first.indexOf('已') === 0 && second.indexOf('已') === 0) second = second.slice(1);
|
|
4085
|
+
return { kind: order[0], text: first + '并' + second };
|
|
4086
|
+
}
|
|
4087
|
+
return {
|
|
4088
|
+
kind: order[0],
|
|
4089
|
+
text: first + ',' + labels.slice(1).join(',') + (order.length > 3 ? '等' : ''),
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
4092
|
+
/** 组头的图标与标题:跑着是活动词(正在…),收官是组合标题。 */
|
|
4093
|
+
function trajGroupTitle(g) {
|
|
4094
|
+
if (g.state === 'running') {
|
|
4095
|
+
var es = g.entries || [];
|
|
4096
|
+
var last = null;
|
|
4097
|
+
for (var i = es.length - 1; i >= 0; i--) {
|
|
4098
|
+
if (es[i] && es[i].state === 'running') { last = es[i]; break; }
|
|
4099
|
+
}
|
|
4100
|
+
// 还没有工具步在跑(正在想)就落「正在分析请求」——和 PC 的兜底同一个词。
|
|
4101
|
+
var kind = last && last.kind === 'tool' ? trajActivity(last.name || '') : 'thinking';
|
|
4102
|
+
// 活片段优先(正在想的那一句,随打字刷新,PC 的 label · detail 同款);
|
|
4103
|
+
// 没有片段才退到条目自己的摘要。
|
|
4104
|
+
var prose = state.trajectoryLive
|
|
4105
|
+
|| (last && last.kind === 'think' ? trajFirstLine(last.output) : '');
|
|
4106
|
+
var plain = !prose && last ? (last.summary || '') : '';
|
|
4107
|
+
// 右边那半边是模型原话时走行内渲染:**加粗**、`代码` 要成样式,不是把记号原样贴在
|
|
4108
|
+
// 脸上(2026-10-06 用户报的,和 2026-09-27 气泡那回同一个根因)。
|
|
4109
|
+
// 工具摘要**不**走——那是命令/路径原文,一条「ls *.ts *.js」里的两个星号会被斜体
|
|
4110
|
+
// 规则吃掉(在真渲染器上试过),摘要就照旧只转义。
|
|
4111
|
+
var detail = prose ? mdInline(prose) : (plain ? escapeHtml(plain) : '');
|
|
4112
|
+
return { kind: kind, live: true, text: TRAJ_LIVE[kind], detailHtml: detail };
|
|
4113
|
+
}
|
|
4114
|
+
return trajTitleOf(g);
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
/**
|
|
4118
|
+
* 旁白那一段(对用户说的话,别的模式由鲸鱼娘气泡念,2026-10-04 起完整模式塞回轨迹):
|
|
4119
|
+
* 照 PC 是过程里的一段正文——不带图标、不可点开,说完就摆在这儿。
|
|
4120
|
+
* 正文走 markdown 渲染(和回答区一个待遇:粗体、行内代码、列表都认),
|
|
4121
|
+
* 不是原样贴星号(同日用户顺手报的)。
|
|
4122
|
+
*/
|
|
4123
|
+
function trajSayHtml(e) {
|
|
4124
|
+
var tr = e.truncated || {};
|
|
4125
|
+
return '<div class="traj-say">'
|
|
4126
|
+
+ mdToHtml((e.output || '') + trajTruncNote(tr.output, e.output || '')) + '</div>';
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
/** 一行一条(PC 的成员行):图标 +「显示名 · 摘要」,失败/进行中顶在这一行上。 */
|
|
4130
|
+
function trajEntryHtml(e) {
|
|
4131
|
+
if (e.kind === 'say') return trajSayHtml(e);
|
|
4132
|
+
var think = e.kind === 'think';
|
|
4133
|
+
var kind = think ? 'thinking' : trajActivity(e.name || '');
|
|
4134
|
+
var label = think ? '思考' : trajToolTitle(e.name || '');
|
|
4135
|
+
// 自动审查拒绝(照 PC):行上摘要直接换成「Auto review 已拒绝」,别的都不重要了。
|
|
4136
|
+
var denial = e.error && e.error.name === 'AutoReviewDeniedError'
|
|
4137
|
+
&& e.error.code === 'AUTO_REVIEW_DENIED';
|
|
4138
|
+
// 思考行展开后行上只留「思考」(照 PC:摘录是折叠态的预览,展开了就退场);
|
|
4139
|
+
// 工具行不一样——展开后标题·摘要照旧留着(PC 的 keepContentWhenOpen)。
|
|
4140
|
+
var open = trajEntryIsOpen(e);
|
|
4141
|
+
var detail = denial ? 'Auto review 已拒绝'
|
|
4142
|
+
: think ? (open ? '' : trajFirstLine(e.output)) : (e.summary || '');
|
|
4143
|
+
// 跑着的行整行扫光(2026-10-04 用户点名:「某一行正在运行时会有一个渐变的效果
|
|
4144
|
+
// 闪过去」)。不跑的行一个字节不变——类名后缀为空时拼出来就是原样。
|
|
4145
|
+
var shim = e.state === 'running';
|
|
4146
|
+
var shimCls = shim ? ' traj-shimmer' : '';
|
|
4147
|
+
var shimSty = shim ? trajShimStyle() : '';
|
|
4148
|
+
// 内容段与状态段**分开**:内容段一行截断(照 PC 的折叠行——折叠行是给人扫的),
|
|
4149
|
+
// 出错/进行中的字样是独立的一段,不许被省略号吃掉(2026-10-04 真机返修)。
|
|
4150
|
+
var body = '<span class="traj-detail' + shimCls + '"' + shimSty
|
|
4151
|
+
+ '>' + (detail ? ' · ' + escapeHtml(detail) : '') + '</span>';
|
|
4152
|
+
var chips = '';
|
|
4153
|
+
if (e.error) chips = ' · <span class="traj-err">' + escapeHtml(trajErrLine(e)) + '</span>';
|
|
4154
|
+
else if (e.state === 'running') chips = ' · <span class="traj-run">进行中</span>';
|
|
4155
|
+
var row = '<button type="button" class="traj-row" data-traj-entry="'
|
|
4156
|
+
+ escapeHtml(e.id) + '"><span class="traj-ico">' + trajIco(kind) + '</span>'
|
|
4157
|
+
+ '<span class="traj-name' + shimCls + '"' + shimSty + '>' + escapeHtml(label) + '</span>'
|
|
4158
|
+
+ body + chips + '</button>';
|
|
4159
|
+
return '<div class="traj-entry">' + row
|
|
4160
|
+
+ (trajEntryIsOpen(e) ? trajEntryBody(e) : '') + '</div>';
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4163
|
+
/** 一个轮次组:PC 的组头一行(图标 + 状态词/标题),摊开时下面挂条目。 */
|
|
4164
|
+
function trajGroupHtml(g) {
|
|
4165
|
+
var open = trajGroupIsOpen(g);
|
|
4166
|
+
var t = trajGroupTitle(g);
|
|
4167
|
+
// 右半边(活片段/思考原话)在来源处已经渲染好、也转义过,见 trajGroupTitle;
|
|
4168
|
+
// 状态词与收官标题是我们自己拼的词,这里照旧转义。
|
|
4169
|
+
var title = t.live
|
|
4170
|
+
? '<span class="traj-run traj-shimmer"' + trajShimStyle() + '>' + escapeHtml(t.text)
|
|
4171
|
+
+ (t.detailHtml ? ' · ' + t.detailHtml : '') + '</span>'
|
|
4172
|
+
: '<span class="traj-title">' + escapeHtml(t.text) + '</span>';
|
|
4173
|
+
// 组头只显示状态词/标题,失败信息在下方条目行里显示,不重复。
|
|
4174
|
+
var head = '<button type="button" class="traj-head" data-traj-group="'
|
|
4175
|
+
+ escapeHtml(String(g.turn)) + '"><span class="traj-ico">' + trajIco(t.kind) + '</span>'
|
|
4176
|
+
+ '<span class="traj-chev">' + (open ? '▾' : '▸') + '</span>'
|
|
4177
|
+
+ title + '</button>';
|
|
4178
|
+
var entries = open && g.entries.length
|
|
4179
|
+
? '<div class="traj-entries">' + g.entries.map(trajEntryHtml).join('') + '</div>'
|
|
4180
|
+
: '';
|
|
4181
|
+
return '<div class="traj-group">' + head + entries + '</div>';
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
/**
|
|
4185
|
+
* 把每轮的过程组插回它那轮对话的原位(10.3-1,对齐 DSH 的 turn-process 位置)。
|
|
4186
|
+
* 落点四条:
|
|
4187
|
+
* a 该轮**第一个回答之前**——「指令 → 过程 → 回答」;
|
|
4188
|
+
* b 该轮没有回答(跑着/失败)落该轮**簇尾**(活轮即页尾,新步骤从这里冒出来);
|
|
4189
|
+
* c 该轮有消息但都不在窗口里 → **不画**(组跟消息走,翻页上来自然带进来);
|
|
4190
|
+
* d 该轮一条消息都没有(排队被滤掉、本地回显没号)→ 兜底**页尾**。
|
|
4191
|
+
* rows[i] 与 list[i] 一一对应(list=裁好窗的消息,all=滤掉排队后的全量)。
|
|
4192
|
+
* 返回插好之后的新数组,不动入参。
|
|
4193
|
+
*/
|
|
4194
|
+
|
|
4195
|
+
function trajInterleave(rows, list, all) {
|
|
4196
|
+
var groups = (state.trajectory || []).filter(function (g) {
|
|
4197
|
+
// 空组也可能是「正在想」的那一轮:turn/start 刚到、条目要等这一步说完才落
|
|
4198
|
+
// (DSH 的事件就是这么给的)。它就是进展本身,不许因为还没条目就整组不画——
|
|
4199
|
+
// 真机上那正是「跑的时候一片安静、跑完一次性全冒出来」的观感
|
|
4200
|
+
// (2026-10-04 用户报的)。跑完还是空的才不画:那一组确实没有过程。
|
|
4201
|
+
return g && Array.isArray(g.entries) && (g.entries.length > 0 || g.state === 'running');
|
|
4202
|
+
});
|
|
4203
|
+
// 服务端那句「这不是全部」跟着轨迹内容走(11.1-C):排在第一个过程组前面;
|
|
4204
|
+
// 一个过程组都没有(含轨迹读不到那条路)就兜底页尾——话必须说,位置不抢戏。
|
|
4205
|
+
var noteHtml = trajNoticeHtml();
|
|
4206
|
+
if (!groups.length) return rows.slice().concat(noteHtml ? [noteHtml] : []);
|
|
4207
|
+
var slots = [];
|
|
4208
|
+
for (var i = 0; i < groups.length; i++) {
|
|
4209
|
+
var g = groups[i];
|
|
4210
|
+
var at = -1;
|
|
4211
|
+
// a:该轮第一个回答之前
|
|
4212
|
+
for (var j = 0; j < list.length && at < 0; j++) {
|
|
4213
|
+
if (list[j] && list[j].role === 'assistant' && sameTurn(list[j].turn, g.turn)) at = j;
|
|
4214
|
+
}
|
|
4215
|
+
// b:没有回答就落簇尾
|
|
4216
|
+
if (at < 0) {
|
|
4217
|
+
for (var k = list.length - 1; k >= 0; k--) {
|
|
4218
|
+
if (list[k] && sameTurn(list[k].turn, g.turn)) { at = k + 1; break; }
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
if (at < 0) {
|
|
4222
|
+
// c/d:窗口里没有这一轮的消息——全量里有=出窗不画;没有=兜底页尾。
|
|
4223
|
+
var anywhere = false;
|
|
4224
|
+
for (var n = 0; n < (all || []).length; n++) {
|
|
4225
|
+
if (all[n] && sameTurn(all[n].turn, g.turn)) { anywhere = true; break; }
|
|
4226
|
+
}
|
|
4227
|
+
if (anywhere) continue;
|
|
4228
|
+
at = list.length;
|
|
4229
|
+
}
|
|
4230
|
+
slots.push({ at: at, turn: g.turn, html: trajGroupHtml(g) });
|
|
4231
|
+
}
|
|
4232
|
+
// 同一落点上的组按轮号排(兜底页尾时可能好几组),插的时候按落点从前往后放。
|
|
4233
|
+
slots.sort(function (x, y) { return x.at - y.at || x.turn - y.turn; });
|
|
4234
|
+
var out = [];
|
|
4235
|
+
var s = 0;
|
|
4236
|
+
var noted = false;
|
|
4237
|
+
for (var p = 0; p <= rows.length; p++) {
|
|
4238
|
+
while (s < slots.length && slots[s].at === p) {
|
|
4239
|
+
if (noteHtml && !noted) { out.push(noteHtml); noted = true; }
|
|
4240
|
+
out.push(slots[s++].html);
|
|
4241
|
+
}
|
|
4242
|
+
if (p < rows.length) out.push(rows[p]);
|
|
4243
|
+
}
|
|
4244
|
+
if (noteHtml && !noted) out.push(noteHtml);
|
|
4245
|
+
return out;
|
|
4246
|
+
}
|
|
4247
|
+
/** 轮号对得上吗:两边都要有号,再比字符串(号来自消息字段/组字段,类型不总一致)。 */
|
|
4248
|
+
function sameTurn(a, b) {
|
|
4249
|
+
return a != null && b != null && String(a) === String(b);
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
/**
|
|
4253
|
+
* 服务端「这份轨迹不是全部」的说明(state.trajectoryNote / trajectoryTruncated)。
|
|
4254
|
+
* 给了原句就原样显示;只有截断标志没给句子时补一句大实话兜底。**存在才显示**——
|
|
4255
|
+
* 一个字都没有时不许占地方,完整模式的观感必须和聊天一致。轨迹空了但服务端留了
|
|
4256
|
+
* 话(读取失败那条路)也要说:「读不到」不等于「没有过程」。
|
|
4257
|
+
*/
|
|
4258
|
+
function trajNoticeHtml() {
|
|
4259
|
+
if (state.trajectoryNote) {
|
|
4260
|
+
return '<div class="traj-note">' + escapeHtml(state.trajectoryNote) + '</div>';
|
|
4261
|
+
}
|
|
4262
|
+
if (state.trajectoryTruncated) return '<div class="traj-note">这份轨迹不是全部。</div>';
|
|
4263
|
+
return '';
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
// 轨迹区的点击全走这一根委托。先认条目再认组头——行和头各带各的 data 属性,
|
|
4267
|
+
// 认错一个就拨错层。
|
|
4268
|
+
function onTrajectoryClick(e) {
|
|
4269
|
+
var t = e.target && e.target.closest ? e.target : null;
|
|
4270
|
+
if (!t) return;
|
|
4271
|
+
var row = t.closest('[data-traj-entry]');
|
|
4272
|
+
if (row) { toggleTrajEntry(row.getAttribute('data-traj-entry')); return; }
|
|
4273
|
+
var head = t.closest('[data-traj-group]');
|
|
4274
|
+
if (head) toggleTrajGroup(head.getAttribute('data-traj-group'));
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
/**
|
|
4278
|
+
* 「回到底部」那颗浮标的露 / 收。
|
|
4279
|
+
*
|
|
4280
|
+
* 判据直接用 atBottom(),不另设一个数。这样有个好处:**浮标出现的那一刻,正好
|
|
4281
|
+
* 就是页面停止自动跟随的那一刻**——两处要是各用各的容差(比如浮标 25px、跟随 48px),
|
|
4282
|
+
* 中间那段就会出现「浮标说没到底、页面却自己在往下滚」这种自相矛盾的样子。
|
|
4283
|
+
* 电脑端那颗用的是它自己的 25px 容差(useChatReading 里的 useScrollFollow(_, 25)),
|
|
4284
|
+
* 这里跟的是本页既有的 48px,因为本页的黏底判据就是它。
|
|
4285
|
+
*
|
|
4286
|
+
* **单帧模式下一律不出现**(用户 2026-10-08 要求)。那一模式整屏只留最新一条回答,
|
|
4287
|
+
* 回答落定的那一刻页面**故意**把它的开头对齐到顶部(见 renderMinimal 里
|
|
4288
|
+
* scrolledReplyAt 那一段),好让人从第一行读起。所以那儿「不在底部」是设计如此,
|
|
4289
|
+
* **不是「用户翻上去了」**——这时候挂一颗「回到底部」,等于劝用户把刚对齐好的
|
|
4290
|
+
* 开头再滚掉,正好把这个模式唯一要做的事抵消掉。
|
|
4291
|
+
* 聊天 / 完整是多帧(一串历史一条条往上排),那里不在底部就是用户自己翻上去的,
|
|
4292
|
+
* 照旧出现。所以这条判的是**模式**(state.mode),不是新设一个「离底多少像素」。
|
|
4293
|
+
*
|
|
4294
|
+
* 只改 hidden,**不碰任何位置和尺寸**:位置全在 CSS 的 sticky 里算好了(见样式表)。
|
|
4295
|
+
* 每次现取元素、取不到就静静退出——页面测试会把 render 那一段单独抠出来跑,
|
|
4296
|
+
* 那里给的 $ 是替身,不能假设它一定认得这个 id。
|
|
4297
|
+
*/
|
|
4298
|
+
function paintToBottom() {
|
|
4299
|
+
var el = $('toBottom');
|
|
4300
|
+
if (!el) return;
|
|
4301
|
+
el.hidden = atBottom() || state.mode === 'minimal';
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
function render() {
|
|
4305
|
+
$('hintMode').textContent = modeHint();
|
|
4306
|
+
if (state.mode === 'full') renderFull(); else if (state.mode === 'chat') renderChat(); else renderMinimal();
|
|
4307
|
+
// 「完整」模式不放装饰(11.1-B):给常驻的 #work 挂 bare,立绘、气泡、光流
|
|
4308
|
+
// 进度条由 CSS 藏掉,只留「已 N 秒 / 停止」。#work 不跟着 #reply 重画,
|
|
4309
|
+
// 所以这一下挂在总分发这里,切模式时各归各位。
|
|
4310
|
+
$('work').className = 'work' + (state.mode === 'full' ? ' bare' : '');
|
|
4311
|
+
// 重画之后滚动位置可能被浏览器夹回来(内容变短)——那种情况不一定发 scroll 事件,
|
|
4312
|
+
// 所以浮标的状态也在这里跟一次。放在最后:上面几句可能刚动过 scrollTop。
|
|
4313
|
+
paintToBottom();
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4316
|
+
function applySnapshot(snap) {
|
|
4317
|
+
if (!snap) return;
|
|
4318
|
+
if ('latest' in snap) state.latest = snap.latest;
|
|
4319
|
+
// 正在流出来的那一段。服务端只在自己决定要显示之后才写它,所以这里是空字符串
|
|
4320
|
+
// 不代表模型没在写,只代表「还不到显示的时候」。
|
|
4321
|
+
if ('live' in snap) state.live = snap.live || '';
|
|
4322
|
+
// 模型此刻的自言自语(鲸鱼娘的台词)。null = 这会儿没话说,气泡回到该姿势自己的词条。
|
|
4323
|
+
if ('thought' in snap) state.thought = snap.thought || null;
|
|
4324
|
+
if (snap.history) state.history = snap.history;
|
|
4325
|
+
// 「服务端正去读这个会话的完整记录」。点开一个没看过的会话时,那一串要读一会儿
|
|
4326
|
+
// 才补上来(最大的那条日志 20 MB、约半秒),这期间不能装作「这个会话是空的」。
|
|
4327
|
+
state.historyLoading = snap.historyLoading === true;
|
|
4328
|
+
// 「这份历史只到最近一段」。服务端读历史有上限(会话太大只读日志尾部),
|
|
4329
|
+
// 所以内容可能是截断的——**这件事必须跟着内容一起传上来**,不能只在服务端知道。
|
|
4330
|
+
state.historyTruncated = snap.historyTruncated === true;
|
|
4331
|
+
state.historyNote = snap.historyNote || '';
|
|
4332
|
+
if (snap.sessions) state.sessions = snap.sessions;
|
|
4333
|
+
if (Array.isArray(snap.queued)) state.queued = snap.queued;
|
|
4334
|
+
if ('boundSessionId' in snap) {
|
|
4335
|
+
var wasBound = state.boundSessionId;
|
|
4336
|
+
state.boundSessionId = snap.boundSessionId;
|
|
4337
|
+
if (state.boundSessionId !== wasBound) {
|
|
4338
|
+
saCache = null;
|
|
4339
|
+
saCacheSession = null;
|
|
4340
|
+
saCacheAt = 0;
|
|
4341
|
+
}
|
|
4342
|
+
// 手里那份轨迹属于上一个会话——换会话立刻作废:两场会话的轮号会撞在一起,
|
|
4343
|
+
// 不清掉就会把新会话的过程贴到旧会话那一组上。完整模式下再拉一份新的。
|
|
4344
|
+
if (state.boundSessionId !== wasBound) {
|
|
4345
|
+
state.trajectory = [];
|
|
4346
|
+
state.trajectorySeq = 0;
|
|
4347
|
+
if (state.mode === 'full') requestTrajectory();
|
|
4348
|
+
}
|
|
4349
|
+
// 换了会话,指令菜单那份缓存就作废了(指令表是每个会话一份的,这个会话没在跑
|
|
4350
|
+
// 时一条都读不到,见「斜杠指令」那一段)。重画一次,它自己会发现数据不属于
|
|
4351
|
+
// 这个会话、去重取——否则用户点到的是上一个会话的指令,点下去必然被拒。
|
|
4352
|
+
if (state.boundSessionId !== wasBound) paintCmdMenu();
|
|
4353
|
+
// 权限档位也是每个会话各一份的(宿主的 currentValue 读的是这个会话那份投影)。
|
|
4354
|
+
// 不重读的话,抽屉里显示的是上一个会话那一档——用户看到的就是一句假话。
|
|
4355
|
+
if (state.boundSessionId !== wasBound) loadPermissions(state.boundSessionId);
|
|
4356
|
+
}
|
|
4357
|
+
// running 也在快照里,而且算的是**当前绑定的那个会话**。不接的话,
|
|
4358
|
+
// 在导航栏切到别的会话时,那个会话会继承上一个会话的「正在执行」。
|
|
4359
|
+
if ('running' in snap) setRunning(snap.running);
|
|
4360
|
+
if ('subagentsRunning' in snap) {
|
|
4361
|
+
state.subagentsRunning = snap.subagentsRunning === true;
|
|
4362
|
+
$('btnSubagents').classList.toggle('subagent-running', state.subagentsRunning);
|
|
4363
|
+
$('btnSubagents').setAttribute('aria-label', state.subagentsRunning ? '子智能体(运行中)' : '子智能体');
|
|
4364
|
+
}
|
|
4365
|
+
paintNavCurrent();
|
|
4366
|
+
render();
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
// ---------------- 左侧导航栏 ----------------
|
|
4370
|
+
// 数据分两层取:先列工作区(便宜),展开某个再去要它的会话(要读日志折标题,贵)。
|
|
4371
|
+
// 本机实测 35 个工作区、最多的一个有 452 个会话,一次全拉会卡住手机。
|
|
4372
|
+
var navList = null; // 工作区列表,开一次取一次
|
|
4373
|
+
var navSessions = {}; // workspaceId -> { sessions, total, truncated }
|
|
4374
|
+
var navFollowTimers = {}; // workspaceId -> 当前标题补齐轮询定时器
|
|
4375
|
+
var navExpanded = {}; // workspaceId -> 是否展开
|
|
4376
|
+
var navWsFetching = {}; // workspaceId -> 会话列表请求在途(同一工作区只发一趟)
|
|
4377
|
+
var navPresets = null; // Agent 模式清单(2026-10-04:新建会话时手机上也要能选模式)
|
|
4378
|
+
var navPresetsLoaded = false;
|
|
4379
|
+
var navPickerWs = ''; // 正在挑模式的那个工作区(清单行内展开在它的「新建会话」下)
|
|
4380
|
+
|
|
4381
|
+
function openNav() {
|
|
4382
|
+
$('nav').classList.add('open');
|
|
4383
|
+
loadWorkspaces(false);
|
|
4384
|
+
}
|
|
4385
|
+
function closeNav() { $('nav').classList.remove('open'); }
|
|
4386
|
+
|
|
4387
|
+
function loadWorkspaces(force) {
|
|
4388
|
+
if (navList && !force) { renderNav(); return; }
|
|
4389
|
+
if (!force && navPrefetchPromise) {
|
|
4390
|
+
$('navBody').innerHTML = '<div class="placeholder">正在读取…</div>';
|
|
2670
4391
|
navPrefetchPromise.then(function () {
|
|
2671
4392
|
if ($('nav').classList.contains('open')) renderNav();
|
|
2672
4393
|
}).catch(function (err) {
|
|
@@ -2694,6 +4415,8 @@
|
|
|
2694
4415
|
// 趁页面空闲先取一次;真正打开菜单时若已完成就直接画,不再把等待放在点击之后。
|
|
2695
4416
|
var navPrefetchPromise = null;
|
|
2696
4417
|
function prefetchWorkspaces() {
|
|
4418
|
+
// Agent 模式清单也是导航栏要用的、也几乎不变——顺手一起预取(见 prefetchPresets)。
|
|
4419
|
+
prefetchPresets();
|
|
2697
4420
|
if (navList || navPrefetchPromise || navView !== 'tree' || !state.token) return;
|
|
2698
4421
|
var run = function () {
|
|
2699
4422
|
navPrefetchPromise = api('/mini/api/workspaces').then(function (body) {
|
|
@@ -2712,6 +4435,22 @@
|
|
|
2712
4435
|
}
|
|
2713
4436
|
}
|
|
2714
4437
|
|
|
4438
|
+
/**
|
|
4439
|
+
* Agent 模式清单(2026-10-04 用户要求:新建会话时手机上也要能选模式,PC 一直可以)。
|
|
4440
|
+
* 只取一次;读不到就如实记「没有可选的」——建会话那条路会直接建默认,
|
|
4441
|
+
* **不摆一个只有一项的选择题**,也不假装自己知道 DSH 有哪些模式。
|
|
4442
|
+
*/
|
|
4443
|
+
function prefetchPresets() {
|
|
4444
|
+
if (navPresetsLoaded || !state.token) return;
|
|
4445
|
+
navPresetsLoaded = true;
|
|
4446
|
+
api('/mini/api/agent-presets').then(function (body) {
|
|
4447
|
+
navPresets = body && body.ok ? body : { ok: false, reason: (body && body.reason) || 'failed' };
|
|
4448
|
+
if (navPickerWs && $('nav').classList.contains('open')) renderNav();
|
|
4449
|
+
}).catch(function () {
|
|
4450
|
+
navPresets = { ok: false, reason: 'failed' };
|
|
4451
|
+
});
|
|
4452
|
+
}
|
|
4453
|
+
|
|
2715
4454
|
function dayLabel(ts) {
|
|
2716
4455
|
if (!ts) return '';
|
|
2717
4456
|
var d = new Date(ts);
|
|
@@ -2724,6 +4463,11 @@
|
|
|
2724
4463
|
function renderNav() {
|
|
2725
4464
|
// 目录浏览那一屏接管整个面板。它自己画返回按钮,所以不走下面这套。
|
|
2726
4465
|
if (navView === 'browse') { renderBrowse(); return; }
|
|
4466
|
+
// 自愈:navigation-refresh(会话开跑/收工都会广播)清得掉数据、清不掉展开态,
|
|
4467
|
+
// 重画出来的就是永远停着的「正在读取…」——取列表的请求原来只在点击时发,
|
|
4468
|
+
// 没人补发就一直停着(真机报过:收起再展开才好,2026-10-03)。缺数据的补拉一次,
|
|
4469
|
+
// loadWsSessions 自带在途闩,重复调用不会发第二趟。
|
|
4470
|
+
Object.keys(navExpanded).forEach(function (id) { loadWsSessions(id); });
|
|
2727
4471
|
var list = navList || [];
|
|
2728
4472
|
// 「新建工作区」常驻在最上面:它是这一屏的入口,不该等有了会话才出现——
|
|
2729
4473
|
// 一个工作区都还没有的人,恰恰最需要它。
|
|
@@ -2754,6 +4498,22 @@
|
|
|
2754
4498
|
// 这一行都在。放在列表末尾的话,一个 452 条会话的工作区要划到底才看得到。
|
|
2755
4499
|
var html = '<button class="ws-new" type="button" data-newws="' + escapeHtml(wsId) + '">'
|
|
2756
4500
|
+ '+ 新建会话</button>';
|
|
4501
|
+
// 模式清单(2026-10-04):多于一种模式时,「新建会话」先把清单行内展开,
|
|
4502
|
+
// 点谁建谁;坏档如实标「不可用」——不装作能点。
|
|
4503
|
+
if (navPickerWs === wsId && navPresets && navPresets.ok && navPresets.presets.length > 1) {
|
|
4504
|
+
html += '<div class="ws-modes">' + navPresets.presets.map(function (m) {
|
|
4505
|
+
var title = '<b>' + escapeHtml(m.name || m.id) + '</b>';
|
|
4506
|
+
var desc = m.description
|
|
4507
|
+
? '<span class="ws-mode-desc">' + escapeHtml(m.description) + '</span>' : '';
|
|
4508
|
+
var def = navPresets.defaultId === m.id ? '<span class="ws-mode-def">默认</span>' : '';
|
|
4509
|
+
if (m.broken) {
|
|
4510
|
+
return '<div class="ws-mode ws-mode-broken">' + title
|
|
4511
|
+
+ '<span class="ws-mode-desc">不可用:' + escapeHtml(m.broken) + '</span></div>';
|
|
4512
|
+
}
|
|
4513
|
+
return '<button class="ws-mode" type="button" data-mode-ws="' + escapeHtml(wsId)
|
|
4514
|
+
+ '" data-mode-preset="' + escapeHtml(m.id) + '">' + title + desc + def + '</button>';
|
|
4515
|
+
}).join('') + '</div>';
|
|
4516
|
+
}
|
|
2757
4517
|
var got = navSessions[wsId];
|
|
2758
4518
|
if (!got) return html + '<div class="placeholder">正在读取…</div>';
|
|
2759
4519
|
if (!got.sessions.length) return html + '<div class="placeholder">这个工作区还没有会话。</div>';
|
|
@@ -2995,7 +4755,17 @@
|
|
|
2995
4755
|
}
|
|
2996
4756
|
navExpanded[wsId] = true;
|
|
2997
4757
|
renderNav();
|
|
2998
|
-
|
|
4758
|
+
loadWsSessions(wsId);
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
// 取一个工作区的会话列表。点击和 renderNav 的自愈(见 renderNav 顶部)都走这里:
|
|
4762
|
+
// navWsFetching 这道闩让同一工作区同时只有一趟在飞,别重复付读日志折标题的钱。
|
|
4763
|
+
// 15 秒兜底解锁是给「请求永远不回来」留的——闩本身不能变成新的卡死点,
|
|
4764
|
+
// 超过 15 秒就放行,让下一次渲染或点击还能再试(服务端有缓存,重发很便宜)。
|
|
4765
|
+
function loadWsSessions(wsId) {
|
|
4766
|
+
if (navSessions[wsId] || navWsFetching[wsId]) return;
|
|
4767
|
+
navWsFetching[wsId] = true;
|
|
4768
|
+
var bail = setTimeout(function () { delete navWsFetching[wsId]; }, 15000);
|
|
2999
4769
|
/**
|
|
3000
4770
|
* 接着问,直到服务端说「一条都不剩」。
|
|
3001
4771
|
*
|
|
@@ -3025,11 +4795,15 @@
|
|
|
3025
4795
|
}
|
|
3026
4796
|
api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
|
|
3027
4797
|
.then(function (body) {
|
|
4798
|
+
clearTimeout(bail);
|
|
3028
4799
|
navSessions[wsId] = body;
|
|
4800
|
+
delete navWsFetching[wsId];
|
|
3029
4801
|
renderNav();
|
|
3030
4802
|
followTitles(body, Date.now() + TITLE_FOLLOW_LIMIT_MS);
|
|
3031
4803
|
})
|
|
3032
4804
|
.catch(function (err) {
|
|
4805
|
+
clearTimeout(bail);
|
|
4806
|
+
delete navWsFetching[wsId];
|
|
3033
4807
|
navSessions[wsId] = { sessions: [], total: 0 };
|
|
3034
4808
|
renderNav();
|
|
3035
4809
|
toast(err.message);
|
|
@@ -3041,7 +4815,17 @@
|
|
|
3041
4815
|
// 服务端那道闸门拦不住这个:两个请求都是合法的。
|
|
3042
4816
|
var creatingSession = false;
|
|
3043
4817
|
|
|
3044
|
-
function createSession(wsId) {
|
|
4818
|
+
function createSession(wsId, agentPreset) {
|
|
4819
|
+
// 2026-10-04 用户要求:新建会话时手机上也要能选 agent 模式(PC 一直可以)。
|
|
4820
|
+
// 多于一种模式、又没点名要哪种:先把清单行内展开让用户挑(照「新建文件夹」的
|
|
4821
|
+
// 先例,不弹窗);只有一种、或读不到清单:直接建 DSH 的默认模式——
|
|
4822
|
+
// **不摆只有一个选项的选择题**,也不替用户猜。
|
|
4823
|
+
if (agentPreset === undefined && navPresets && navPresets.ok && navPresets.presets.length > 1) {
|
|
4824
|
+
navPickerWs = navPickerWs === wsId ? '' : wsId;
|
|
4825
|
+
renderNav();
|
|
4826
|
+
return Promise.resolve();
|
|
4827
|
+
}
|
|
4828
|
+
navPickerWs = '';
|
|
3045
4829
|
if (creatingSession) return;
|
|
3046
4830
|
creatingSession = true;
|
|
3047
4831
|
Array.prototype.forEach.call($('navBody').querySelectorAll('.ws-new'), function (el) {
|
|
@@ -3061,7 +4845,7 @@
|
|
|
3061
4845
|
// 不返回的话外面拿到的是 undefined,接不上任何后续。
|
|
3062
4846
|
return api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions', {
|
|
3063
4847
|
method: 'POST',
|
|
3064
|
-
body: '{}',
|
|
4848
|
+
body: agentPreset ? JSON.stringify({ agentPreset: agentPreset }) : '{}',
|
|
3065
4849
|
}).then(function (res) {
|
|
3066
4850
|
done();
|
|
3067
4851
|
// 接口把绑定后的快照一起给了,直接渲染——不这样的话,切过去的一瞬间
|
|
@@ -3132,6 +4916,12 @@
|
|
|
3132
4916
|
if (at('[data-mkdir-cancel]')) { browseMkdir = false; renderNav(); return; }
|
|
3133
4917
|
if (at('[data-mkdir-ok]')) { makeFolder(); return; }
|
|
3134
4918
|
|
|
4919
|
+
// 模式清单里点一项:带着这个模式去建(2026-10-04,手机上也能选 agent 模式)。
|
|
4920
|
+
var mode = at('[data-mode-preset]');
|
|
4921
|
+
if (mode) {
|
|
4922
|
+
createSession(mode.getAttribute('data-mode-ws'), mode.getAttribute('data-mode-preset'));
|
|
4923
|
+
return;
|
|
4924
|
+
}
|
|
3135
4925
|
// 「新建会话」要排在 ws-head 之前判:它在 ws-body 里面、不是 ws-head,
|
|
3136
4926
|
// 顺序上无所谓,但放在前面更清楚——这一支是动作,那一支是展开/收起。
|
|
3137
4927
|
var fresh = at('.ws-new');
|
|
@@ -3142,24 +4932,91 @@
|
|
|
3142
4932
|
});
|
|
3143
4933
|
|
|
3144
4934
|
// ---------------- SSE ----------------
|
|
4935
|
+
/** 从轨迹中取当前轮号:正在跑的那一组的轮号。没有就返回 null。 */
|
|
4936
|
+
function currentTrajectoryTurn() {
|
|
4937
|
+
var groups = state.trajectory || [];
|
|
4938
|
+
for (var i = groups.length - 1; i >= 0; i--) {
|
|
4939
|
+
var g = groups[i];
|
|
4940
|
+
if (g && g.state === 'running' && g.turn != null) return g.turn;
|
|
4941
|
+
}
|
|
4942
|
+
// 没有正在跑的,取最近一个有轮号的
|
|
4943
|
+
for (var j = groups.length - 1; j >= 0; j--) {
|
|
4944
|
+
var h = groups[j];
|
|
4945
|
+
if (h && h.turn != null) return h.turn;
|
|
4946
|
+
}
|
|
4947
|
+
return null;
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4950
|
+
/**
|
|
4951
|
+
* 这条流是谁——轨迹订阅按连接记,就得先有身份。
|
|
4952
|
+
*
|
|
4953
|
+
* 服务端手上只有一条条 SSE 连接(`res`),一次普通的 POST 认不出「我是哪条流」。
|
|
4954
|
+
* 所以由客户端自己报一个随机串,两处都带上:连流时的 `?clientId=…`,和之后每次
|
|
4955
|
+
* 开关订阅的 POST。不报也能用,只是那种连接订不了轨迹。
|
|
4956
|
+
*
|
|
4957
|
+
* 存 `sessionStorage`(不是 localStorage):一个标签页一个身份,第二个标签页不会
|
|
4958
|
+
* 跟这一页抢同一条订阅;刷新后还在,免得每刷一次就多出一个「认不出」的连接。
|
|
4959
|
+
* 无痕模式下存取会抛,就地退回「本次运行内固定」——订不上,但不影响别的功能。
|
|
4960
|
+
*/
|
|
4961
|
+
var clientId = '';
|
|
4962
|
+
try {
|
|
4963
|
+
clientId = sessionStorage.getItem('dshMiniClient') || '';
|
|
4964
|
+
if (!clientId) {
|
|
4965
|
+
clientId = 'c' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
4966
|
+
sessionStorage.setItem('dshMiniClient', clientId);
|
|
4967
|
+
}
|
|
4968
|
+
} catch (e) {
|
|
4969
|
+
clientId = 'c' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
4970
|
+
}
|
|
4971
|
+
/** 服务端此刻认作「订了轨迹」的那个身份;空串 = 没订(切换模式的开关拿它判断)。 */
|
|
4972
|
+
var subscribedClientId = '';
|
|
3145
4973
|
var es = null;
|
|
3146
4974
|
function connect() {
|
|
3147
4975
|
if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
3148
4976
|
if (!state.token) { showGate(); return; }
|
|
3149
4977
|
setStatus('', '连接中…');
|
|
3150
|
-
|
|
4978
|
+
// clientId 每条连接都带(订阅的 POST 靠它认出这条流);full=1 只在「完整」模式带,
|
|
4979
|
+
// 服务端见到就自动把这条连接订上轨迹——断线重连沿用同一个地址,重新订上由服务端
|
|
4980
|
+
// 自己完成,客户端不用再握手一次。
|
|
4981
|
+
es = new EventSource('/mini/api/stream?token=' + encodeURIComponent(state.token)
|
|
4982
|
+
+ '&clientId=' + encodeURIComponent(clientId)
|
|
4983
|
+
+ (state.mode === 'full' ? '&full=1' : ''));
|
|
3151
4984
|
|
|
3152
4985
|
es.addEventListener('open', function () {
|
|
3153
4986
|
state.connected = true;
|
|
3154
4987
|
updateStatusUI();
|
|
3155
4988
|
$('connHint').textContent = '已连接';
|
|
3156
4989
|
prefetchWorkspaces();
|
|
4990
|
+
prefetchSubagents();
|
|
4991
|
+
if (state.mode === 'full') {
|
|
4992
|
+
// 首连与重连同一处:full=1 已让服务端订上这条连接,拿住身份,再对齐一次——
|
|
4993
|
+
// 断线那一段路上可能丢过帧,用这份 GET 把 epoch/seq 校正(换代就整个换新)。
|
|
4994
|
+
subscribedClientId = clientId;
|
|
4995
|
+
requestTrajectory();
|
|
4996
|
+
}
|
|
3157
4997
|
});
|
|
3158
4998
|
es.addEventListener('state', function (e) {
|
|
3159
4999
|
state.connected = true;
|
|
3160
5000
|
updateStatusUI();
|
|
3161
5001
|
try { applySnapshot(JSON.parse(e.data)); } catch (err) {}
|
|
3162
5002
|
prefetchWorkspaces();
|
|
5003
|
+
prefetchSubagents();
|
|
5004
|
+
});
|
|
5005
|
+
// 轨迹的增量帧。JSON 解析失败就丢——宁可等下一份 GET 对齐,也不拿半帧去拼。
|
|
5006
|
+
es.addEventListener('trajectory', function (e) {
|
|
5007
|
+
var frame;
|
|
5008
|
+
try { frame = JSON.parse(e.data); } catch (err) { return; }
|
|
5009
|
+
receiveTrajectoryFrame(frame);
|
|
5010
|
+
});
|
|
5011
|
+
// 活片段帧(2026-10-04 用户解禁:完整模式要「正在想的片段一点点顺出来」)。
|
|
5012
|
+
// 只进完整模式的轨迹组头;聊天/单帧、鲸鱼娘气泡一个字节都不碰。
|
|
5013
|
+
es.addEventListener('trajectory-live', function (e) {
|
|
5014
|
+
var frame;
|
|
5015
|
+
try { frame = JSON.parse(e.data); } catch (err) { return; }
|
|
5016
|
+
if (state.mode !== 'full') return;
|
|
5017
|
+
if (frame.sessionId && state.boundSessionId && frame.sessionId !== state.boundSessionId) return;
|
|
5018
|
+
state.trajectoryLive = typeof frame.detail === 'string' ? frame.detail : '';
|
|
5019
|
+
render();
|
|
3163
5020
|
});
|
|
3164
5021
|
es.addEventListener('navigation-title', function (e) {
|
|
3165
5022
|
var payload;
|
|
@@ -3177,6 +5034,37 @@
|
|
|
3177
5034
|
}
|
|
3178
5035
|
if (navExpanded[payload.workspaceId] && navView === 'tree') renderNav();
|
|
3179
5036
|
});
|
|
5037
|
+
es.addEventListener('subagent-metrics', function (e) {
|
|
5038
|
+
var payload;
|
|
5039
|
+
try { payload = JSON.parse(e.data); } catch (err) { return; }
|
|
5040
|
+
if (!payload || payload.parentSessionId !== state.boundSessionId) return;
|
|
5041
|
+
var row = null;
|
|
5042
|
+
for (var i = 0; i < saRows.length; i++) {
|
|
5043
|
+
if (saRows[i].id === payload.childId) { row = saRows[i]; break; }
|
|
5044
|
+
}
|
|
5045
|
+
if (!row) return;
|
|
5046
|
+
row.tokens = payload.tokens;
|
|
5047
|
+
row.durationMs = payload.durationMs;
|
|
5048
|
+
row.lastTurnCompleted = payload.lastTurnCompleted;
|
|
5049
|
+
if (saCache && saCacheSession === state.boundSessionId) {
|
|
5050
|
+
for (var k = 0; k < (saCache.subagents || []).length; k++) {
|
|
5051
|
+
if (saCache.subagents[k].id === payload.childId) {
|
|
5052
|
+
Object.assign(saCache.subagents[k], payload);
|
|
5053
|
+
break;
|
|
5054
|
+
}
|
|
5055
|
+
}
|
|
5056
|
+
}
|
|
5057
|
+
if (saPanel.classList.contains('open') && !saCurrent) saRenderList(saCache || { ok: true, subagents: saRows });
|
|
5058
|
+
});
|
|
5059
|
+
es.addEventListener('navigation-refresh', function () {
|
|
5060
|
+
navList = null;
|
|
5061
|
+
navSessions = {};
|
|
5062
|
+
saCache = null;
|
|
5063
|
+
saCacheSession = null;
|
|
5064
|
+
saCacheAt = 0;
|
|
5065
|
+
if ($('nav').classList.contains('open')) loadWorkspaces(false);
|
|
5066
|
+
else prefetchWorkspaces();
|
|
5067
|
+
});
|
|
3180
5068
|
es.addEventListener('reply', function (e) {
|
|
3181
5069
|
var payload = JSON.parse(e.data);
|
|
3182
5070
|
// interrupted 必须带上:这条是「实时推过来的那一条」,走的是和刷新时
|
|
@@ -3187,11 +5075,16 @@
|
|
|
3187
5075
|
text: payload.text, timestamp: payload.timestamp,
|
|
3188
5076
|
sessionId: payload.sessionId, interrupted: cut,
|
|
3189
5077
|
};
|
|
3190
|
-
|
|
5078
|
+
// 完整模式画的也是这串聊天记录(renderFull 复用 renderChat),回复落定
|
|
5079
|
+
// 同样要追加进去,否则要等下一份快照才补上;单帧不画记录,照旧不进。
|
|
5080
|
+
if (state.mode !== 'minimal') {
|
|
3191
5081
|
state.history = (state.history || []).concat([
|
|
3192
5082
|
{
|
|
3193
5083
|
role: 'assistant', text: payload.text, timestamp: payload.timestamp,
|
|
3194
5084
|
sessionId: payload.sessionId, interrupted: cut,
|
|
5085
|
+
// 完整模式把轨迹块插回对话原位要用:从轨迹中取当前轮号。
|
|
5086
|
+
// 手机本地回显时轮还没开,先留空;服务端快照补上来时会带上真轮号。
|
|
5087
|
+
turn: currentTrajectoryTurn(),
|
|
3195
5088
|
}
|
|
3196
5089
|
]);
|
|
3197
5090
|
}
|
|
@@ -3463,17 +5356,29 @@
|
|
|
3463
5356
|
// **不能显示成空列表**:那等于说「这个会话没有指令」,是另一回事。
|
|
3464
5357
|
state.commandsError = (res && res.error) || '读不到指令列表';
|
|
3465
5358
|
}
|
|
3466
|
-
|
|
5359
|
+
paintCommandViews();
|
|
3467
5360
|
}).catch(function (err) {
|
|
3468
5361
|
state.commandsLoading = false;
|
|
3469
5362
|
if (forSession !== state.boundSessionId) return;
|
|
3470
5363
|
state.commandsFor = forSession;
|
|
3471
5364
|
state.commands = null;
|
|
3472
5365
|
state.commandsError = err.message || '读不到指令列表';
|
|
3473
|
-
|
|
5366
|
+
paintCommandViews();
|
|
3474
5367
|
});
|
|
3475
5368
|
}
|
|
3476
5369
|
|
|
5370
|
+
/**
|
|
5371
|
+
* 指令表换了一份之后,**所有正在看着它的地方**都要重画。
|
|
5372
|
+
*
|
|
5373
|
+
* 现在有两处:打 / 弹出来的菜单,和统一操作面板里的那个列表。回调那一个由面板
|
|
5374
|
+
* 那一段自己挂上——因为它在这一段下面,而这一段会被测试单独抠出来跑(那时面板还不存在)。
|
|
5375
|
+
*/
|
|
5376
|
+
var onCommandsReady = null;
|
|
5377
|
+
function paintCommandViews() {
|
|
5378
|
+
paintCmdMenu();
|
|
5379
|
+
if (onCommandsReady) onCommandsReady();
|
|
5380
|
+
}
|
|
5381
|
+
|
|
3477
5382
|
/**
|
|
3478
5383
|
* 画(或收起)那只菜单。
|
|
3479
5384
|
*
|
|
@@ -3484,6 +5389,9 @@
|
|
|
3484
5389
|
function paintCmdMenu() {
|
|
3485
5390
|
var el = $('cmdMenu');
|
|
3486
5391
|
if (!el) return;
|
|
5392
|
+
// 输入框被清空了 = 用户把刚才那条指令整个删了,这是「明确取消」那一步。
|
|
5393
|
+
// 放在这儿而不是 send() 里:清空之后他往往还要改写成普通提问,那次 send 不该再被拦。
|
|
5394
|
+
if (!inputEl.value.trim()) state.pendingCommand = null;
|
|
3487
5395
|
var prefix = slashQuery();
|
|
3488
5396
|
if (prefix === null) { hideCmdMenu(); return; }
|
|
3489
5397
|
var wasOpen = !el.hidden;
|
|
@@ -3516,16 +5424,22 @@
|
|
|
3516
5424
|
}
|
|
3517
5425
|
|
|
3518
5426
|
/**
|
|
3519
|
-
*
|
|
5427
|
+
* 菜单/面板里点了一条。
|
|
3520
5428
|
*
|
|
3521
|
-
* 分两路,判据是服务端给的 hint
|
|
5429
|
+
* 分两路,判据是服务端给的 hint(这条指令要不要参数)。两路都**不执行**,
|
|
5430
|
+
* 除非是打了 / 之后从菜单里点了一条不要参数的(那种当场跑,见 deferRun 的说明):
|
|
3522
5431
|
* · 不要参数 —— 直接执行。让用户再按一次发送是白白多一步。
|
|
3523
5432
|
* · 要参数 —— 把 `/名字 ` 填进输入框,末尾留一个空格、光标停在后面,并把
|
|
3524
5433
|
* hint 那句话挂到输入框的 placeholder 上。
|
|
3525
5434
|
* **不自己编一句「请输入参数」**:只有服务端知道这条指令要什么,
|
|
3526
5435
|
* 编一句就等于让用户照着一个错的提示去填。
|
|
5436
|
+
*
|
|
5437
|
+
* @param {string} name
|
|
5438
|
+
* @param {boolean} [deferRun] 面板那条路传 true:**一次点击不执行**,只把这一行放进
|
|
5439
|
+
* 输入框,由「发送」那一下确认(没有参数提示的指令也照此办理,宁可多问一句)。
|
|
5440
|
+
* 打 / 弹出来的那个菜单不传——它本来就是「扫一眼挑一个」,多一步反而碍事。
|
|
3527
5441
|
*/
|
|
3528
|
-
function pickCommand(name) {
|
|
5442
|
+
function pickCommand(name, deferRun) {
|
|
3529
5443
|
var list = state.commands || [];
|
|
3530
5444
|
var cmd = null;
|
|
3531
5445
|
for (var i = 0; i < list.length; i++) { if (list[i].name === name) cmd = list[i]; }
|
|
@@ -3537,12 +5451,25 @@
|
|
|
3537
5451
|
if (cmd.hint) {
|
|
3538
5452
|
inputEl.value = '/' + cmd.name + ' ';
|
|
3539
5453
|
inputEl.placeholder = cmd.hint;
|
|
5454
|
+
// 这一行「曾经是一条指令」:名字要是被删掉,send 不许拿它当普通提问发出去。
|
|
5455
|
+
state.pendingCommand = cmd.name;
|
|
3540
5456
|
inputEl.focus();
|
|
3541
5457
|
// 光标停在最后:停在名字中间的话,用户接着打的字会插进名字里去。
|
|
3542
5458
|
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
|
|
3543
5459
|
autoGrow();
|
|
3544
5460
|
return;
|
|
3545
5461
|
}
|
|
5462
|
+
if (deferRun) {
|
|
5463
|
+
// 面板那条路:只填不跑。等用户按「发送」——那就是明确的确认执行动作。
|
|
5464
|
+
inputEl.value = '/' + cmd.name;
|
|
5465
|
+
state.pendingCommand = cmd.name;
|
|
5466
|
+
inputEl.focus();
|
|
5467
|
+
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
|
|
5468
|
+
autoGrow();
|
|
5469
|
+
return;
|
|
5470
|
+
}
|
|
5471
|
+
// 当场执行:框里不留东西,也就不存在「曾是指令」这回事。
|
|
5472
|
+
state.pendingCommand = null;
|
|
3546
5473
|
inputEl.value = '';
|
|
3547
5474
|
autoGrow();
|
|
3548
5475
|
runCommand('/' + cmd.name);
|
|
@@ -3605,10 +5532,13 @@
|
|
|
3605
5532
|
// 本地上限和服务端是同一个数(由服务端注入),先拦一道省得白传一场。
|
|
3606
5533
|
if (MAX_UPLOAD > 0 && file.size > MAX_UPLOAD) {
|
|
3607
5534
|
toast('「' + name + '」有 ' + humanSize(file.size) + ',超过上限 ' + humanSize(MAX_UPLOAD));
|
|
3608
|
-
|
|
5535
|
+
// 多选时是排队传的(见 filePick 的 change),所以这里也要回一个「承诺」——
|
|
5536
|
+
// 否则这一份被拦下会让整条链断掉,后面几张就再也传不出去了。
|
|
5537
|
+
return Promise.resolve();
|
|
3609
5538
|
}
|
|
3610
5539
|
setAttachBusy(true);
|
|
3611
|
-
|
|
5540
|
+
// 返回这条链:多选时调用方靠它排队(一张传完再传下一张)。
|
|
5541
|
+
return api('/mini/api/upload?name=' + encodeURIComponent(name) + '&size=' + file.size,
|
|
3612
5542
|
{ method: 'POST', body: file })
|
|
3613
5543
|
.then(function (res) {
|
|
3614
5544
|
state.uploads.push({ uploadId: res.uploadId, name: res.name, bytes: res.bytes });
|
|
@@ -3620,17 +5550,38 @@
|
|
|
3620
5550
|
.then(function () { setAttachBusy(false); });
|
|
3621
5551
|
}
|
|
3622
5552
|
|
|
3623
|
-
/**
|
|
5553
|
+
/**
|
|
5554
|
+
* 附件小条。有东西才出现,没有就整块收起来——不占地方。
|
|
5555
|
+
*
|
|
5556
|
+
* 统一操作面板里那几行「已选文件」是**同一份数据、同一份 HTML**:都由这里画,
|
|
5557
|
+
* 都读 state.uploads。别在面板里另存一个附件数组——那样两处迟早对不上
|
|
5558
|
+
* (面板里删了一条,小条还挂着,用户就不知道该信哪个)。
|
|
5559
|
+
*/
|
|
3624
5560
|
function renderAttach() {
|
|
3625
5561
|
var bar = $('attachBar');
|
|
3626
5562
|
if (!bar) return;
|
|
3627
5563
|
var list = state.uploads || [];
|
|
3628
|
-
|
|
3629
|
-
bar.innerHTML = list.map(function (f, i) {
|
|
5564
|
+
var chips = list.map(function (f, i) {
|
|
3630
5565
|
return '<span class="chip"><span class="nm">' + escapeHtml(f.name) + '</span>'
|
|
3631
5566
|
+ '<span class="sz">' + humanSize(f.bytes) + '</span>'
|
|
3632
5567
|
+ '<button class="x" type="button" data-drop="' + i + '" aria-label="去掉">×</button></span>';
|
|
3633
5568
|
}).join('');
|
|
5569
|
+
bar.hidden = !list.length;
|
|
5570
|
+
bar.innerHTML = chips;
|
|
5571
|
+
var box = $('opsFiles');
|
|
5572
|
+
if (box) { box.hidden = !list.length; box.innerHTML = chips; }
|
|
5573
|
+
}
|
|
5574
|
+
|
|
5575
|
+
/**
|
|
5576
|
+
* 去掉一条已选文件。小条和面板共用这一处,别写两份。
|
|
5577
|
+
*
|
|
5578
|
+
* 编号必须从**被点的按钮**上读(`closest('[data-drop]')`),不能读 e.target:
|
|
5579
|
+
* ✕ 是按钮里那个图形,手指落上去 e.target 就是图形本身,读它只能拿到 null,
|
|
5580
|
+
* 于是静默什么都不做(2026-09-28 实机报过的那个 bug)。
|
|
5581
|
+
*/
|
|
5582
|
+
function dropUpload(btn) {
|
|
5583
|
+
state.uploads.splice(Number(btn.getAttribute('data-drop')), 1);
|
|
5584
|
+
renderAttach();
|
|
3634
5585
|
}
|
|
3635
5586
|
|
|
3636
5587
|
function send() {
|
|
@@ -3643,6 +5594,21 @@
|
|
|
3643
5594
|
// 只带文件、一个字不写也是合法的:「这份文件你看一下」本来就是常见用法。
|
|
3644
5595
|
// 两样都没有才算空。
|
|
3645
5596
|
if (!text && !files.length) return;
|
|
5597
|
+
|
|
5598
|
+
// **点过指令、又把名字删掉的那一行,不许当普通提问发出去。**
|
|
5599
|
+
//
|
|
5600
|
+
// 从面板/菜单点了一条「要参数」的指令之后,输入框里躺着 `/名字 参数`。用户要是把
|
|
5601
|
+
// 名字删了、只留参数,这一行就不再以 / 开头,原来会被当成一次普通提问发给模型——
|
|
5602
|
+
// 白花一次额度,而且他还以为自己跑的是那条指令。
|
|
5603
|
+
//
|
|
5604
|
+
// 拦在**清空输入框之前**:那一行字要留着,补回名字就能接着发。出路有两条,
|
|
5605
|
+
// 都在提示里说清楚了:补回开头的指令名,或者清空输入框(清空 = 明确取消这条指令,
|
|
5606
|
+
// 由 paintCmdMenu 在每次输入时把那枚标记撤掉)。
|
|
5607
|
+
if (text.charAt(0) !== '/' && state.pendingCommand) {
|
|
5608
|
+
toast('这是 /' + state.pendingCommand + ' 的参数,指令名被删掉了。'
|
|
5609
|
+
+ '把 /' + state.pendingCommand + ' 补回开头,或者清空输入框改问别的。');
|
|
5610
|
+
return;
|
|
5611
|
+
}
|
|
3646
5612
|
unlockAudio();
|
|
3647
5613
|
// **发指令这个动作,无条件拉回底部。**
|
|
3648
5614
|
//
|
|
@@ -3677,6 +5643,8 @@
|
|
|
3677
5643
|
// 这里**不动 state.uploads**——清掉就等于把用户刚传上来的文件悄悄扔了,
|
|
3678
5644
|
// 而他还以为跟着发出去了。留在小条上看得见,想发就配一句话再发一次。
|
|
3679
5645
|
if (files.length) toast('指令带不了附件,附件还留在下面');
|
|
5646
|
+
// 跑出去了,那枚「曾是指令」的标记就该撤掉:留着会拦住下一条普通提问。
|
|
5647
|
+
state.pendingCommand = null;
|
|
3680
5648
|
runCommand(text);
|
|
3681
5649
|
return;
|
|
3682
5650
|
}
|
|
@@ -3702,22 +5670,34 @@
|
|
|
3702
5670
|
|
|
3703
5671
|
api('/mini/api/send', { method: 'POST', body: JSON.stringify({ text: text, uploadIds: uploadIds }) })
|
|
3704
5672
|
.then(function (res) {
|
|
3705
|
-
//
|
|
5673
|
+
// 服务端说这条排队了 / 插话了(发的时候正在跑),给一句话。
|
|
3706
5674
|
// 队列那块也会跟着推过来,但那要等一个来回;先给个即时反馈,
|
|
3707
5675
|
// 免得用户以为又石沉大海了。
|
|
5676
|
+
//
|
|
5677
|
+
// **两条要说不同的话**(2026-10-06):插话是「下一个步骤边界就取走」,
|
|
5678
|
+
// 通常几秒;排队是「等这一轮跑完」,可能好几分钟。都回一句「已排队」
|
|
5679
|
+
// 就等于把插话也说成了排队——用户就是这么发现「插话好像没实现」的。
|
|
5680
|
+
if (res && res.steered) {
|
|
5681
|
+
toast('已插话,它下一步就看到');
|
|
5682
|
+
return;
|
|
5683
|
+
}
|
|
3708
5684
|
if (res && res.queued) {
|
|
3709
5685
|
toast('已排队,跑完这轮就轮到它');
|
|
3710
5686
|
// **排队不算发出去,所以这里不回显气泡。** 用户 2026-09-22 实机提的:
|
|
3711
5687
|
// 排队的指令立刻长成一条指令气泡,看着就像已经发出去了,其实还在队列里。
|
|
3712
5688
|
// 它已经在下面的排队列表里露过面了,那就是它此刻该待的地方;
|
|
3713
5689
|
// 轮到它真跑起来时,服务端会把它记进 history,气泡那时才出现。
|
|
5690
|
+
// 插话走的是同一条账(等它真被取走时才长气泡),所以这里一起 return。
|
|
3714
5691
|
return;
|
|
3715
5692
|
}
|
|
3716
5693
|
// 真发出去了才回显。放在这里而不是发之前,是因为**发之前还不知道会不会排队**;
|
|
3717
5694
|
// 顺带也解决了发送失败时屏幕上留一条假气泡的问题。
|
|
3718
5695
|
if (state.mode !== 'minimal') {
|
|
3719
5696
|
state.history = (state.history || []).concat([
|
|
3720
|
-
{ role: 'user', text: shown, timestamp: Date.now()
|
|
5697
|
+
{ role: 'user', text: shown, timestamp: Date.now(),
|
|
5698
|
+
// 完整模式把轨迹块插回对话原位要用:从轨迹中取当前轮号。
|
|
5699
|
+
// 手机本地回显时轮还没开,先留空;服务端快照补上来时会带上真轮号。
|
|
5700
|
+
turn: currentTrajectoryTurn() }
|
|
3721
5701
|
]);
|
|
3722
5702
|
render();
|
|
3723
5703
|
}
|
|
@@ -3735,22 +5715,160 @@
|
|
|
3735
5715
|
});
|
|
3736
5716
|
}
|
|
3737
5717
|
|
|
3738
|
-
|
|
5718
|
+
// ---------------- 统一操作面板(输入框旁边那一个入口) ----------------
|
|
5719
|
+
//
|
|
5720
|
+
// 形态是用户 2026-10-02 定的:输入框旁边**一个**入口,点开是一张底部抽屉,
|
|
5721
|
+
// 上面「添加文件」、下面「调用指令」。原来这两件事各有各的门——回形针直接开文件
|
|
5722
|
+
// 选择器、打 `/` 才弹指令菜单——手机上没人知道打一个斜杠还能调指令。
|
|
5723
|
+
//
|
|
5724
|
+
// 四条复用纪律(都不许另起一套):
|
|
5725
|
+
// · 外壳 = 设置抽屉那一套(#settings.open + .sheet),不新建样式体系;
|
|
5726
|
+
// · 指令数据 = state.commands 那份按会话缓存,与打 / 弹出来的菜单**同一份**,
|
|
5727
|
+
// 判据也共用 needCommands(),不写「面板专用」的第二套;
|
|
5728
|
+
// · 指令行 = 现成的 pickCommand(name, true),一次点击**不执行**;
|
|
5729
|
+
// · 附件 = state.uploads + renderAttach(),面板里那几行就是小条那几行。
|
|
5730
|
+
//
|
|
5731
|
+
// 「指令带附件一起发」这一轮**不做**(手机的上传小票 DSH 的指令接口不认,见 onCommand
|
|
5732
|
+
// 那段说明)——所以对声明了要附件的指令,面板上如实标一句「手机暂时带不了附件」,
|
|
5733
|
+
// 不假装支持。
|
|
5734
|
+
var ops = $('ops');
|
|
5735
|
+
// 面板是不是开着。指令表换一份时要按它决定要不要重画列表(见 paintCommandViews)。
|
|
5736
|
+
var opsOpen = false;
|
|
5737
|
+
|
|
5738
|
+
function openOps() {
|
|
5739
|
+
opsOpen = true;
|
|
5740
|
+
// 先把键盘收起来:这张抽屉贴着底边,键盘不收,它就被顶到看不见的地方。
|
|
5741
|
+
if (inputEl.blur) inputEl.blur();
|
|
5742
|
+
ops.classList.add('open');
|
|
5743
|
+
// 与打 / 时同一个判据:换了会话要重取,上次没取到过就再试一次。
|
|
5744
|
+
if (needCommands() || state.commandsError) loadCommands();
|
|
5745
|
+
paintOps();
|
|
5746
|
+
}
|
|
5747
|
+
|
|
5748
|
+
function closeOps() {
|
|
5749
|
+
opsOpen = false;
|
|
5750
|
+
ops.classList.remove('open');
|
|
5751
|
+
}
|
|
5752
|
+
|
|
5753
|
+
/**
|
|
5754
|
+
* 画面板里的指令列表。
|
|
5755
|
+
*
|
|
5756
|
+
* 和打 / 那个菜单同源(state.commands),但这里是「搜」不是「按前缀」:面板上那个框是
|
|
5757
|
+
* 一句话搜索框,名字和说明都匹配(桌面端也是名字和标题一起匹配)。顺手带个 `/c` 也算,
|
|
5758
|
+
* 那是用户平时打指令的手势。
|
|
5759
|
+
*
|
|
5760
|
+
* **取不到就说原因**,不显示成「一条都没有」——两件事,用户能做的也不同。
|
|
5761
|
+
*/
|
|
5762
|
+
function paintOps() {
|
|
5763
|
+
var listEl = $('opsCmds');
|
|
5764
|
+
if (!listEl) return;
|
|
5765
|
+
if (state.commandsError) {
|
|
5766
|
+
listEl.innerHTML = '<div class="cmd-note">' + escapeHtml(state.commandsError) + '</div>';
|
|
5767
|
+
return;
|
|
5768
|
+
}
|
|
5769
|
+
if (!state.commands) {
|
|
5770
|
+
// 取回来之前先占个位——不然打开面板是一片空白,看着像坏了。
|
|
5771
|
+
listEl.innerHTML = '<div class="cmd-note">正在读取可用的指令…</div>';
|
|
5772
|
+
return;
|
|
5773
|
+
}
|
|
5774
|
+
var filterEl = $('opsFilter');
|
|
5775
|
+
var q = String((filterEl && filterEl.value) || '').trim().toLowerCase();
|
|
5776
|
+
if (q.charAt(0) === '/') q = q.slice(1);
|
|
5777
|
+
var rows = state.commands.filter(function (c) {
|
|
5778
|
+
if (!q) return true;
|
|
5779
|
+
return (String(c.name) + ' ' + String(c.description || '')).toLowerCase().indexOf(q) >= 0;
|
|
5780
|
+
});
|
|
5781
|
+
if (!rows.length) {
|
|
5782
|
+
listEl.innerHTML = '<div class="cmd-note">没有对得上的指令。</div>';
|
|
5783
|
+
return;
|
|
5784
|
+
}
|
|
5785
|
+
listEl.innerHTML = rows.map(function (c) {
|
|
5786
|
+
return '<button class="cmd-row" type="button" data-cmd="' + escapeHtml(c.name) + '">'
|
|
5787
|
+
+ '<span class="cmd-top">'
|
|
5788
|
+
+ '<span class="cmd-name">/' + escapeHtml(c.name) + '</span>'
|
|
5789
|
+
+ '<span class="cmd-desc">' + escapeHtml(c.description || '') + '</span>'
|
|
5790
|
+
+ '</span>'
|
|
5791
|
+
+ (c.attachments ? '<span class="cmd-flag">手机暂时带不了附件</span>' : '')
|
|
5792
|
+
+ '</button>';
|
|
5793
|
+
}).join('');
|
|
5794
|
+
}
|
|
5795
|
+
|
|
5796
|
+
// 面板里点一行:**一次点击不执行**(deferRun)。要参数的把 hint 挂成占位提示、把
|
|
5797
|
+
// `/名字 ` 填进输入框;不要参数的也只填进去,等「发送」那一下——宁可多问一句,
|
|
5798
|
+
// 也不能点错一下就真跑掉。
|
|
5799
|
+
$('opsCmds').addEventListener('click', function (e) {
|
|
5800
|
+
var btn = e.target && e.target.closest ? e.target.closest('[data-cmd]') : null;
|
|
5801
|
+
if (!btn) return;
|
|
5802
|
+
closeOps();
|
|
5803
|
+
pickCommand(btn.getAttribute('data-cmd'), true);
|
|
5804
|
+
});
|
|
5805
|
+
|
|
5806
|
+
$('opsFilter').addEventListener('input', paintOps);
|
|
5807
|
+
$('opsClose').addEventListener('click', closeOps);
|
|
5808
|
+
// 点抽屉外面那一层 = 关掉(和设置抽屉同一条规矩)。
|
|
5809
|
+
ops.addEventListener('click', function (e) { if (e.target === ops) closeOps(); });
|
|
5810
|
+
|
|
5811
|
+
// 「添加文件」两件事:选文件、拍照。都走原来那个藏起来的 #filePick——手机上这两件事
|
|
5812
|
+
// 本来就是同一个系统选择器,区别只在「要不要限定图片、要不要调后置摄像头」。
|
|
5813
|
+
//
|
|
5814
|
+
// **多选只给「选文件」**(2026-10-05 用户要求:图库和文件里只能单选,改成多选)。
|
|
5815
|
+
// 拍照那一条不加 multiple:相机一次就出一张,摆个多选没有意义。
|
|
5816
|
+
function openFilePicker(camera) {
|
|
5817
|
+
var pick = $('filePick');
|
|
5818
|
+
if (!pick) return;
|
|
5819
|
+
if (camera) {
|
|
5820
|
+
pick.setAttribute('accept', 'image/*');
|
|
5821
|
+
pick.setAttribute('capture', 'environment');
|
|
5822
|
+
pick.removeAttribute('multiple');
|
|
5823
|
+
} else {
|
|
5824
|
+
pick.removeAttribute('accept');
|
|
5825
|
+
pick.removeAttribute('capture');
|
|
5826
|
+
pick.setAttribute('multiple', '');
|
|
5827
|
+
}
|
|
5828
|
+
pick.click();
|
|
5829
|
+
}
|
|
5830
|
+
$('opsPick').addEventListener('click', function () { openFilePicker(false); });
|
|
5831
|
+
$('opsShot').addEventListener('click', function () { openFilePicker(true); });
|
|
5832
|
+
|
|
5833
|
+
// 面板里那行已选文件上的 ✕。走的是和小条同一个 dropUpload()。
|
|
5834
|
+
$('opsFiles').addEventListener('click', function (e) {
|
|
5835
|
+
var btn = e.target && e.target.closest ? e.target.closest('[data-drop]') : null;
|
|
5836
|
+
if (!btn) return;
|
|
5837
|
+
dropUpload(btn);
|
|
5838
|
+
});
|
|
5839
|
+
|
|
5840
|
+
// 指令表换了一份(换会话、第一次取到)时,面板开着就跟着重画。
|
|
5841
|
+
onCommandsReady = function () { if (opsOpen) paintOps(); };
|
|
5842
|
+
|
|
5843
|
+
$('btnAttach').addEventListener('click', function () { openOps(); });
|
|
3739
5844
|
$('filePick').addEventListener('change', function (e) {
|
|
3740
|
-
|
|
5845
|
+
// 多选:一次可能挑了好几张,**一个接一个传**(2026-10-05 用户要求多选)。
|
|
5846
|
+
// 为什么不用并发:上传的「忙碌」是同一个开关(setAttachBusy 控制那个回形针),
|
|
5847
|
+
// 并发传的话最后一个传完就把按钮放开了,其实还有在飞的——用户会以为传完了。
|
|
5848
|
+
// 排队传则从头到尾都是忙的,什么时候能再点一目了然。
|
|
5849
|
+
var files = Array.prototype.slice.call(e.target.files || []);
|
|
3741
5850
|
// 清掉 value:不清的话,连着选同一个文件第二次不会触发 change,
|
|
3742
5851
|
// 用户会觉得「点了没反应」。
|
|
3743
5852
|
e.target.value = '';
|
|
3744
|
-
if (
|
|
5853
|
+
if (!files.length) return;
|
|
5854
|
+
// **选完就回主页面**(2026-10-05 用户要求:选完文件点完成,直接回到主页面,
|
|
5855
|
+
// 不要停在这张「添加文件」面板上)。
|
|
5856
|
+
// 关在这里是安全的:文件是**后台在传**的,主页面上的附件小条会一条条冒出来;
|
|
5857
|
+
// 面板里那几行「已选文件」本来也只是同一份数据的另一处画法(见 renderAttach),
|
|
5858
|
+
// 关掉面板不会打断上传。
|
|
5859
|
+
closeOps();
|
|
5860
|
+
files.reduce(function (chain, f) {
|
|
5861
|
+
return chain.then(function () { return uploadFile(f); });
|
|
5862
|
+
}, Promise.resolve());
|
|
3745
5863
|
});
|
|
3746
5864
|
$('attachBar').addEventListener('click', function (e) {
|
|
3747
5865
|
var btn = e.target.closest ? e.target.closest('[data-drop]') : null;
|
|
3748
5866
|
if (!btn) return;
|
|
3749
|
-
|
|
3750
|
-
renderAttach();
|
|
5867
|
+
dropUpload(btn);
|
|
3751
5868
|
});
|
|
3752
5869
|
|
|
3753
5870
|
$('btnSend').addEventListener('click', send);
|
|
5871
|
+
|
|
3754
5872
|
inputEl.addEventListener('input', autoGrow);
|
|
3755
5873
|
// 每敲一个字重算一次菜单。它自己会先看「是不是一个没打完的 / 名字」再决定动不动,
|
|
3756
5874
|
// 所以这里不用加判据。
|
|
@@ -3781,8 +5899,21 @@
|
|
|
3781
5899
|
$('btnSettings').addEventListener('click', function () {
|
|
3782
5900
|
sheet.classList.add('open');
|
|
3783
5901
|
$('connHint').textContent = state.connected ? '已连接' : '未连接';
|
|
5902
|
+
// 权限档位那块也在这儿重读一次:它显示的是「现在在哪一档」的实时状态(电脑端
|
|
5903
|
+
// 随时能改),而且启动时那一次要是没读成,不重读就整块再也不出现。
|
|
5904
|
+
loadPermissions(state.boundSessionId);
|
|
5905
|
+
// 版本号那行也现读一次:它只在设置页里看得见,没必要开机就为它跑一趟。
|
|
5906
|
+
// (watchBuild 是同一个 script 块里的函数声明,这里调得到。)
|
|
5907
|
+
watchBuild();
|
|
5908
|
+
// 繁忙时的发送行为也现读一次:**电脑上随时能改**,开机读那一次会过期。
|
|
5909
|
+
loadBusyEnter();
|
|
5910
|
+
// 进阶设置(电脑端界面)同样现读。它还会被电脑端从小标签关掉——
|
|
5911
|
+
// 手机上那份状态不能一直停在「开着」。
|
|
5912
|
+
loadMirror();
|
|
3784
5913
|
});
|
|
3785
5914
|
$('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
|
|
5915
|
+
// 顶部吸顶那个关闭口(2026-10-05 设置页改整屏时加的)。
|
|
5916
|
+
$('btnSettingsClose').addEventListener('click', function () { sheet.classList.remove('open'); });
|
|
3786
5917
|
sheet.addEventListener('click', function (e) { if (e.target === sheet) sheet.classList.remove('open'); });
|
|
3787
5918
|
|
|
3788
5919
|
// ---------------- 子智能体 ----------------
|
|
@@ -3796,6 +5927,11 @@
|
|
|
3796
5927
|
var saPanel = $('subagents');
|
|
3797
5928
|
var saRows = []; // 最近一次读到的清单,点行时按 id 找回来
|
|
3798
5929
|
var saCurrent = null; // 正在看运行记录的那一道;null = 现在在看清单
|
|
5930
|
+
var saCache = null;
|
|
5931
|
+
var saCacheSession = null;
|
|
5932
|
+
var saCacheAt = 0;
|
|
5933
|
+
var saInflight = null;
|
|
5934
|
+
var SA_CACHE_TTL = 5000;
|
|
3799
5935
|
|
|
3800
5936
|
function saToList() {
|
|
3801
5937
|
saCurrent = null;
|
|
@@ -3857,14 +5993,17 @@
|
|
|
3857
5993
|
|
|
3858
5994
|
function saRenderList(body) {
|
|
3859
5995
|
var listEl = $('subagentsList');
|
|
5996
|
+
// 这两句空态都落在 .sa-list 里,而那个容器用负 16px 把内容拽到贴屏边
|
|
5997
|
+
// (为了让分隔线横贯整屏)——所以得补上 sa-none-indent 把 16px 加回来,
|
|
5998
|
+
// 否则这行字会顶到屏幕最左边。这个类是早就写好的,之前没给空态用上。
|
|
3860
5999
|
if (!body || !body.ok) {
|
|
3861
|
-
listEl.innerHTML = '<div class="sa-none">'
|
|
6000
|
+
listEl.innerHTML = '<div class="sa-none sa-none-indent">'
|
|
3862
6001
|
+ escapeHtml((body && body.error) || '读不到子智能体。') + '</div>';
|
|
3863
6002
|
return;
|
|
3864
6003
|
}
|
|
3865
6004
|
saRows = body.subagents || [];
|
|
3866
6005
|
if (!saRows.length) {
|
|
3867
|
-
listEl.innerHTML = '<div class="sa-none">这个会话还没有派出过子智能体。</div>';
|
|
6006
|
+
listEl.innerHTML = '<div class="sa-none sa-none-indent">这个会话还没有派出过子智能体。</div>';
|
|
3868
6007
|
return;
|
|
3869
6008
|
}
|
|
3870
6009
|
listEl.innerHTML = saRows.map(saRowHtml).join('');
|
|
@@ -3876,11 +6015,48 @@
|
|
|
3876
6015
|
}
|
|
3877
6016
|
}
|
|
3878
6017
|
|
|
3879
|
-
function saLoad() {
|
|
6018
|
+
function saLoad(force) {
|
|
6019
|
+
var sessionId = state.boundSessionId || '';
|
|
6020
|
+
if (!force && saCache && saCacheSession === sessionId && Date.now() - saCacheAt < SA_CACHE_TTL) {
|
|
6021
|
+
saRenderList(saCache);
|
|
6022
|
+
return;
|
|
6023
|
+
}
|
|
6024
|
+
if (!force && saInflight && saInflight.sessionId === sessionId) return;
|
|
3880
6025
|
$('subagentsList').innerHTML = '<div class="sa-none sa-none-indent">正在读取…</div>';
|
|
3881
|
-
api('/mini/api/subagents')
|
|
3882
|
-
|
|
6026
|
+
var request = api('/mini/api/subagents');
|
|
6027
|
+
saInflight = { sessionId: sessionId, request: request };
|
|
6028
|
+
request.then(function (body) {
|
|
6029
|
+
if (state.boundSessionId === sessionId && body && body.ok) {
|
|
6030
|
+
saCache = body;
|
|
6031
|
+
saCacheSession = sessionId;
|
|
6032
|
+
saCacheAt = Date.now();
|
|
6033
|
+
}
|
|
6034
|
+
saRenderList(body);
|
|
6035
|
+
}).catch(function (err) {
|
|
6036
|
+
$('subagentsList').innerHTML = '<div class="sa-none sa-none-indent">'
|
|
3883
6037
|
+ escapeHtml(err && err.message || '没读出来。') + '</div>';
|
|
6038
|
+
}).finally(function () {
|
|
6039
|
+
if (saInflight && saInflight.request === request) saInflight = null;
|
|
6040
|
+
});
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
// 连接后趁页面空闲预取当前会话的子代理清单。首次读取可能很慢,不能把这段等待
|
|
6044
|
+
// 放到用户点入口之后;入口点击时会复用 saInflight,和导航工作区同一套策略。
|
|
6045
|
+
function prefetchSubagents() {
|
|
6046
|
+
var sessionId = state.boundSessionId || '';
|
|
6047
|
+
if (!sessionId || saCacheSession === sessionId || saInflight) return;
|
|
6048
|
+
var request = api('/mini/api/subagents');
|
|
6049
|
+
saInflight = { sessionId: sessionId, request: request };
|
|
6050
|
+
request.then(function (body) {
|
|
6051
|
+
if (state.boundSessionId !== sessionId || !body || !body.ok) return;
|
|
6052
|
+
saCache = body;
|
|
6053
|
+
saCacheSession = sessionId;
|
|
6054
|
+
saCacheAt = Date.now();
|
|
6055
|
+
if (saPanel.classList.contains('open') && !saCurrent) saRenderList(body);
|
|
6056
|
+
}).catch(function () {
|
|
6057
|
+
// 预取失败不影响入口;用户点开时仍会重试。
|
|
6058
|
+
}).finally(function () {
|
|
6059
|
+
if (saInflight && saInflight.request === request) saInflight = null;
|
|
3884
6060
|
});
|
|
3885
6061
|
}
|
|
3886
6062
|
|
|
@@ -4005,6 +6181,167 @@
|
|
|
4005
6181
|
saLoad();
|
|
4006
6182
|
});
|
|
4007
6183
|
$('subagentsClose').addEventListener('click', function () { saPanel.classList.remove('open'); });
|
|
6184
|
+
|
|
6185
|
+
// ---------------- 插件页面(2026-10-04) ----------------
|
|
6186
|
+
// 从设置里点「插件」→ 立刻盖到设置上面(#plugins 的 z-index 比 #settings 高一档);
|
|
6187
|
+
// 关掉它就回到设置,不用退出设置再去找。分层和两条留白见上面 #plugins 那段 CSS。
|
|
6188
|
+
var pluginsPanel = $('plugins');
|
|
6189
|
+
$('btnPlugins').addEventListener('click', function () {
|
|
6190
|
+
pluginsPanel.classList.add('open');
|
|
6191
|
+
loadPlugins();
|
|
6192
|
+
});
|
|
6193
|
+
$('pluginsClose').addEventListener('click', function () { pluginsPanel.classList.remove('open'); });
|
|
6194
|
+
// 点浮层空白处也关掉(和子智能体面板同一套)。
|
|
6195
|
+
pluginsPanel.addEventListener('click', function (e) {
|
|
6196
|
+
if (e.target === pluginsPanel) pluginsPanel.classList.remove('open');
|
|
6197
|
+
});
|
|
6198
|
+
|
|
6199
|
+
function loadPlugins() {
|
|
6200
|
+
var listEl = $('pluginsList');
|
|
6201
|
+
listEl.innerHTML = pluginNote('正在读取插件列表…');
|
|
6202
|
+
api('/mini/api/plugins').then(function (r) {
|
|
6203
|
+
listEl.innerHTML = renderPlugins(r || {});
|
|
6204
|
+
}).catch(function (err) {
|
|
6205
|
+
listEl.innerHTML = pluginNote('读不到插件列表:' + escapeHtml(err.message || '出错了'), true);
|
|
6206
|
+
});
|
|
6207
|
+
}
|
|
6208
|
+
|
|
6209
|
+
/**
|
|
6210
|
+
* 三块,和 PC 的「插件」页 + 「内置插件」页对齐:
|
|
6211
|
+
*
|
|
6212
|
+
* 官方 —— 能装还没装的(npm 包那一层)
|
|
6213
|
+
* 已安装 · 内置 —— **Cordis Loader 的实时条目表**,分「全局插件」和「会话插件」。
|
|
6214
|
+
* 这才是 DSH 的本体粒度:一整套系统是几百个插件模块拼起来的。
|
|
6215
|
+
* (2026-10-04 用户指出:内置插件拿 npm 包充数是不对的,那只有几个。)
|
|
6216
|
+
* 已安装 · 第三方 —— 用户自己装的包。DSH 自家的包不在这里列——它们在上面
|
|
6217
|
+
* 以「模块」的身份出现,那才是「一切皆插件」该有的样子。
|
|
6218
|
+
*/
|
|
6219
|
+
function renderPlugins(r) {
|
|
6220
|
+
var bundles = r.bundles || [];
|
|
6221
|
+
var official = bundles.filter(function (b) { return b.optional && !b.installed; });
|
|
6222
|
+
var third = bundles.filter(function (b) {
|
|
6223
|
+
return b.installed && b.name.indexOf('@deepseek-ai/') !== 0;
|
|
6224
|
+
});
|
|
6225
|
+
var entries = r.entries || [];
|
|
6226
|
+
var presets = r.presets || [];
|
|
6227
|
+
var sessionRows = 0;
|
|
6228
|
+
presets.forEach(function (p) { sessionRows += (p.rows || []).length; });
|
|
6229
|
+
|
|
6230
|
+
var html = '';
|
|
6231
|
+
if (r.bundlesError) html += pluginNote('包清单读不到:' + escapeHtml(r.bundlesError), true);
|
|
6232
|
+
if (r.officialError) html += pluginNote('官方卡片读不到:' + escapeHtml(r.officialError), true);
|
|
6233
|
+
// 官方那一栏是两样拼的(PC 也是这个次序):可开关的官方包在前,
|
|
6234
|
+
// 注册了设置卡片的官方插件(终端 / Agent 循环 / 子智能体 / 网页搜索)在后。
|
|
6235
|
+
html += pluginGroup('官方', official.concat(r.official || []), officialRow);
|
|
6236
|
+
|
|
6237
|
+
if (r.builtinError) {
|
|
6238
|
+
html += pluginNote('内置插件读不到:' + escapeHtml(r.builtinError), true);
|
|
6239
|
+
} else {
|
|
6240
|
+
// 内置这一层自己也是一个可折的分组,里面再套「全局插件」「会话插件」两层。
|
|
6241
|
+
var inner = '';
|
|
6242
|
+
if (entries.length) inner += pluginGroup('全局插件', entries, invRow, 2);
|
|
6243
|
+
if (sessionRows) {
|
|
6244
|
+
// 这一层的数目是**四个预设各自行数之和**:同一个插件被好几个预设都带上,
|
|
6245
|
+
// 所以会重复计(实测共 95 行、其实只有 32 个不重复的插件)。
|
|
6246
|
+
// 这里**不再拿括号去解释**(2026-10-05 用户要那句去掉)——下面每个预设
|
|
6247
|
+
// 各报各的数(29/30/6/30),加起来就是这个数,顺着看下去就能对上。
|
|
6248
|
+
var sessionInner = '';
|
|
6249
|
+
presets.forEach(function (p) {
|
|
6250
|
+
var rows = p.rows || [];
|
|
6251
|
+
if (!rows.length) return;
|
|
6252
|
+
if (p.broken) sessionInner += pluginNote('这个预设的组合读不出来:' + escapeHtml(p.broken), true);
|
|
6253
|
+
var body = rows.map(invRow).join('');
|
|
6254
|
+
// 只有一个预设就不必再套一层;多预设才把预设名露出来(PC 那边是个下拉)。
|
|
6255
|
+
// 「(默认)」照 PC 标上:它说的是「不指定模式时按它来」,用户得有这个参照。
|
|
6256
|
+
sessionInner += presets.length > 1
|
|
6257
|
+
? pluginFold(p.name + (p.isDefault ? '(默认)' : ''), rows.length, body, 2)
|
|
6258
|
+
: body;
|
|
6259
|
+
});
|
|
6260
|
+
inner += pluginFold('会话插件', sessionRows, sessionInner, 2);
|
|
6261
|
+
}
|
|
6262
|
+
html += inner
|
|
6263
|
+
? pluginFold('已安装 · 内置', entries.length + sessionRows, inner, 1)
|
|
6264
|
+
: pluginNote('这台电脑没报出内置插件。');
|
|
6265
|
+
}
|
|
6266
|
+
|
|
6267
|
+
html += pluginGroup('已安装 · 第三方', third, bundleRow);
|
|
6268
|
+
if (!html) html = pluginNote('没有插件。');
|
|
6269
|
+
return html;
|
|
6270
|
+
}
|
|
6271
|
+
|
|
6272
|
+
function pluginNote(text, isErr) {
|
|
6273
|
+
return '<div class="' + (isErr ? 'plugin-note' : 'sa-none sa-none-indent') + '">' + text + '</div>';
|
|
6274
|
+
}
|
|
6275
|
+
|
|
6276
|
+
/**
|
|
6277
|
+
* 一层**可折叠**的分组。默认收起——HTML 里不写 open 属性就自然是收着的,
|
|
6278
|
+
* 「默认折叠」不需要额外的代码去保证。没有正文的空层整个不画(点开是个空壳没意义)。
|
|
6279
|
+
*
|
|
6280
|
+
* `depth` 只影响字号轻重(1 = 官方 / 已安装那一级,2 = 里面那两级)。
|
|
6281
|
+
* `note` 是接在计数后面的小注(比如「32 个不重复,4 个预设各算各的」)。
|
|
6282
|
+
*/
|
|
6283
|
+
function pluginFold(title, count, bodyHtml, depth, note) {
|
|
6284
|
+
if (!bodyHtml) return '';
|
|
6285
|
+
return '<details class="plugin-fold depth-' + (depth || 1) + '"><summary>'
|
|
6286
|
+
+ escapeHtml(title) + ' ' + count + (note ? '(' + escapeHtml(note) + ')' : '')
|
|
6287
|
+
+ '</summary>' + bodyHtml + '</details>';
|
|
6288
|
+
}
|
|
6289
|
+
|
|
6290
|
+
/** 一堆插件:可折的一层。空的那堆整个不画(不占地方、也不说假话)。 */
|
|
6291
|
+
function pluginGroup(title, rows, rowFn, depth) {
|
|
6292
|
+
if (!rows.length) return '';
|
|
6293
|
+
return pluginFold(title, rows.length, rows.map(rowFn).join(''), depth);
|
|
6294
|
+
}
|
|
6295
|
+
|
|
6296
|
+
function rowHtml(name, desc, badge, descCls) {
|
|
6297
|
+
return '<div class="plugin-row">'
|
|
6298
|
+
+ '<div class="plugin-info">'
|
|
6299
|
+
+ '<span class="plugin-name">' + escapeHtml(name) + '</span>'
|
|
6300
|
+
+ (desc ? '<span class="plugin-desc' + (descCls || '') + '">' + escapeHtml(desc) + '</span>' : '')
|
|
6301
|
+
+ '</div>'
|
|
6302
|
+
+ (badge ? '<span class="plugin-badge ' + badge[0] + '">' + escapeHtml(badge[1]) + '</span>' : '')
|
|
6303
|
+
+ '</div>';
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
/**
|
|
6307
|
+
* 一行「包」(官方 / 第三方)。显示名用服务端给的 `title`(本地化标题),
|
|
6308
|
+
* 没有就是包名——官方插件在 PC 上显示的正是「智能体团队」这类中文名。
|
|
6309
|
+
*/
|
|
6310
|
+
function bundleRow(b) {
|
|
6311
|
+
var desc = b.description || '';
|
|
6312
|
+
if (b.problem) desc = desc ? desc + ' · ' + b.problem : b.problem;
|
|
6313
|
+
return rowHtml(b.title || b.name, desc,
|
|
6314
|
+
[b.enabled ? 'on' : 'off', b.enabled ? '已启用' : '已停用'],
|
|
6315
|
+
b.problem ? ' plugin-problem' : '');
|
|
6316
|
+
}
|
|
6317
|
+
|
|
6318
|
+
/**
|
|
6319
|
+
* 官方那一栏的一行:两种东西都在这里。
|
|
6320
|
+
* · 包(bundles)——带启用状态,能开关;
|
|
6321
|
+
* · 设置卡片(cards)——不是开关,是一张设置页,所以没有状态徽标(PC 上也是这样)。
|
|
6322
|
+
*/
|
|
6323
|
+
function officialRow(item) {
|
|
6324
|
+
if (item.kind === 'card') return rowHtml(item.title, item.description || '', null);
|
|
6325
|
+
return bundleRow(item);
|
|
6326
|
+
}
|
|
6327
|
+
|
|
6328
|
+
/**
|
|
6329
|
+
* 一行「内置插件」(Loader 条目 / 预设组合行)。`title` 是服务端压好的短名
|
|
6330
|
+
* (`@deepseek-ai/dsh-tool-fs` → `tool-fs`),照 PC 的内置插件面板。
|
|
6331
|
+
*/
|
|
6332
|
+
function invRow(e) {
|
|
6333
|
+
return rowHtml(e.title || e.moduleName, e.description || '', invBadge(e));
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6336
|
+
/** 运行时状态。**问不到就不编**——预设没挂载时那一行本来就没有运行状态。 */
|
|
6337
|
+
function invBadge(e) {
|
|
6338
|
+
if (e.phase === 'failed') return ['err', '失败'];
|
|
6339
|
+
if (e.conditional) return ['off', '按条件'];
|
|
6340
|
+
if (!e.enabled) return ['off', '已停用'];
|
|
6341
|
+
if (e.phase === 'active') return ['on', '运行中'];
|
|
6342
|
+
if (e.phase === 'loading' || e.phase === 'pending') return ['off', '加载中'];
|
|
6343
|
+
return null;
|
|
6344
|
+
}
|
|
4008
6345
|
$('subagentsBack').addEventListener('click', saToList);
|
|
4009
6346
|
saPanel.addEventListener('click', function (e) {
|
|
4010
6347
|
if (e.target === saPanel) saPanel.classList.remove('open');
|
|
@@ -4027,17 +6364,53 @@
|
|
|
4027
6364
|
// 映射不到时会退回标识符本身,所以这里拿到的一定是个真名字,不会是空格。
|
|
4028
6365
|
// 页面只管显示拿到的东西。
|
|
4029
6366
|
var permOptions = null; // null = 还没读到(或这台电脑没这个能力)
|
|
6367
|
+
var permFor = null; // 手上这一份 currentValue 是**哪个会话**的
|
|
4030
6368
|
var permArmed = ''; // 哪一档正在等「再点一次」
|
|
4031
6369
|
|
|
4032
|
-
|
|
6370
|
+
/**
|
|
6371
|
+
* 读一次档位盘。
|
|
6372
|
+
*
|
|
6373
|
+
* **这是个要反复读的动作,不是启动时读一次就够。** 三个理由:
|
|
6374
|
+
* · 答案会变——电脑端随时能改档位,而这一块的作用就是「标出现在是哪一档」;
|
|
6375
|
+
* · 一次读不到就会永远缺这一块——宿主刚重启、绑定会话还没装进 `ctx.agents`
|
|
6376
|
+
* 那段时间里,服务端如实回 `no-session`(见 lib/permissions.js 的 listPresets)。
|
|
6377
|
+
* 启动时那一次正好撞上,不重读就再也回不来,只有整页刷新能让它出现。
|
|
6378
|
+
* 所以设置抽屉每次打开都重读(见上面「设置」那一段);
|
|
6379
|
+
* · **`currentValue` 是每个会话各一份的**(宿主的 `permission/preset` 记在会话自己
|
|
6380
|
+
* 的日志里,`service.current(session)` 读的是这个会话那份投影)。换了会话就不能
|
|
6381
|
+
* 拿上一个会话那一档接着显示——那是在说一个假话。所以换会话也要重读
|
|
6382
|
+
* (见 applySnapshot 里换会话那一段)。
|
|
6383
|
+
*
|
|
6384
|
+
* 传参就是「按这个会话读」;不传是「还是这个会话,重读一次」。
|
|
6385
|
+
*
|
|
6386
|
+
* 读不到时**只在这块本来就空着的情况下才藏起来**:已经画出来的档位不许因为一次
|
|
6387
|
+
* 读失败当场消失——那是把「此刻读不到」错当成「这台电脑没有这个能力」。
|
|
6388
|
+
* 换了会话则相反:那一份高亮**必须**当场撤掉,因为它不是这个会话的值。
|
|
6389
|
+
* 档位列表本身留着不动——那是宿主的本子,所有会话共用一份。
|
|
6390
|
+
*/
|
|
6391
|
+
function loadPermissions(forSession) {
|
|
6392
|
+
var session = forSession === undefined ? permFor : (forSession || null);
|
|
6393
|
+
if (session !== permFor) {
|
|
6394
|
+
permFor = session;
|
|
6395
|
+
permArmed = ''; // 「再点一次」那半次确认也是上一个会话的,不跟着走
|
|
6396
|
+
if (permOptions) {
|
|
6397
|
+
permOptions.currentValue = '';
|
|
6398
|
+
renderPerms();
|
|
6399
|
+
}
|
|
6400
|
+
}
|
|
4033
6401
|
api('/mini/api/permissions').then(function (body) {
|
|
4034
|
-
|
|
6402
|
+
// 请求飞在路上时用户又切走了:这一份属于上一个会话,扔掉。
|
|
6403
|
+
if (session !== permFor) return;
|
|
6404
|
+
if (!body || !body.ok) {
|
|
6405
|
+
if (!permOptions) $('permBlock').hidden = true;
|
|
6406
|
+
return;
|
|
6407
|
+
}
|
|
4035
6408
|
permOptions = body;
|
|
4036
6409
|
renderPerms();
|
|
4037
6410
|
}).catch(function () {
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
$('permBlock').hidden = true;
|
|
6411
|
+
if (session !== permFor) return;
|
|
6412
|
+
// 附加能力缺了不该影响发指令:一个档位都没读到过时,整块不显示。
|
|
6413
|
+
if (!permOptions) $('permBlock').hidden = true;
|
|
4041
6414
|
});
|
|
4042
6415
|
}
|
|
4043
6416
|
|
|
@@ -4104,7 +6477,7 @@
|
|
|
4104
6477
|
});
|
|
4105
6478
|
}
|
|
4106
6479
|
|
|
4107
|
-
loadPermissions();
|
|
6480
|
+
loadPermissions(state.boundSessionId);
|
|
4108
6481
|
|
|
4109
6482
|
// 档位按钮的点击。
|
|
4110
6483
|
//
|
|
@@ -4125,10 +6498,12 @@
|
|
|
4125
6498
|
Array.prototype.forEach.call($('segMode').children, function (btn) {
|
|
4126
6499
|
btn.addEventListener('click', function () {
|
|
4127
6500
|
state.mode = btn.dataset.mode;
|
|
4128
|
-
|
|
6501
|
+
$('hintMode').textContent = modeHint();
|
|
4129
6502
|
localStorage.setItem('dshMiniMode', state.mode);
|
|
4130
6503
|
syncSeg();
|
|
4131
6504
|
render();
|
|
6505
|
+
// 切到「完整」:订上并把已有的过程拉下来;切走:退订、停推。
|
|
6506
|
+
syncTrajectorySubscription();
|
|
4132
6507
|
});
|
|
4133
6508
|
});
|
|
4134
6509
|
Array.prototype.forEach.call($('segSound').children, function (btn) {
|
|
@@ -4172,6 +6547,21 @@
|
|
|
4172
6547
|
}
|
|
4173
6548
|
applyTheme(); // 补上首屏那次没做的一件事:把状态栏颜色对齐
|
|
4174
6549
|
|
|
6550
|
+
// ---------------- 字体大小(小 / 标准 / 大) ----------------
|
|
6551
|
+
function applyFontSize() {
|
|
6552
|
+
var sizes = { small: '13px', normal: '15.5px', large: '18px' };
|
|
6553
|
+
document.documentElement.style.setProperty('--font-base', sizes[state.fontSize] || sizes.normal);
|
|
6554
|
+
}
|
|
6555
|
+
Array.prototype.forEach.call($('segFont').children, function (btn) {
|
|
6556
|
+
btn.addEventListener('click', function () {
|
|
6557
|
+
state.fontSize = btn.dataset.font;
|
|
6558
|
+
localStorage.setItem('dshMiniFont', state.fontSize);
|
|
6559
|
+
applyFontSize();
|
|
6560
|
+
syncSeg();
|
|
6561
|
+
});
|
|
6562
|
+
});
|
|
6563
|
+
applyFontSize(); // 首屏应用
|
|
6564
|
+
|
|
4175
6565
|
function syncSeg() {
|
|
4176
6566
|
Array.prototype.forEach.call($('segMode').children, function (b) {
|
|
4177
6567
|
b.classList.toggle('active', b.dataset.mode === state.mode);
|
|
@@ -4182,6 +6572,9 @@
|
|
|
4182
6572
|
Array.prototype.forEach.call($('segTheme').children, function (b) {
|
|
4183
6573
|
b.classList.toggle('active', b.dataset.theme === state.theme);
|
|
4184
6574
|
});
|
|
6575
|
+
Array.prototype.forEach.call($('segFont').children, function (b) {
|
|
6576
|
+
b.classList.toggle('active', b.dataset.font === state.fontSize);
|
|
6577
|
+
});
|
|
4185
6578
|
}
|
|
4186
6579
|
|
|
4187
6580
|
// 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
|
|
@@ -4230,6 +6623,247 @@
|
|
|
4230
6623
|
});
|
|
4231
6624
|
}
|
|
4232
6625
|
|
|
6626
|
+
/**
|
|
6627
|
+
* 繁忙时的发送行为(2026-10-05 用户要求搬进手机设置)。
|
|
6628
|
+
*
|
|
6629
|
+
* 这是 PC「设置 → 通用」里那一条的同款:智能体跑着的时候,你发的话是
|
|
6630
|
+
* 排队等这一轮跑完,还是当场插进去让它调整方向。**两端共用宿主那一份设置**,
|
|
6631
|
+
* 所以这里改完,电脑上也是这个值——这是用户拍板要的(同一个开关,别两端各存一份)。
|
|
6632
|
+
*
|
|
6633
|
+
* **读不到就整条不画**:拿不到宿主设置时把那一行藏起来,不摆一个点了没用的开关。
|
|
6634
|
+
* 这和插件页「宁可少画,不凭空多画」是同一条口径。
|
|
6635
|
+
*
|
|
6636
|
+
* 写的时候带上服务端给的 revision(乐观锁):万一电脑上刚改过,这次写入会被挡下来,
|
|
6637
|
+
* 而不是把对方那次改动悄悄盖掉——那种覆盖用户看不见,最难查。
|
|
6638
|
+
*/
|
|
6639
|
+
var busyEnter = { current: 'queue', revision: null, ready: false };
|
|
6640
|
+
function paintBusyEnter() {
|
|
6641
|
+
Array.prototype.forEach.call($('segBusyEnter').children, function (b) {
|
|
6642
|
+
b.classList.toggle('active', b.dataset.busy === busyEnter.current);
|
|
6643
|
+
});
|
|
6644
|
+
}
|
|
6645
|
+
function loadBusyEnter() {
|
|
6646
|
+
api('/mini/api/busy-enter').then(function (r) {
|
|
6647
|
+
if (!r || !r.ok) {
|
|
6648
|
+
// 这台电脑没提供这个设置(老版本 DSH、或设置文档里没这一项)。
|
|
6649
|
+
// 藏起来就好,不用报错——那不是「出错」,是「这里本来就没有」。
|
|
6650
|
+
$('rowBusyEnter').hidden = true;
|
|
6651
|
+
return;
|
|
6652
|
+
}
|
|
6653
|
+
busyEnter.current = r.current === 'steer' ? 'steer' : 'queue';
|
|
6654
|
+
busyEnter.revision = typeof r.revision === 'number' ? r.revision : null;
|
|
6655
|
+
busyEnter.ready = true;
|
|
6656
|
+
$('rowBusyEnter').hidden = false;
|
|
6657
|
+
paintBusyEnter();
|
|
6658
|
+
}).catch(function () {
|
|
6659
|
+
$('rowBusyEnter').hidden = true;
|
|
6660
|
+
});
|
|
6661
|
+
}
|
|
6662
|
+
$('segBusyEnter').addEventListener('click', function (e) {
|
|
6663
|
+
var btn = e.target.closest ? e.target.closest('button[data-busy]') : null;
|
|
6664
|
+
if (!btn || !busyEnter.ready) return;
|
|
6665
|
+
var next = btn.dataset.busy;
|
|
6666
|
+
if (next === busyEnter.current) return;
|
|
6667
|
+
// 先画再发:这一下是用户自己按的,立刻有反馈;发失败了再画回去并说清。
|
|
6668
|
+
var prev = busyEnter.current;
|
|
6669
|
+
busyEnter.current = next;
|
|
6670
|
+
paintBusyEnter();
|
|
6671
|
+
api('/mini/api/busy-enter', {
|
|
6672
|
+
method: 'POST',
|
|
6673
|
+
body: JSON.stringify({ value: next }),
|
|
6674
|
+
}).then(function (r) {
|
|
6675
|
+
if (r && r.ok) {
|
|
6676
|
+
toast(r.current === 'steer' ? '繁忙时改为插话发送' : '繁忙时改为排队发送');
|
|
6677
|
+
busyEnter.revision = typeof r.revision === 'number' ? r.revision : busyEnter.revision;
|
|
6678
|
+
return;
|
|
6679
|
+
}
|
|
6680
|
+
// 失败要画回去——留着那个没生效的选中态,用户会以为改成功了。
|
|
6681
|
+
busyEnter.current = prev;
|
|
6682
|
+
paintBusyEnter();
|
|
6683
|
+
toast(r && r.reason === 'unavailable' ? '这台电脑改不了这一项' : '没能改成,再试一次');
|
|
6684
|
+
}).catch(function () {
|
|
6685
|
+
busyEnter.current = prev;
|
|
6686
|
+
paintBusyEnter();
|
|
6687
|
+
toast('没能改成,检查一下连接');
|
|
6688
|
+
});
|
|
6689
|
+
});
|
|
6690
|
+
|
|
6691
|
+
/**
|
|
6692
|
+
* 「进阶设置(电脑端界面)」(2026-10-06 用户要求)。
|
|
6693
|
+
*
|
|
6694
|
+
* 手机上这张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
|
|
6695
|
+
* 这一项是退路:**把电脑端的界面整个搬过来看**——同一份界面,不迁移、不逐个适配。
|
|
6696
|
+
* 服务端怎么把这个界面转出来的,写在 lib/mirror.js 开头。
|
|
6697
|
+
*
|
|
6698
|
+
* **它是个权限开关,不是普通偏好。** 打开它 = 这个令牌的权限从「我们这张手机页
|
|
6699
|
+
* 暴露的那点事」扩到整个电脑端设置面(模型配置、凭据页、插件装卸、会话记录……)。
|
|
6700
|
+
* 所以三件事一个都不能省:
|
|
6701
|
+
* ① 默认关;
|
|
6702
|
+
* ② 后果写在提示里,而且要等**第二次点击**才真开(`armed`)——照「完全权限」
|
|
6703
|
+
* 那一档的规矩,理由也一样(页内两步,不用 window.confirm:那种弹窗在微信
|
|
6704
|
+
* 这类内嵌浏览器里会被直接屏蔽,屏蔽时万一返回真值就等于确认被跳过);
|
|
6705
|
+
* ③ 关着的时候**入口那一行也不显示**——不是露一个点了没用的按钮。
|
|
6706
|
+
*
|
|
6707
|
+
* 读不到就整块不画:这台电脑没这个能力时(没拿到 DSH_WEB_URL),
|
|
6708
|
+
* 藏起来就好,不摆一个假开关。
|
|
6709
|
+
*/
|
|
6710
|
+
var mirror = { available: false, enabled: false, armed: false, ready: false };
|
|
6711
|
+
|
|
6712
|
+
function paintMirror() {
|
|
6713
|
+
$('rowMirror').hidden = !mirror.available;
|
|
6714
|
+
if (!mirror.available) {
|
|
6715
|
+
$('rowMirrorOpen').hidden = true;
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
6718
|
+
Array.prototype.forEach.call($('segMirror').children, function (b) {
|
|
6719
|
+
b.classList.toggle('active', (b.dataset.mirror === 'on') === mirror.enabled);
|
|
6720
|
+
});
|
|
6721
|
+
$('mirrorHint').textContent = mirror.enabled
|
|
6722
|
+
? '已打开。手机现在能进整个电脑设置面'
|
|
6723
|
+
: mirror.armed
|
|
6724
|
+
? '打开的后果:手机将获得完整的电脑设置权限。再点一次「开」确认'
|
|
6725
|
+
: '打开电脑上的 DSH 界面,改那些手机上没有的设置';
|
|
6726
|
+
$('rowMirrorOpen').hidden = !mirror.enabled;
|
|
6727
|
+
}
|
|
6728
|
+
|
|
6729
|
+
/**
|
|
6730
|
+
* 镜像入口那个地址。
|
|
6731
|
+
*
|
|
6732
|
+
* **尾斜杠不能省**:那个界面外壳写着 `<base href="./">`,在 `/mini/mirror` 上它
|
|
6733
|
+
* 解析成 `/mini/`,在 `/mini/mirror/` 上才是 `/mini/mirror/`——差这一个字符,
|
|
6734
|
+
* 里面引用的脚本会全 404、界面白屏(真浏览器验出来的)。
|
|
6735
|
+
*
|
|
6736
|
+
* 地址里带 token:框里那些请求不一定都带得上 cookie(这个框是新开的文档),
|
|
6737
|
+
* 入口那一下顺手把 cookie 写上,之后就靠 cookie 认人了。
|
|
6738
|
+
*/
|
|
6739
|
+
function mirrorUrl() {
|
|
6740
|
+
return '/mini/mirror/?token=' + encodeURIComponent(state.token || '');
|
|
6741
|
+
}
|
|
6742
|
+
|
|
6743
|
+
/**
|
|
6744
|
+
* 点「打开」:把那一整屏显示出来,并**在那一刻**才给框设地址(没点之前不加载)。
|
|
6745
|
+
*
|
|
6746
|
+
* 这一版为什么能成:**没有跳转、没有新标签、没有弹窗**——浏览器没有任何东西可以拦。
|
|
6747
|
+
* 前两版都在赌「跳转」这个动作(详见页面里 `#mirrorView` 上面那段注释)。
|
|
6748
|
+
*/
|
|
6749
|
+
function openMirror() {
|
|
6750
|
+
if (!mirror.enabled) return;
|
|
6751
|
+
// **先给一句提示,再显示那一屏。**
|
|
6752
|
+
//
|
|
6753
|
+
// 这一句不是装饰,是**诊断**(2026-10-06 用户三次报「点了没反应」之后加的):
|
|
6754
|
+
// 三种情况从此分得开——看到提示且界面出来=成了;只看到提示=显示那一步的问题;
|
|
6755
|
+
// 连提示都没有=点击压根没到按钮上。前三次就是分不开这三种,才一路猜错。
|
|
6756
|
+
toast('正在打开电脑端界面…');
|
|
6757
|
+
// **顺序要紧:先把这一层显示出来,再给框设地址。**
|
|
6758
|
+
//
|
|
6759
|
+
// 反过来的话,那个界面是**在 `display:none` 里开始加载的**——在不可见的上下文里
|
|
6760
|
+
// 渲染出来的东西,要等用户点一下触发重绘才画全(真机上就是「图标旁边的文字
|
|
6761
|
+
// 点一下才出来」)。2026-10-06 查出:**直接打开镜像没有这个毛病**,
|
|
6762
|
+
// 差别就在这两行的先后。
|
|
6763
|
+
$('mirrorView').hidden = false;
|
|
6764
|
+
$('mirrorFrame').src = mirrorUrl();
|
|
6765
|
+
}
|
|
6766
|
+
|
|
6767
|
+
function closeMirror() {
|
|
6768
|
+
$('mirrorView').hidden = true;
|
|
6769
|
+
// 把地址清掉:那一屏里是个完整应用(还在跑、还连着),关掉就该真停,
|
|
6770
|
+
// 而不是留在后台继续占内存和连接。
|
|
6771
|
+
$('mirrorFrame').src = 'about:blank';
|
|
6772
|
+
}
|
|
6773
|
+
|
|
6774
|
+
// 按钮和**整行**都绑:那个按钮只有 54×40,手指容易点偏;整行点下去都算数。
|
|
6775
|
+
$('btnMirrorOpen').addEventListener('click', openMirror);
|
|
6776
|
+
$('rowMirrorOpen').addEventListener('click', function (e) {
|
|
6777
|
+
// 点在按钮上时上面那个监听已经处理了,别处理两遍。
|
|
6778
|
+
if (e.target === $('btnMirrorOpen')) return;
|
|
6779
|
+
openMirror();
|
|
6780
|
+
});
|
|
6781
|
+
$('mirrorClose').addEventListener('click', closeMirror);
|
|
6782
|
+
|
|
6783
|
+
/**
|
|
6784
|
+
* 框里那一页自己发的「返回手机页」。
|
|
6785
|
+
*
|
|
6786
|
+
* **为什么需要**:镜像那一屏里,框中的文档正常情况下是电脑端界面(它有自己的退出方式),
|
|
6787
|
+
* 但**打不开的时候**它是我们自己发的一页提示(见 lib/server.js 的 sendMirrorUnavailable)
|
|
6788
|
+
* ——那一页里除了浏览器自己的返回,没有别的出口,用户会卡在一个提示页上。
|
|
6789
|
+
* 所以那一页的「返回」按钮往这里发一条消息,我们把这整屏收起来。
|
|
6790
|
+
*
|
|
6791
|
+
* 只认这一条字符串,且只认同源:这个监听**只做「收屏」这一件事**,别的消息一概不理。
|
|
6792
|
+
*/
|
|
6793
|
+
window.addEventListener('message', function (e) {
|
|
6794
|
+
if (e.origin === location.origin && e.data === 'mini-mirror-close') closeMirror();
|
|
6795
|
+
});
|
|
6796
|
+
|
|
6797
|
+
function loadMirror() {
|
|
6798
|
+
api('/mini/api/mirror').then(function (r) {
|
|
6799
|
+
if (!r || !r.ok || !r.available) {
|
|
6800
|
+
mirror.available = false;
|
|
6801
|
+
paintMirror();
|
|
6802
|
+
return;
|
|
6803
|
+
}
|
|
6804
|
+
mirror.available = true;
|
|
6805
|
+
mirror.enabled = r.enabled === true;
|
|
6806
|
+
mirror.ready = true;
|
|
6807
|
+
paintMirror();
|
|
6808
|
+
}).catch(function () {
|
|
6809
|
+
mirror.available = false;
|
|
6810
|
+
mirror.armed = false;
|
|
6811
|
+
paintMirror();
|
|
6812
|
+
});
|
|
6813
|
+
}
|
|
6814
|
+
|
|
6815
|
+
function tapMirror(next) {
|
|
6816
|
+
if (!mirror.ready) return;
|
|
6817
|
+
if (next === 'off') {
|
|
6818
|
+
// 关掉不需要确认:往「更安全」的方向走,不用拦。
|
|
6819
|
+
mirror.armed = false;
|
|
6820
|
+
if (!mirror.enabled) { paintMirror(); return; }
|
|
6821
|
+
mirror.enabled = false;
|
|
6822
|
+
paintMirror();
|
|
6823
|
+
api('/mini/api/mirror', { method: 'POST', body: JSON.stringify({ enabled: false }) })
|
|
6824
|
+
.then(function (r) {
|
|
6825
|
+
if (r && r.ok) { toast('已关掉电脑端界面'); return; }
|
|
6826
|
+
mirror.enabled = true;
|
|
6827
|
+
paintMirror();
|
|
6828
|
+
toast('没能关掉,再试一次');
|
|
6829
|
+
})
|
|
6830
|
+
.catch(function () {
|
|
6831
|
+
mirror.enabled = true;
|
|
6832
|
+
paintMirror();
|
|
6833
|
+
toast('没能关掉,检查一下连接');
|
|
6834
|
+
});
|
|
6835
|
+
return;
|
|
6836
|
+
}
|
|
6837
|
+
// 开:第一次点只是「举起来」,把后果摆出来;再点一次才真开。
|
|
6838
|
+
if (!mirror.enabled && !mirror.armed) {
|
|
6839
|
+
mirror.armed = true;
|
|
6840
|
+
paintMirror();
|
|
6841
|
+
return;
|
|
6842
|
+
}
|
|
6843
|
+
if (mirror.enabled) return;
|
|
6844
|
+
mirror.armed = false;
|
|
6845
|
+
mirror.enabled = true;
|
|
6846
|
+
paintMirror();
|
|
6847
|
+
api('/mini/api/mirror', { method: 'POST', body: JSON.stringify({ enabled: true }) })
|
|
6848
|
+
.then(function (r) {
|
|
6849
|
+
if (r && r.ok) { toast('已打开,点下面的「打开」进去'); return; }
|
|
6850
|
+
mirror.enabled = false;
|
|
6851
|
+
paintMirror();
|
|
6852
|
+
toast(r && r.reason === 'unavailable' ? '这台电脑没有这个能力' : '没能打开,再试一次');
|
|
6853
|
+
})
|
|
6854
|
+
.catch(function () {
|
|
6855
|
+
mirror.enabled = false;
|
|
6856
|
+
paintMirror();
|
|
6857
|
+
toast('没能打开,检查一下连接');
|
|
6858
|
+
});
|
|
6859
|
+
}
|
|
6860
|
+
|
|
6861
|
+
$('segMirror').addEventListener('click', function (e) {
|
|
6862
|
+
var btn = e.target.closest ? e.target.closest('button[data-mirror]') : null;
|
|
6863
|
+
if (!btn) return;
|
|
6864
|
+
tapMirror(btn.dataset.mirror);
|
|
6865
|
+
});
|
|
6866
|
+
|
|
4233
6867
|
if (NOTIFY_OK) {
|
|
4234
6868
|
// 先注册 service worker。**这一步不申请权限**——权限要用户主动点一下才给,
|
|
4235
6869
|
// 页面一加载就弹窗会被浏览器直接拒掉,而且很讨厌。
|
|
@@ -4405,6 +7039,14 @@
|
|
|
4405
7039
|
function watchBuild() {
|
|
4406
7040
|
if (!state.token) return;
|
|
4407
7041
|
api('/mini/api/version').then(function (r) {
|
|
7042
|
+
// 设置页底部那行版本。**问到才显示**——问不到就整行不出现,
|
|
7043
|
+
// 不拿「unknown」糊一行上去(那个字对用户没意义,还像页面坏了)。
|
|
7044
|
+
// 产品名写全称(DeepSeek Harness):缩写 DSH 只有我们自己人认。
|
|
7045
|
+
// 大小写照 DSH 自己的界面串(实测 49 处全是这个写法)。
|
|
7046
|
+
if (r && r.dshVersion && r.dshVersion !== 'unknown') {
|
|
7047
|
+
var el = $('dshVersion');
|
|
7048
|
+
if (el) { el.textContent = 'DeepSeek Harness ' + r.dshVersion; el.hidden = false; }
|
|
7049
|
+
}
|
|
4408
7050
|
if (!r || !r.build || r.build === BUILD) return;
|
|
4409
7051
|
if (inputEl.value && inputEl.value.trim()) return;
|
|
4410
7052
|
location.reload();
|
|
@@ -4430,6 +7072,30 @@
|
|
|
4430
7072
|
// 整块抠出来求值,测试里的假 mainEl 没有 addEventListener,写在那边好几个测试当场报错。
|
|
4431
7073
|
mainEl.addEventListener('scroll', onMainScroll);
|
|
4432
7074
|
|
|
7075
|
+
// 「回到底部」浮标(2026-10-08)也挂在这一根滚动线上,但**另起一条监听**:
|
|
7076
|
+
// onMainScroll 被页面测试整块抠出来跑(chatPager 那个替身只给了 state / replyEl /
|
|
7077
|
+
// mainEl / timeLabel,没有 $),把浮标并进去会让那几个测试当场报 ReferenceError。
|
|
7078
|
+
mainEl.addEventListener('scroll', paintToBottom);
|
|
7079
|
+
// 首屏先定一次:刚进来是空页,按判据算「在底部」,浮标该是收着的。
|
|
7080
|
+
paintToBottom();
|
|
7081
|
+
|
|
7082
|
+
// 点它:**瞬时**到底,不做平滑滚动。
|
|
7083
|
+
//
|
|
7084
|
+
// 两条理由,都是「跟现成的东西保持一致」而不是我挑的:
|
|
7085
|
+
// · 用户原话就是「瞬间」;
|
|
7086
|
+
// · 电脑端那颗也是瞬时的(ChatView 里 reading.followTail → viewport.scrollToBottom
|
|
7087
|
+
// → follow.toBottom(..., 'instant'));
|
|
7088
|
+
// · 本页「按发送回底部」本来就是直接赋值(见 send 里那句
|
|
7089
|
+
// mainEl.scrollTop = mainEl.scrollHeight),同一件事用同一套动作,
|
|
7090
|
+
// 用户不会看到「有时滑一下、有时直接跳」两种脾气。
|
|
7091
|
+
//
|
|
7092
|
+
// 赋值本身会发一次 scroll 事件,浮标自己就收了;这里再补一次是防「本来就在最底、
|
|
7093
|
+
// 位置没变所以不发事件」那一种——补一下,状态就是确定的。
|
|
7094
|
+
$('toBottom').addEventListener('click', function () {
|
|
7095
|
+
mainEl.scrollTop = mainEl.scrollHeight;
|
|
7096
|
+
paintToBottom();
|
|
7097
|
+
});
|
|
7098
|
+
|
|
4433
7099
|
// ---------------- 启动 ----------------
|
|
4434
7100
|
syncSeg();
|
|
4435
7101
|
autoGrow();
|