dsh-mini-remote 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.en.md +198 -141
- package/README.md +198 -148
- package/client/client.js +1931 -3
- package/lib/build.js +32 -1
- package/lib/devices.js +372 -0
- package/lib/index.js +1773 -36
- package/lib/mirror.js +1715 -0
- package/lib/page.html +1556 -71
- package/lib/page.js +4 -1
- package/lib/remotes.js +279 -0
- package/lib/server.js +1278 -24
- package/lib/store.js +41 -0
- package/package.json +2 -2
package/lib/page.html
CHANGED
|
@@ -44,6 +44,12 @@
|
|
|
44
44
|
--run: #a9c8e8;
|
|
45
45
|
--shimmer: #eaf4ff; /* 跑着的行那道扫光的亮色(照 PC 的 label-shimmer,2026-10-04) */
|
|
46
46
|
--err: #d4614f;
|
|
47
|
+
/* 「等你在手机上动手」那一类状态(待审批 / 计划待审 / 待回答)。
|
|
48
|
+
和 --ok/--run/--err 同一档:**状态点的语义色**,占的是一个小点/一截短字,
|
|
49
|
+
不算强调色,不参与"一屏最多两处暖金"那条规矩。
|
|
50
|
+
照 PC 侧栏的说法,那一类用的是共享的 warning 橙点——我们这边补一个对应的令牌,
|
|
51
|
+
PC 上的原话是「待处理交互的优先级高于共享 ongoing loading」。 */
|
|
52
|
+
--warn: #e0904a;
|
|
47
53
|
--r: 2px;
|
|
48
54
|
--sh-1: 0 1px 2px rgba(0, 0, 0, .28);
|
|
49
55
|
--sh-2: 0 2px 6px rgba(0, 0, 0, .34);
|
|
@@ -76,6 +82,7 @@
|
|
|
76
82
|
--run: #3b6fc4;
|
|
77
83
|
--shimmer: #7ea6e6; /* 浅色下的扫光要比 --run 亮、又不能白到看不见 */
|
|
78
84
|
--err: #c0392b;
|
|
85
|
+
--warn: #b56a17; /* 白底上的警示橙:比深色那档压深,才读得清 */
|
|
79
86
|
--sh-1: 0 1px 2px rgba(28, 60, 110, .07);
|
|
80
87
|
--sh-2: 0 2px 8px rgba(28, 60, 110, .12);
|
|
81
88
|
--glass: rgba(255, 255, 255, .74);
|
|
@@ -656,6 +663,14 @@
|
|
|
656
663
|
display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
|
657
664
|
font-size: 13.5px; line-height: 1.45;
|
|
658
665
|
}
|
|
666
|
+
/* 「插话」那一行的标记(2026-10-06)。不标的话,插话和排队在列表里长得一模一样,
|
|
667
|
+
而两者的等待时间差一个数量级(插话几秒、排队可能几分钟)——用户根本分不出
|
|
668
|
+
自己那条到底插进去了没有。颜色用既有的状态色 --run(和轨迹里的「进行中」同色)。 */
|
|
669
|
+
.queue .q-tag {
|
|
670
|
+
flex: 0 0 auto; font-size: 11px; line-height: 1;
|
|
671
|
+
padding: 2px 5px; border-radius: var(--r);
|
|
672
|
+
color: var(--run); border: 1px solid var(--run);
|
|
673
|
+
}
|
|
659
674
|
/* 长指令折行,但最多两行——队列是「提醒还有几条」,不是给人重读全文的地方。 */
|
|
660
675
|
.queue .q-text {
|
|
661
676
|
flex: 1 1 auto; min-width: 0; color: var(--fg);
|
|
@@ -872,6 +887,108 @@
|
|
|
872
887
|
flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
|
|
873
888
|
background: var(--run);
|
|
874
889
|
}
|
|
890
|
+
/* ---------- 会话条目上的执行动作(2026-10-09) ----------
|
|
891
|
+
电脑端是"悬停时行尾冒出按钮",手机没有悬停——所以这里改成**常驻**一个小入口(⋯)。
|
|
892
|
+
选常驻不选长按:长按是看不见的隐藏手势,还和系统的选字/链接预览打架;
|
|
893
|
+
而这份列表里一半条目没名字,最需要的恰恰是一个看得见的入口。
|
|
894
|
+
动作本身不弹菜单,在那一行下面**行内展开**(照「新建文件夹」「模式清单」已有的先例)。 */
|
|
895
|
+
/* 一行会话 = 一个"打开它"的按钮 + 一个常驻的 ⋯。
|
|
896
|
+
两者是**兄弟**而不是父子:塞进去就成了"按钮里套按钮"(无效结构),
|
|
897
|
+
点 ⋯ 也会先把会话打开。所以多包一层,让它们并排。 */
|
|
898
|
+
.sess-line { display: flex; align-items: center; }
|
|
899
|
+
.sess-line .sess { flex: 1 1 auto; min-width: 0; padding-right: 4px; }
|
|
900
|
+
.s-more {
|
|
901
|
+
flex: 0 0 auto; width: 26px; height: 26px; padding: 0; margin-right: 8px;
|
|
902
|
+
display: flex; align-items: center; justify-content: center;
|
|
903
|
+
background: none; border: none; color: var(--muted);
|
|
904
|
+
font: inherit; font-size: 15px; line-height: 1; cursor: pointer;
|
|
905
|
+
border-radius: var(--r);
|
|
906
|
+
}
|
|
907
|
+
.s-more:active { background: var(--line); color: var(--fg); }
|
|
908
|
+
.sess.acting, .sess.renaming { background: var(--panel-2); }
|
|
909
|
+
/* 待处理那一类(待审批 / 计划待审 / 待回答):占的是行尾更新时间的位置——
|
|
910
|
+
和电脑端同一条规矩("以待批准、计划待审或待回答替换尾部更新时间")。 */
|
|
911
|
+
.sess .s-flag.pending { color: var(--warn); }
|
|
912
|
+
.sess .s-dot.pending { background: var(--warn); }
|
|
913
|
+
/* 已归档:整行置灰,并在行尾标出来。**不去掉这一行**——用户要看的是"它还在,
|
|
914
|
+
只是收起来了",凭空消失只会让人以为会话被删了(官方:会话可以归档但绝不会被删除)。 */
|
|
915
|
+
.sess.archived { color: var(--muted); }
|
|
916
|
+
.sess .s-flag { flex: 0 0 auto; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
|
|
917
|
+
|
|
918
|
+
.sess-acts { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 14px 9px 30px; }
|
|
919
|
+
.sess-acts button {
|
|
920
|
+
flex: 1 1 auto; min-width: 0; padding: 8px 10px; font: inherit; font-size: 13px;
|
|
921
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
922
|
+
border-radius: var(--r); color: var(--fg); cursor: pointer;
|
|
923
|
+
}
|
|
924
|
+
.sess-acts button:active { background: var(--line); }
|
|
925
|
+
.sess-acts button.danger { color: var(--err); }
|
|
926
|
+
/* 动作条下面的一句说明(目前只有「分叉会话」那一项用得上)。
|
|
927
|
+
**只在动作条展开时才出现**,所以它不占常驻的地方;字号比按钮小一档、用灰色——
|
|
928
|
+
它是解释,不是又一个能点的东西,所以不做成按钮的样子。 */
|
|
929
|
+
.sess-acts-hint { padding: 0 14px 9px 30px; margin-top: -3px; font-size: 12px; line-height: 1.6; color: var(--muted); }
|
|
930
|
+
|
|
931
|
+
/* 改名字那一行。形状照抄下面的「新建文件夹」:同样是内联输入,不用 window.prompt
|
|
932
|
+
(prompt 在微信这类内嵌浏览器里会被直接屏蔽,点了什么都不发生、也没提示)。 */
|
|
933
|
+
.sess-rename { display: flex; gap: 6px; padding: 0 14px 9px 30px; align-items: center; }
|
|
934
|
+
.sess-rename input {
|
|
935
|
+
flex: 1 1 auto; min-width: 0; padding: 9px 10px; font: inherit; font-size: 14px;
|
|
936
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
937
|
+
border-radius: var(--r); color: var(--fg);
|
|
938
|
+
}
|
|
939
|
+
.sess-rename button {
|
|
940
|
+
flex: 0 0 auto; padding: 9px 12px; font: inherit; font-size: 13px; cursor: pointer;
|
|
941
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
942
|
+
border-radius: var(--r); color: var(--fg);
|
|
943
|
+
}
|
|
944
|
+
.sess-rename button:active { background: var(--line); }
|
|
945
|
+
|
|
946
|
+
/* 「展开其余 N 个 / 收起」。官方口径:默认只显示 5 条空闲会话,每点一次多 5 条。
|
|
947
|
+
它是一个动作,所以用强调**字色**(--ice),不是强调底色——底那一档一屏只有一个。 */
|
|
948
|
+
.sess-more {
|
|
949
|
+
display: block; width: 100%; padding: 10px 14px 10px 30px;
|
|
950
|
+
background: none; border: none; color: var(--ice);
|
|
951
|
+
font: inherit; font-size: 13px; text-align: left; cursor: pointer;
|
|
952
|
+
}
|
|
953
|
+
.sess-more:active { background: var(--panel-2); }
|
|
954
|
+
|
|
955
|
+
/* 抽屉顶部的「显示已归档」。默认关——官方的默认项就是"隐藏已归档"。
|
|
956
|
+
关着的时候是一句普通灰字,开着的时候用 --ice 亮起来,一眼看得出当前在哪一档。 */
|
|
957
|
+
.nav-filter {
|
|
958
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
959
|
+
padding: 9px 14px; background: none; border: none; border-bottom: 1px solid var(--line);
|
|
960
|
+
color: var(--muted); font: inherit; font-size: 13px; text-align: left; cursor: pointer;
|
|
961
|
+
}
|
|
962
|
+
.nav-filter.on { color: var(--ice); }
|
|
963
|
+
.nav-filter:active { background: var(--panel-2); }
|
|
964
|
+
|
|
965
|
+
/* 归档确认("停止并归档此会话?")。**这一处是真弹的**——官方原话是
|
|
966
|
+
"仍有工作在跑的 Session 是唯一会先询问的情形……侧栏随即打开『停止并归档』对话框",
|
|
967
|
+
而这件事会停掉正在跑的工作,不能做成一行不显眼的文字。 */
|
|
968
|
+
#navConfirm {
|
|
969
|
+
position: fixed; inset: 0; z-index: 120; display: none;
|
|
970
|
+
background: var(--scrim); padding: 24px 18px calc(24px + env(safe-area-inset-bottom));
|
|
971
|
+
align-items: center; justify-content: center;
|
|
972
|
+
}
|
|
973
|
+
#navConfirm.on { display: flex; }
|
|
974
|
+
#navConfirm .nc-panel {
|
|
975
|
+
width: 100%; max-width: 340px; max-height: 100%; overflow-y: auto;
|
|
976
|
+
background: var(--pop); border: 1px solid var(--line); border-radius: var(--r);
|
|
977
|
+
box-shadow: var(--sh-2); padding: 16px;
|
|
978
|
+
}
|
|
979
|
+
#navConfirm h3 { margin: 0 0 10px; font-size: 15.5px; font-weight: 650; }
|
|
980
|
+
#navConfirm p { margin: 0 0 10px; font-size: 13.5px; line-height: 1.7; color: var(--muted); }
|
|
981
|
+
#navConfirm ul { margin: 0 0 14px; padding-left: 18px; font-size: 13.5px; line-height: 1.8; }
|
|
982
|
+
#navConfirm li b { font-weight: 650; }
|
|
983
|
+
#navConfirm .nc-foot { display: flex; gap: 8px; }
|
|
984
|
+
#navConfirm .nc-foot button {
|
|
985
|
+
flex: 1 1 0; padding: 11px; font: inherit; font-size: 14px; cursor: pointer;
|
|
986
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
987
|
+
border-radius: var(--r); color: var(--fg);
|
|
988
|
+
}
|
|
989
|
+
#navConfirm .nc-foot button:active { background: var(--line); }
|
|
990
|
+
#navConfirm .nc-foot button.danger { color: var(--err); border-color: var(--err); }
|
|
991
|
+
#navConfirm .nc-foot button[disabled] { color: var(--muted); cursor: default; }
|
|
875
992
|
/* 「新建会话」那一行。跟会话条目同宽、同缩进,但用强调色——
|
|
876
993
|
它不是一个会话,是一个动作。长得跟会话一样会让人以为点进去就是那条会话。 */
|
|
877
994
|
.ws-new {
|
|
@@ -1036,6 +1153,18 @@
|
|
|
1036
1153
|
/* 四类状态的字样:只上色、零动效(动效守卫只认那五种关键帧),颜色全取现成令牌。 */
|
|
1037
1154
|
.traj-err { color: var(--err); } /* 失败首行:折叠处一眼可见 */
|
|
1038
1155
|
.traj-run { color: var(--run); } /* 进行中:有标识;2026-10-04 起再加一道扫光(用户点名) */
|
|
1156
|
+
/* 条目行里那两个字样**不许被压扁、不许折行**(2026-10-06 用户真机反馈:
|
|
1157
|
+
「进行中」的「中」挂到下一行)。
|
|
1158
|
+
根因:它们是直接拼在 .traj-row 里的裸 <span>,而 .traj-row 是**弹性容器**
|
|
1159
|
+
——于是各自变成一个弹性项,默认 flex: 0 1 auto、min-width: auto。
|
|
1160
|
+
中文的「最小内容宽度」是**一个字**,空间一紧它就缩成一字宽,文字自己折行,
|
|
1161
|
+
变成「进 / 行 / 中」竖着排,还把整行撑高(真机复现过,长摘要那一行最明显)。
|
|
1162
|
+
修法:让它不参与压缩、且不许折行——**正好也是 2026-10-04 定下的口径**
|
|
1163
|
+
(「出错/进行中的字样是独立的一段,不许被省略号吃掉」):它保持完整宽度,
|
|
1164
|
+
让左边那段 .traj-detail 去缩、去打省略号。
|
|
1165
|
+
只挂在 .traj-row 下:组头那份 .traj-head .traj-run 自己有 nowrap + 省略号,
|
|
1166
|
+
不要动它。 */
|
|
1167
|
+
.traj-row .traj-err, .traj-row .traj-run { flex: none; white-space: nowrap; }
|
|
1039
1168
|
/* 跑着的行有一道渐变光扫过去(照 DSH 的 TextShimmer,2026-10-04 用户点名复现,
|
|
1040
1169
|
要适配主题)。机制照 PC:底色还是语义色(--run),一道亮色(--shimmer)扫过;
|
|
1041
1170
|
每秒 48 格、1.5 秒一轮、走前 2/3 停后 1/3,与 PC 同参。颜色全走现成令牌,
|
|
@@ -1122,6 +1251,16 @@
|
|
|
1122
1251
|
gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line);
|
|
1123
1252
|
}
|
|
1124
1253
|
.row:last-of-type { border-bottom: none; }
|
|
1254
|
+
|
|
1255
|
+
/* **这一句不能省。** `hidden` 只是 UA 样式表里的一条 `display:none`,
|
|
1256
|
+
而上面 `.row` 写了 `display:flex`——作者样式表压得住 UA 那一句(同一个道理
|
|
1257
|
+
见 `#mirrorView[hidden]`、`.sheet .version[hidden]`)。
|
|
1258
|
+
少了它,`.row` 上的 `hidden` 等于没写:元素照样撑开一段高度(2026-10-08 实测
|
|
1259
|
+
358×70,`getComputedStyle` 回来还是 `flex`),于是设置里「电脑端界面」那一行
|
|
1260
|
+
在进阶设置关着时照样露着。**改脚本没用**——脚本一直是对的,它确实把 hidden
|
|
1261
|
+
设上了;漏的是这条兜底样式。 */
|
|
1262
|
+
.row[hidden] { display: none; }
|
|
1263
|
+
|
|
1125
1264
|
/* 权限档位那一块外面套了一层(#permBlock,服务拿不到时整块要藏起来)。
|
|
1126
1265
|
它下面**没有分割线**,而别的板块之间都有——2026-10-05 用户截图点名。
|
|
1127
1266
|
原因:别的板块的分割线都是「那一行自己的下边线」(.row 的 border-bottom),
|
|
@@ -1208,6 +1347,79 @@
|
|
|
1208
1347
|
}
|
|
1209
1348
|
.sheet .foot .btn { flex: 1 1 0; text-align: center; }
|
|
1210
1349
|
|
|
1350
|
+
/* ---------- 「电脑端设置界面」那一整屏(内嵌,不跳转) ----------
|
|
1351
|
+
原委写在页面里 `#mirrorView` 上面那段注释里:跳转那条路在用户手机上三次都没成,
|
|
1352
|
+
所以改成嵌在这一页里——点一下只是把这一层显示出来、给里面的框设个地址,
|
|
1353
|
+
**浏览器没有任何东西可以拦**。
|
|
1354
|
+
|
|
1355
|
+
层级 80:要盖住设置抽屉(50)和那些动态小面板(70),但**必须在**授权卡(300+)
|
|
1356
|
+
和门禁页(100)**下面**——那两样是打断性的,任何时候都该在最上面。
|
|
1357
|
+
|
|
1358
|
+
`[hidden]` 那一句不能省:下面写了 display: flex,作者样式表里的 [hidden] 会被压住
|
|
1359
|
+
(和 `.sheet .version[hidden]` 同一个道理,见上面那段注释)。 */
|
|
1360
|
+
#mirrorView {
|
|
1361
|
+
position: fixed; inset: 0; z-index: 80;
|
|
1362
|
+
/* 高度跟「看得见的那块」走,而不是跟「地址栏收起时的那块」走(2026-10-07)。
|
|
1363
|
+
`inset:0` 的高度是按**大视口**算的(地址栏收起时的高度),而手机地址栏拉出来之后,
|
|
1364
|
+
真正看得见的只有下面那一截——于是框比可视区高,里面那份界面在框里**垂直居中**,
|
|
1365
|
+
底边就落到屏幕外面去了(用户报过「面板底边被切掉」)。
|
|
1366
|
+
`100dvh` 跟可视区走。**这条是「从参考插件学来、但落点要换」的典型**:它给官方面板补
|
|
1367
|
+
`max-height:calc(100dvh - 32px)`,可那个 `dvh` 是**框里面**的,框里的可视区单位等于
|
|
1368
|
+
框自己的高,等于白写;要治这个病只能写在**外层**这一页——这里 `dvh` 才是真的。
|
|
1369
|
+
老浏览器不认 `dvh` 会丢掉这一条,退回现在的行为,不会崩。 */
|
|
1370
|
+
height: 100dvh;
|
|
1371
|
+
display: flex; flex-direction: column;
|
|
1372
|
+
background: var(--bg);
|
|
1373
|
+
}
|
|
1374
|
+
#mirrorView[hidden] { display: none; }
|
|
1375
|
+
.mv-bar {
|
|
1376
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
|
|
1377
|
+
padding: 10px 12px;
|
|
1378
|
+
padding-top: calc(10px + env(safe-area-inset-top, 0px));
|
|
1379
|
+
border-bottom: 1px solid var(--line);
|
|
1380
|
+
background: var(--bg-top);
|
|
1381
|
+
}
|
|
1382
|
+
.mv-title { flex: 1 1 auto; font-size: 14px; color: var(--fg); }
|
|
1383
|
+
|
|
1384
|
+
/* ---------- 顶部那条横条的「收 / 放」(2026-10-15 用户要求) ----------
|
|
1385
|
+
平时**完全看不见**:整条收进一个 0 高的壳里。**用「收起来」而不是「透明」**——
|
|
1386
|
+
透明还占着 61px 的地方,官方界面就长不高、屏幕上留一道空当;收起来的话框自己
|
|
1387
|
+
长上来填满(高度是过渡的,不跳)。
|
|
1388
|
+
|
|
1389
|
+
桌面:鼠标贴到屏幕顶部才滑出来,移开就滑走。
|
|
1390
|
+
**顶上那条 16px 的感应带不能省**:鼠标一落到框里那份文档上,事件就进了那份文档,
|
|
1391
|
+
外层页一个都收不到——不盖这一条,最顶上就没有任何东西能「碰到」。
|
|
1392
|
+
它透明、只有 16px,压在官方界面最上面那道空白上:实测宽屏(1440/1024)下 0~21px
|
|
1393
|
+
里没有任何可点的东西(官方自己的按钮最低也从第 22px 起,右上角那个「打开右侧边栏」
|
|
1394
|
+
在第 28px);**窄到 800px 时最早的可点元素从第 18px 起**(左上那颗图标按钮),所以
|
|
1395
|
+
带的高度停在 16px——两边各留 2~6px 余量,谁也不挡。
|
|
1396
|
+
|
|
1397
|
+
**只在「主输入设备是有悬停能力的精细指针」时才收起来**(上面那个媒体查询管的就是
|
|
1398
|
+
这件事):触摸优先的设备上它匹配不上,横条保持**常驻**——触摸没有「悬停」这回事,
|
|
1399
|
+
藏起来就会变成摸不到的东西。它是唯一回手机页的出口,这一条不能赌。 */
|
|
1400
|
+
#mirrorHot { display: none; }
|
|
1401
|
+
.mv-hot { flex: 0 0 auto; overflow: hidden; }
|
|
1402
|
+
@media (hover: hover) and (pointer: fine) {
|
|
1403
|
+
#mirrorHot {
|
|
1404
|
+
display: block; position: absolute; left: 0; right: 0; top: 0;
|
|
1405
|
+
height: 16px; z-index: 2;
|
|
1406
|
+
}
|
|
1407
|
+
/* 100px 是留给 `max-height` 过渡的余量:横条实测 61px,留够就不会被削。
|
|
1408
|
+
收起那一侧同理——`max-height` 掉到 61px 以下才真的开始收。 */
|
|
1409
|
+
.mv-hot { max-height: 0; transition: max-height .25s ease; }
|
|
1410
|
+
#mirrorView.bar-on .mv-hot { max-height: 100px; }
|
|
1411
|
+
}
|
|
1412
|
+
#mirrorFrame {
|
|
1413
|
+
flex: 1 1 auto; width: 100%; border: 0; background: var(--bg);
|
|
1414
|
+
/* 底部让开手势条(2026-10-07 从「被镜像的那份页面」搬回来的)。
|
|
1415
|
+
这条原先是写在 lib/mirror.js 的 ADAPT_CSS 里,而那段样式是**注进框里那份文档**的
|
|
1416
|
+
——`#mirrorFrame` 是本文件(外层)的元素,两个不同的文档,所以那条规则一个像素
|
|
1417
|
+
都没生效:活服务上量过,框的 `padding-bottom` 是 0px,被镜像的那份 HTML 里反倒
|
|
1418
|
+
躺着一份没人用的 `#mirrorFrame` 规则(官方界面里根本没有这个 id)。
|
|
1419
|
+
顶部那条不归这里管:外层横条 .mv-bar 自己写了 padding-top。 */
|
|
1420
|
+
padding-bottom: env(safe-area-inset-bottom, 0px); box-sizing: border-box;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1211
1423
|
/* ---------- 统一操作面板(输入框旁边那一个入口点开的东西) ----------
|
|
1212
1424
|
骨架和设置抽屉**是同一套**:同一个 `#xxx.open` 即时显隐、同一个贴底的 `.sheet`
|
|
1213
1425
|
(它自带 max-height / overflow-y / safe-area 那三样,窄屏顶不出去),不新建样式体系。
|
|
@@ -1255,6 +1467,16 @@
|
|
|
1255
1467
|
color: var(--on-act); font-size: 16px; font-weight: 600;
|
|
1256
1468
|
}
|
|
1257
1469
|
#gate .err { color: var(--err); font-size: 13px; min-height: 18px; }
|
|
1470
|
+
/* 被撤销的那台设备要看到的一句话。**默认不出现**,只有服务端说"这枚凭据的名册记录
|
|
1471
|
+
已经被删掉了"那一次才露(见 lib/server.js 里那一条判据,以及 lib/page.js 里那个
|
|
1472
|
+
注入给页面的开关)。
|
|
1473
|
+
这句注释里**不许写出那个占位符本身**:服务端是拿字符串替换注入的,而 replace 只
|
|
1474
|
+
换第一处——注释里写一遍,真正该被换掉的那一行就永远换不掉了(2026-10-16 真踩过)。
|
|
1475
|
+
下面那句 `[hidden]` 也不能省:这个文件里 `.sheet .version[hidden]`、
|
|
1476
|
+
`#mirrorView[hidden]` 都是同一件事——作者样式表里的 display 会压住浏览器默认的
|
|
1477
|
+
[hidden],少一句就变成"一句本来不该出现的话一直挂在屏幕上"。 */
|
|
1478
|
+
#gate p.revoked[hidden] { display: none; }
|
|
1479
|
+
#gate p.revoked { color: var(--err); }
|
|
1258
1480
|
|
|
1259
1481
|
#toast {
|
|
1260
1482
|
position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%) translateY(8px);
|
|
@@ -1264,6 +1486,17 @@
|
|
|
1264
1486
|
transition: opacity .2s, transform .2s; z-index: 200; max-width: 86vw; text-align: center;
|
|
1265
1487
|
}
|
|
1266
1488
|
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
1489
|
+
/* 带「撤销」那一版的提示:只有它需要点得动,所以只有它开 pointer-events。
|
|
1490
|
+
普通提示还是原来那样——纯字、点不中,不给用户一个按了没反应的假按钮。 */
|
|
1491
|
+
#toast.with-act {
|
|
1492
|
+
pointer-events: auto; display: flex; align-items: center; gap: 12px; text-align: left;
|
|
1493
|
+
}
|
|
1494
|
+
#toast .toast-act {
|
|
1495
|
+
flex: 0 0 auto; padding: 4px 8px; background: none; border: none;
|
|
1496
|
+
color: var(--ice); font: inherit; font-size: 13.5px; font-weight: 650;
|
|
1497
|
+
cursor: pointer; border-radius: var(--r);
|
|
1498
|
+
}
|
|
1499
|
+
#toast .toast-act:active { background: var(--panel-2); }
|
|
1267
1500
|
|
|
1268
1501
|
/* ——— 回答电脑提问的卡片 ———
|
|
1269
1502
|
整屏遮住聊天区:手机上一次只专心答一题,答完自动消失。
|
|
@@ -1317,6 +1550,208 @@
|
|
|
1317
1550
|
width: 18px; height: 18px; fill: none; stroke: currentColor;
|
|
1318
1551
|
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
|
|
1319
1552
|
}
|
|
1553
|
+
|
|
1554
|
+
/* ---------- 「回到底部」浮标(2026-10-08) ----------
|
|
1555
|
+
用户要的是「瞬间回到底部的按钮,圈子里一个简约的往下箭头」,参考电脑端 DSH 那颗。
|
|
1556
|
+
|
|
1557
|
+
形状照电脑端那颗抄:圆底 + 一根下折线(矢量 svg,不是字形、不是 emoji——
|
|
1558
|
+
字形换个机型就可能变成豆腐块)。尺寸不一样:电脑端 34×34,这一颗 38×38
|
|
1559
|
+
(第四轮缩到 27、第五轮回到 31,见下面那两段)。
|
|
1560
|
+
圆角走 999px 那一档,不新造档位。
|
|
1561
|
+
|
|
1562
|
+
---- 第二轮:用户要「克制而高级的主题适配」,外观只动下面这三处 ----
|
|
1563
|
+
|
|
1564
|
+
① 边界从「画上去的一根 1px 线」改成「从面里长出来的一道 0.5px 环」:
|
|
1565
|
+
去掉 border,环改由 box-shadow 的第一层 `0 0 0 .5px` 来画。这不是自创——
|
|
1566
|
+
电脑端那套高度系统本来就长这样(dsh-client-ui-theme 里那道 elevation-stroke
|
|
1567
|
+
就是「0 0 0 .5px 加描边色」,官方那颗浮标挂的 elevation-panel 第一位正是它)。
|
|
1568
|
+
0.5px 在 2 倍屏上正好落成 1 个物理像素:比 1px 细一半,看得见轮廓,
|
|
1569
|
+
又找不到那根线。颜色仍走 --line(全页的细线都用它)。
|
|
1570
|
+
|
|
1571
|
+
② 投影换轻一档,深色单独处理:
|
|
1572
|
+
· 浅色用 --sh-1,不再用 --sh-2。--sh-2(0 2px 8px / 12%)是这两档里更重的
|
|
1573
|
+
一档,压在这颗 38px 的小圆上像贴了块膏药;--sh-1(0 1px 2px / 7%)
|
|
1574
|
+
同一个蓝调,更小更柔。官方那套的第二三层(0 3px 8px @3% 加 0 0 16px @2%)
|
|
1575
|
+
比这更浅也更宽——要贴到那个程度就得现调一组新数,与这一轮「克制」的调子
|
|
1576
|
+
相悖,所以取现成的那一档。
|
|
1577
|
+
· 深色把投影层整个去掉。实测它在深色下等于零:浮标落在页面底色渐变最暗的
|
|
1578
|
+
那一头(近黑的 #070b11),再压一层黑色投影,同一像素开关投影前后最多差
|
|
1579
|
+
5/255(整幅平均 0.07/255)。层次改由**底色本身**立——实测按钮的 --panel #121a25 比它脚下的
|
|
1580
|
+
背景 #070b11 亮 (+11,+15,+20),本身就已经高出一档,和官方那颗(深色下用
|
|
1581
|
+
比背景亮的浮层底色)是同一个道理。底线是静息色不能改成 --panel-2:
|
|
1582
|
+
那是这颗按钮**按下**时的底色,拿它当静息色,按下去就没有反馈了。
|
|
1583
|
+
|
|
1584
|
+
③ 看得见的圆缩到 38,手指的范围反而变大:原来 42×42 是「看得见多大就点得到
|
|
1585
|
+
多大」,而 42 其实低于常说的那条 44 下限;现在圆缩到 38,看不见的热区用
|
|
1586
|
+
::before 撑到 46×46。图标等比缩到 14.5px(16 × 38/42),箭头在圆里占的比例
|
|
1587
|
+
和原来一样,既不空也不挤。
|
|
1588
|
+
|
|
1589
|
+
颜色**一个令牌都不新造**,全用主屏现成的那几个:底 --panel、线 --line、
|
|
1590
|
+
图标 --fg、按下 --panel-2、投影 --sh-1。两套主题各自成对。
|
|
1591
|
+
|
|
1592
|
+
---- 第四轮(2026-10-08):看得见的面积减半,同时往左让开滚动条那一条 ----
|
|
1593
|
+
|
|
1594
|
+
用户两条原话:「这个按钮还可以再缩小一半的面积」「如果旁边滚动条显示的话,
|
|
1595
|
+
点不下去,是否可以触压位往左侧再拖拽一些」。两件事分开算:
|
|
1596
|
+
|
|
1597
|
+
① 面积减半 = 直径乘 √2 的一半(0.707),不是把直径砍一半。
|
|
1598
|
+
38 × 0.707 = 26.87 → **27**。(真把直径砍成 19,面积就只有四分之一,
|
|
1599
|
+
那不是「减半」,是「小得离谱」。)
|
|
1600
|
+
看得见的地方小一半,可点的地方一点没缩:热区仍 **≥44**(46 → 45,见 ::before),
|
|
1601
|
+
这是手指那条下限,不能跟着圆一起缩。
|
|
1602
|
+
图标跟着等比缩:16 × 27/38 = **10.3px**——占圆的比例和 38 那会儿一模一样,
|
|
1603
|
+
所以圆里既不空也不挤。
|
|
1604
|
+
|
|
1605
|
+
② 往左让开滚动条(这条更要紧,用户是点不下去才说的)。两件事都在**活页面**上量过:
|
|
1606
|
+
· 手机仿真(= 手机上的实际形态)下 main 的 `offsetWidth - clientWidth` **= 0**:
|
|
1607
|
+
滚动条是**覆盖式**的,版面上一个像素都不占,内容区照样通到最右边 390。
|
|
1608
|
+
所以按钮的右沿天生停在 390 − 14(main 的 padding-right)= **376**,
|
|
1609
|
+
右边只剩 14px——滚动条一冒出来就贴在那一条上,正好压住按钮右半边。
|
|
1610
|
+
· 换一个**经典式**(占宽度)的形态量,滚动条占的正是**最右边那条 15px**
|
|
1611
|
+
(x 375..390,盒模型与像素两边对得上,见 ui.test.mjs 里那条断言)。
|
|
1612
|
+
也就是:不管哪一种形态,那条带都从 375 起头。
|
|
1613
|
+
于是往左挪的量按**实测那条带**算,不拍脑袋:
|
|
1614
|
+
滚动条带左沿 375 —— 热区右沿要退到 372(留 3px 余量,免得取整蹭上去)
|
|
1615
|
+
—— 热区比圆右侧多出 9px(inset −9)—— 所以圆的右沿要退到 363。
|
|
1616
|
+
现在圆右沿是 376,**往左 13px** 正好落在 363。
|
|
1617
|
+
落法只有一处:`.to-bottom` 自己的 `margin-right: 13px`。槽是 flex-end,
|
|
1618
|
+
给它一个右外边距就等于把它从槽的右沿往里推 13px。
|
|
1619
|
+
sticky 那套机制、main 的布局、滚动条的样式,一行都没碰。
|
|
1620
|
+
(经典式形态下滚动条本来就把内容区挤窄 15px,那时圆右沿是 361,
|
|
1621
|
+
再让 13px 是 348——只会更宽裕,不会挤到别处。)
|
|
1622
|
+
|
|
1623
|
+
为什么不用脚本算滚动条有多宽:覆盖式的宽度脚本根本量不到(上面那个 0 就是证据),
|
|
1624
|
+
而且 sticky 的位置本来就不该由脚本碰。按实测到的那条**最宽**的带留出固定余量,
|
|
1625
|
+
两种形态都盖得住,这才是稳的写法。
|
|
1626
|
+
|
|
1627
|
+
---- 第五轮(2026-10-08):用户说「有点缩得太小了,增大 15%」 ----
|
|
1628
|
+
|
|
1629
|
+
① 「15%」按**看得见的直径**读:27 × 1.15 = 31.05 → **31**。
|
|
1630
|
+
(另一种读法是「面积增大 15%」,那只有 27 × √1.15 = 28.96 → 29。
|
|
1631
|
+
两个读法只差 2px;这颗圆刚被上一轮削到 27,取大的那个才叫「不再偏小」。)
|
|
1632
|
+
图标继续等比:10.3 × 31/27 = **11.8px**——箭头在圆里占的比例,和 38、27 那两轮
|
|
1633
|
+
一模一样(16/38 = 10.3/27 = 11.8/31),所以圆里既不空也不挤。
|
|
1634
|
+
`margin-top` 必须跟着等于 −31:它是负的自身高度,槽高 0,只有相等下沿才落在槽线上。
|
|
1635
|
+
|
|
1636
|
+
② 热区一个数都没动,它自己长大了:31 + 9×2 = **49**(44 那条手指下限照旧守住)。
|
|
1637
|
+
这正是 inset 不必跟着改的原因——圆缩时热区不许跟着缩,圆长时它跟着长是白赚的。
|
|
1638
|
+
|
|
1639
|
+
③ **第四轮那条「让开滚动条」没有被弄坏,因为要守的是右沿、不是宽度**:
|
|
1640
|
+
按钮走 flex-end,直径一变只往**左**边长;右沿的位置只由
|
|
1641
|
+
main 的 padding-right(14)加按钮自己的 margin-right(13)定,
|
|
1642
|
+
所以圆的右沿仍是 376 − 13 = **363**,热区再往右 9px 是 **372**,
|
|
1643
|
+
离滚动条那条带(左沿 375)仍是 3px。margin-right 一个字没动。
|
|
1644
|
+
|
|
1645
|
+
位置的关键在 .to-bottom-slot:它是 main 的最后一个孩子,靠 `position: sticky`
|
|
1646
|
+
贴在**滚动口下沿往上 16px** 的地方。这一手的好处是位置不用脚本算——
|
|
1647
|
+
main 的下沿天生就是输入区那一摞(附件条 / 指令菜单 / 底栏)的上沿,
|
|
1648
|
+
所以输入框长高、附件条冒出来、指令菜单弹开,它都跟着一起让位,永远不会盖住
|
|
1649
|
+
输入框和发送键;底部那条手势条也由 footer 自己的 safe-area 内边距让开了,
|
|
1650
|
+
它比输入区还高一截,离手势条更远。 */
|
|
1651
|
+
.to-bottom-slot {
|
|
1652
|
+
position: sticky; bottom: 16px; z-index: 5;
|
|
1653
|
+
height: 0; display: flex; justify-content: flex-end;
|
|
1654
|
+
/* 这一条不能省:槽是通栏的,不设 none 就会在底边上拦掉底下内容的点击。
|
|
1655
|
+
按钮自己再打开 auto,所以只有那一个圆圈可点。 */
|
|
1656
|
+
pointer-events: none;
|
|
1657
|
+
}
|
|
1658
|
+
.to-bottom {
|
|
1659
|
+
/* position 只是为了给下面那片透明热区一个定位原点;左右位置由 margin-right 让出来。 */
|
|
1660
|
+
position: relative;
|
|
1661
|
+
/* margin-top 是负的自身高度:按钮的下沿才会正好落在槽那条线上(槽高 0)。
|
|
1662
|
+
第五轮起圆是 31(第四轮 27 × 1.15),这个数就得跟着是 -31——
|
|
1663
|
+
留着 -27 会把它整圈**下**移 4px(下沿掉到槽线下面去)。
|
|
1664
|
+
margin-right: 13px 是第四轮那条「让开滚动条」的全部落法,数怎么来的见上面;
|
|
1665
|
+
它让的是**右沿**,而右沿只由 14 + 13 定,跟直径无关——所以这一条不用重算。 */
|
|
1666
|
+
width: 31px; height: 31px; margin-top: -31px; margin-right: 13px; padding: 0;
|
|
1667
|
+
border: 0; border-radius: 999px;
|
|
1668
|
+
background: var(--panel); color: var(--fg);
|
|
1669
|
+
/* ① 第一层就是那道 0.5px 环;深色到此为止(② 的深色那半:投影在那儿等于零)。 */
|
|
1670
|
+
box-shadow: 0 0 0 .5px var(--line);
|
|
1671
|
+
cursor: pointer; pointer-events: auto;
|
|
1672
|
+
display: flex; align-items: center; justify-content: center;
|
|
1673
|
+
/* ---- 第三轮(2026-10-08):出现 / 消失各来一段 130ms 的淡入淡出 ----
|
|
1674
|
+
用户原话「这个按键消失的时候应该有个渐出效果,官方 pc 端是做了的」。
|
|
1675
|
+
真去量了电脑端那份界面(/mini/mirror 上逐帧采 opacity + MutationObserver 看 DOM),
|
|
1676
|
+
结论是**两半都对一半**,两头都得记下来:
|
|
1677
|
+
|
|
1678
|
+
· 电脑端那颗按钮**身上确实挂着一条 `opacity .13s ease`**——但它不是 ChatView 自己的
|
|
1679
|
+
样式,是外形(skin)那层的基础按钮规则给的(选择器是
|
|
1680
|
+
`html[data-dsh-skin="claude"] button, …[role="button"], …`,实测这条很清楚地匹配到
|
|
1681
|
+
那颗浮标)。所以「官方做了渐出」这句话在**声明**上是对的。
|
|
1682
|
+
· 可它**画面上从来没淡过**:ChatView 是 `!followingTail && <div class=toBottomSlot>…`
|
|
1683
|
+
的条件挂载,一滚到底整块节点直接被 React 摘掉,那条 transition 根本没有机会跑。
|
|
1684
|
+
活服务上逐帧采样实测:消失前后只有两个状态(t≈1.5ms 时 opacity=1 且 isConnected=true,
|
|
1685
|
+
t≈18.6ms 时节点已 isConnected=false、computed 全空),中间**一帧过渡值都没有**;
|
|
1686
|
+
MutationObserver 同一时刻记下的是 toBottomSlot 被 childList 整块移除,没有任何
|
|
1687
|
+
「先加类、过一会儿再摘」的动作。也就是说:**设计语言里那一档是 130ms ease,
|
|
1688
|
+
但官方的手法是硬卸载**。
|
|
1689
|
+
|
|
1690
|
+
所以这里按用户的要求把它补上,并且**只补官方自己没接上的那一段**:时长与缓动
|
|
1691
|
+
直接取官方那颗按钮身上现成的那一档 `130ms / ease`,不另起一个数。
|
|
1692
|
+
方向对称:消失淡出、出现淡入,两边同一档,不做缩放、不做弹跳。
|
|
1693
|
+
|
|
1694
|
+
为什么不用 `display:none` 收:`display:none` 一挂上,元素就不在渲染树里了,
|
|
1695
|
+
过渡根本无从谈起——所以「用 display 收尾」和「要一段淡出」这两件事天生互斥。两条正路:
|
|
1696
|
+
(a) 脚本两端配合(先淡出,等过渡结束再置 hidden);
|
|
1697
|
+
(b) `transition-behavior: allow-discrete` + `@starting-style`。
|
|
1698
|
+
这里走的是第三条、也是同一件事的**更保险写法**:藏起来用
|
|
1699
|
+
`visibility: hidden` + `pointer-events: none` 代替 `display:none`。
|
|
1700
|
+
理由有两条,都是实打实的:
|
|
1701
|
+
· 兼容面。`allow-discrete` / `@starting-style` 要 Chrome 117 / Safari 17.4 /
|
|
1702
|
+
Firefox 129 往上;手机浏览器五花八门,老一档的机器上那两条会被整条丢掉,
|
|
1703
|
+
结果就是**一点淡出都没有**——正好回到用户抱怨的那个状态。而 `visibility`
|
|
1704
|
+
的过渡是十年以上的老写法,哪台机器都认。
|
|
1705
|
+
· 「过渡走完才真正藏起来」这件事由浏览器替我办:`visibility` 的插值规则很特别,
|
|
1706
|
+
只要两端有一端是 `visible`,整个过渡期间它就一直算 `visible`,
|
|
1707
|
+
到进度 1 才翻成 `hidden`。所以淡出那 130ms 里它照样画在屏幕上,
|
|
1708
|
+
走完就真的不可见——等于把 (a) 交给浏览器做,脚本一行都不用加,
|
|
1709
|
+
`paintToBottom` 仍然是「只改 hidden」那一个动作(页面测试也是这么钉的)。
|
|
1710
|
+
|
|
1711
|
+
`visibility` 那条也顺手把「出现」这半边解决了:元素在藏着的时候**仍然有计算样式**
|
|
1712
|
+
(因为它不是 display:none),opacity 老老实实是 0,所以一露出来就是 0→1 的淡入,
|
|
1713
|
+
不需要 `@starting-style` 去给一个起点。
|
|
1714
|
+
|
|
1715
|
+
最后一句:这里只有 `transition`,没有关键帧、没有 animation——
|
|
1716
|
+
它是一颗按钮,不是一段表演(`test/ui.test.mjs` 里那条护栏就是钉这个的)。 */
|
|
1717
|
+
transition: opacity .13s ease, visibility .13s;
|
|
1718
|
+
opacity: 1; visibility: visible;
|
|
1719
|
+
}
|
|
1720
|
+
/* ③ 手指要的那 44 由这片**看不见**的热区补上:圆 31,热区 49。
|
|
1721
|
+
绝对定位 + 负 inset 让它溢出去,不参与版式(槽的高度还是 0,位置照旧全交给
|
|
1722
|
+
CSS 的 sticky);它是按钮自己的伪元素,命中的点事件目标仍然是按钮,所以
|
|
1723
|
+
「只有那一个圆圈可点」这条规矩没破,只是那个圈比看得见的那个大 9px。
|
|
1724
|
+
inset 从 −4 放到 −9 是**跟着第四轮那个 27 一起算的**:27 + 9×2 = 45 ≥ 44。
|
|
1725
|
+
第五轮圆长到 31 之后一直是 31 + 9×2 = 49 ≥ 44,所以这个数**不用动**——
|
|
1726
|
+
热区跟着圆一起长是白赚的,而它绝不会跟着圆一起缩(那是手指的下限,不是外观)。 */
|
|
1727
|
+
.to-bottom::before {
|
|
1728
|
+
content: ''; position: absolute; inset: -9px; border-radius: 999px;
|
|
1729
|
+
}
|
|
1730
|
+
/* ② 浅色那边:环比深色**深一档**,再叠第二层更安静的那一档投影(深色两层都不动)。
|
|
1731
|
+
为什么不用现成变量:浅色这套色阶里,--line(相对亮度 0.747)往下一格是空的——
|
|
1732
|
+
再往下就只剩 --dim(0.298)和 --muted(0.207),一用就把「一道细线」变成「一圈描边」
|
|
1733
|
+
(实测它们相对底色要差 85 / 109 个灰阶、对比度 2.4 / 3.1:1,放在这颗小圆上太重)。
|
|
1734
|
+
所以在 --line 与 --fg(正文色,同一个蓝调家族)之间取一成的 --fg:
|
|
1735
|
+
实测环相对相邻底色由 11/255 变成 28/255(对比度 1.096 → 1.292:1),
|
|
1736
|
+
刚好从「几乎看不见」跨成「看得见的一道细线」,离 --dim 那一档还差着三倍。
|
|
1737
|
+
颜色仍全部来自现成令牌,不写死任何色值;深色那边一个数没动,还是 --line。 */
|
|
1738
|
+
[data-theme="light"] .to-bottom {
|
|
1739
|
+
box-shadow: 0 0 0 .5px color-mix(in srgb, var(--line) 90%, var(--fg)), var(--sh-1);
|
|
1740
|
+
}
|
|
1741
|
+
.to-bottom:active { background: var(--panel-2); }
|
|
1742
|
+
/* 藏起来的那个状态。**这一句不能省**:作者样式表里的 display:flex 会顶掉 hidden 自带的
|
|
1743
|
+
display:none,所以「藏」这件事必须由我们自己在这条规则里写实。
|
|
1744
|
+
原来写的是 display:none,现在换成下面这三条(为什么换见上面那段长注释):
|
|
1745
|
+
· opacity: 0 —— 淡出的终点,130ms 里从 1 走到 0;
|
|
1746
|
+
· visibility: hidden —— 过渡期间它一直是 visible(visibility 的插值规则),
|
|
1747
|
+
走完才翻成 hidden,所以真正不可见发生在淡出**结束之后**;
|
|
1748
|
+
· pointer-events: none —— 这一条**不进过渡**,属性一挂上就立刻生效,
|
|
1749
|
+
所以淡出那 130ms 里它已经点不到了;淡完之后它也不是一层透明的可点层。
|
|
1750
|
+
版式一个像素都没动:槽高是 0,按钮靠 margin-top 负的自身高度挂在槽线上方,
|
|
1751
|
+
这两个状态里它的盒子一样大,所以不产生任何位移。 */
|
|
1752
|
+
.to-bottom[hidden] { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
1753
|
+
/* 箭头等比缩:10.3 × 31/27 = 11.8。占圆的比例和 27、38 那两轮一样,不空也不挤。 */
|
|
1754
|
+
.to-bottom svg { width: 11.8px; height: 11.8px; }
|
|
1320
1755
|
</style>
|
|
1321
1756
|
<script>
|
|
1322
1757
|
// 主题必须在**首屏画出来之前**定下来,否则浅色手机打开会先闪一下深色底。
|
|
@@ -1413,6 +1848,22 @@
|
|
|
1413
1848
|
排队和鲸鱼娘是两件事——前者是「还有几条在等」,后者是「她正在忙」,
|
|
1414
1849
|
别因为后者要藏就把前者一起藏了。 -->
|
|
1415
1850
|
<div class="queue" id="queue" hidden></div>
|
|
1851
|
+
|
|
1852
|
+
<!-- 「回到底部」浮标(2026-10-08 用户要求)。样子参考电脑端 DSH 那颗
|
|
1853
|
+
(ChatView.module.css 的 .xz4KEq_toBottom:34×34 圆底 + 一根 14×14 的下折线 svg),
|
|
1854
|
+
位置、尺寸和配色按手机这页的规格重做,见样式表里那一段。
|
|
1855
|
+
|
|
1856
|
+
**它必须留在 #reply 外面**:跟 #work 同一个道理——拼进重绘里的话,每次状态广播
|
|
1857
|
+
都会把它重建一次,用户正按着它就没了。也**必须在 main 里面**:只有作为滚动口的孩子,
|
|
1858
|
+
CSS 的 sticky 才贴得住(位置由 CSS 算,脚本一个像素都不碰)。
|
|
1859
|
+
|
|
1860
|
+
箭头路径原样抄自电脑端那颗图标(dsh-client-ui-primitives 的 IconChevronDownOutline),
|
|
1861
|
+
没有自己重画:viewBox 0 0 16 16、1px 描边、fill:none、颜色走 currentColor。 -->
|
|
1862
|
+
<div class="to-bottom-slot" id="toBottomSlot">
|
|
1863
|
+
<button class="to-bottom" id="toBottom" type="button" aria-label="回到底部" hidden>
|
|
1864
|
+
<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>
|
|
1865
|
+
</button>
|
|
1866
|
+
</div>
|
|
1416
1867
|
</main>
|
|
1417
1868
|
|
|
1418
1869
|
<div class="attach-bar" id="attachBar" hidden></div>
|
|
@@ -1447,6 +1898,21 @@
|
|
|
1447
1898
|
</aside>
|
|
1448
1899
|
</div>
|
|
1449
1900
|
|
|
1901
|
+
<!-- 归档确认。**独立于抽屉之外**:抽屉能被关掉,而这一问必须挡住整屏——
|
|
1902
|
+
它要停下来的是正在电脑上跑的工作,不该因为用户顺手关了抽屉就悄悄消失。
|
|
1903
|
+
里面的内容(会停掉哪几族工作)由 renderNavConfirm 现填。 -->
|
|
1904
|
+
<div id="navConfirm" role="dialog" aria-modal="true" aria-labelledby="navConfirmTitle">
|
|
1905
|
+
<div class="nc-panel">
|
|
1906
|
+
<h3 id="navConfirmTitle">停止并归档此会话?</h3>
|
|
1907
|
+
<p id="navConfirmDesc"></p>
|
|
1908
|
+
<ul id="navConfirmList"></ul>
|
|
1909
|
+
<div class="nc-foot">
|
|
1910
|
+
<button type="button" data-nc-cancel="1">取消</button>
|
|
1911
|
+
<button type="button" class="danger" data-nc-ok="1">停止并归档</button>
|
|
1912
|
+
</div>
|
|
1913
|
+
</div>
|
|
1914
|
+
</div>
|
|
1915
|
+
|
|
1450
1916
|
<div id="settings">
|
|
1451
1917
|
<div class="sheet">
|
|
1452
1918
|
<!-- 页眉。整屏之后「点外面关掉」没了,所以这里必须有一个一直看得见的关闭口
|
|
@@ -1598,8 +2064,39 @@
|
|
|
1598
2064
|
<button class="btn" id="btnPlugins">查看</button>
|
|
1599
2065
|
</div>
|
|
1600
2066
|
|
|
2067
|
+
<!--
|
|
2068
|
+
「进阶设置(电脑端界面)」(2026-10-06 用户要求)。
|
|
2069
|
+
|
|
2070
|
+
手机上这张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
|
|
2071
|
+
这一项是退路:**把电脑端的界面整个搬过来看**——同一份界面,不迁移、不逐个适配。
|
|
2072
|
+
|
|
2073
|
+
**默认关,而且开着要付代价**:打开它 = 把这个令牌的权限从「我们那张手机页暴露的
|
|
2074
|
+
那点事」扩到**整个电脑端设置面**(模型配置、凭据页、插件装卸、会话记录……)。
|
|
2075
|
+
所以:默认关、后果写在提示里、开启要点两次(见下面 tapMirror)。
|
|
2076
|
+
|
|
2077
|
+
服务端没这个能力时(没拿到 DSH_WEB_URL)整行不显示——**不摆一个假的开关**,
|
|
2078
|
+
和「繁忙时的发送行为」同一条口径。 -->
|
|
2079
|
+
<div class="row" id="rowMirror" hidden>
|
|
2080
|
+
<div>
|
|
2081
|
+
<div class="label">进阶设置</div>
|
|
2082
|
+
<div class="hint" id="mirrorHint">打开电脑上的 DSH 界面,改那些手机上没有的设置</div>
|
|
2083
|
+
</div>
|
|
2084
|
+
<div class="seg" id="segMirror">
|
|
2085
|
+
<button data-mirror="on">开</button>
|
|
2086
|
+
<button data-mirror="off">关</button>
|
|
2087
|
+
</div>
|
|
2088
|
+
</div>
|
|
2089
|
+
|
|
2090
|
+
<div class="row" id="rowMirrorOpen" hidden>
|
|
2091
|
+
<div>
|
|
2092
|
+
<div class="label">电脑端界面</div>
|
|
2093
|
+
<div class="hint">电脑端dsh完整页面</div>
|
|
2094
|
+
</div>
|
|
2095
|
+
<button class="btn" id="btnMirrorOpen" type="button">打开</button>
|
|
2096
|
+
</div>
|
|
2097
|
+
|
|
1601
2098
|
<div class="foot">
|
|
1602
|
-
<button class="btn" id="btnForget"
|
|
2099
|
+
<button class="btn" id="btnForget">重新输入令牌</button>
|
|
1603
2100
|
<button class="btn primary" id="btnClose">完成</button>
|
|
1604
2101
|
</div>
|
|
1605
2102
|
|
|
@@ -1608,6 +2105,39 @@
|
|
|
1608
2105
|
</div>
|
|
1609
2106
|
</div>
|
|
1610
2107
|
|
|
2108
|
+
<!--
|
|
2109
|
+
「电脑端设置界面」那一整屏:**嵌在这一页里,不跳转、不开新标签。**
|
|
2110
|
+
|
|
2111
|
+
## 为什么最后做成内嵌(2026-10-06 用户三次实机报「点击打开没反应」)
|
|
2112
|
+
|
|
2113
|
+
前两版都想让它「打开」出去,都栽了:
|
|
2114
|
+
· 第一版 `<a target="_blank">`:内嵌浏览器(微信这类)常把新标签直接拦掉;
|
|
2115
|
+
· 第二版加了 JS 兜底(`window.open` 失败就同标签跳):还是没反应——那些浏览器里
|
|
2116
|
+
`window.open` 会「成功」返回一个对象,代码以为开好了就不再跳,而新标签开在后台;
|
|
2117
|
+
· 第三版干脆用普通 `<a href>` 同标签跳(理论上拦不掉):**用户那边仍然没反应**。
|
|
2118
|
+
|
|
2119
|
+
三次都在赌「跳转」这个动作本身。**这一版不赌了**:点一下只是把这一层显示出来、
|
|
2120
|
+
给里面的框设个地址——没有跳转、没有新标签、没有弹窗,**浏览器没有任何东西可以拦**。
|
|
2121
|
+
已经用真浏览器验过:那个界面**愿意被嵌**(不设「不许被嵌」的响应头,实测里面
|
|
2122
|
+
标题、侧栏、欢迎页全都正常)。
|
|
2123
|
+
|
|
2124
|
+
另外整行都能点(不只右边那个按钮)——按钮只有 54×40,手指容易点偏。
|
|
2125
|
+
-->
|
|
2126
|
+
<div id="mirrorView" hidden>
|
|
2127
|
+
<!-- 顶部感应带:桌面上「鼠标贴到屏幕顶部」那一下由它接住。手机上它是 display:none,
|
|
2128
|
+
那时横条本来就常驻——为什么必须有它、为什么手机不吃这一套,见样式表那段说明。 -->
|
|
2129
|
+
<div id="mirrorHot"></div>
|
|
2130
|
+
<!-- 横条外面这层壳是「收 / 放」用的:收起时压成 0 高,官方界面自己长上来填满。 -->
|
|
2131
|
+
<div class="mv-hot" id="mirrorBar">
|
|
2132
|
+
<div class="mv-bar">
|
|
2133
|
+
<span class="mv-title">电脑端界面</span>
|
|
2134
|
+
<button class="btn" id="mirrorClose" type="button">返回</button>
|
|
2135
|
+
</div>
|
|
2136
|
+
</div>
|
|
2137
|
+
<!-- 地址是点开那一刻才设的:没点之前一个字节都不加载。 -->
|
|
2138
|
+
<iframe id="mirrorFrame" title="电脑端界面"></iframe>
|
|
2139
|
+
</div>
|
|
2140
|
+
|
|
1611
2141
|
<!-- 统一操作面板:输入框旁边那一个入口点开的就是它,上面「添加文件」、下面「调用指令」。
|
|
1612
2142
|
原来这两件事各有各的门(回形针直接开文件选择器、打 `/` 才弹指令菜单),手机上没人
|
|
1613
2143
|
知道打一个斜杠还能调指令。
|
|
@@ -1676,6 +2206,7 @@
|
|
|
1676
2206
|
|
|
1677
2207
|
<div id="gate">
|
|
1678
2208
|
<h1>需要访问令牌</h1>
|
|
2209
|
+
<p class="revoked" id="gateRevoked" hidden>这台设备的授权已被移除,请重新扫码。</p>
|
|
1679
2210
|
<p>在电脑上打开 DSH 的「设置 → 手机遥控」,用手机重新扫一次二维码就能进来。
|
|
1680
2211
|
或者把那一页上的链接整条复制到浏览器地址栏打开。</p>
|
|
1681
2212
|
<input id="gateInput" type="text" placeholder="粘贴 token" autocomplete="off"
|
|
@@ -1692,6 +2223,9 @@
|
|
|
1692
2223
|
|
|
1693
2224
|
var DEFAULT_MODE = '__DEFAULT_MODE__';
|
|
1694
2225
|
var NEEDS_TOKEN = __NEEDS_TOKEN__;
|
|
2226
|
+
// 这台设备是**被撤销了**(名册里那条记录被删掉),还是从来没进过门?
|
|
2227
|
+
// 由服务端判(它才知道凭据兑不兑得出来),页面只负责把那句话说给用户听。
|
|
2228
|
+
var DEVICE_REVOKED = __DEVICE_REVOKED__;
|
|
1695
2229
|
// 构建指纹,由服务端注入。拼在立绘地址后面,换了图浏览器就不会一直用缓存那张。
|
|
1696
2230
|
var BUILD = '__BUILD__';
|
|
1697
2231
|
// 单个文件的大小上限(字节),由服务端注入。页面里不写第二份——两处各写一个数,
|
|
@@ -1701,7 +2235,14 @@
|
|
|
1701
2235
|
// ---------------- 状态 ----------------
|
|
1702
2236
|
var params = new URLSearchParams(location.search);
|
|
1703
2237
|
var state = {
|
|
1704
|
-
|
|
2238
|
+
/**
|
|
2239
|
+
* 进没进门——**由服务端说了算**(2026-10-16 第二步)。
|
|
2240
|
+
*
|
|
2241
|
+
* 服务端只在「没放行」的那一次渲染门禁页(needsToken=true);放行时永远是 false。
|
|
2242
|
+
* 这是页面之后唯一需要的身份信息:真正的凭据是一枚 HttpOnly 的设备 cookie,
|
|
2243
|
+
* 页面看不见、也不该管它——浏览器自己会给同源的每一趟请求带上。
|
|
2244
|
+
*/
|
|
2245
|
+
entered: !NEEDS_TOKEN,
|
|
1705
2246
|
mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
|
|
1706
2247
|
// 外观:'dark' | 'light' | 'auto'。默认深色——不给老用户换掉现在的样子;
|
|
1707
2248
|
// 要浅色或跟着系统走,去设置页「外观」点一下。
|
|
@@ -1774,11 +2315,14 @@
|
|
|
1774
2315
|
*/
|
|
1775
2316
|
pendingCommand: null
|
|
1776
2317
|
};
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
2318
|
+
/**
|
|
2319
|
+
* 地址里那一枚令牌**只是入场那一下**(2026-10-16 第二步)。
|
|
2320
|
+
*
|
|
2321
|
+
* 服务端在上面那一次请求里已经把它换成了设备凭据(HttpOnly cookie,浏览器自己带),
|
|
2322
|
+
* 页面从此不读它、不存它、也不再往任何请求里放它。这里只做一件事:把地址栏擦干净,
|
|
2323
|
+
* 免得被截图/分享带出去。
|
|
2324
|
+
*/
|
|
2325
|
+
if (params.get('token')) history.replaceState(null, '', location.pathname);
|
|
1782
2326
|
|
|
1783
2327
|
/**
|
|
1784
2328
|
* ---------------- 执行轨迹:订阅与收帧(数据层) ----------------
|
|
@@ -1943,7 +2487,7 @@
|
|
|
1943
2487
|
* 读不到就把原因写出来,不说「没有过程」。
|
|
1944
2488
|
*/
|
|
1945
2489
|
function requestTrajectory() {
|
|
1946
|
-
if (state.mode !== 'full' || !state.
|
|
2490
|
+
if (state.mode !== 'full' || !state.entered) return;
|
|
1947
2491
|
var sessionId = state.boundSessionId;
|
|
1948
2492
|
if (!sessionId) {
|
|
1949
2493
|
state.trajectory = [];
|
|
@@ -1977,7 +2521,7 @@
|
|
|
1977
2521
|
* 重连成功与否都不用在这里补救——`open` 里那一套会再走一遍。
|
|
1978
2522
|
*/
|
|
1979
2523
|
function syncTrajectorySubscription() {
|
|
1980
|
-
if (!state.
|
|
2524
|
+
if (!state.entered) return;
|
|
1981
2525
|
var want = state.mode === 'full';
|
|
1982
2526
|
if (!want) {
|
|
1983
2527
|
if (subscribedClientId) {
|
|
@@ -2051,8 +2595,8 @@
|
|
|
2051
2595
|
var name = src.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
2052
2596
|
if (/^lib\/art\//i.test(name)) name = name.slice(8);
|
|
2053
2597
|
if (/^[a-z0-9-]+(\.webp)?$/i.test(name)) return artUrl(name.replace(/(\.webp)?$/i, ''));
|
|
2054
|
-
|
|
2055
|
-
|
|
2598
|
+
// **不拼令牌**:这条路和别的接口一样,靠浏览器自动带上的设备 cookie 认人。
|
|
2599
|
+
return '/mini/art/?p=' + encodeURIComponent(src);
|
|
2056
2600
|
}
|
|
2057
2601
|
|
|
2058
2602
|
function mdInline(raw) {
|
|
@@ -2262,14 +2806,59 @@
|
|
|
2262
2806
|
function toast(msg) {
|
|
2263
2807
|
var el = $('toast');
|
|
2264
2808
|
el.textContent = msg;
|
|
2809
|
+
// 普通提示**一定是纯字**:上一版如果带着「撤销」那颗按钮,这里要把它连同
|
|
2810
|
+
// with-act 一起收掉,否则会留下一个按了没反应的按钮(它的回调早就过期了)。
|
|
2811
|
+
el.classList.remove('with-act');
|
|
2265
2812
|
el.classList.add('show');
|
|
2266
2813
|
clearTimeout(toastTimer);
|
|
2267
2814
|
toastTimer = setTimeout(function () { el.classList.remove('show'); }, 1800);
|
|
2268
2815
|
}
|
|
2816
|
+
|
|
2817
|
+
/**
|
|
2818
|
+
* 带一个动作的提示。目前只有一处用:归档成功后的「撤销」。
|
|
2819
|
+
*
|
|
2820
|
+
* 为什么单独一个函数、而不是给 toast 加参数:普通提示是**纯字、点不中**的
|
|
2821
|
+
* (见 #toast 的 pointer-events),带动作就得反过来——两种形状差别够大,
|
|
2822
|
+
* 混在一个函数里迟早会让某一条普通提示也莫名其妙能点。
|
|
2823
|
+
*
|
|
2824
|
+
* 停留 6 秒:短于这个数,用户看到"归档了"再去找按钮就已经没了;长于这个数,
|
|
2825
|
+
* 它会一直压着输入区上方那块地方。
|
|
2826
|
+
*/
|
|
2827
|
+
function toastAction(msg, label, onTap) {
|
|
2828
|
+
var el = $('toast');
|
|
2829
|
+
el.textContent = '';
|
|
2830
|
+
var text = document.createElement('span');
|
|
2831
|
+
text.textContent = msg;
|
|
2832
|
+
el.appendChild(text);
|
|
2833
|
+
var btn = document.createElement('button');
|
|
2834
|
+
btn.type = 'button';
|
|
2835
|
+
btn.className = 'toast-act';
|
|
2836
|
+
btn.textContent = label;
|
|
2837
|
+
btn.addEventListener('click', function () {
|
|
2838
|
+
el.classList.remove('show');
|
|
2839
|
+
el.classList.remove('with-act');
|
|
2840
|
+
clearTimeout(toastTimer);
|
|
2841
|
+
onTap();
|
|
2842
|
+
});
|
|
2843
|
+
el.appendChild(btn);
|
|
2844
|
+
el.classList.add('with-act');
|
|
2845
|
+
el.classList.add('show');
|
|
2846
|
+
clearTimeout(toastTimer);
|
|
2847
|
+
toastTimer = setTimeout(function () {
|
|
2848
|
+
el.classList.remove('show');
|
|
2849
|
+
// 收掉的时候把 with-act 一起收:光藏起来的话,那层透明的提示仍然接着点击。
|
|
2850
|
+
setTimeout(function () { el.classList.remove('with-act'); }, 220);
|
|
2851
|
+
}, 6000);
|
|
2852
|
+
}
|
|
2853
|
+
/**
|
|
2854
|
+
* 每一趟请求带什么头。
|
|
2855
|
+
*
|
|
2856
|
+
* **一个凭据都不带**(2026-10-16 第二步):进门那一下服务端已经把身份换成了设备 cookie
|
|
2857
|
+
* (HttpOnly),浏览器自己会给同源的每一趟请求带上它——请求头和地址里从此不再出现令牌。
|
|
2858
|
+
* 这也是「名册里删掉那条记录 = 那台设备当场什么都做不了」能成立的前提。
|
|
2859
|
+
*/
|
|
2269
2860
|
function authHeaders(isJson) {
|
|
2270
|
-
return isJson === false
|
|
2271
|
-
? { 'X-Mini-Token': state.token }
|
|
2272
|
-
: { 'Content-Type': 'application/json', 'X-Mini-Token': state.token };
|
|
2861
|
+
return isJson === false ? {} : { 'Content-Type': 'application/json' };
|
|
2273
2862
|
}
|
|
2274
2863
|
function api(path, options) {
|
|
2275
2864
|
var opts = options || {};
|
|
@@ -2285,6 +2874,9 @@
|
|
|
2285
2874
|
// 状态码挂在错上带出去:调用方要区分「服务端说这题已经结束了」(409)和
|
|
2286
2875
|
// 「压根没连上」(fetch 直接抛)——靠中文字面去猜太脆。
|
|
2287
2876
|
err.status = r.status;
|
|
2877
|
+
// 整个响应体也挂上。归档那条要**读里面的 activity**(会停掉哪些工作)才能
|
|
2878
|
+
// 弹确认框——只靠状态码看不出"还有工作在跑"和"工作区服务拿不到"的区别。
|
|
2879
|
+
err.body = body;
|
|
2288
2880
|
throw err;
|
|
2289
2881
|
}
|
|
2290
2882
|
return body;
|
|
@@ -2469,10 +3061,12 @@
|
|
|
2469
3061
|
return out;
|
|
2470
3062
|
}
|
|
2471
3063
|
|
|
2472
|
-
/**
|
|
3064
|
+
/**
|
|
3065
|
+
* 立绘地址。**不带令牌**(2026-10-16 第二步):进门之后浏览器手里有设备 cookie,
|
|
3066
|
+
* 同源的 <img> 自己会带上它。指纹照旧——换了图浏览器不会一直用缓存那张。
|
|
3067
|
+
*/
|
|
2473
3068
|
function artUrl(file) {
|
|
2474
|
-
return '/mini/art/' + file + '.webp?
|
|
2475
|
-
+ '&v=' + encodeURIComponent(BUILD);
|
|
3069
|
+
return '/mini/art/' + file + '.webp?v=' + encodeURIComponent(BUILD);
|
|
2476
3070
|
}
|
|
2477
3071
|
|
|
2478
3072
|
/**
|
|
@@ -2743,13 +3337,30 @@
|
|
|
2743
3337
|
if (list.length > shown) nodes += '<div class="q-node"><i></i><b>…</b></div>';
|
|
2744
3338
|
var rows = '';
|
|
2745
3339
|
for (var i = 0; i < list.length; i += 1) {
|
|
3340
|
+
// 插话和排队**要分得出来**(2026-10-06):服务端每条都带 placement,
|
|
3341
|
+
// `next-step` 是插话(下一个步骤边界就取走,通常几秒),
|
|
3342
|
+
// `next-turn` 是排队(等这一轮跑完,可能几分钟)。原来两条画得一模一样。
|
|
3343
|
+
var isSteer = list[i].placement === 'next-step';
|
|
2746
3344
|
rows += '<div class="q-row">'
|
|
3345
|
+
+ (isSteer ? '<span class="q-tag">插话</span>' : '')
|
|
2747
3346
|
+ '<span class="q-text">' + escapeHtml(list[i].text) + '</span>'
|
|
2748
3347
|
+ '<button class="q-drop" type="button" data-drop="' + escapeHtml(list[i].id) + '"'
|
|
2749
3348
|
+ ' aria-label="撤掉这条"><svg viewBox="0 0 24 24" aria-hidden="true">'
|
|
2750
3349
|
+ '<path d="M6 6l12 12M18 6L6 18"/></svg></button></div>';
|
|
2751
3350
|
}
|
|
2752
|
-
|
|
3351
|
+
// 标题得跟着实际内容说:**只要有一条插话,「跑完这轮就轮到它们」就是假话**。
|
|
3352
|
+
// 三种情况各说各的,不糊一句通用的。
|
|
3353
|
+
var steerCount = 0;
|
|
3354
|
+
for (var k = 0; k < list.length; k += 1) {
|
|
3355
|
+
if (list[k].placement === 'next-step') steerCount += 1;
|
|
3356
|
+
}
|
|
3357
|
+
var waitCount = list.length - steerCount;
|
|
3358
|
+
var head = steerCount && waitCount
|
|
3359
|
+
? '插话 ' + steerCount + ' 条(下一步就看到) · 排队 ' + waitCount + ' 条'
|
|
3360
|
+
: steerCount
|
|
3361
|
+
? '插话 ' + steerCount + ' 条 · 它下一步就看到'
|
|
3362
|
+
: '排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们';
|
|
3363
|
+
el.innerHTML = '<div class="q-head">' + head + '</div>'
|
|
2753
3364
|
+ '<div class="q-scale">' + nodes + '</div>' + rows;
|
|
2754
3365
|
}
|
|
2755
3366
|
|
|
@@ -3307,6 +3918,33 @@
|
|
|
3307
3918
|
*/
|
|
3308
3919
|
var pinnedAnswerKey = '';
|
|
3309
3920
|
|
|
3921
|
+
/**
|
|
3922
|
+
* 这一轮**助手侧内容的第一块**:从最后那条回答往回退,只跨紧挨着它的过程组
|
|
3923
|
+
* 与轨迹说明(trajInterleave 把组插在**该轮第一个回答之前**,见那里的落点 a),
|
|
3924
|
+
* 退不动就停在哪儿。
|
|
3925
|
+
*
|
|
3926
|
+
* 为什么「完整」模式要多退这一步:用户 2026-10-08 报的——单帧和聊天停在回答的开头
|
|
3927
|
+
* 是对的,完整模式照做就错了。回答前面还有这一轮的执行轨迹,把回答的开头顶到屏幕
|
|
3928
|
+
* 最上沿,等于把那串过程整个推到屏幕上方:他落定那一刻看到的不是这一轮的开头,
|
|
3929
|
+
* 而是轨迹的尾巴接着正文。停在这一轮的头上,才是从他要读的地方读起。
|
|
3930
|
+
*
|
|
3931
|
+
* **用户自己发的那条指令不算**:他刚敲过,不用再读一遍;要读的是助手这一侧。
|
|
3932
|
+
* 所以往回走只认过程组和那句「这份轨迹不是全部」的说明,碰到别的节点
|
|
3933
|
+
* (`.bubble.user`、`.cmd`、子智能体卡片、页面上那几条说明)就停——一轮的边界
|
|
3934
|
+
* 本来就在那儿。这也顺带保证了**这一轮没有过程**时原样返回那条回答:完整模式
|
|
3935
|
+
* 退化成聊天模式的样子,不会跑到上一轮的轨迹上去。
|
|
3936
|
+
*/
|
|
3937
|
+
function trajRunHead(saidEl) {
|
|
3938
|
+
var node = saidEl;
|
|
3939
|
+
for (;;) {
|
|
3940
|
+
var prev = node.previousElementSibling;
|
|
3941
|
+
if (!prev || !prev.classList) break;
|
|
3942
|
+
if (!prev.classList.contains('traj-group') && !prev.classList.contains('traj-note')) break;
|
|
3943
|
+
node = prev;
|
|
3944
|
+
}
|
|
3945
|
+
return node;
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3310
3948
|
/**
|
|
3311
3949
|
* 聊天模式的滚动:**回答的开头才是要读的地方,不是末尾。**
|
|
3312
3950
|
*
|
|
@@ -3323,7 +3961,8 @@
|
|
|
3323
3961
|
* 就被拽回末尾,等于没改。
|
|
3324
3962
|
*
|
|
3325
3963
|
* · **落定**(timestamp 变了)——对齐最后那条 `.said` 的开头,
|
|
3326
|
-
*
|
|
3964
|
+
* 让最终版从第一行开始读。(**完整**模式再往前退到这一轮的头上,
|
|
3965
|
+
* 理由见 trajRunHead:那一轮的过程组就排在回答前面。)
|
|
3327
3966
|
*
|
|
3328
3967
|
* 为什么要记 key、而不是「每次都对齐」:用户往下读的时候,每次重画都把他拽回
|
|
3329
3968
|
* 开头,比不对齐还烦人。这一点和单帧模式里 scrolledReplyAt 的道理是一样的。
|
|
@@ -3352,6 +3991,9 @@
|
|
|
3352
3991
|
} else if (replyEl.querySelectorAll) {
|
|
3353
3992
|
var saids = replyEl.querySelectorAll('.said');
|
|
3354
3993
|
target = saids.length ? saids[saids.length - 1] : null;
|
|
3994
|
+
// 「完整」模式要停的是**这一轮的头上**,不是这条回答的头上(见 trajRunHead)。
|
|
3995
|
+
// 「正在流」那一路不对齐这里:用户看的就是刚流出来的那段本身。
|
|
3996
|
+
if (target && state.mode === 'full') target = trajRunHead(target);
|
|
3355
3997
|
}
|
|
3356
3998
|
pinToTop(target);
|
|
3357
3999
|
return;
|
|
@@ -3734,9 +4376,15 @@
|
|
|
3734
4376
|
var kind = last && last.kind === 'tool' ? trajActivity(last.name || '') : 'thinking';
|
|
3735
4377
|
// 活片段优先(正在想的那一句,随打字刷新,PC 的 label · detail 同款);
|
|
3736
4378
|
// 没有片段才退到条目自己的摘要。
|
|
3737
|
-
var
|
|
3738
|
-
|| (last
|
|
3739
|
-
|
|
4379
|
+
var prose = state.trajectoryLive
|
|
4380
|
+
|| (last && last.kind === 'think' ? trajFirstLine(last.output) : '');
|
|
4381
|
+
var plain = !prose && last ? (last.summary || '') : '';
|
|
4382
|
+
// 右边那半边是模型原话时走行内渲染:**加粗**、`代码` 要成样式,不是把记号原样贴在
|
|
4383
|
+
// 脸上(2026-10-06 用户报的,和 2026-09-27 气泡那回同一个根因)。
|
|
4384
|
+
// 工具摘要**不**走——那是命令/路径原文,一条「ls *.ts *.js」里的两个星号会被斜体
|
|
4385
|
+
// 规则吃掉(在真渲染器上试过),摘要就照旧只转义。
|
|
4386
|
+
var detail = prose ? mdInline(prose) : (plain ? escapeHtml(plain) : '');
|
|
4387
|
+
return { kind: kind, live: true, text: TRAJ_LIVE[kind], detailHtml: detail };
|
|
3740
4388
|
}
|
|
3741
4389
|
return trajTitleOf(g);
|
|
3742
4390
|
}
|
|
@@ -3791,8 +4439,11 @@
|
|
|
3791
4439
|
function trajGroupHtml(g) {
|
|
3792
4440
|
var open = trajGroupIsOpen(g);
|
|
3793
4441
|
var t = trajGroupTitle(g);
|
|
4442
|
+
// 右半边(活片段/思考原话)在来源处已经渲染好、也转义过,见 trajGroupTitle;
|
|
4443
|
+
// 状态词与收官标题是我们自己拼的词,这里照旧转义。
|
|
3794
4444
|
var title = t.live
|
|
3795
|
-
? '<span class="traj-run traj-shimmer"' + trajShimStyle() + '>' + escapeHtml(t.text)
|
|
4445
|
+
? '<span class="traj-run traj-shimmer"' + trajShimStyle() + '>' + escapeHtml(t.text)
|
|
4446
|
+
+ (t.detailHtml ? ' · ' + t.detailHtml : '') + '</span>'
|
|
3796
4447
|
: '<span class="traj-title">' + escapeHtml(t.text) + '</span>';
|
|
3797
4448
|
// 组头只显示状态词/标题,失败信息在下方条目行里显示,不重复。
|
|
3798
4449
|
var head = '<button type="button" class="traj-head" data-traj-group="'
|
|
@@ -3898,6 +4549,33 @@
|
|
|
3898
4549
|
if (head) toggleTrajGroup(head.getAttribute('data-traj-group'));
|
|
3899
4550
|
}
|
|
3900
4551
|
|
|
4552
|
+
/**
|
|
4553
|
+
* 「回到底部」那颗浮标的露 / 收。
|
|
4554
|
+
*
|
|
4555
|
+
* 判据直接用 atBottom(),不另设一个数。这样有个好处:**浮标出现的那一刻,正好
|
|
4556
|
+
* 就是页面停止自动跟随的那一刻**——两处要是各用各的容差(比如浮标 25px、跟随 48px),
|
|
4557
|
+
* 中间那段就会出现「浮标说没到底、页面却自己在往下滚」这种自相矛盾的样子。
|
|
4558
|
+
* 电脑端那颗用的是它自己的 25px 容差(useChatReading 里的 useScrollFollow(_, 25)),
|
|
4559
|
+
* 这里跟的是本页既有的 48px,因为本页的黏底判据就是它。
|
|
4560
|
+
*
|
|
4561
|
+
* **单帧模式下一律不出现**(用户 2026-10-08 要求)。那一模式整屏只留最新一条回答,
|
|
4562
|
+
* 回答落定的那一刻页面**故意**把它的开头对齐到顶部(见 renderMinimal 里
|
|
4563
|
+
* scrolledReplyAt 那一段),好让人从第一行读起。所以那儿「不在底部」是设计如此,
|
|
4564
|
+
* **不是「用户翻上去了」**——这时候挂一颗「回到底部」,等于劝用户把刚对齐好的
|
|
4565
|
+
* 开头再滚掉,正好把这个模式唯一要做的事抵消掉。
|
|
4566
|
+
* 聊天 / 完整是多帧(一串历史一条条往上排),那里不在底部就是用户自己翻上去的,
|
|
4567
|
+
* 照旧出现。所以这条判的是**模式**(state.mode),不是新设一个「离底多少像素」。
|
|
4568
|
+
*
|
|
4569
|
+
* 只改 hidden,**不碰任何位置和尺寸**:位置全在 CSS 的 sticky 里算好了(见样式表)。
|
|
4570
|
+
* 每次现取元素、取不到就静静退出——页面测试会把 render 那一段单独抠出来跑,
|
|
4571
|
+
* 那里给的 $ 是替身,不能假设它一定认得这个 id。
|
|
4572
|
+
*/
|
|
4573
|
+
function paintToBottom() {
|
|
4574
|
+
var el = $('toBottom');
|
|
4575
|
+
if (!el) return;
|
|
4576
|
+
el.hidden = atBottom() || state.mode === 'minimal';
|
|
4577
|
+
}
|
|
4578
|
+
|
|
3901
4579
|
function render() {
|
|
3902
4580
|
$('hintMode').textContent = modeHint();
|
|
3903
4581
|
if (state.mode === 'full') renderFull(); else if (state.mode === 'chat') renderChat(); else renderMinimal();
|
|
@@ -3905,6 +4583,9 @@
|
|
|
3905
4583
|
// 进度条由 CSS 藏掉,只留「已 N 秒 / 停止」。#work 不跟着 #reply 重画,
|
|
3906
4584
|
// 所以这一下挂在总分发这里,切模式时各归各位。
|
|
3907
4585
|
$('work').className = 'work' + (state.mode === 'full' ? ' bare' : '');
|
|
4586
|
+
// 重画之后滚动位置可能被浏览器夹回来(内容变短)——那种情况不一定发 scroll 事件,
|
|
4587
|
+
// 所以浮标的状态也在这里跟一次。放在最后:上面几句可能刚动过 scrollTop。
|
|
4588
|
+
paintToBottom();
|
|
3908
4589
|
}
|
|
3909
4590
|
|
|
3910
4591
|
function applySnapshot(snap) {
|
|
@@ -3971,6 +4652,46 @@
|
|
|
3971
4652
|
var navPresets = null; // Agent 模式清单(2026-10-04:新建会话时手机上也要能选模式)
|
|
3972
4653
|
var navPresetsLoaded = false;
|
|
3973
4654
|
var navPickerWs = ''; // 正在挑模式的那个工作区(清单行内展开在它的「新建会话」下)
|
|
4655
|
+
// —— 会话条目的执行功能(2026-10-09)——
|
|
4656
|
+
/**
|
|
4657
|
+
* 「显示已归档」这枚筛选开关**摆在界面外**:用户 2026-10-09 要求——这个功能不需要,
|
|
4658
|
+
* 但既然做了,先别删、隐藏起来。
|
|
4659
|
+
*
|
|
4660
|
+
* 所以下面这一整套**一行都没删**,只是不再画到界面上:开关的渲染(renderNav 里
|
|
4661
|
+
* 那一段还在)、navShowArchived 这个状态、setShowArchived、拼请求时的 archived=1、
|
|
4662
|
+
* 服务端那条筛选口径、以及相关测试。
|
|
4663
|
+
*
|
|
4664
|
+
* **要恢复这个开关:把这一行的 false 改成 true 就行**,别的地方一处都不用动。
|
|
4665
|
+
*/
|
|
4666
|
+
var SHOW_ARCHIVED_FILTER = false;
|
|
4667
|
+
/**
|
|
4668
|
+
* 「另有 N 条已归档、M 条还是空白的新会话」这句**也不摆到界面上**:
|
|
4669
|
+
* 用户 2026-10-09 要求——这句不需要显示,先不删、隐藏起来。
|
|
4670
|
+
*
|
|
4671
|
+
* 所以那句话的渲染、以及支撑它的两个数(hiddenArchived / hiddenBlank,服务端照旧在算、
|
|
4672
|
+
* 照旧随列表一起回来)**一行都没删**,只是不再画出来。
|
|
4673
|
+
* 注意闸住的**只是这半句**:同一块里前面那句「这个工作区里没有能列出来的会话。」
|
|
4674
|
+
* 照旧摆着——那是"会话全被筛掉了"这个情形的兜底文字,一起藏掉的话,
|
|
4675
|
+
* 工作区标题下面就是一片空白,用户会以为界面坏了。
|
|
4676
|
+
*
|
|
4677
|
+
* **要恢复这句话:把下面这一行的 false 改成 true 就行**,别的地方一处都不用动。
|
|
4678
|
+
*/
|
|
4679
|
+
var SHOW_NAV_COUNT_HINT = false;
|
|
4680
|
+
var navShowArchived = false; // 筛选:默认**隐藏已归档**(官方默认项就是这个)
|
|
4681
|
+
var navActing = ''; // 正在行内展开动作条的那条会话 id
|
|
4682
|
+
var navRenaming = ''; // 正在改名字的那条会话 id
|
|
4683
|
+
var navConfirm = null; // 归档确认:{ sessionId, title, activity };非空就是弹着
|
|
4684
|
+
var navExtra = {}; // workspaceId -> 已经点了几次「展开其余」
|
|
4685
|
+
var navLimit = {}; // workspaceId -> 这次向服务端要了多少条(要更多时递增)
|
|
4686
|
+
|
|
4687
|
+
/**
|
|
4688
|
+
* 「展开其余」一次多几条。**这个 5 是官方口径**,不是我们挑的:
|
|
4689
|
+
* PC 侧栏的文档原话是「默认只显示 5 条空闲 Session……每次点击『展开其余』最多再显示五条」。
|
|
4690
|
+
* 正在跑的会话不占这 5 条配额,始终显示——同一个来源。
|
|
4691
|
+
*/
|
|
4692
|
+
var NAV_IDLE_QUOTA = 5;
|
|
4693
|
+
/** 一次向服务端要多少条。要少了「展开其余」很快就没得展开;要多了标题那一步很贵。 */
|
|
4694
|
+
var NAV_PAGE = 20;
|
|
3974
4695
|
|
|
3975
4696
|
function openNav() {
|
|
3976
4697
|
$('nav').classList.add('open');
|
|
@@ -4011,7 +4732,7 @@
|
|
|
4011
4732
|
function prefetchWorkspaces() {
|
|
4012
4733
|
// Agent 模式清单也是导航栏要用的、也几乎不变——顺手一起预取(见 prefetchPresets)。
|
|
4013
4734
|
prefetchPresets();
|
|
4014
|
-
if (navList || navPrefetchPromise || navView !== 'tree' || !state.
|
|
4735
|
+
if (navList || navPrefetchPromise || navView !== 'tree' || !state.entered) return;
|
|
4015
4736
|
var run = function () {
|
|
4016
4737
|
navPrefetchPromise = api('/mini/api/workspaces').then(function (body) {
|
|
4017
4738
|
navList = body.workspaces || [];
|
|
@@ -4035,7 +4756,7 @@
|
|
|
4035
4756
|
* **不摆一个只有一项的选择题**,也不假装自己知道 DSH 有哪些模式。
|
|
4036
4757
|
*/
|
|
4037
4758
|
function prefetchPresets() {
|
|
4038
|
-
if (navPresetsLoaded || !state.
|
|
4759
|
+
if (navPresetsLoaded || !state.entered) return;
|
|
4039
4760
|
navPresetsLoaded = true;
|
|
4040
4761
|
api('/mini/api/agent-presets').then(function (body) {
|
|
4041
4762
|
navPresets = body && body.ok ? body : { ok: false, reason: (body && body.reason) || 'failed' };
|
|
@@ -4066,12 +4787,24 @@
|
|
|
4066
4787
|
// 「新建工作区」常驻在最上面:它是这一屏的入口,不该等有了会话才出现——
|
|
4067
4788
|
// 一个工作区都还没有的人,恰恰最需要它。
|
|
4068
4789
|
var cta = '<button class="nav-cta" type="button" data-new-ws="1">+ 新建工作区</button>';
|
|
4790
|
+
// 「显示已归档」是**整屏一个**的口径,所以放在 CTA 下面、工作区上面:
|
|
4791
|
+
// 它管的不是某一个工作区,是所有工作区里"已归档的那些要不要出现"。
|
|
4792
|
+
// 默认关(官方的默认项就是"隐藏已归档")。
|
|
4793
|
+
//
|
|
4794
|
+
// 整块由 SHOW_ARCHIVED_FILTER 闸住(见那个常量上的说明):闸住时这一段一个字都不画,
|
|
4795
|
+
// **但渲染代码原样留着**——恢复就是改那一个词,不用重写。
|
|
4796
|
+
var filter = SHOW_ARCHIVED_FILTER
|
|
4797
|
+
? '<button class="nav-filter' + (navShowArchived ? ' on' : '')
|
|
4798
|
+
+ '" type="button" data-archived-toggle="1" aria-pressed="' + (navShowArchived ? 'true' : 'false')
|
|
4799
|
+
+ '">' + (navShowArchived ? '✓ ' : '')
|
|
4800
|
+
+ (navShowArchived ? '显示已归档(点一下收起来)' : '显示已归档') + '</button>'
|
|
4801
|
+
: '';
|
|
4069
4802
|
if (!list.length) {
|
|
4070
|
-
$('navBody').innerHTML = cta + '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
4803
|
+
$('navBody').innerHTML = cta + filter + '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
4071
4804
|
+ '在电脑上开一个,这里就会出现了。</div>';
|
|
4072
4805
|
return;
|
|
4073
4806
|
}
|
|
4074
|
-
$('navBody').innerHTML = cta + list.map(function (ws) {
|
|
4807
|
+
$('navBody').innerHTML = cta + filter + list.map(function (ws) {
|
|
4075
4808
|
var open = Boolean(navExpanded[ws.id]);
|
|
4076
4809
|
return '<div class="ws' + (open ? ' open' : '') + '" data-ws="' + escapeHtml(ws.id) + '">'
|
|
4077
4810
|
+ '<button class="ws-head" type="button">'
|
|
@@ -4087,6 +4820,76 @@
|
|
|
4087
4820
|
}).join('');
|
|
4088
4821
|
}
|
|
4089
4822
|
|
|
4823
|
+
/** 这个工作区现在到底该摆出哪几条。**口径都在这里,一处说了算。** */
|
|
4824
|
+
function navVisible(wsId, got) {
|
|
4825
|
+
var ex = navExtra[wsId] || 0;
|
|
4826
|
+
var quota = NAV_IDLE_QUOTA * (ex + 1);
|
|
4827
|
+
var rows = [];
|
|
4828
|
+
var idleShown = 0;
|
|
4829
|
+
var idleTotal = 0;
|
|
4830
|
+
(got.sessions || []).forEach(function (s) {
|
|
4831
|
+
if (s.running === true) { rows.push(s); return; }
|
|
4832
|
+
idleTotal += 1;
|
|
4833
|
+
if (idleShown < quota) { rows.push(s); idleShown += 1; }
|
|
4834
|
+
});
|
|
4835
|
+
var remaining = Math.max(0, idleTotal - idleShown);
|
|
4836
|
+
return {
|
|
4837
|
+
rows: rows,
|
|
4838
|
+
quota: quota,
|
|
4839
|
+
// 页里还有没摆出来的,或者服务端说这个工作区还有没取回来的——两种都算"还有"
|
|
4840
|
+
more: remaining > 0 || got.truncated === true,
|
|
4841
|
+
remaining: remaining,
|
|
4842
|
+
expandable: remaining > 0 || got.truncated === true,
|
|
4843
|
+
// 按钮上那个数:官方口径是"每次最多再显示五条",所以最多写 5。
|
|
4844
|
+
step: remaining > 0 ? Math.min(NAV_IDLE_QUOTA, remaining) : NAV_IDLE_QUOTA,
|
|
4845
|
+
collapsed: ex > 0 && !(remaining > 0 || got.truncated === true),
|
|
4846
|
+
};
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
/** 一条会话在行尾该显示什么。**一行只显示一个**:尾部那块地方只够一样。 */
|
|
4850
|
+
function navTail(s) {
|
|
4851
|
+
if (s.pending === 'approval') return '<span class="s-flag pending">待审批</span>';
|
|
4852
|
+
if (s.pending === 'plan') return '<span class="s-flag pending">计划待审</span>';
|
|
4853
|
+
if (s.pending === 'question') return '<span class="s-flag pending">待回答</span>';
|
|
4854
|
+
var badge = '';
|
|
4855
|
+
if (s.archived) badge = '<span class="s-flag">已归档</span>';
|
|
4856
|
+
else if (s.pinned) badge = '<span class="s-flag">已置顶</span>';
|
|
4857
|
+
return badge + '<span class="s-meta">' + dayLabel(s.createdAt) + '</span>';
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
/**
|
|
4861
|
+
* 动作条。**归档行不给置顶**——官方规定归档与置顶互斥(归档会把置顶一并丢掉),
|
|
4862
|
+
* 摆一个按了会被拒的按钮,比不摆更糟。
|
|
4863
|
+
*
|
|
4864
|
+
* 顺序照电脑端的槽位号摆(2026-10-09 从 0.2.0-rc.2 的 `dsh-client-ui-workspace` 读到原文):
|
|
4865
|
+
* `sidebar.workspaces.session.menu.item` 上 pin=order 100、rename=200、**fork=300**、
|
|
4866
|
+
* archive=400。手机这一条横排也是一样的先后,用户从电脑换到手机上不用重新找。
|
|
4867
|
+
*
|
|
4868
|
+
* 分叉这一项**归档行也留着**:官方的菜单项没有"归档就不给分叉"这条判断
|
|
4869
|
+
* (只有 pin 那一个在归档行上返回 null),而且分叉出来的是一条独立的新会话。
|
|
4870
|
+
*/
|
|
4871
|
+
function navActs(s) {
|
|
4872
|
+
var id = escapeHtml(s.id);
|
|
4873
|
+
var pin = s.archived
|
|
4874
|
+
? ''
|
|
4875
|
+
: '<button type="button" data-act="pin" data-act-id="' + id + '" data-act-on="'
|
|
4876
|
+
+ (s.pinned ? '1' : '0') + '">' + (s.pinned ? '取消置顶' : '置顶会话') + '</button>';
|
|
4877
|
+
var arc = s.archived
|
|
4878
|
+
? '<button type="button" data-act="unarchive" data-act-id="' + id + '">取消归档</button>'
|
|
4879
|
+
: '<button type="button" class="danger" data-act="archive" data-act-id="' + id + '">归档会话</button>';
|
|
4880
|
+
return '<div class="sess-acts">'
|
|
4881
|
+
+ pin
|
|
4882
|
+
+ '<button type="button" data-act="rename" data-act-id="' + id + '">重命名</button>'
|
|
4883
|
+
+ '<button type="button" data-act="fork" data-act-id="' + id + '">分叉会话</button>'
|
|
4884
|
+
+ arc
|
|
4885
|
+
+ '</div>'
|
|
4886
|
+
// 「分叉」是个电脑词,非技术用户看不出它会干什么。这句是**极短的说明**:
|
|
4887
|
+
// 只说两件事——从哪儿分(最近完成的一轮)、原会话会怎样(不动)。
|
|
4888
|
+
// 不加的话,用户能猜到的只有"分叉"两个字,而按下去是**在电脑上真建一个会话**,
|
|
4889
|
+
// 不是可以随便试的手势,所以这笔字值得花。
|
|
4890
|
+
+ '<div class="sess-acts-hint">分叉=从最近完成的一轮复制出一个新会话,原来这条不动。</div>';
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4090
4893
|
function sessionsHtml(wsId) {
|
|
4091
4894
|
// 「新建会话」永远排在最上面:不管这个工作区有几个会话、甚至一个都没有,
|
|
4092
4895
|
// 这一行都在。放在列表末尾的话,一个 452 条会话的工作区要划到底才看得到。
|
|
@@ -4110,25 +4913,341 @@
|
|
|
4110
4913
|
}
|
|
4111
4914
|
var got = navSessions[wsId];
|
|
4112
4915
|
if (!got) return html + '<div class="placeholder">正在读取…</div>';
|
|
4113
|
-
if (!got.sessions.length)
|
|
4916
|
+
if (!got.sessions.length) {
|
|
4917
|
+
// 「一条都没有」和「都被筛掉了」是两件事,得分开说——不然用户会以为
|
|
4918
|
+
// 自己的会话不见了(就在刚才,那还是他亲手归档的)。
|
|
4919
|
+
var hid = (got.hiddenArchived || 0) + (got.hiddenBlank || 0);
|
|
4920
|
+
if (hid > 0) {
|
|
4921
|
+
// 后半句(那两条条数)由 SHOW_NAV_COUNT_HINT 闸住(见那个常量上的说明):
|
|
4922
|
+
// 闸住时只少这半句,**前面那句兜底照旧画**——全都不画就成了工作区标题下面
|
|
4923
|
+
// 一片空白,用户只会以为界面坏了。恢复就是改那一个词。
|
|
4924
|
+
var hint = SHOW_NAV_COUNT_HINT
|
|
4925
|
+
? '<br>另有 ' + (got.hiddenArchived || 0) + ' 条已归档、'
|
|
4926
|
+
+ (got.hiddenBlank || 0) + ' 条还是空白的新会话。'
|
|
4927
|
+
: '';
|
|
4928
|
+
return html + '<div class="placeholder">这个工作区里没有能列出来的会话。'
|
|
4929
|
+
+ hint + '</div>';
|
|
4930
|
+
}
|
|
4931
|
+
return html + '<div class="placeholder">这个工作区还没有会话。</div>';
|
|
4932
|
+
}
|
|
4114
4933
|
var cur = state.boundSessionId;
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4934
|
+
var view = navVisible(wsId, got);
|
|
4935
|
+
html += view.rows.map(function (s) {
|
|
4936
|
+
var cls = 'sess';
|
|
4937
|
+
if (s.id === cur) cls += ' cur';
|
|
4938
|
+
// 已归档的行置灰,而且**点不开**——官方原话是「已归档对话暂时无法查看,
|
|
4939
|
+
// 请取消归档后查看」。留着可点会是更坏的结果:点进去是一个空会话。
|
|
4940
|
+
if (s.archived) cls += ' archived';
|
|
4941
|
+
if (navActing === s.id) cls += ' acting';
|
|
4942
|
+
if (navRenaming === s.id) cls += ' renaming';
|
|
4943
|
+
// 行首那颗点:**有等着的就显示等着的**(照 PC:「待处理交互的优先级高于
|
|
4944
|
+
// 共享 ongoing loading」);没有等着才轮到"正在跑"。
|
|
4945
|
+
var dot = s.pending
|
|
4946
|
+
? '<span class="s-dot pending"></span>'
|
|
4947
|
+
: (s.running ? '<span class="s-dot"></span>' : '');
|
|
4948
|
+
var row = '<div class="sess-line">'
|
|
4949
|
+
+ '<button class="' + cls + '" type="button"'
|
|
4119
4950
|
+ ' data-sess="' + escapeHtml(s.id) + '">'
|
|
4120
|
-
+
|
|
4951
|
+
+ dot
|
|
4952
|
+
// 没有标题就用日期——原来退到取 id 的前 8 位当名字,而本机真有一个会话的 id
|
|
4953
|
+
// 以「session-」开头,于是整列都显示成一样的「session-」,等于没有名字。
|
|
4121
4954
|
+ '<span class="s-title">' + escapeHtml(s.title || '未命名会话') + '</span>'
|
|
4122
|
-
+
|
|
4123
|
-
+ '</button>'
|
|
4955
|
+
+ navTail(s)
|
|
4956
|
+
+ '</button>'
|
|
4957
|
+
// ⋯ 是**兄弟**不是子节点:塞进那个 button 里就成了"按钮里套按钮",
|
|
4958
|
+
// 点它会同时触发"打开会话",而且那种结构本身是无效的。
|
|
4959
|
+
+ '<button class="s-more" type="button" aria-label="会话操作"'
|
|
4960
|
+
+ ' data-more-menu="' + escapeHtml(s.id) + '">⋯</button>'
|
|
4961
|
+
+ '</div>';
|
|
4962
|
+
if (navActing === s.id) row += navActs(s);
|
|
4963
|
+
if (navRenaming === s.id) {
|
|
4964
|
+
row += '<div class="sess-rename">'
|
|
4965
|
+
// 预填的是**真实标题**(没有就是空的),绝不是「未命名会话」那个显示占位——
|
|
4966
|
+
// 把占位当标题提交,就等于凭空把"未命名"三个字钉成一个真标题。
|
|
4967
|
+
+ '<input id="renameInput" type="text" value="' + escapeHtml(s.title || '')
|
|
4968
|
+
+ '" placeholder="给这个会话起个名字" aria-label="会话标题">'
|
|
4969
|
+
+ '<button type="button" data-rename-cancel="1">取消</button>'
|
|
4970
|
+
+ '<button type="button" data-rename-ok="1">确定</button>'
|
|
4971
|
+
+ '</div>';
|
|
4972
|
+
}
|
|
4973
|
+
return row;
|
|
4124
4974
|
}).join('');
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4975
|
+
// 「展开其余 N 个 / 收起」。**这里原来是一句死话**(「只列了最近 N 条,共 M 条」),
|
|
4976
|
+
// 展不开也搜不了,用户只能去电脑上找。现在按官方口径真的展开。
|
|
4977
|
+
if (view.expandable) {
|
|
4978
|
+
html += '<button class="sess-more" type="button" data-more="' + escapeHtml(wsId) + '">'
|
|
4979
|
+
+ '展开其余 ' + view.step + ' 个</button>';
|
|
4980
|
+
} else if (view.collapsed) {
|
|
4981
|
+
html += '<button class="sess-more" type="button" data-less="' + escapeHtml(wsId) + '">收起</button>';
|
|
4128
4982
|
}
|
|
4129
4983
|
return html;
|
|
4130
4984
|
}
|
|
4131
4985
|
|
|
4986
|
+
/**
|
|
4987
|
+
* 会话列表里的一条(拿真实数据用,比如"当前标题是什么")。
|
|
4988
|
+
* 找不到返回 null——调用方据此如实说"刷新一下再看看",而不是凭空造一个。
|
|
4989
|
+
*/
|
|
4990
|
+
function navSessionById(id) {
|
|
4991
|
+
var keys = Object.keys(navSessions);
|
|
4992
|
+
for (var i = 0; i < keys.length; i++) {
|
|
4993
|
+
var got = navSessions[keys[i]];
|
|
4994
|
+
var list = (got && got.sessions) || [];
|
|
4995
|
+
for (var j = 0; j < list.length; j++) {
|
|
4996
|
+
if (list[j].id === id) return { wsId: keys[i], row: list[j], got: got };
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4999
|
+
return null;
|
|
5000
|
+
}
|
|
5001
|
+
|
|
5002
|
+
function toggleActs(id) {
|
|
5003
|
+
navRenaming = '';
|
|
5004
|
+
navActing = navActing === id ? '' : id;
|
|
5005
|
+
renderNav();
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
function startRename(id) {
|
|
5009
|
+
navActing = '';
|
|
5010
|
+
navRenaming = id;
|
|
5011
|
+
renderNav();
|
|
5012
|
+
// 光标落在末尾:用户多半是想改几个字,不是想全部重打。
|
|
5013
|
+
var input = $('renameInput');
|
|
5014
|
+
if (input && input.focus) {
|
|
5015
|
+
try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (e) {}
|
|
5016
|
+
}
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
/**
|
|
5020
|
+
* 提交改名。
|
|
5021
|
+
*
|
|
5022
|
+
* **这一步就是"不误钉"的全部**:官方 `SessionTitleService.rename` 的原文是
|
|
5023
|
+
* 「Appends a `session/title` event with the `user` source, which **pins** the title:
|
|
5024
|
+
* in-flight automatic generation is superseded and later user messages schedule none」
|
|
5025
|
+
* ——也就是说**传什么都会钉住**,哪怕跟当前那条自动标题一字不差。
|
|
5026
|
+
* 所以"没改"就**根本不发这一趟**(用户双击进来又原样退出的情形很常见)。
|
|
5027
|
+
*/
|
|
5028
|
+
function renameSubmit() {
|
|
5029
|
+
var id = navRenaming;
|
|
5030
|
+
var found = navSessionById(id);
|
|
5031
|
+
if (!found) { navRenaming = ''; renderNav(); toast('这个会话不在了,刷新一下再看看'); return; }
|
|
5032
|
+
var input = $('renameInput');
|
|
5033
|
+
var next = input ? input.value : '';
|
|
5034
|
+
var current = typeof found.row.title === 'string' ? found.row.title : '';
|
|
5035
|
+
// 只比"归一化之后"的那一份(去空白)。官方那边也会 trim,比原始串会把
|
|
5036
|
+
// "结尾多了一个空格"当成真改动,那正是要避开的误钉。
|
|
5037
|
+
if (next.trim() === current.trim()) {
|
|
5038
|
+
navRenaming = '';
|
|
5039
|
+
renderNav();
|
|
5040
|
+
return;
|
|
5041
|
+
}
|
|
5042
|
+
if (!next.trim()) { toast('标题不能为空'); return; }
|
|
5043
|
+
return api('/mini/api/session/rename', {
|
|
5044
|
+
method: 'POST',
|
|
5045
|
+
body: JSON.stringify({ sessionId: id, title: next }),
|
|
5046
|
+
}).then(function (res) {
|
|
5047
|
+
navRenaming = '';
|
|
5048
|
+
// 用**服务端接受的那一份**改本地这一行:官方会归一化(去控制字符、按字节截断),
|
|
5049
|
+
// 界面要显示的是它认下来的那个标题,不是我们递过去的那串。
|
|
5050
|
+
found.row.title = res && typeof res.title === 'string' ? res.title : next;
|
|
5051
|
+
renderNav();
|
|
5052
|
+
toast('已改名');
|
|
5053
|
+
}).catch(function (err) { toast(err.message); });
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
/**
|
|
5057
|
+
* 分叉会话。电脑端那个功能在手机上等价的一下:在**最后一个已完成轮次**处复制出
|
|
5058
|
+
* 一个新会话,标题递增。
|
|
5059
|
+
*
|
|
5060
|
+
* 三件事照电脑端的原样,一件都不能走样:
|
|
5061
|
+
* · **不打开新会话**:PC 那条路上 `navigation.forkSession()` 之后就结束了,
|
|
5062
|
+
* 没有任何切换动作。所以这里也**一个字都不碰 `state.boundSessionId`**。
|
|
5063
|
+
* · **成功后刷新列表**:新会话是 PC 侧栏那种"排在源会话旁边"的普通行,得重新问一次
|
|
5064
|
+
* 服务端才看得见。而且必须带 `refresh=1`——服务端那份会话清册有 30 秒缓存,
|
|
5065
|
+
* 不绕过的话用户会看到"按了但什么都没多出来"(刚建的会话它还答不上来)。
|
|
5066
|
+
* · **失败时说 Host 那句话**:`err.message` 就是服务端转发过来的原话
|
|
5067
|
+
* ("没有已结束的轮次"那一种带上 Host 的英文原文),这里**不加工、不换词**。
|
|
5068
|
+
*/
|
|
5069
|
+
function forkAct(id) {
|
|
5070
|
+
return api('/mini/api/session/fork', {
|
|
5071
|
+
method: 'POST',
|
|
5072
|
+
body: JSON.stringify({ sessionId: id }),
|
|
5073
|
+
}).then(function (res) {
|
|
5074
|
+
navActing = '';
|
|
5075
|
+
var found = navSessionById(id);
|
|
5076
|
+
refreshSessions(found ? found.wsId : '', true);
|
|
5077
|
+
// 把新标题报出来:手机列表可能很长,"多了一条"这句话不如"多了一条叫什么"有用,
|
|
5078
|
+
// 标题里那个 (1) 也正是"它是刚才那条分出来的"最直接的凭据。
|
|
5079
|
+
toast(res && res.title ? '已分叉出「' + res.title + '」' : '已分叉出新会话');
|
|
5080
|
+
}).catch(function (err) { toast(err.message); });
|
|
5081
|
+
}
|
|
5082
|
+
|
|
5083
|
+
/** 置顶 / 取消置顶。*/
|
|
5084
|
+
function pinAct(id, on) {
|
|
5085
|
+
return api('/mini/api/session/pin', {
|
|
5086
|
+
method: 'POST',
|
|
5087
|
+
body: JSON.stringify({ sessionId: id, pinned: on }),
|
|
5088
|
+
}).then(function () {
|
|
5089
|
+
var found = navSessionById(id);
|
|
5090
|
+
if (found) found.row.pinned = on;
|
|
5091
|
+
// 置顶会改整个列表的顺序,所以这次要重新问一遍服务端(别自己在本地排——
|
|
5092
|
+
// 官方的置顶顺序是"最近置顶的在前",本地排不出那个顺序)。
|
|
5093
|
+
refreshSessions(found ? found.wsId : '');
|
|
5094
|
+
navActing = '';
|
|
5095
|
+
toast(on ? '已置顶' : '已取消置顶');
|
|
5096
|
+
}).catch(function (err) { toast(err.message); });
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
/**
|
|
5100
|
+
* 归档 / 取消归档。
|
|
5101
|
+
*
|
|
5102
|
+
* `stop` 那一次往返是**官方那条两段式流程**:不带它,会话还有工作在跑时服务端会拒绝,
|
|
5103
|
+
* 同时把"会停掉什么"按族带回来(见 lib/index.js 的 archiveSession)。
|
|
5104
|
+
* 拿到那一份就弹确认框问过用户再来一次——**不自己猜会停掉什么**。
|
|
5105
|
+
*/
|
|
5106
|
+
function archiveAct(id, stop) {
|
|
5107
|
+
return api('/mini/api/session/archive', {
|
|
5108
|
+
method: 'POST',
|
|
5109
|
+
body: JSON.stringify({ sessionId: id, stop: stop === true }),
|
|
5110
|
+
}).then(function () {
|
|
5111
|
+
// **先把确认框收掉**,再动别的。漏了这一句的后果实测过:那一层是
|
|
5112
|
+
// fixed/inset:0,z-index 120——归档成功之后它仍然盖在整屏上,
|
|
5113
|
+
// 用户接下来点哪儿都是点到它(点哪儿都当成"取消",什么都没发生)。
|
|
5114
|
+
navConfirm = null;
|
|
5115
|
+
renderNavConfirm();
|
|
5116
|
+
navActing = '';
|
|
5117
|
+
var found = navSessionById(id);
|
|
5118
|
+
if (found) { found.row.archived = true; found.row.pinned = false; }
|
|
5119
|
+
refreshSessions(found ? found.wsId : '');
|
|
5120
|
+
toastAction('会话已归档', '撤销', function () {
|
|
5121
|
+
api('/mini/api/session/unarchive', {
|
|
5122
|
+
method: 'POST', body: JSON.stringify({ sessionId: id }),
|
|
5123
|
+
}).then(function () {
|
|
5124
|
+
var f = navSessionById(id);
|
|
5125
|
+
if (f) f.row.archived = false;
|
|
5126
|
+
refreshSessions(f ? f.wsId : '');
|
|
5127
|
+
toast('已取消归档');
|
|
5128
|
+
}).catch(function (err) { toast(err.message); });
|
|
5129
|
+
});
|
|
5130
|
+
}).catch(function (err) {
|
|
5131
|
+
var body = err && err.body;
|
|
5132
|
+
if (err && err.status === 409 && body && body.reason === 'active') {
|
|
5133
|
+
var found = navSessionById(id);
|
|
5134
|
+
navConfirm = {
|
|
5135
|
+
sessionId: id,
|
|
5136
|
+
title: (found && found.row.title) || '',
|
|
5137
|
+
wsId: found ? found.wsId : '',
|
|
5138
|
+
activity: Array.isArray(body.activity) ? body.activity : [],
|
|
5139
|
+
};
|
|
5140
|
+
renderNavConfirm();
|
|
5141
|
+
return;
|
|
5142
|
+
}
|
|
5143
|
+
toast(err.message);
|
|
5144
|
+
});
|
|
5145
|
+
}
|
|
5146
|
+
|
|
5147
|
+
/** 取消归档。说明里那句"不恢复置顶"是官方的规矩,如实说。 */
|
|
5148
|
+
function unarchiveAct(id) {
|
|
5149
|
+
return api('/mini/api/session/unarchive', {
|
|
5150
|
+
method: 'POST', body: JSON.stringify({ sessionId: id }),
|
|
5151
|
+
}).then(function () {
|
|
5152
|
+
navActing = '';
|
|
5153
|
+
var found = navSessionById(id);
|
|
5154
|
+
if (found) found.row.archived = false;
|
|
5155
|
+
refreshSessions(found ? found.wsId : '');
|
|
5156
|
+
toast('已取消归档');
|
|
5157
|
+
}).catch(function (err) { toast(err.message); });
|
|
5158
|
+
}
|
|
5159
|
+
|
|
5160
|
+
/**
|
|
5161
|
+
* 官方那四族工作的中文说法。**四种一一对应,不多不少**——`kind` 是从
|
|
5162
|
+
* `SessionActivityKindMap`(turn / job / subagent / schedule)来的,认不出的
|
|
5163
|
+
* 如实写它的原名,不硬套一个近义词。
|
|
5164
|
+
*/
|
|
5165
|
+
var ACTIVITY_COPY = {
|
|
5166
|
+
turn: '进行中的回合',
|
|
5167
|
+
job: '后台任务',
|
|
5168
|
+
subagent: '运行中的子智能体',
|
|
5169
|
+
schedule: '定时提醒',
|
|
5170
|
+
};
|
|
5171
|
+
|
|
5172
|
+
function renderNavConfirm() {
|
|
5173
|
+
var el = $('navConfirm');
|
|
5174
|
+
if (!el) return;
|
|
5175
|
+
if (!navConfirm) { el.classList.remove('on'); return; }
|
|
5176
|
+
$('navConfirmDesc').textContent = '「' + (navConfirm.title || '这个会话')
|
|
5177
|
+
+ '」还有正在进行的工作。归档会先停止这些工作;被停止的工作不会自动继续。'
|
|
5178
|
+
+ '归档之后它就不在列表里了,接下来 6 秒里可以点提示上的「撤销」把它找回来;'
|
|
5179
|
+
+ '过了这几秒,就得到电脑端才能取消归档。';
|
|
5180
|
+
var items = (navConfirm.activity || []).map(function (fam) {
|
|
5181
|
+
var name = ACTIVITY_COPY[fam.kind] || fam.kind;
|
|
5182
|
+
var labels = (fam.items || []).map(function (it) {
|
|
5183
|
+
return escapeHtml(it.label || it.id || '(没有名字)');
|
|
5184
|
+
});
|
|
5185
|
+
var head = '<b>' + escapeHtml(name) + '</b>:';
|
|
5186
|
+
// 一项一项列出来;带不下就如实说"共 N 项",不假装只有这几项。
|
|
5187
|
+
var detail = labels.length ? labels.join('、') : '(没有名字)';
|
|
5188
|
+
var more = fam.count > labels.length ? '(共 ' + fam.count + ' 项)' : '';
|
|
5189
|
+
return '<li>' + head + detail + more + '</li>';
|
|
5190
|
+
}).join('');
|
|
5191
|
+
$('navConfirmList').innerHTML = items || '<li>(没能问出具体是哪几项工作)</li>';
|
|
5192
|
+
el.classList.add('on');
|
|
5193
|
+
}
|
|
5194
|
+
|
|
5195
|
+
/**
|
|
5196
|
+
* 重新问一遍某个工作区的会话(置顶/归档之后顺序和状态都变了)。
|
|
5197
|
+
*
|
|
5198
|
+
* `force` 是给**分叉**用的:会话清册在服务端有 30 秒缓存,而分叉是"多出来一条",
|
|
5199
|
+
* 缓存里根本没有它。不带 force 的那几处(置顶/归档)只要状态和顺序,缓存那份就够。
|
|
5200
|
+
*
|
|
5201
|
+
* **`loadWsSessions` 必须排在 `renderNav` 前面**(2026-10-09 活页面验证逮住的):
|
|
5202
|
+
* renderNav 自己会给每个展开着的工作区起一趟 `loadWsSessions(id)`(**不带 force**),
|
|
5203
|
+
* 而那一趟会先把 `navWsFetching` 占住——后面这一趟带 force 的就被那道闩挡在门外,
|
|
5204
|
+
* 结果新会话压根没被取回来(界面看着像"按了没反应")。先起带 force 的这一趟,
|
|
5205
|
+
* renderNav 顺手起的那一趟自然就被同一道闩挡掉了。
|
|
5206
|
+
*/
|
|
5207
|
+
function refreshSessions(wsId, force) {
|
|
5208
|
+
if (!wsId) { loadWorkspaces(true); return; }
|
|
5209
|
+
delete navSessions[wsId];
|
|
5210
|
+
delete navWsFetching[wsId];
|
|
5211
|
+
loadWsSessions(wsId, force);
|
|
5212
|
+
renderNav();
|
|
5213
|
+
}
|
|
5214
|
+
|
|
5215
|
+
/** 「展开其余」:页里还有就只是多摆几条;页里没了就去服务端多要一批。 */
|
|
5216
|
+
function expandMore(wsId) {
|
|
5217
|
+
var got = navSessions[wsId];
|
|
5218
|
+
var view = got ? navVisible(wsId, got) : null;
|
|
5219
|
+
if (view && view.remaining > 0) {
|
|
5220
|
+
navExtra[wsId] = (navExtra[wsId] || 0) + 1;
|
|
5221
|
+
renderNav();
|
|
5222
|
+
return;
|
|
5223
|
+
}
|
|
5224
|
+
// 页里已经摆完了,但服务端说这个工作区还有没取回来的:把这次要的条数翻上去。
|
|
5225
|
+
navExtra[wsId] = (navExtra[wsId] || 0) + 1;
|
|
5226
|
+
navLimit[wsId] = Math.min(100, (navLimit[wsId] || NAV_PAGE) + NAV_PAGE * 2);
|
|
5227
|
+
delete navSessions[wsId];
|
|
5228
|
+
delete navWsFetching[wsId];
|
|
5229
|
+
loadWsSessions(wsId);
|
|
5230
|
+
}
|
|
5231
|
+
|
|
5232
|
+
function collapseMore(wsId) {
|
|
5233
|
+
navExtra[wsId] = 0;
|
|
5234
|
+
renderNav();
|
|
5235
|
+
}
|
|
5236
|
+
|
|
5237
|
+
// 筛选一改,所有工作区那份数据就都不作数了(服务端按筛选给的是不同的列表)。
|
|
5238
|
+
// **展开次数也一起归零**:换了口径还停在"展开了 3 页",用户会莫名其妙的。
|
|
5239
|
+
function setShowArchived(on) {
|
|
5240
|
+
navShowArchived = on === true;
|
|
5241
|
+
navSessions = {};
|
|
5242
|
+
navExtra = {};
|
|
5243
|
+
navLimit = {};
|
|
5244
|
+
navActing = '';
|
|
5245
|
+
navRenaming = '';
|
|
5246
|
+
Object.keys(navFollowTimers).forEach(function (id) { clearTimeout(navFollowTimers[id]); });
|
|
5247
|
+
navFollowTimers = {};
|
|
5248
|
+
renderNav();
|
|
5249
|
+
}
|
|
5250
|
+
|
|
4132
5251
|
// ---------------- 新建工作区:浏览电脑目录 ----------------
|
|
4133
5252
|
//
|
|
4134
5253
|
// 面板里有两个「屏」:工作区树(默认)和目录浏览。用一个变量记当前在哪一屏,
|
|
@@ -4356,10 +5475,11 @@
|
|
|
4356
5475
|
// navWsFetching 这道闩让同一工作区同时只有一趟在飞,别重复付读日志折标题的钱。
|
|
4357
5476
|
// 15 秒兜底解锁是给「请求永远不回来」留的——闩本身不能变成新的卡死点,
|
|
4358
5477
|
// 超过 15 秒就放行,让下一次渲染或点击还能再试(服务端有缓存,重发很便宜)。
|
|
4359
|
-
function loadWsSessions(wsId) {
|
|
5478
|
+
function loadWsSessions(wsId, force) {
|
|
4360
5479
|
if (navSessions[wsId] || navWsFetching[wsId]) return;
|
|
4361
5480
|
navWsFetching[wsId] = true;
|
|
4362
5481
|
var bail = setTimeout(function () { delete navWsFetching[wsId]; }, 15000);
|
|
5482
|
+
var sessionsUrl = sessionsUrlOf(wsId, force);
|
|
4363
5483
|
/**
|
|
4364
5484
|
* 接着问,直到服务端说「一条都不剩」。
|
|
4365
5485
|
*
|
|
@@ -4375,7 +5495,7 @@
|
|
|
4375
5495
|
delete navFollowTimers[wsId];
|
|
4376
5496
|
// 用户收起了、或者这份数据已经被刷新换掉,就放手:别跟人抢。
|
|
4377
5497
|
if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
|
|
4378
|
-
api(
|
|
5498
|
+
api(sessionsUrlOf(wsId))
|
|
4379
5499
|
.then(function (body) {
|
|
4380
5500
|
if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
|
|
4381
5501
|
navSessions[wsId] = body;
|
|
@@ -4387,7 +5507,7 @@
|
|
|
4387
5507
|
});
|
|
4388
5508
|
}, TITLE_FOLLOW_MS);
|
|
4389
5509
|
}
|
|
4390
|
-
api(
|
|
5510
|
+
api(sessionsUrl)
|
|
4391
5511
|
.then(function (body) {
|
|
4392
5512
|
clearTimeout(bail);
|
|
4393
5513
|
navSessions[wsId] = body;
|
|
@@ -4404,6 +5524,23 @@
|
|
|
4404
5524
|
});
|
|
4405
5525
|
}
|
|
4406
5526
|
|
|
5527
|
+
/**
|
|
5528
|
+
* 取一个工作区会话列表的地址。三个参数都是**这次的口径**:
|
|
5529
|
+
* · `archived=1` 只在「显示已归档」打开时带——默认那一档是隐藏已归档(官方默认项)。
|
|
5530
|
+
* · `limit` 只在"要过更多"时才带,平时不带。**不带就还是原来那条地址**:
|
|
5531
|
+
* 多带一个参数会让服务端每次都走一条不一样的路径,也没必要。
|
|
5532
|
+
* · `refresh=1` 只在**刚刚多出一条会话**(分叉)时才带:服务端那份清册有 30 秒缓存,
|
|
5533
|
+
* 不绕过的话新会话不在里面。
|
|
5534
|
+
*/
|
|
5535
|
+
function sessionsUrlOf(wsId, force) {
|
|
5536
|
+
var url = '/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions';
|
|
5537
|
+
var parts = [];
|
|
5538
|
+
if (navShowArchived) parts.push('archived=1');
|
|
5539
|
+
if (navLimit[wsId]) parts.push('limit=' + navLimit[wsId]);
|
|
5540
|
+
if (force) parts.push('refresh=1');
|
|
5541
|
+
return parts.length ? url + '?' + parts.join('&') : url;
|
|
5542
|
+
}
|
|
5543
|
+
|
|
4407
5544
|
// 防连点。建会话**不是幂等的**——点两下就在电脑上真建出两个会话来,
|
|
4408
5545
|
// 而手机上什么都看不出来(第二个只是把列表撑长了一行)。所以这里必须自己拦。
|
|
4409
5546
|
// 服务端那道闸门拦不住这个:两个请求都是合法的。
|
|
@@ -4481,6 +5618,33 @@
|
|
|
4481
5618
|
});
|
|
4482
5619
|
}
|
|
4483
5620
|
|
|
5621
|
+
// 归档确认面板。**它自己带两个按钮,不走 navBody 那套委托**:
|
|
5622
|
+
// 它在抽屉之外、是一整屏的浮层,混进列表的事件里只会让人以为它也是列表的一部分。
|
|
5623
|
+
//
|
|
5624
|
+
// 确认后**才**带 stop:true 重发——被停掉的工作不会自动继续,所以这一下必须是人按的。
|
|
5625
|
+
$('navConfirm').addEventListener('click', function (e) {
|
|
5626
|
+
var t = e.target;
|
|
5627
|
+
if (t === $('navConfirm') || (t && t.closest && t.closest('[data-nc-cancel]'))) {
|
|
5628
|
+
navConfirm = null;
|
|
5629
|
+
renderNavConfirm();
|
|
5630
|
+
return;
|
|
5631
|
+
}
|
|
5632
|
+
var ok = t && t.closest ? t.closest('[data-nc-ok]') : null;
|
|
5633
|
+
if (!ok || !navConfirm) return;
|
|
5634
|
+
// 按下去先禁掉:这一趟会在电脑上真停东西,连点两下没有第二件事可做。
|
|
5635
|
+
ok.disabled = true;
|
|
5636
|
+
archiveAct(navConfirm.sessionId, true);
|
|
5637
|
+
});
|
|
5638
|
+
// 改名那一行:手机键盘上是「回车」,桌面上是 Enter。不接这个键的话,
|
|
5639
|
+
// 用户打完字的第一反应(回车)什么都不会发生。
|
|
5640
|
+
$('navBody').addEventListener('keydown', function (e) {
|
|
5641
|
+
if (e.key !== 'Enter') return;
|
|
5642
|
+
var t = e.target;
|
|
5643
|
+
if (!t || t.id !== 'renameInput') return;
|
|
5644
|
+
e.preventDefault();
|
|
5645
|
+
renameSubmit();
|
|
5646
|
+
});
|
|
5647
|
+
|
|
4484
5648
|
$('btnNav').addEventListener('click', openNav);
|
|
4485
5649
|
$('btnNavClose').addEventListener('click', closeNav);
|
|
4486
5650
|
$('btnNavRefresh').addEventListener('click', function () { loadWorkspaces(true); });
|
|
@@ -4488,15 +5652,45 @@
|
|
|
4488
5652
|
|
|
4489
5653
|
$('navBody').addEventListener('click', function (e) {
|
|
4490
5654
|
var t = e.target;
|
|
5655
|
+
var at = function (sel) { return t && t.closest ? t.closest(sel) : null; };
|
|
4491
5656
|
var sess = t && t.closest ? t.closest('.sess') : null;
|
|
4492
5657
|
if (sess) {
|
|
4493
5658
|
// 目录浏览那一屏里的 .sess 是「进这个目录」,不是「切到这个会话」。
|
|
4494
5659
|
// 两屏共用同一个条目样式,所以要先看当前在哪一屏。
|
|
4495
5660
|
if (navView === 'browse') { browseTo(sess.getAttribute('data-browse-to')); return; }
|
|
5661
|
+
// 已归档的**点不开**(官方原话:「已归档对话暂时无法查看,请取消归档后查看」)。
|
|
5662
|
+
// 留着能点更坏:点进去是一个空会话,用户只会以为会话被清空了。
|
|
5663
|
+
var bound = navSessionById(sess.getAttribute('data-sess'));
|
|
5664
|
+
if (bound && bound.row.archived) {
|
|
5665
|
+
toast('已归档的会话打不开,先取消归档');
|
|
5666
|
+
return;
|
|
5667
|
+
}
|
|
4496
5668
|
bindSession(sess.getAttribute('data-sess'));
|
|
4497
5669
|
return;
|
|
4498
5670
|
}
|
|
4499
|
-
|
|
5671
|
+
|
|
5672
|
+
// —— 会话条目的执行入口(2026-10-09)。都排在下面那些之前,
|
|
5673
|
+
// 因为它们都挂在会话那一行上,和「新建会话 / 展开工作区」不是一类。 ——
|
|
5674
|
+
var menu = at('[data-more-menu]');
|
|
5675
|
+
if (menu) { toggleActs(menu.getAttribute('data-more-menu')); return; }
|
|
5676
|
+
var act = at('[data-act]');
|
|
5677
|
+
if (act) {
|
|
5678
|
+
var actId = act.getAttribute('data-act-id');
|
|
5679
|
+
var which = act.getAttribute('data-act');
|
|
5680
|
+
if (which === 'rename') startRename(actId);
|
|
5681
|
+
else if (which === 'fork') forkAct(actId);
|
|
5682
|
+
else if (which === 'pin') pinAct(actId, act.getAttribute('data-act-on') !== '1');
|
|
5683
|
+
else if (which === 'archive') archiveAct(actId, false);
|
|
5684
|
+
else if (which === 'unarchive') unarchiveAct(actId);
|
|
5685
|
+
return;
|
|
5686
|
+
}
|
|
5687
|
+
if (at('[data-rename-cancel]')) { navRenaming = ''; renderNav(); return; }
|
|
5688
|
+
if (at('[data-rename-ok]')) { renameSubmit(); return; }
|
|
5689
|
+
var more = at('[data-more]');
|
|
5690
|
+
if (more) { expandMore(more.getAttribute('data-more')); return; }
|
|
5691
|
+
var less = at('[data-less]');
|
|
5692
|
+
if (less) { collapseMore(less.getAttribute('data-less')); return; }
|
|
5693
|
+
if (at('[data-archived-toggle]')) { setShowArchived(!navShowArchived); return; }
|
|
4500
5694
|
|
|
4501
5695
|
var newWs = at('[data-new-ws]');
|
|
4502
5696
|
if (newWs) { openBrowse(); return; }
|
|
@@ -4567,13 +5761,13 @@
|
|
|
4567
5761
|
var es = null;
|
|
4568
5762
|
function connect() {
|
|
4569
5763
|
if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
4570
|
-
if (!state.
|
|
5764
|
+
if (!state.entered) { showGate(); return; }
|
|
4571
5765
|
setStatus('', '连接中…');
|
|
4572
5766
|
// clientId 每条连接都带(订阅的 POST 靠它认出这条流);full=1 只在「完整」模式带,
|
|
4573
5767
|
// 服务端见到就自动把这条连接订上轨迹——断线重连沿用同一个地址,重新订上由服务端
|
|
4574
5768
|
// 自己完成,客户端不用再握手一次。
|
|
4575
|
-
|
|
4576
|
-
|
|
5769
|
+
// **地址里不带令牌**(2026-10-16 第二步):同源的 EventSource 会带上设备 cookie。
|
|
5770
|
+
es = new EventSource('/mini/api/stream?clientId=' + encodeURIComponent(clientId)
|
|
4577
5771
|
+ (state.mode === 'full' ? '&full=1' : ''));
|
|
4578
5772
|
|
|
4579
5773
|
es.addEventListener('open', function () {
|
|
@@ -4628,6 +5822,23 @@
|
|
|
4628
5822
|
}
|
|
4629
5823
|
if (navExpanded[payload.workspaceId] && navView === 'tree') renderNav();
|
|
4630
5824
|
});
|
|
5825
|
+
es.addEventListener('navigation-pending', function (e) {
|
|
5826
|
+
// 谁在等人变了。**只就地换那几行的尾部标记**,不重绘整棵树——
|
|
5827
|
+
// 重绘会把用户正在划的滚动位置弹回顶部(和 paintNavCurrent 同一条理由)。
|
|
5828
|
+
var map;
|
|
5829
|
+
try { map = JSON.parse(e.data); } catch (err) { return; }
|
|
5830
|
+
if (!map || typeof map !== 'object') return;
|
|
5831
|
+
var touched = false;
|
|
5832
|
+
Object.keys(navSessions).forEach(function (wsId) {
|
|
5833
|
+
var got = navSessions[wsId];
|
|
5834
|
+
if (!got || !Array.isArray(got.sessions)) return;
|
|
5835
|
+
got.sessions.forEach(function (s) {
|
|
5836
|
+
var next = typeof map[s.id] === 'string' ? map[s.id] : null;
|
|
5837
|
+
if (s.pending !== next) { s.pending = next; touched = true; }
|
|
5838
|
+
});
|
|
5839
|
+
});
|
|
5840
|
+
if (touched && $('nav').classList.contains('open') && navView === 'tree') renderNav();
|
|
5841
|
+
});
|
|
4631
5842
|
es.addEventListener('subagent-metrics', function (e) {
|
|
4632
5843
|
var payload;
|
|
4633
5844
|
try { payload = JSON.parse(e.data); } catch (err) { return; }
|
|
@@ -5264,15 +6475,24 @@
|
|
|
5264
6475
|
|
|
5265
6476
|
api('/mini/api/send', { method: 'POST', body: JSON.stringify({ text: text, uploadIds: uploadIds }) })
|
|
5266
6477
|
.then(function (res) {
|
|
5267
|
-
//
|
|
6478
|
+
// 服务端说这条排队了 / 插话了(发的时候正在跑),给一句话。
|
|
5268
6479
|
// 队列那块也会跟着推过来,但那要等一个来回;先给个即时反馈,
|
|
5269
6480
|
// 免得用户以为又石沉大海了。
|
|
6481
|
+
//
|
|
6482
|
+
// **两条要说不同的话**(2026-10-06):插话是「下一个步骤边界就取走」,
|
|
6483
|
+
// 通常几秒;排队是「等这一轮跑完」,可能好几分钟。都回一句「已排队」
|
|
6484
|
+
// 就等于把插话也说成了排队——用户就是这么发现「插话好像没实现」的。
|
|
6485
|
+
if (res && res.steered) {
|
|
6486
|
+
toast('已插话,它下一步就看到');
|
|
6487
|
+
return;
|
|
6488
|
+
}
|
|
5270
6489
|
if (res && res.queued) {
|
|
5271
6490
|
toast('已排队,跑完这轮就轮到它');
|
|
5272
6491
|
// **排队不算发出去,所以这里不回显气泡。** 用户 2026-09-22 实机提的:
|
|
5273
6492
|
// 排队的指令立刻长成一条指令气泡,看着就像已经发出去了,其实还在队列里。
|
|
5274
6493
|
// 它已经在下面的排队列表里露过面了,那就是它此刻该待的地方;
|
|
5275
6494
|
// 轮到它真跑起来时,服务端会把它记进 history,气泡那时才出现。
|
|
6495
|
+
// 插话走的是同一条账(等它真被取走时才长气泡),所以这里一起 return。
|
|
5276
6496
|
return;
|
|
5277
6497
|
}
|
|
5278
6498
|
// 真发出去了才回显。放在这里而不是发之前,是因为**发之前还不知道会不会排队**;
|
|
@@ -5492,6 +6712,9 @@
|
|
|
5492
6712
|
watchBuild();
|
|
5493
6713
|
// 繁忙时的发送行为也现读一次:**电脑上随时能改**,开机读那一次会过期。
|
|
5494
6714
|
loadBusyEnter();
|
|
6715
|
+
// 进阶设置(电脑端界面)同样现读。它还会被电脑端从小标签关掉——
|
|
6716
|
+
// 手机上那份状态不能一直停在「开着」。
|
|
6717
|
+
loadMirror();
|
|
5495
6718
|
});
|
|
5496
6719
|
$('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
|
|
5497
6720
|
// 顶部吸顶那个关闭口(2026-10-05 设置页改整屏时加的)。
|
|
@@ -6270,6 +7493,236 @@
|
|
|
6270
7493
|
});
|
|
6271
7494
|
});
|
|
6272
7495
|
|
|
7496
|
+
/**
|
|
7497
|
+
* 「进阶设置(电脑端界面)」(2026-10-06 用户要求)。
|
|
7498
|
+
*
|
|
7499
|
+
* 手机上这张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
|
|
7500
|
+
* 这一项是退路:**把电脑端的界面整个搬过来看**——同一份界面,不迁移、不逐个适配。
|
|
7501
|
+
* 服务端怎么把这个界面转出来的,写在 lib/mirror.js 开头。
|
|
7502
|
+
*
|
|
7503
|
+
* **它是个权限开关,不是普通偏好。** 打开它 = 这个令牌的权限从「我们这张手机页
|
|
7504
|
+
* 暴露的那点事」扩到整个电脑端设置面(模型配置、凭据页、插件装卸、会话记录……)。
|
|
7505
|
+
* 所以三件事一个都不能省:
|
|
7506
|
+
* ① 默认关;
|
|
7507
|
+
* ② 后果写在提示里,而且要等**第二次点击**才真开(`armed`)——照「完全权限」
|
|
7508
|
+
* 那一档的规矩,理由也一样(页内两步,不用 window.confirm:那种弹窗在微信
|
|
7509
|
+
* 这类内嵌浏览器里会被直接屏蔽,屏蔽时万一返回真值就等于确认被跳过);
|
|
7510
|
+
* ③ 关着的时候**入口那一行也不显示**——不是露一个点了没用的按钮。
|
|
7511
|
+
*
|
|
7512
|
+
* 读不到就整块不画:这台电脑没这个能力时(没拿到 DSH_WEB_URL),
|
|
7513
|
+
* 藏起来就好,不摆一个假开关。
|
|
7514
|
+
*/
|
|
7515
|
+
var mirror = { available: false, enabled: false, armed: false, ready: false };
|
|
7516
|
+
|
|
7517
|
+
function paintMirror() {
|
|
7518
|
+
$('rowMirror').hidden = !mirror.available;
|
|
7519
|
+
if (!mirror.available) {
|
|
7520
|
+
$('rowMirrorOpen').hidden = true;
|
|
7521
|
+
return;
|
|
7522
|
+
}
|
|
7523
|
+
Array.prototype.forEach.call($('segMirror').children, function (b) {
|
|
7524
|
+
b.classList.toggle('active', (b.dataset.mirror === 'on') === mirror.enabled);
|
|
7525
|
+
});
|
|
7526
|
+
$('mirrorHint').textContent = mirror.enabled
|
|
7527
|
+
? '已打开。手机现在能进整个电脑设置面'
|
|
7528
|
+
: mirror.armed
|
|
7529
|
+
? '打开的后果:手机将获得完整的电脑设置权限。再点一次「开」确认'
|
|
7530
|
+
: '打开电脑上的 DSH 界面,改那些手机上没有的设置';
|
|
7531
|
+
$('rowMirrorOpen').hidden = !mirror.enabled;
|
|
7532
|
+
}
|
|
7533
|
+
|
|
7534
|
+
/**
|
|
7535
|
+
* 镜像入口那个地址。
|
|
7536
|
+
*
|
|
7537
|
+
* **尾斜杠不能省**:那个界面外壳写着 `<base href="./">`,在 `/mini/mirror` 上它
|
|
7538
|
+
* 解析成 `/mini/`,在 `/mini/mirror/` 上才是 `/mini/mirror/`——差这一个字符,
|
|
7539
|
+
* 里面引用的脚本会全 404、界面白屏(真浏览器验出来的)。
|
|
7540
|
+
*
|
|
7541
|
+
*
|
|
7542
|
+
* **地址里不带令牌**(2026-10-16 第二步):这个框是同源文档,设备 cookie 会自动跟着
|
|
7543
|
+
* 它的每一条请求走(文档本身、静态资源、界面自己发的 `/api/*`),入口那一下不再需要
|
|
7544
|
+
* 顺手写 cookie。上游收到的东西里一个字节的凭据都没有——第一步已经钉住。
|
|
7545
|
+
*/
|
|
7546
|
+
function mirrorUrl() {
|
|
7547
|
+
return '/mini/mirror/';
|
|
7548
|
+
}
|
|
7549
|
+
|
|
7550
|
+
/**
|
|
7551
|
+
* 点「打开」:把那一整屏显示出来,并**在那一刻**才给框设地址(没点之前不加载)。
|
|
7552
|
+
*
|
|
7553
|
+
* 这一版为什么能成:**没有跳转、没有新标签、没有弹窗**——浏览器没有任何东西可以拦。
|
|
7554
|
+
* 前两版都在赌「跳转」这个动作(详见页面里 `#mirrorView` 上面那段注释)。
|
|
7555
|
+
*/
|
|
7556
|
+
function openMirror() {
|
|
7557
|
+
if (!mirror.enabled) return;
|
|
7558
|
+
// **先给一句提示,再显示那一屏。**
|
|
7559
|
+
//
|
|
7560
|
+
// 这一句不是装饰,是**诊断**(2026-10-06 用户三次报「点了没反应」之后加的):
|
|
7561
|
+
// 三种情况从此分得开——看到提示且界面出来=成了;只看到提示=显示那一步的问题;
|
|
7562
|
+
// 连提示都没有=点击压根没到按钮上。前三次就是分不开这三种,才一路猜错。
|
|
7563
|
+
toast('正在打开电脑端界面…');
|
|
7564
|
+
// **顺序要紧:先把这一层显示出来,再给框设地址。**
|
|
7565
|
+
//
|
|
7566
|
+
// 反过来的话,那个界面是**在 `display:none` 里开始加载的**——在不可见的上下文里
|
|
7567
|
+
// 渲染出来的东西,要等用户点一下触发重绘才画全(真机上就是「图标旁边的文字
|
|
7568
|
+
// 点一下才出来」)。2026-10-06 查出:**直接打开镜像没有这个毛病**,
|
|
7569
|
+
// 差别就在这两行的先后。
|
|
7570
|
+
$('mirrorView').hidden = false;
|
|
7571
|
+
$('mirrorFrame').src = mirrorUrl();
|
|
7572
|
+
}
|
|
7573
|
+
|
|
7574
|
+
function closeMirror() {
|
|
7575
|
+
$('mirrorView').hidden = true;
|
|
7576
|
+
// 收起状态一起复位:下次进来从「看不见」开始(鼠标若正好停在顶上,
|
|
7577
|
+
// 那一下 mousemove 会把它勾出来,见下面的接线)。
|
|
7578
|
+
mirrorBarOff();
|
|
7579
|
+
// 把地址清掉:那一屏里是个完整应用(还在跑、还连着),关掉就该真停,
|
|
7580
|
+
// 而不是留在后台继续占内存和连接。
|
|
7581
|
+
$('mirrorFrame').src = 'about:blank';
|
|
7582
|
+
}
|
|
7583
|
+
|
|
7584
|
+
// 按钮和**整行**都绑:那个按钮只有 54×40,手指容易点偏;整行点下去都算数。
|
|
7585
|
+
$('btnMirrorOpen').addEventListener('click', openMirror);
|
|
7586
|
+
$('rowMirrorOpen').addEventListener('click', function (e) {
|
|
7587
|
+
// 点在按钮上时上面那个监听已经处理了,别处理两遍。
|
|
7588
|
+
if (e.target === $('btnMirrorOpen')) return;
|
|
7589
|
+
openMirror();
|
|
7590
|
+
});
|
|
7591
|
+
$('mirrorClose').addEventListener('click', closeMirror);
|
|
7592
|
+
|
|
7593
|
+
/**
|
|
7594
|
+
* 顶栏的「收 / 放」(2026-10-15 用户要的甲案:鼠标移到屏幕顶部才滑出来)。
|
|
7595
|
+
*
|
|
7596
|
+
* 为什么非要有那条 16px 感应带、为什么手机不走这一套:样式表里那一段说清了。
|
|
7597
|
+
* 这里只说两件必须做对的事:
|
|
7598
|
+
*
|
|
7599
|
+
* ① **贴到顶上先停 0.18 秒再滑出**。鼠标路过顶边去点官方界面时(右上角那个
|
|
7600
|
+
* 「打开右侧边栏」就在第 28px),不该把它勾出来;停这么一下就不会误触发。
|
|
7601
|
+
*
|
|
7602
|
+
* ② **「返回」永远可达**——它是唯一回手机页的出口,绝不能藏死:
|
|
7603
|
+
* · 鼠标碰到顶上 16px 就出来(滑出只是一次 0.18 秒的等待);光标停在感应带上
|
|
7604
|
+
* 不动也没关系:显示之后感应带还在最上面,鼠标一动就再勾一次;
|
|
7605
|
+
* · 键盘 Tab 停在「返回」上时也把它亮出来(下面 focus 那一条),
|
|
7606
|
+
* 不留一个「焦点在这、按钮看不见」的坑;
|
|
7607
|
+
* · 滑出时横条**占自己的位置**、不盖在官方界面上,谁也挡不住它;
|
|
7608
|
+
* · 手机上它压根不藏(触摸没有悬停,藏了就等于摸不到)。
|
|
7609
|
+
*/
|
|
7610
|
+
var mirrorHot = $('mirrorHot');
|
|
7611
|
+
var mirrorBar = $('mirrorBar');
|
|
7612
|
+
var barShowTimer = null;
|
|
7613
|
+
var barHideTimer = null;
|
|
7614
|
+
|
|
7615
|
+
function mirrorBarOn() { $('mirrorView').classList.toggle('bar-on', true); }
|
|
7616
|
+
function mirrorBarOff() { $('mirrorView').classList.toggle('bar-on', false); }
|
|
7617
|
+
|
|
7618
|
+
if (mirrorHot && mirrorBar && mirrorHot.addEventListener) {
|
|
7619
|
+
function barScheduleShow() {
|
|
7620
|
+
if (barShowTimer) return;
|
|
7621
|
+
clearTimeout(barHideTimer);
|
|
7622
|
+
barShowTimer = setTimeout(function () { barShowTimer = null; mirrorBarOn(); }, 180);
|
|
7623
|
+
}
|
|
7624
|
+
// 收的时候也先等 0.2 秒:鼠标可能是从感应带往下挪进横条里(那条就贴在它下面),
|
|
7625
|
+
// 中途路过那一下不该被判成「离开了」。
|
|
7626
|
+
function barScheduleHide() {
|
|
7627
|
+
clearTimeout(barShowTimer);
|
|
7628
|
+
barShowTimer = null;
|
|
7629
|
+
clearTimeout(barHideTimer);
|
|
7630
|
+
barHideTimer = setTimeout(mirrorBarOff, 200);
|
|
7631
|
+
}
|
|
7632
|
+
mirrorHot.addEventListener('mouseenter', barScheduleShow);
|
|
7633
|
+
// 显示这一屏那一刻鼠标可能就停在顶上(那时不会再有 mouseenter)——动一下也算数。
|
|
7634
|
+
mirrorHot.addEventListener('mousemove', barScheduleShow);
|
|
7635
|
+
mirrorHot.addEventListener('mouseleave', barScheduleHide);
|
|
7636
|
+
mirrorBar.addEventListener('mouseenter', function () { clearTimeout(barHideTimer); });
|
|
7637
|
+
mirrorBar.addEventListener('mouseleave', barScheduleHide);
|
|
7638
|
+
}
|
|
7639
|
+
// Tab 走到「返回」上要看得见它。
|
|
7640
|
+
$('mirrorClose').addEventListener('focus', mirrorBarOn);
|
|
7641
|
+
|
|
7642
|
+
/**
|
|
7643
|
+
* 框里那一页自己发的「返回手机页」。
|
|
7644
|
+
*
|
|
7645
|
+
* **为什么需要**:镜像那一屏里,框中的文档正常情况下是电脑端界面(它有自己的退出方式),
|
|
7646
|
+
* 但**打不开的时候**它是我们自己发的一页提示(见 lib/server.js 的 sendMirrorUnavailable)
|
|
7647
|
+
* ——那一页里除了浏览器自己的返回,没有别的出口,用户会卡在一个提示页上。
|
|
7648
|
+
* 所以那一页的「返回」按钮往这里发一条消息,我们把这整屏收起来。
|
|
7649
|
+
*
|
|
7650
|
+
* 只认这一条字符串,且只认同源:这个监听**只做「收屏」这一件事**,别的消息一概不理。
|
|
7651
|
+
*/
|
|
7652
|
+
window.addEventListener('message', function (e) {
|
|
7653
|
+
if (e.origin === location.origin && e.data === 'mini-mirror-close') closeMirror();
|
|
7654
|
+
});
|
|
7655
|
+
|
|
7656
|
+
function loadMirror() {
|
|
7657
|
+
api('/mini/api/mirror').then(function (r) {
|
|
7658
|
+
if (!r || !r.ok || !r.available) {
|
|
7659
|
+
mirror.available = false;
|
|
7660
|
+
paintMirror();
|
|
7661
|
+
return;
|
|
7662
|
+
}
|
|
7663
|
+
mirror.available = true;
|
|
7664
|
+
mirror.enabled = r.enabled === true;
|
|
7665
|
+
mirror.ready = true;
|
|
7666
|
+
paintMirror();
|
|
7667
|
+
}).catch(function () {
|
|
7668
|
+
mirror.available = false;
|
|
7669
|
+
mirror.armed = false;
|
|
7670
|
+
paintMirror();
|
|
7671
|
+
});
|
|
7672
|
+
}
|
|
7673
|
+
|
|
7674
|
+
function tapMirror(next) {
|
|
7675
|
+
if (!mirror.ready) return;
|
|
7676
|
+
if (next === 'off') {
|
|
7677
|
+
// 关掉不需要确认:往「更安全」的方向走,不用拦。
|
|
7678
|
+
mirror.armed = false;
|
|
7679
|
+
if (!mirror.enabled) { paintMirror(); return; }
|
|
7680
|
+
mirror.enabled = false;
|
|
7681
|
+
paintMirror();
|
|
7682
|
+
api('/mini/api/mirror', { method: 'POST', body: JSON.stringify({ enabled: false }) })
|
|
7683
|
+
.then(function (r) {
|
|
7684
|
+
if (r && r.ok) { toast('已关掉电脑端界面'); return; }
|
|
7685
|
+
mirror.enabled = true;
|
|
7686
|
+
paintMirror();
|
|
7687
|
+
toast('没能关掉,再试一次');
|
|
7688
|
+
})
|
|
7689
|
+
.catch(function () {
|
|
7690
|
+
mirror.enabled = true;
|
|
7691
|
+
paintMirror();
|
|
7692
|
+
toast('没能关掉,检查一下连接');
|
|
7693
|
+
});
|
|
7694
|
+
return;
|
|
7695
|
+
}
|
|
7696
|
+
// 开:第一次点只是「举起来」,把后果摆出来;再点一次才真开。
|
|
7697
|
+
if (!mirror.enabled && !mirror.armed) {
|
|
7698
|
+
mirror.armed = true;
|
|
7699
|
+
paintMirror();
|
|
7700
|
+
return;
|
|
7701
|
+
}
|
|
7702
|
+
if (mirror.enabled) return;
|
|
7703
|
+
mirror.armed = false;
|
|
7704
|
+
mirror.enabled = true;
|
|
7705
|
+
paintMirror();
|
|
7706
|
+
api('/mini/api/mirror', { method: 'POST', body: JSON.stringify({ enabled: true }) })
|
|
7707
|
+
.then(function (r) {
|
|
7708
|
+
if (r && r.ok) { toast('已打开,点下面的「打开」进去'); return; }
|
|
7709
|
+
mirror.enabled = false;
|
|
7710
|
+
paintMirror();
|
|
7711
|
+
toast(r && r.reason === 'unavailable' ? '这台电脑没有这个能力' : '没能打开,再试一次');
|
|
7712
|
+
})
|
|
7713
|
+
.catch(function () {
|
|
7714
|
+
mirror.enabled = false;
|
|
7715
|
+
paintMirror();
|
|
7716
|
+
toast('没能打开,检查一下连接');
|
|
7717
|
+
});
|
|
7718
|
+
}
|
|
7719
|
+
|
|
7720
|
+
$('segMirror').addEventListener('click', function (e) {
|
|
7721
|
+
var btn = e.target.closest ? e.target.closest('button[data-mirror]') : null;
|
|
7722
|
+
if (!btn) return;
|
|
7723
|
+
tapMirror(btn.dataset.mirror);
|
|
7724
|
+
});
|
|
7725
|
+
|
|
6273
7726
|
if (NOTIFY_OK) {
|
|
6274
7727
|
// 先注册 service worker。**这一步不申请权限**——权限要用户主动点一下才给,
|
|
6275
7728
|
// 页面一加载就弹窗会被浏览器直接拒掉,而且很讨厌。
|
|
@@ -6371,10 +7824,14 @@
|
|
|
6371
7824
|
toast('正在重连…');
|
|
6372
7825
|
});
|
|
6373
7826
|
|
|
7827
|
+
/**
|
|
7828
|
+
* 「重新输入令牌」:把闸门露出来,让用户再贴一枚令牌进来。
|
|
7829
|
+
*
|
|
7830
|
+
* **这一下不再清除任何东西**(2026-10-16 第二步):身份已经是一枚 HttpOnly 的设备
|
|
7831
|
+
* cookie,页面看不到也删不掉它——真正的「把这台设备赶出去」在电脑那边(名册里删掉
|
|
7832
|
+
* 它,下一步做进设置页)。按钮原来叫「清除 token」,那个名字从这一刻起就是假的。
|
|
7833
|
+
*/
|
|
6374
7834
|
$('btnForget').addEventListener('click', function () {
|
|
6375
|
-
localStorage.removeItem('dshMiniToken');
|
|
6376
|
-
state.token = '';
|
|
6377
|
-
if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
6378
7835
|
sheet.classList.remove('open');
|
|
6379
7836
|
showGate();
|
|
6380
7837
|
});
|
|
@@ -6389,28 +7846,25 @@
|
|
|
6389
7846
|
$('gateInput').addEventListener('keydown', function (e) {
|
|
6390
7847
|
if (e.key === 'Enter') submitGate();
|
|
6391
7848
|
});
|
|
7849
|
+
/**
|
|
7850
|
+
* 把手里这枚令牌交回**门口那一次**(和扫码进来是同一条路)。
|
|
7851
|
+
*
|
|
7852
|
+
* 为什么不在这里校验一次、把它留在手里:令牌留在手里就等于**撤销失效**——名册里
|
|
7853
|
+
* 把这条记录删掉之后,这台设备还会拿令牌进门。所以这里只是**跳一次**:服务端在门口
|
|
7854
|
+
* 认这枚令牌、换成设备 cookie(HttpOnly),页面自己一个字节都不记。
|
|
7855
|
+
* 令牌不对时服务端会重新发下门禁页;下面启动那一段看地址里那枚 token 就知道要报什么。
|
|
7856
|
+
*/
|
|
6392
7857
|
function submitGate() {
|
|
6393
7858
|
var value = $('gateInput').value.trim();
|
|
6394
7859
|
if (!value) { $('gateErr').textContent = '请先粘贴 token'; return; }
|
|
6395
7860
|
$('gateErr').textContent = '校验中…';
|
|
6396
|
-
|
|
6397
|
-
.then(function (r) {
|
|
6398
|
-
if (!r.ok) throw new Error('token 不正确');
|
|
6399
|
-
localStorage.setItem('dshMiniToken', value);
|
|
6400
|
-
state.token = value;
|
|
6401
|
-
$('gate').classList.remove('open');
|
|
6402
|
-
// 手输 token 进来的人也得有立绘——立绘地址是拿 state.token 拼的,
|
|
6403
|
-
// 不在这里补一次,这一路进来的用户会看到空白的立绘位。
|
|
6404
|
-
preloadPoses();
|
|
6405
|
-
connect();
|
|
6406
|
-
})
|
|
6407
|
-
.catch(function () { $('gateErr').textContent = 'token 不正确,请核对后重试'; });
|
|
7861
|
+
location.replace(location.pathname + '?token=' + encodeURIComponent(value));
|
|
6408
7862
|
}
|
|
6409
7863
|
|
|
6410
7864
|
// ---------------- presence 与心跳 ----------------
|
|
6411
7865
|
document.addEventListener('visibilitychange', function () {
|
|
6412
7866
|
var hidden = document.hidden;
|
|
6413
|
-
if (state.
|
|
7867
|
+
if (state.entered) {
|
|
6414
7868
|
api('/mini/api/presence', {
|
|
6415
7869
|
method: 'POST',
|
|
6416
7870
|
body: JSON.stringify({ state: hidden ? 'background' : 'foreground' })
|
|
@@ -6419,12 +7873,12 @@
|
|
|
6419
7873
|
if (!hidden) { render(); connectIfDead(); }
|
|
6420
7874
|
});
|
|
6421
7875
|
setInterval(function () {
|
|
6422
|
-
if (state.
|
|
7876
|
+
if (state.entered) api('/mini/api/heartbeat', { method: 'POST', body: '{}' }).catch(function () {});
|
|
6423
7877
|
}, 30000);
|
|
6424
7878
|
|
|
6425
7879
|
function connectIfDead() {
|
|
6426
7880
|
// 切回前台时 EventSource 可能已经悄悄死了,探一下。
|
|
6427
|
-
if (!state.
|
|
7881
|
+
if (!state.entered) return;
|
|
6428
7882
|
if (es && es.readyState === EventSource.OPEN) return;
|
|
6429
7883
|
connect();
|
|
6430
7884
|
}
|
|
@@ -6443,7 +7897,7 @@
|
|
|
6443
7897
|
* **正在打字就先不刷**——用户没发出去的话不能被吞掉,等下一轮再说。
|
|
6444
7898
|
*/
|
|
6445
7899
|
function watchBuild() {
|
|
6446
|
-
if (!state.
|
|
7900
|
+
if (!state.entered) return;
|
|
6447
7901
|
api('/mini/api/version').then(function (r) {
|
|
6448
7902
|
// 设置页底部那行版本。**问到才显示**——问不到就整行不出现,
|
|
6449
7903
|
// 不拿「unknown」糊一行上去(那个字对用户没意义,还像页面坏了)。
|
|
@@ -6478,13 +7932,44 @@
|
|
|
6478
7932
|
// 整块抠出来求值,测试里的假 mainEl 没有 addEventListener,写在那边好几个测试当场报错。
|
|
6479
7933
|
mainEl.addEventListener('scroll', onMainScroll);
|
|
6480
7934
|
|
|
7935
|
+
// 「回到底部」浮标(2026-10-08)也挂在这一根滚动线上,但**另起一条监听**:
|
|
7936
|
+
// onMainScroll 被页面测试整块抠出来跑(chatPager 那个替身只给了 state / replyEl /
|
|
7937
|
+
// mainEl / timeLabel,没有 $),把浮标并进去会让那几个测试当场报 ReferenceError。
|
|
7938
|
+
mainEl.addEventListener('scroll', paintToBottom);
|
|
7939
|
+
// 首屏先定一次:刚进来是空页,按判据算「在底部」,浮标该是收着的。
|
|
7940
|
+
paintToBottom();
|
|
7941
|
+
|
|
7942
|
+
// 点它:**瞬时**到底,不做平滑滚动。
|
|
7943
|
+
//
|
|
7944
|
+
// 两条理由,都是「跟现成的东西保持一致」而不是我挑的:
|
|
7945
|
+
// · 用户原话就是「瞬间」;
|
|
7946
|
+
// · 电脑端那颗也是瞬时的(ChatView 里 reading.followTail → viewport.scrollToBottom
|
|
7947
|
+
// → follow.toBottom(..., 'instant'));
|
|
7948
|
+
// · 本页「按发送回底部」本来就是直接赋值(见 send 里那句
|
|
7949
|
+
// mainEl.scrollTop = mainEl.scrollHeight),同一件事用同一套动作,
|
|
7950
|
+
// 用户不会看到「有时滑一下、有时直接跳」两种脾气。
|
|
7951
|
+
//
|
|
7952
|
+
// 赋值本身会发一次 scroll 事件,浮标自己就收了;这里再补一次是防「本来就在最底、
|
|
7953
|
+
// 位置没变所以不发事件」那一种——补一下,状态就是确定的。
|
|
7954
|
+
$('toBottom').addEventListener('click', function () {
|
|
7955
|
+
mainEl.scrollTop = mainEl.scrollHeight;
|
|
7956
|
+
paintToBottom();
|
|
7957
|
+
});
|
|
7958
|
+
|
|
6481
7959
|
// ---------------- 启动 ----------------
|
|
6482
7960
|
syncSeg();
|
|
6483
7961
|
autoGrow();
|
|
6484
|
-
if (
|
|
6485
|
-
|
|
6486
|
-
|
|
7962
|
+
if (!state.entered) {
|
|
7963
|
+
// 服务端没放行:这是一张只用来输入令牌的门禁页。
|
|
7964
|
+
// 地址里那枚 token = 刚才手输(或扫码)交上来的那一枚,它没被认下来才会走到这儿——
|
|
7965
|
+
// 报一句人话,并把地址栏擦掉(不把输错的令牌留在那儿)。
|
|
7966
|
+
var bad = params.get('token');
|
|
7967
|
+
if (bad) history.replaceState(null, '', location.pathname);
|
|
6487
7968
|
showGate();
|
|
7969
|
+
if (bad) $('gateErr').textContent = 'token 不正确,请核对后重试';
|
|
7970
|
+
// 被撤销的那台设备:**说清发生了什么、下一步做什么**。不说的话,用户看到的
|
|
7971
|
+
// 只是一张"要令牌"的页面——他昨天还能用,今天莫名其妙进不去了。
|
|
7972
|
+
if (DEVICE_REVOKED) $('gateRevoked').hidden = false;
|
|
6488
7973
|
} else {
|
|
6489
7974
|
preloadPoses();
|
|
6490
7975
|
connect();
|
|
@@ -6645,7 +8130,7 @@
|
|
|
6645
8130
|
var body = { provider: provider, model: model }
|
|
6646
8131
|
if (effort) body.reasoningEffort = effort
|
|
6647
8132
|
busy = true
|
|
6648
|
-
fetch('/mini/api/set-model
|
|
8133
|
+
fetch('/mini/api/set-model', {
|
|
6649
8134
|
method: 'POST',
|
|
6650
8135
|
headers: { 'Content-Type': 'application/json' },
|
|
6651
8136
|
body: JSON.stringify(body)
|
|
@@ -6667,8 +8152,8 @@
|
|
|
6667
8152
|
|
|
6668
8153
|
// ---- 读目录 ----------------------------------------------------------
|
|
6669
8154
|
function load() {
|
|
6670
|
-
if (!state.
|
|
6671
|
-
fetch('/mini/api/models
|
|
8155
|
+
if (!state.entered) return
|
|
8156
|
+
fetch('/mini/api/models', { cache: 'no-store' })
|
|
6672
8157
|
.then(function (r) { return r.ok ? r.json() : null })
|
|
6673
8158
|
.then(function (d) {
|
|
6674
8159
|
if (!d || !d.ok) return
|