dsh-mini-remote 0.2.0 → 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 +41 -0
- package/README.en.md +198 -147
- package/README.md +198 -154
- package/client/client.js +1931 -3
- package/lib/build.js +32 -1
- package/lib/devices.js +372 -0
- package/lib/index.js +1625 -32
- package/lib/mirror.js +296 -3
- package/lib/page.html +938 -71
- package/lib/page.js +4 -1
- package/lib/remotes.js +279 -0
- package/lib/server.js +937 -55
- package/package.json +1 -1
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);
|
|
@@ -880,6 +887,108 @@
|
|
|
880
887
|
flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
|
|
881
888
|
background: var(--run);
|
|
882
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; }
|
|
883
992
|
/* 「新建会话」那一行。跟会话条目同宽、同缩进,但用强调色——
|
|
884
993
|
它不是一个会话,是一个动作。长得跟会话一样会让人以为点进去就是那条会话。 */
|
|
885
994
|
.ws-new {
|
|
@@ -1271,6 +1380,35 @@
|
|
|
1271
1380
|
background: var(--bg-top);
|
|
1272
1381
|
}
|
|
1273
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
|
+
}
|
|
1274
1412
|
#mirrorFrame {
|
|
1275
1413
|
flex: 1 1 auto; width: 100%; border: 0; background: var(--bg);
|
|
1276
1414
|
/* 底部让开手势条(2026-10-07 从「被镜像的那份页面」搬回来的)。
|
|
@@ -1329,6 +1467,16 @@
|
|
|
1329
1467
|
color: var(--on-act); font-size: 16px; font-weight: 600;
|
|
1330
1468
|
}
|
|
1331
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); }
|
|
1332
1480
|
|
|
1333
1481
|
#toast {
|
|
1334
1482
|
position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%) translateY(8px);
|
|
@@ -1338,6 +1486,17 @@
|
|
|
1338
1486
|
transition: opacity .2s, transform .2s; z-index: 200; max-width: 86vw; text-align: center;
|
|
1339
1487
|
}
|
|
1340
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); }
|
|
1341
1500
|
|
|
1342
1501
|
/* ——— 回答电脑提问的卡片 ———
|
|
1343
1502
|
整屏遮住聊天区:手机上一次只专心答一题,答完自动消失。
|
|
@@ -1739,6 +1898,21 @@
|
|
|
1739
1898
|
</aside>
|
|
1740
1899
|
</div>
|
|
1741
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
|
+
|
|
1742
1916
|
<div id="settings">
|
|
1743
1917
|
<div class="sheet">
|
|
1744
1918
|
<!-- 页眉。整屏之后「点外面关掉」没了,所以这里必须有一个一直看得见的关闭口
|
|
@@ -1922,7 +2096,7 @@
|
|
|
1922
2096
|
</div>
|
|
1923
2097
|
|
|
1924
2098
|
<div class="foot">
|
|
1925
|
-
<button class="btn" id="btnForget"
|
|
2099
|
+
<button class="btn" id="btnForget">重新输入令牌</button>
|
|
1926
2100
|
<button class="btn primary" id="btnClose">完成</button>
|
|
1927
2101
|
</div>
|
|
1928
2102
|
|
|
@@ -1950,9 +2124,15 @@
|
|
|
1950
2124
|
另外整行都能点(不只右边那个按钮)——按钮只有 54×40,手指容易点偏。
|
|
1951
2125
|
-->
|
|
1952
2126
|
<div id="mirrorView" hidden>
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
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>
|
|
1956
2136
|
</div>
|
|
1957
2137
|
<!-- 地址是点开那一刻才设的:没点之前一个字节都不加载。 -->
|
|
1958
2138
|
<iframe id="mirrorFrame" title="电脑端界面"></iframe>
|
|
@@ -2026,6 +2206,7 @@
|
|
|
2026
2206
|
|
|
2027
2207
|
<div id="gate">
|
|
2028
2208
|
<h1>需要访问令牌</h1>
|
|
2209
|
+
<p class="revoked" id="gateRevoked" hidden>这台设备的授权已被移除,请重新扫码。</p>
|
|
2029
2210
|
<p>在电脑上打开 DSH 的「设置 → 手机遥控」,用手机重新扫一次二维码就能进来。
|
|
2030
2211
|
或者把那一页上的链接整条复制到浏览器地址栏打开。</p>
|
|
2031
2212
|
<input id="gateInput" type="text" placeholder="粘贴 token" autocomplete="off"
|
|
@@ -2042,6 +2223,9 @@
|
|
|
2042
2223
|
|
|
2043
2224
|
var DEFAULT_MODE = '__DEFAULT_MODE__';
|
|
2044
2225
|
var NEEDS_TOKEN = __NEEDS_TOKEN__;
|
|
2226
|
+
// 这台设备是**被撤销了**(名册里那条记录被删掉),还是从来没进过门?
|
|
2227
|
+
// 由服务端判(它才知道凭据兑不兑得出来),页面只负责把那句话说给用户听。
|
|
2228
|
+
var DEVICE_REVOKED = __DEVICE_REVOKED__;
|
|
2045
2229
|
// 构建指纹,由服务端注入。拼在立绘地址后面,换了图浏览器就不会一直用缓存那张。
|
|
2046
2230
|
var BUILD = '__BUILD__';
|
|
2047
2231
|
// 单个文件的大小上限(字节),由服务端注入。页面里不写第二份——两处各写一个数,
|
|
@@ -2051,7 +2235,14 @@
|
|
|
2051
2235
|
// ---------------- 状态 ----------------
|
|
2052
2236
|
var params = new URLSearchParams(location.search);
|
|
2053
2237
|
var state = {
|
|
2054
|
-
|
|
2238
|
+
/**
|
|
2239
|
+
* 进没进门——**由服务端说了算**(2026-10-16 第二步)。
|
|
2240
|
+
*
|
|
2241
|
+
* 服务端只在「没放行」的那一次渲染门禁页(needsToken=true);放行时永远是 false。
|
|
2242
|
+
* 这是页面之后唯一需要的身份信息:真正的凭据是一枚 HttpOnly 的设备 cookie,
|
|
2243
|
+
* 页面看不见、也不该管它——浏览器自己会给同源的每一趟请求带上。
|
|
2244
|
+
*/
|
|
2245
|
+
entered: !NEEDS_TOKEN,
|
|
2055
2246
|
mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
|
|
2056
2247
|
// 外观:'dark' | 'light' | 'auto'。默认深色——不给老用户换掉现在的样子;
|
|
2057
2248
|
// 要浅色或跟着系统走,去设置页「外观」点一下。
|
|
@@ -2124,11 +2315,14 @@
|
|
|
2124
2315
|
*/
|
|
2125
2316
|
pendingCommand: null
|
|
2126
2317
|
};
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2318
|
+
/**
|
|
2319
|
+
* 地址里那一枚令牌**只是入场那一下**(2026-10-16 第二步)。
|
|
2320
|
+
*
|
|
2321
|
+
* 服务端在上面那一次请求里已经把它换成了设备凭据(HttpOnly cookie,浏览器自己带),
|
|
2322
|
+
* 页面从此不读它、不存它、也不再往任何请求里放它。这里只做一件事:把地址栏擦干净,
|
|
2323
|
+
* 免得被截图/分享带出去。
|
|
2324
|
+
*/
|
|
2325
|
+
if (params.get('token')) history.replaceState(null, '', location.pathname);
|
|
2132
2326
|
|
|
2133
2327
|
/**
|
|
2134
2328
|
* ---------------- 执行轨迹:订阅与收帧(数据层) ----------------
|
|
@@ -2293,7 +2487,7 @@
|
|
|
2293
2487
|
* 读不到就把原因写出来,不说「没有过程」。
|
|
2294
2488
|
*/
|
|
2295
2489
|
function requestTrajectory() {
|
|
2296
|
-
if (state.mode !== 'full' || !state.
|
|
2490
|
+
if (state.mode !== 'full' || !state.entered) return;
|
|
2297
2491
|
var sessionId = state.boundSessionId;
|
|
2298
2492
|
if (!sessionId) {
|
|
2299
2493
|
state.trajectory = [];
|
|
@@ -2327,7 +2521,7 @@
|
|
|
2327
2521
|
* 重连成功与否都不用在这里补救——`open` 里那一套会再走一遍。
|
|
2328
2522
|
*/
|
|
2329
2523
|
function syncTrajectorySubscription() {
|
|
2330
|
-
if (!state.
|
|
2524
|
+
if (!state.entered) return;
|
|
2331
2525
|
var want = state.mode === 'full';
|
|
2332
2526
|
if (!want) {
|
|
2333
2527
|
if (subscribedClientId) {
|
|
@@ -2401,8 +2595,8 @@
|
|
|
2401
2595
|
var name = src.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
2402
2596
|
if (/^lib\/art\//i.test(name)) name = name.slice(8);
|
|
2403
2597
|
if (/^[a-z0-9-]+(\.webp)?$/i.test(name)) return artUrl(name.replace(/(\.webp)?$/i, ''));
|
|
2404
|
-
|
|
2405
|
-
|
|
2598
|
+
// **不拼令牌**:这条路和别的接口一样,靠浏览器自动带上的设备 cookie 认人。
|
|
2599
|
+
return '/mini/art/?p=' + encodeURIComponent(src);
|
|
2406
2600
|
}
|
|
2407
2601
|
|
|
2408
2602
|
function mdInline(raw) {
|
|
@@ -2612,14 +2806,59 @@
|
|
|
2612
2806
|
function toast(msg) {
|
|
2613
2807
|
var el = $('toast');
|
|
2614
2808
|
el.textContent = msg;
|
|
2809
|
+
// 普通提示**一定是纯字**:上一版如果带着「撤销」那颗按钮,这里要把它连同
|
|
2810
|
+
// with-act 一起收掉,否则会留下一个按了没反应的按钮(它的回调早就过期了)。
|
|
2811
|
+
el.classList.remove('with-act');
|
|
2615
2812
|
el.classList.add('show');
|
|
2616
2813
|
clearTimeout(toastTimer);
|
|
2617
2814
|
toastTimer = setTimeout(function () { el.classList.remove('show'); }, 1800);
|
|
2618
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
|
+
*/
|
|
2619
2860
|
function authHeaders(isJson) {
|
|
2620
|
-
return isJson === false
|
|
2621
|
-
? { 'X-Mini-Token': state.token }
|
|
2622
|
-
: { 'Content-Type': 'application/json', 'X-Mini-Token': state.token };
|
|
2861
|
+
return isJson === false ? {} : { 'Content-Type': 'application/json' };
|
|
2623
2862
|
}
|
|
2624
2863
|
function api(path, options) {
|
|
2625
2864
|
var opts = options || {};
|
|
@@ -2635,6 +2874,9 @@
|
|
|
2635
2874
|
// 状态码挂在错上带出去:调用方要区分「服务端说这题已经结束了」(409)和
|
|
2636
2875
|
// 「压根没连上」(fetch 直接抛)——靠中文字面去猜太脆。
|
|
2637
2876
|
err.status = r.status;
|
|
2877
|
+
// 整个响应体也挂上。归档那条要**读里面的 activity**(会停掉哪些工作)才能
|
|
2878
|
+
// 弹确认框——只靠状态码看不出"还有工作在跑"和"工作区服务拿不到"的区别。
|
|
2879
|
+
err.body = body;
|
|
2638
2880
|
throw err;
|
|
2639
2881
|
}
|
|
2640
2882
|
return body;
|
|
@@ -2819,10 +3061,12 @@
|
|
|
2819
3061
|
return out;
|
|
2820
3062
|
}
|
|
2821
3063
|
|
|
2822
|
-
/**
|
|
3064
|
+
/**
|
|
3065
|
+
* 立绘地址。**不带令牌**(2026-10-16 第二步):进门之后浏览器手里有设备 cookie,
|
|
3066
|
+
* 同源的 <img> 自己会带上它。指纹照旧——换了图浏览器不会一直用缓存那张。
|
|
3067
|
+
*/
|
|
2823
3068
|
function artUrl(file) {
|
|
2824
|
-
return '/mini/art/' + file + '.webp?
|
|
2825
|
-
+ '&v=' + encodeURIComponent(BUILD);
|
|
3069
|
+
return '/mini/art/' + file + '.webp?v=' + encodeURIComponent(BUILD);
|
|
2826
3070
|
}
|
|
2827
3071
|
|
|
2828
3072
|
/**
|
|
@@ -3674,6 +3918,33 @@
|
|
|
3674
3918
|
*/
|
|
3675
3919
|
var pinnedAnswerKey = '';
|
|
3676
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
|
+
|
|
3677
3948
|
/**
|
|
3678
3949
|
* 聊天模式的滚动:**回答的开头才是要读的地方,不是末尾。**
|
|
3679
3950
|
*
|
|
@@ -3690,7 +3961,8 @@
|
|
|
3690
3961
|
* 就被拽回末尾,等于没改。
|
|
3691
3962
|
*
|
|
3692
3963
|
* · **落定**(timestamp 变了)——对齐最后那条 `.said` 的开头,
|
|
3693
|
-
*
|
|
3964
|
+
* 让最终版从第一行开始读。(**完整**模式再往前退到这一轮的头上,
|
|
3965
|
+
* 理由见 trajRunHead:那一轮的过程组就排在回答前面。)
|
|
3694
3966
|
*
|
|
3695
3967
|
* 为什么要记 key、而不是「每次都对齐」:用户往下读的时候,每次重画都把他拽回
|
|
3696
3968
|
* 开头,比不对齐还烦人。这一点和单帧模式里 scrolledReplyAt 的道理是一样的。
|
|
@@ -3719,6 +3991,9 @@
|
|
|
3719
3991
|
} else if (replyEl.querySelectorAll) {
|
|
3720
3992
|
var saids = replyEl.querySelectorAll('.said');
|
|
3721
3993
|
target = saids.length ? saids[saids.length - 1] : null;
|
|
3994
|
+
// 「完整」模式要停的是**这一轮的头上**,不是这条回答的头上(见 trajRunHead)。
|
|
3995
|
+
// 「正在流」那一路不对齐这里:用户看的就是刚流出来的那段本身。
|
|
3996
|
+
if (target && state.mode === 'full') target = trajRunHead(target);
|
|
3722
3997
|
}
|
|
3723
3998
|
pinToTop(target);
|
|
3724
3999
|
return;
|
|
@@ -4377,6 +4652,46 @@
|
|
|
4377
4652
|
var navPresets = null; // Agent 模式清单(2026-10-04:新建会话时手机上也要能选模式)
|
|
4378
4653
|
var navPresetsLoaded = false;
|
|
4379
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;
|
|
4380
4695
|
|
|
4381
4696
|
function openNav() {
|
|
4382
4697
|
$('nav').classList.add('open');
|
|
@@ -4417,7 +4732,7 @@
|
|
|
4417
4732
|
function prefetchWorkspaces() {
|
|
4418
4733
|
// Agent 模式清单也是导航栏要用的、也几乎不变——顺手一起预取(见 prefetchPresets)。
|
|
4419
4734
|
prefetchPresets();
|
|
4420
|
-
if (navList || navPrefetchPromise || navView !== 'tree' || !state.
|
|
4735
|
+
if (navList || navPrefetchPromise || navView !== 'tree' || !state.entered) return;
|
|
4421
4736
|
var run = function () {
|
|
4422
4737
|
navPrefetchPromise = api('/mini/api/workspaces').then(function (body) {
|
|
4423
4738
|
navList = body.workspaces || [];
|
|
@@ -4441,7 +4756,7 @@
|
|
|
4441
4756
|
* **不摆一个只有一项的选择题**,也不假装自己知道 DSH 有哪些模式。
|
|
4442
4757
|
*/
|
|
4443
4758
|
function prefetchPresets() {
|
|
4444
|
-
if (navPresetsLoaded || !state.
|
|
4759
|
+
if (navPresetsLoaded || !state.entered) return;
|
|
4445
4760
|
navPresetsLoaded = true;
|
|
4446
4761
|
api('/mini/api/agent-presets').then(function (body) {
|
|
4447
4762
|
navPresets = body && body.ok ? body : { ok: false, reason: (body && body.reason) || 'failed' };
|
|
@@ -4472,12 +4787,24 @@
|
|
|
4472
4787
|
// 「新建工作区」常驻在最上面:它是这一屏的入口,不该等有了会话才出现——
|
|
4473
4788
|
// 一个工作区都还没有的人,恰恰最需要它。
|
|
4474
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
|
+
: '';
|
|
4475
4802
|
if (!list.length) {
|
|
4476
|
-
$('navBody').innerHTML = cta + '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
4803
|
+
$('navBody').innerHTML = cta + filter + '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
4477
4804
|
+ '在电脑上开一个,这里就会出现了。</div>';
|
|
4478
4805
|
return;
|
|
4479
4806
|
}
|
|
4480
|
-
$('navBody').innerHTML = cta + list.map(function (ws) {
|
|
4807
|
+
$('navBody').innerHTML = cta + filter + list.map(function (ws) {
|
|
4481
4808
|
var open = Boolean(navExpanded[ws.id]);
|
|
4482
4809
|
return '<div class="ws' + (open ? ' open' : '') + '" data-ws="' + escapeHtml(ws.id) + '">'
|
|
4483
4810
|
+ '<button class="ws-head" type="button">'
|
|
@@ -4493,6 +4820,76 @@
|
|
|
4493
4820
|
}).join('');
|
|
4494
4821
|
}
|
|
4495
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
|
+
|
|
4496
4893
|
function sessionsHtml(wsId) {
|
|
4497
4894
|
// 「新建会话」永远排在最上面:不管这个工作区有几个会话、甚至一个都没有,
|
|
4498
4895
|
// 这一行都在。放在列表末尾的话,一个 452 条会话的工作区要划到底才看得到。
|
|
@@ -4516,25 +4913,341 @@
|
|
|
4516
4913
|
}
|
|
4517
4914
|
var got = navSessions[wsId];
|
|
4518
4915
|
if (!got) return html + '<div class="placeholder">正在读取…</div>';
|
|
4519
|
-
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
|
+
}
|
|
4520
4933
|
var cur = state.boundSessionId;
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
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"'
|
|
4525
4950
|
+ ' data-sess="' + escapeHtml(s.id) + '">'
|
|
4526
|
-
+
|
|
4951
|
+
+ dot
|
|
4952
|
+
// 没有标题就用日期——原来退到取 id 的前 8 位当名字,而本机真有一个会话的 id
|
|
4953
|
+
// 以「session-」开头,于是整列都显示成一样的「session-」,等于没有名字。
|
|
4527
4954
|
+ '<span class="s-title">' + escapeHtml(s.title || '未命名会话') + '</span>'
|
|
4528
|
-
+
|
|
4529
|
-
+ '</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;
|
|
4530
4974
|
}).join('');
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
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>';
|
|
4534
4982
|
}
|
|
4535
4983
|
return html;
|
|
4536
4984
|
}
|
|
4537
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
|
+
|
|
4538
5251
|
// ---------------- 新建工作区:浏览电脑目录 ----------------
|
|
4539
5252
|
//
|
|
4540
5253
|
// 面板里有两个「屏」:工作区树(默认)和目录浏览。用一个变量记当前在哪一屏,
|
|
@@ -4762,10 +5475,11 @@
|
|
|
4762
5475
|
// navWsFetching 这道闩让同一工作区同时只有一趟在飞,别重复付读日志折标题的钱。
|
|
4763
5476
|
// 15 秒兜底解锁是给「请求永远不回来」留的——闩本身不能变成新的卡死点,
|
|
4764
5477
|
// 超过 15 秒就放行,让下一次渲染或点击还能再试(服务端有缓存,重发很便宜)。
|
|
4765
|
-
function loadWsSessions(wsId) {
|
|
5478
|
+
function loadWsSessions(wsId, force) {
|
|
4766
5479
|
if (navSessions[wsId] || navWsFetching[wsId]) return;
|
|
4767
5480
|
navWsFetching[wsId] = true;
|
|
4768
5481
|
var bail = setTimeout(function () { delete navWsFetching[wsId]; }, 15000);
|
|
5482
|
+
var sessionsUrl = sessionsUrlOf(wsId, force);
|
|
4769
5483
|
/**
|
|
4770
5484
|
* 接着问,直到服务端说「一条都不剩」。
|
|
4771
5485
|
*
|
|
@@ -4781,7 +5495,7 @@
|
|
|
4781
5495
|
delete navFollowTimers[wsId];
|
|
4782
5496
|
// 用户收起了、或者这份数据已经被刷新换掉,就放手:别跟人抢。
|
|
4783
5497
|
if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
|
|
4784
|
-
api(
|
|
5498
|
+
api(sessionsUrlOf(wsId))
|
|
4785
5499
|
.then(function (body) {
|
|
4786
5500
|
if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
|
|
4787
5501
|
navSessions[wsId] = body;
|
|
@@ -4793,7 +5507,7 @@
|
|
|
4793
5507
|
});
|
|
4794
5508
|
}, TITLE_FOLLOW_MS);
|
|
4795
5509
|
}
|
|
4796
|
-
api(
|
|
5510
|
+
api(sessionsUrl)
|
|
4797
5511
|
.then(function (body) {
|
|
4798
5512
|
clearTimeout(bail);
|
|
4799
5513
|
navSessions[wsId] = body;
|
|
@@ -4810,6 +5524,23 @@
|
|
|
4810
5524
|
});
|
|
4811
5525
|
}
|
|
4812
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
|
+
|
|
4813
5544
|
// 防连点。建会话**不是幂等的**——点两下就在电脑上真建出两个会话来,
|
|
4814
5545
|
// 而手机上什么都看不出来(第二个只是把列表撑长了一行)。所以这里必须自己拦。
|
|
4815
5546
|
// 服务端那道闸门拦不住这个:两个请求都是合法的。
|
|
@@ -4887,6 +5618,33 @@
|
|
|
4887
5618
|
});
|
|
4888
5619
|
}
|
|
4889
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
|
+
|
|
4890
5648
|
$('btnNav').addEventListener('click', openNav);
|
|
4891
5649
|
$('btnNavClose').addEventListener('click', closeNav);
|
|
4892
5650
|
$('btnNavRefresh').addEventListener('click', function () { loadWorkspaces(true); });
|
|
@@ -4894,15 +5652,45 @@
|
|
|
4894
5652
|
|
|
4895
5653
|
$('navBody').addEventListener('click', function (e) {
|
|
4896
5654
|
var t = e.target;
|
|
5655
|
+
var at = function (sel) { return t && t.closest ? t.closest(sel) : null; };
|
|
4897
5656
|
var sess = t && t.closest ? t.closest('.sess') : null;
|
|
4898
5657
|
if (sess) {
|
|
4899
5658
|
// 目录浏览那一屏里的 .sess 是「进这个目录」,不是「切到这个会话」。
|
|
4900
5659
|
// 两屏共用同一个条目样式,所以要先看当前在哪一屏。
|
|
4901
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
|
+
}
|
|
4902
5668
|
bindSession(sess.getAttribute('data-sess'));
|
|
4903
5669
|
return;
|
|
4904
5670
|
}
|
|
4905
|
-
|
|
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; }
|
|
4906
5694
|
|
|
4907
5695
|
var newWs = at('[data-new-ws]');
|
|
4908
5696
|
if (newWs) { openBrowse(); return; }
|
|
@@ -4973,13 +5761,13 @@
|
|
|
4973
5761
|
var es = null;
|
|
4974
5762
|
function connect() {
|
|
4975
5763
|
if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
4976
|
-
if (!state.
|
|
5764
|
+
if (!state.entered) { showGate(); return; }
|
|
4977
5765
|
setStatus('', '连接中…');
|
|
4978
5766
|
// clientId 每条连接都带(订阅的 POST 靠它认出这条流);full=1 只在「完整」模式带,
|
|
4979
5767
|
// 服务端见到就自动把这条连接订上轨迹——断线重连沿用同一个地址,重新订上由服务端
|
|
4980
5768
|
// 自己完成,客户端不用再握手一次。
|
|
4981
|
-
|
|
4982
|
-
|
|
5769
|
+
// **地址里不带令牌**(2026-10-16 第二步):同源的 EventSource 会带上设备 cookie。
|
|
5770
|
+
es = new EventSource('/mini/api/stream?clientId=' + encodeURIComponent(clientId)
|
|
4983
5771
|
+ (state.mode === 'full' ? '&full=1' : ''));
|
|
4984
5772
|
|
|
4985
5773
|
es.addEventListener('open', function () {
|
|
@@ -5034,6 +5822,23 @@
|
|
|
5034
5822
|
}
|
|
5035
5823
|
if (navExpanded[payload.workspaceId] && navView === 'tree') renderNav();
|
|
5036
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
|
+
});
|
|
5037
5842
|
es.addEventListener('subagent-metrics', function (e) {
|
|
5038
5843
|
var payload;
|
|
5039
5844
|
try { payload = JSON.parse(e.data); } catch (err) { return; }
|
|
@@ -6733,11 +7538,13 @@
|
|
|
6733
7538
|
* 解析成 `/mini/`,在 `/mini/mirror/` 上才是 `/mini/mirror/`——差这一个字符,
|
|
6734
7539
|
* 里面引用的脚本会全 404、界面白屏(真浏览器验出来的)。
|
|
6735
7540
|
*
|
|
6736
|
-
*
|
|
6737
|
-
*
|
|
7541
|
+
*
|
|
7542
|
+
* **地址里不带令牌**(2026-10-16 第二步):这个框是同源文档,设备 cookie 会自动跟着
|
|
7543
|
+
* 它的每一条请求走(文档本身、静态资源、界面自己发的 `/api/*`),入口那一下不再需要
|
|
7544
|
+
* 顺手写 cookie。上游收到的东西里一个字节的凭据都没有——第一步已经钉住。
|
|
6738
7545
|
*/
|
|
6739
7546
|
function mirrorUrl() {
|
|
6740
|
-
return '/mini/mirror
|
|
7547
|
+
return '/mini/mirror/';
|
|
6741
7548
|
}
|
|
6742
7549
|
|
|
6743
7550
|
/**
|
|
@@ -6766,6 +7573,9 @@
|
|
|
6766
7573
|
|
|
6767
7574
|
function closeMirror() {
|
|
6768
7575
|
$('mirrorView').hidden = true;
|
|
7576
|
+
// 收起状态一起复位:下次进来从「看不见」开始(鼠标若正好停在顶上,
|
|
7577
|
+
// 那一下 mousemove 会把它勾出来,见下面的接线)。
|
|
7578
|
+
mirrorBarOff();
|
|
6769
7579
|
// 把地址清掉:那一屏里是个完整应用(还在跑、还连着),关掉就该真停,
|
|
6770
7580
|
// 而不是留在后台继续占内存和连接。
|
|
6771
7581
|
$('mirrorFrame').src = 'about:blank';
|
|
@@ -6780,6 +7590,55 @@
|
|
|
6780
7590
|
});
|
|
6781
7591
|
$('mirrorClose').addEventListener('click', closeMirror);
|
|
6782
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
|
+
|
|
6783
7642
|
/**
|
|
6784
7643
|
* 框里那一页自己发的「返回手机页」。
|
|
6785
7644
|
*
|
|
@@ -6965,10 +7824,14 @@
|
|
|
6965
7824
|
toast('正在重连…');
|
|
6966
7825
|
});
|
|
6967
7826
|
|
|
7827
|
+
/**
|
|
7828
|
+
* 「重新输入令牌」:把闸门露出来,让用户再贴一枚令牌进来。
|
|
7829
|
+
*
|
|
7830
|
+
* **这一下不再清除任何东西**(2026-10-16 第二步):身份已经是一枚 HttpOnly 的设备
|
|
7831
|
+
* cookie,页面看不到也删不掉它——真正的「把这台设备赶出去」在电脑那边(名册里删掉
|
|
7832
|
+
* 它,下一步做进设置页)。按钮原来叫「清除 token」,那个名字从这一刻起就是假的。
|
|
7833
|
+
*/
|
|
6968
7834
|
$('btnForget').addEventListener('click', function () {
|
|
6969
|
-
localStorage.removeItem('dshMiniToken');
|
|
6970
|
-
state.token = '';
|
|
6971
|
-
if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
6972
7835
|
sheet.classList.remove('open');
|
|
6973
7836
|
showGate();
|
|
6974
7837
|
});
|
|
@@ -6983,28 +7846,25 @@
|
|
|
6983
7846
|
$('gateInput').addEventListener('keydown', function (e) {
|
|
6984
7847
|
if (e.key === 'Enter') submitGate();
|
|
6985
7848
|
});
|
|
7849
|
+
/**
|
|
7850
|
+
* 把手里这枚令牌交回**门口那一次**(和扫码进来是同一条路)。
|
|
7851
|
+
*
|
|
7852
|
+
* 为什么不在这里校验一次、把它留在手里:令牌留在手里就等于**撤销失效**——名册里
|
|
7853
|
+
* 把这条记录删掉之后,这台设备还会拿令牌进门。所以这里只是**跳一次**:服务端在门口
|
|
7854
|
+
* 认这枚令牌、换成设备 cookie(HttpOnly),页面自己一个字节都不记。
|
|
7855
|
+
* 令牌不对时服务端会重新发下门禁页;下面启动那一段看地址里那枚 token 就知道要报什么。
|
|
7856
|
+
*/
|
|
6986
7857
|
function submitGate() {
|
|
6987
7858
|
var value = $('gateInput').value.trim();
|
|
6988
7859
|
if (!value) { $('gateErr').textContent = '请先粘贴 token'; return; }
|
|
6989
7860
|
$('gateErr').textContent = '校验中…';
|
|
6990
|
-
|
|
6991
|
-
.then(function (r) {
|
|
6992
|
-
if (!r.ok) throw new Error('token 不正确');
|
|
6993
|
-
localStorage.setItem('dshMiniToken', value);
|
|
6994
|
-
state.token = value;
|
|
6995
|
-
$('gate').classList.remove('open');
|
|
6996
|
-
// 手输 token 进来的人也得有立绘——立绘地址是拿 state.token 拼的,
|
|
6997
|
-
// 不在这里补一次,这一路进来的用户会看到空白的立绘位。
|
|
6998
|
-
preloadPoses();
|
|
6999
|
-
connect();
|
|
7000
|
-
})
|
|
7001
|
-
.catch(function () { $('gateErr').textContent = 'token 不正确,请核对后重试'; });
|
|
7861
|
+
location.replace(location.pathname + '?token=' + encodeURIComponent(value));
|
|
7002
7862
|
}
|
|
7003
7863
|
|
|
7004
7864
|
// ---------------- presence 与心跳 ----------------
|
|
7005
7865
|
document.addEventListener('visibilitychange', function () {
|
|
7006
7866
|
var hidden = document.hidden;
|
|
7007
|
-
if (state.
|
|
7867
|
+
if (state.entered) {
|
|
7008
7868
|
api('/mini/api/presence', {
|
|
7009
7869
|
method: 'POST',
|
|
7010
7870
|
body: JSON.stringify({ state: hidden ? 'background' : 'foreground' })
|
|
@@ -7013,12 +7873,12 @@
|
|
|
7013
7873
|
if (!hidden) { render(); connectIfDead(); }
|
|
7014
7874
|
});
|
|
7015
7875
|
setInterval(function () {
|
|
7016
|
-
if (state.
|
|
7876
|
+
if (state.entered) api('/mini/api/heartbeat', { method: 'POST', body: '{}' }).catch(function () {});
|
|
7017
7877
|
}, 30000);
|
|
7018
7878
|
|
|
7019
7879
|
function connectIfDead() {
|
|
7020
7880
|
// 切回前台时 EventSource 可能已经悄悄死了,探一下。
|
|
7021
|
-
if (!state.
|
|
7881
|
+
if (!state.entered) return;
|
|
7022
7882
|
if (es && es.readyState === EventSource.OPEN) return;
|
|
7023
7883
|
connect();
|
|
7024
7884
|
}
|
|
@@ -7037,7 +7897,7 @@
|
|
|
7037
7897
|
* **正在打字就先不刷**——用户没发出去的话不能被吞掉,等下一轮再说。
|
|
7038
7898
|
*/
|
|
7039
7899
|
function watchBuild() {
|
|
7040
|
-
if (!state.
|
|
7900
|
+
if (!state.entered) return;
|
|
7041
7901
|
api('/mini/api/version').then(function (r) {
|
|
7042
7902
|
// 设置页底部那行版本。**问到才显示**——问不到就整行不出现,
|
|
7043
7903
|
// 不拿「unknown」糊一行上去(那个字对用户没意义,还像页面坏了)。
|
|
@@ -7099,10 +7959,17 @@
|
|
|
7099
7959
|
// ---------------- 启动 ----------------
|
|
7100
7960
|
syncSeg();
|
|
7101
7961
|
autoGrow();
|
|
7102
|
-
if (
|
|
7103
|
-
|
|
7104
|
-
|
|
7962
|
+
if (!state.entered) {
|
|
7963
|
+
// 服务端没放行:这是一张只用来输入令牌的门禁页。
|
|
7964
|
+
// 地址里那枚 token = 刚才手输(或扫码)交上来的那一枚,它没被认下来才会走到这儿——
|
|
7965
|
+
// 报一句人话,并把地址栏擦掉(不把输错的令牌留在那儿)。
|
|
7966
|
+
var bad = params.get('token');
|
|
7967
|
+
if (bad) history.replaceState(null, '', location.pathname);
|
|
7105
7968
|
showGate();
|
|
7969
|
+
if (bad) $('gateErr').textContent = 'token 不正确,请核对后重试';
|
|
7970
|
+
// 被撤销的那台设备:**说清发生了什么、下一步做什么**。不说的话,用户看到的
|
|
7971
|
+
// 只是一张"要令牌"的页面——他昨天还能用,今天莫名其妙进不去了。
|
|
7972
|
+
if (DEVICE_REVOKED) $('gateRevoked').hidden = false;
|
|
7106
7973
|
} else {
|
|
7107
7974
|
preloadPoses();
|
|
7108
7975
|
connect();
|
|
@@ -7263,7 +8130,7 @@
|
|
|
7263
8130
|
var body = { provider: provider, model: model }
|
|
7264
8131
|
if (effort) body.reasoningEffort = effort
|
|
7265
8132
|
busy = true
|
|
7266
|
-
fetch('/mini/api/set-model
|
|
8133
|
+
fetch('/mini/api/set-model', {
|
|
7267
8134
|
method: 'POST',
|
|
7268
8135
|
headers: { 'Content-Type': 'application/json' },
|
|
7269
8136
|
body: JSON.stringify(body)
|
|
@@ -7285,8 +8152,8 @@
|
|
|
7285
8152
|
|
|
7286
8153
|
// ---- 读目录 ----------------------------------------------------------
|
|
7287
8154
|
function load() {
|
|
7288
|
-
if (!state.
|
|
7289
|
-
fetch('/mini/api/models
|
|
8155
|
+
if (!state.entered) return
|
|
8156
|
+
fetch('/mini/api/models', { cache: 'no-store' })
|
|
7290
8157
|
.then(function (r) { return r.ok ? r.json() : null })
|
|
7291
8158
|
.then(function (d) {
|
|
7292
8159
|
if (!d || !d.ok) return
|