dsh-mini-remote 0.1.5 → 0.1.6
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 +22 -0
- package/README.md +8 -1
- package/lib/art/out-4legs-fix.gif +0 -0
- package/lib/art/out-anim-all.gif +0 -0
- package/lib/art/out-anim-carousel.gif +0 -0
- package/lib/art/out-anim-run-now-real.gif +0 -0
- package/lib/art/out-anim-run-real.gif +0 -0
- package/lib/art/out-anim-run-slow.gif +0 -0
- package/lib/art/out-anim-run.gif +0 -0
- package/lib/art/out-anim-sprint-real.gif +0 -0
- package/lib/art/out-anim-sprint-slow.gif +0 -0
- package/lib/art/out-anim-sprint.gif +0 -0
- package/lib/art/out-sway-sprint.gif +0 -0
- package/lib/art/work-1-ready.webp +0 -0
- package/lib/art/work-2-reading.webp +0 -0
- package/lib/art/work-3-typing.webp +0 -0
- package/lib/art/work-4-checking.webp +0 -0
- package/lib/art/work-5-thinking.webp +0 -0
- package/lib/art/work-6-running.webp +0 -0
- package/lib/art/work-7-waiting.webp +0 -0
- package/lib/art/work-8-sprinting.webp +0 -0
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/build.js +4 -1
- package/lib/index.js +327 -39
- package/lib/log-tail.js +5 -1
- package/lib/page.html +424 -20
- package/lib/page.js +43 -2
- package/lib/server.js +159 -8
- package/lib/title-cache.js +122 -0
- package/lib/tree.js +222 -49
- package/package.json +3 -3
package/lib/page.html
CHANGED
|
@@ -172,6 +172,12 @@
|
|
|
172
172
|
.md { white-space: normal; }
|
|
173
173
|
/* 回复里的图。地址有两种写法,见下面 mdInline() 里那段注释。
|
|
174
174
|
宽度只封顶不写死:窄屏上占满可用宽度、按比例缩,圆角沿用全站那 2 像素。 */
|
|
175
|
+
/* 图片没加载出来时的那一圈虚线。用中性灰,不动暖金——那是唯一的强调色,一屏最多两次。 */
|
|
176
|
+
.md-img-bad {
|
|
177
|
+
outline: 1px dashed rgba(0, 0, 0, .35);
|
|
178
|
+
outline-offset: 2px;
|
|
179
|
+
}
|
|
180
|
+
|
|
175
181
|
.md-img {
|
|
176
182
|
display: block; max-width: 100%; height: auto; margin: 10px 0;
|
|
177
183
|
border-radius: var(--r); border: 1px solid var(--line); background: var(--panel-2);
|
|
@@ -308,9 +314,10 @@
|
|
|
308
314
|
margin: 0 auto;
|
|
309
315
|
}
|
|
310
316
|
|
|
311
|
-
/*
|
|
312
|
-
|
|
313
|
-
七层叠在一起,换姿势时交叉淡入。
|
|
317
|
+
/* 立绘:每个姿势是一张「并排若干帧」的雪碧图(每帧 380×330),靠 background-position
|
|
318
|
+
在帧与帧之间硬切,手脚就真的在动——不是把一张静图晃一晃。
|
|
319
|
+
七层叠在一起,换姿势时交叉淡入。
|
|
320
|
+
帧数写在数据里(POSES[i].frames,缺省 2);下面默认那套就是 2 帧的写法。 */
|
|
314
321
|
.work-stage {
|
|
315
322
|
position: relative; flex: 0 0 auto;
|
|
316
323
|
width: 122px; height: 106px;
|
|
@@ -335,6 +342,23 @@
|
|
|
335
342
|
0%, 49.9% { background-position: 0 0; }
|
|
336
343
|
50%, 100% { background-position: -122px 0; }
|
|
337
344
|
}
|
|
345
|
+
/* 6 帧的姿势(冲刺的头发是程序化生成的 6 帧,见 tools/whale-sway.py):
|
|
346
|
+
显示宽度 122×6,关键帧每 1/6 走一格。
|
|
347
|
+
step-end 会把每一格的值保持到下一格,所以每格只写一个百分比就够。
|
|
348
|
+
**总时长不动**(还是 POSES[i].flip 秒):这样腿脚换帧的快慢和 2 帧时一模一样,
|
|
349
|
+
多出来的帧只是把头发那一趟摆动切得更细。 */
|
|
350
|
+
.pose[data-frames="6"] {
|
|
351
|
+
background-size: 732px 106px;
|
|
352
|
+
animation-name: poseFlip6;
|
|
353
|
+
}
|
|
354
|
+
@keyframes poseFlip6 {
|
|
355
|
+
0% { background-position: 0 0; }
|
|
356
|
+
16.667% { background-position: -122px 0; }
|
|
357
|
+
33.333% { background-position: -244px 0; }
|
|
358
|
+
50% { background-position: -366px 0; }
|
|
359
|
+
66.667% { background-position: -488px 0; }
|
|
360
|
+
83.333% { background-position: -610px 0; }
|
|
361
|
+
}
|
|
338
362
|
|
|
339
363
|
/* 气泡:左边一个小尖角指向立绘。
|
|
340
364
|
宽度**贴着自己的词条**,多长就多宽,右边不留空。
|
|
@@ -710,6 +734,23 @@
|
|
|
710
734
|
margin: 0 0 14px; padding: 8px 12px; border: 1px dashed var(--line);
|
|
711
735
|
border-radius: var(--r); color: var(--muted); font-size: 12.5px; line-height: 1.6;
|
|
712
736
|
}
|
|
737
|
+
/* 「上面还有更早的」:聊天记录是一屏一屏铺的(见 renderChat 里 CHAT_PAGE 那段)。
|
|
738
|
+
两处讲究:
|
|
739
|
+
· **贴在看得到的顶上**(sticky),不随内容走——随内容走就只在滑到顶那一瞬间可见,
|
|
740
|
+
而那一瞬间正好触发了自动再放一屏,那句话等于永远看不见。
|
|
741
|
+
· **离顶远了就收起来**(.hide)。用户 2026-09-30 真机提的:都滑到最下面了,
|
|
742
|
+
它还挂在顶上。那时候上面确实还有没放出来的记录,这句话没说错;但它是催人
|
|
743
|
+
往上滑的,人已经在底部读内容了,挂在顶上就只剩挡视线。
|
|
744
|
+
长相照抄底栏的 .chip,不为它新造一种元素。 */
|
|
745
|
+
.more-note {
|
|
746
|
+
position: sticky; top: 0; z-index: 1;
|
|
747
|
+
width: fit-content; margin: 0 auto 14px; padding: 4px 12px;
|
|
748
|
+
border-radius: 999px; border: 1px solid var(--line); background: var(--panel-2);
|
|
749
|
+
color: var(--muted); font-size: 12px; line-height: 1.7;
|
|
750
|
+
}
|
|
751
|
+
/* 收起来用 visibility 而不是 display:这一条的高度始终留在内容里,
|
|
752
|
+
露和收之间就不会把下面的消息顶来顶去(那会是一次小跳)。 */
|
|
753
|
+
.more-note.hide { visibility: hidden; }
|
|
713
754
|
|
|
714
755
|
/* ---------- 设置抽屉 ---------- */
|
|
715
756
|
#settings {
|
|
@@ -781,6 +822,25 @@
|
|
|
781
822
|
整屏遮住聊天区:手机上一次只专心答一题,答完自动消失。
|
|
782
823
|
之所以遮全屏而不是弹个小条:非技术用户看到"电脑在等你回答"时,
|
|
783
824
|
最怕的是不知道该点哪里;整屏只剩问题本身和按钮,没有第二个选择。 */
|
|
825
|
+
/* 命令确认(审批)卡。形状与理由都同上面那张答题卡:整屏遮住,只剩「同意 / 拒绝」
|
|
826
|
+
两个选择。**主题交给令牌决定**——主按钮那一处用的就是「当底的强调色」令牌,
|
|
827
|
+
面板、文字、描边全沿用现有那几档,所以深色浅色自动都对,不用为两套主题各写一份。
|
|
828
|
+
(注释里**不写令牌本身那串字**:下面的检查是按"哪一块里出现了它"取材的,
|
|
829
|
+
注释里写上,这一块就会被当成一条规则,取自一个不存在的选择器。) */
|
|
830
|
+
#approveCard { position: fixed; inset: 0; z-index: 310; background: var(--bg);
|
|
831
|
+
display: none; flex-direction: column; padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); overflow-y: auto; }
|
|
832
|
+
#approveCard.on { display: flex; }
|
|
833
|
+
#approveCard .ahead { font-size: 13px; color: var(--muted); margin-bottom: 12px; }
|
|
834
|
+
#approveCard .atool { font-size: 18px; line-height: 1.6; margin-bottom: 10px; }
|
|
835
|
+
#approveCard .areason { font-size: 14px; line-height: 1.65; color: var(--muted); margin-bottom: 16px; white-space: pre-wrap; }
|
|
836
|
+
/* 命令原文:等宽感靠 1px 细线左边界和缩进,不引新字体;长命令照原样折行。 */
|
|
837
|
+
#approveCard .acmd { font-size: 13px; line-height: 1.55; color: var(--muted); margin-bottom: 16px;
|
|
838
|
+
white-space: pre-wrap; word-break: break-all; border-left: 2px solid var(--line); padding-left: 10px; }
|
|
839
|
+
#approveCard .afoot { margin-top: auto; display: flex; gap: 10px; padding-top: 16px; }
|
|
840
|
+
#approveCard .afoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: var(--r);
|
|
841
|
+
border: 1px solid var(--line); background: var(--panel-2); color: inherit; font-family: inherit; }
|
|
842
|
+
/* 这一屏唯一的主按钮走 --act:深色下暖金,浅色下宝蓝配白字。 */
|
|
843
|
+
#approveCard .afoot button#approveAllow { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
|
|
784
844
|
#askCard { position: fixed; inset: 0; z-index: 300; background: var(--bg);
|
|
785
845
|
display: none; flex-direction: column; padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); overflow-y: auto; }
|
|
786
846
|
#askCard.on { display: flex; }
|
|
@@ -841,6 +901,19 @@
|
|
|
841
901
|
<button id="askNext" type="button">下一题</button>
|
|
842
902
|
</div>
|
|
843
903
|
</div>
|
|
904
|
+
<!-- 电脑要做需要拍板的事时弹出来的审批卡。默认隐藏,收到 approval 事件才显示。
|
|
905
|
+
命令原文来自会话事件 tool/call 的 arguments(模型原样产出的 JSON),
|
|
906
|
+
插件按调用编号记着,审批来的时候一起推过来。 -->
|
|
907
|
+
<div id="approveCard">
|
|
908
|
+
<div class="ahead">电脑被拦下来了,等你拍板</div>
|
|
909
|
+
<div class="atool" id="approveTool"></div>
|
|
910
|
+
<div class="areason" id="approveReason"></div>
|
|
911
|
+
<div class="acmd" id="approveCmd"></div>
|
|
912
|
+
<div class="afoot">
|
|
913
|
+
<button id="approveDeny" type="button">拒绝</button>
|
|
914
|
+
<button id="approveAllow" type="button">同意</button>
|
|
915
|
+
</div>
|
|
916
|
+
</div>
|
|
844
917
|
|
|
845
918
|
<header>
|
|
846
919
|
<button class="icon-btn" id="btnNav" aria-label="工作区与会话"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
|
|
@@ -925,7 +998,7 @@
|
|
|
925
998
|
<div class="row">
|
|
926
999
|
<div>
|
|
927
1000
|
<div class="label">显示模式</div>
|
|
928
|
-
<div class="hint">单帧:只留最新一条回复</div>
|
|
1001
|
+
<div class="hint" id="hintMode">单帧:只留最新一条回复</div>
|
|
929
1002
|
</div>
|
|
930
1003
|
<div class="seg" id="segMode">
|
|
931
1004
|
<button data-mode="minimal">单帧</button>
|
|
@@ -938,7 +1011,6 @@
|
|
|
938
1011
|
<div class="row">
|
|
939
1012
|
<div>
|
|
940
1013
|
<div class="label">外观</div>
|
|
941
|
-
<div class="hint">浅色的色调跟宣传图一致</div>
|
|
942
1014
|
</div>
|
|
943
1015
|
<div class="seg" id="segTheme">
|
|
944
1016
|
<button data-theme="dark">深色</button>
|
|
@@ -1138,6 +1210,24 @@
|
|
|
1138
1210
|
// 认不出来的语法原样留着:宁可少渲染,不能吃掉内容。
|
|
1139
1211
|
var CODE_SLOT = '\u0000';
|
|
1140
1212
|
|
|
1213
|
+
/**
|
|
1214
|
+
* 正文里的图片地址 → 真正去取的地址。三种写法见下面 mdInline 那段注释。
|
|
1215
|
+
*
|
|
1216
|
+
* 判据只有一条:**不是 http(s) 就走同源**。所以 `javascript:`、`data:` 这类
|
|
1217
|
+
* 永远不会出现在 src 里;认不出的地址最多是取回来 404,不会变成别的东西。
|
|
1218
|
+
*
|
|
1219
|
+
* 放在这里而不是立绘那一段(artUrl 旁边):页面测试是从 escapeHtml 抠到复制按钮
|
|
1220
|
+
* 那一小段的,放外面就测不到真代码了。
|
|
1221
|
+
*/
|
|
1222
|
+
function imageSrc(src) {
|
|
1223
|
+
if (/^https?:\/\//i.test(src)) return src;
|
|
1224
|
+
var name = src.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
1225
|
+
if (/^lib\/art\//i.test(name)) name = name.slice(8);
|
|
1226
|
+
if (/^[a-z0-9-]+(\.webp)?$/i.test(name)) return artUrl(name.replace(/(\.webp)?$/i, ''));
|
|
1227
|
+
return '/mini/art/?p=' + encodeURIComponent(src)
|
|
1228
|
+
+ '&token=' + encodeURIComponent(state.token);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1141
1231
|
function mdInline(raw) {
|
|
1142
1232
|
// 行内代码先挖出来换成占位符。不这么做的话,`**x**` 这种字面量
|
|
1143
1233
|
// 会被后面的粗体规则吃掉,代码里写的东西就显示错了。
|
|
@@ -1147,16 +1237,18 @@
|
|
|
1147
1237
|
return CODE_SLOT + (codes.length - 1) + CODE_SLOT;
|
|
1148
1238
|
});
|
|
1149
1239
|
out = out
|
|
1150
|
-
// 图片:
|
|
1240
|
+
// 图片:。地址三种写法,判断在 imageSrc 里——
|
|
1151
1241
|
// ① 完整 http(s) 链接:原样用,手机直接去那个网站取图;
|
|
1152
|
-
// ②
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1242
|
+
// ② 一个名字(如 out-demo,`.webp` 可带可不带):走立绘通道 /mini/art/<名字>.webp,
|
|
1243
|
+
// 只认 lib/art/ 下「小写字母数字连字符 + .webp」的文件名;写成
|
|
1244
|
+
// `lib/art/out-demo.webp` 这样带前缀的也认(2026-09-28 助手这么写过,手机上成了破图标);
|
|
1245
|
+
// ③ 电脑上一张图的位置(绝对路径,或相对项目根):走 /mini/art/?p=…,PNG/JPG/WEBP/GIF 都行,
|
|
1246
|
+
// 不要求先拷进 lib/art。
|
|
1247
|
+
// 三者之外的一律按 ③ 拼成同源路径,所以 `javascript:`、`data:` 这类进不了 src。
|
|
1155
1248
|
// 必须排在下面那条链接规则前面:否则 `` 里的 `[x](y)` 会先被链接规则
|
|
1156
1249
|
// 吃掉,只剩下一个光秃秃的感叹号。
|
|
1157
1250
|
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (_, alt, src) {
|
|
1158
|
-
|
|
1159
|
-
return '<img class="md-img" src="' + url + '" alt="' + alt + '"'
|
|
1251
|
+
return '<img class="md-img" src="' + imageSrc(src) + '" alt="' + alt + '"'
|
|
1160
1252
|
+ ' loading="lazy" referrerpolicy="no-referrer">';
|
|
1161
1253
|
})
|
|
1162
1254
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
@@ -1324,6 +1416,22 @@
|
|
|
1324
1416
|
return p(d.getHours()) + ':' + p(d.getMinutes());
|
|
1325
1417
|
}
|
|
1326
1418
|
var toastTimer;
|
|
1419
|
+
/**
|
|
1420
|
+
* 图片没加载出来时要说一声。
|
|
1421
|
+
*
|
|
1422
|
+
* 手机页认三种图片地址:完整的 http(s) 网址、一个裸名字(页面拼成 /mini/art/<名字>.webp),
|
|
1423
|
+
* 或者一张图在电脑上的路径(绝对路径或相对项目根)。三种都认不出来、或者那个文件根本不在,
|
|
1424
|
+
* 浏览器只会画一个破图标——用户看到的是"图没了",却不知道原因(2026-09-28 他为此来过三次)。
|
|
1425
|
+
*
|
|
1426
|
+
* 为什么挂**捕获**阶段:图片的 error 事件不冒泡,挂冒泡阶段一个也收不到。
|
|
1427
|
+
*/
|
|
1428
|
+
document.addEventListener('error', function (e) {
|
|
1429
|
+
var el = e.target
|
|
1430
|
+
if (!el || el.tagName !== 'IMG' || !el.classList || !el.classList.contains('md-img')) return
|
|
1431
|
+
el.classList.add('md-img-bad')
|
|
1432
|
+
toast('这张图手机看不到:那个位置没有这张图,或者它不是 PNG/JPG/WEBP/GIF')
|
|
1433
|
+
}, true)
|
|
1434
|
+
|
|
1327
1435
|
function toast(msg) {
|
|
1328
1436
|
var el = $('toast');
|
|
1329
1437
|
el.textContent = msg;
|
|
@@ -1404,7 +1512,8 @@
|
|
|
1404
1512
|
// 被用户指着问「这一句有问题还是什么」,已改掉。测试钉着这条:见 page.test.mjs
|
|
1405
1513
|
// 的「鲸鱼娘说的话里不许出现让人以为出事的字眼」。
|
|
1406
1514
|
//
|
|
1407
|
-
//
|
|
1515
|
+
// 每个姿势是一张「并排若干帧」的雪碧图,flip 是**走完一整圈翻帧**的秒数
|
|
1516
|
+
// (代码里它就是 animation-duration:两帧时每帧占 flip/2,六帧时每帧占 flip/6):
|
|
1408
1517
|
// 敲键盘和小跑要快,端着茶等和托腮想事情要慢。节奏跟着动作走,才不像机器在闪。
|
|
1409
1518
|
var POSES = [
|
|
1410
1519
|
{ file: 'work-1-ready', line: '收到,这就去办', flip: 0.5 },
|
|
@@ -1417,7 +1526,10 @@
|
|
|
1417
1526
|
// 只说自己在看,不下判断。
|
|
1418
1527
|
{ file: 'work-4-checking', line: '让我检查检查', flip: 1.2 },
|
|
1419
1528
|
{ file: 'work-5-thinking', line: '嗯…让我想想', flip: 1.7 },
|
|
1420
|
-
|
|
1529
|
+
// 跑从 2026-09-28 起是 6 帧:先把两帧画出来的头发摆动收小(甩幅 155.3 → 70.6),
|
|
1530
|
+
// 再用 tools/whale-sway.py 把这一趟切成六帧(甩幅 74.9),身体逐像素不动。
|
|
1531
|
+
// 时长仍是 flip 秒、不乘帧数,所以腿脚换帧的快慢和两帧时一模一样,六帧只是把头发那一趟切得更细。
|
|
1532
|
+
{ file: 'work-6-running', line: '跑一遍看看', flip: 0.4, frames: 6 },
|
|
1421
1533
|
// 用户 2026-09-22 要的「跑步的动作」。它**不是第 8 个姿势,而是姿势之间的过渡**:
|
|
1422
1534
|
// 序列是「姿势1 → 冲刺 → 姿势2 → 冲刺 → …」,表达「她干完一件、跑去干下一件」。
|
|
1423
1535
|
// 所以它带 gap: true——不进轮播循环,只在每个姿势后面闪一下。
|
|
@@ -1425,8 +1537,14 @@
|
|
|
1425
1537
|
// 但用户两次要求拉长,现在 GAP_MS 和 POSE_MS 一样是 3.6 秒。理由见 GAP_MS 的注释。
|
|
1426
1538
|
// 它和上面那张小跑**刻意画得不一样**:侧面视角、身体前倾、步子拉得更开。
|
|
1427
1539
|
// **不带词条**(line 是空串)——用户指定的,冲刺是纯动作,硬配一句话反而弱。
|
|
1428
|
-
// 文件名里的 8 只是个名字,**顺序由数组决定**,别拿它当序号。
|
|
1429
|
-
|
|
1540
|
+
// 文件名里的 8 只是个名字,**顺序由数组决定**,别拿它当序号。 */
|
|
1541
|
+
// **帧数 6(2026-09-29)**:原来只有 2 帧,头发只能整个姿势硬切过去。2 帧的摆动
|
|
1542
|
+
// 没法"流畅"——两帧之间没有中间态。现在这 6 帧的头发是拿冻结基准帧当底、
|
|
1543
|
+
// 用位移场程序化算出来的(tools/whale-sway.py),身体逐像素不动:
|
|
1544
|
+
// 越靠发梢摆得越大、相位越滞后,采样一个周期得到 6 帧。摆幅默认 15 像素、
|
|
1545
|
+
// 相位滞后 300°,量出来的甩幅 128.9(用户给的目标区间 110~140)。
|
|
1546
|
+
// **它只解决头发**:身体和腿仍然是那两帧,跑起来腿脚还是瞬间换的。
|
|
1547
|
+
{ file: 'work-8-sprinting', line: '', flip: 0.28, gap: true, frames: 6 },
|
|
1430
1548
|
// 最后一个只在任务真的跑够久了才进轮播——到那时候说这句话才是实话。
|
|
1431
1549
|
// **它必须留在最后**:posePool 用的是 POSES.length - 1,靠位置认它。
|
|
1432
1550
|
{ file: 'work-7-waiting', line: '还在忙,再等我一会儿', flip: 2.0 }
|
|
@@ -1609,6 +1727,9 @@
|
|
|
1609
1727
|
for (var i = 0; i < POSES.length; i += 1) {
|
|
1610
1728
|
var layer = document.createElement('div');
|
|
1611
1729
|
layer.className = 'pose';
|
|
1730
|
+
// 帧数决定显示宽度和用哪套关键帧(CSS 里 2 帧是默认,6 帧挂在 [data-frames="6"])。
|
|
1731
|
+
// **时长不跟着帧数放大**:一圈还是 flip 秒,这样腿脚换帧的快慢跟以前一样。
|
|
1732
|
+
layer.setAttribute('data-frames', POSES[i].frames || 2);
|
|
1612
1733
|
layer.style.backgroundImage = 'url("' + artUrl(POSES[i].file) + '")';
|
|
1613
1734
|
layer.style.animationDuration = POSES[i].flip + 's';
|
|
1614
1735
|
stage.appendChild(layer);
|
|
@@ -1621,6 +1742,7 @@
|
|
|
1621
1742
|
// 说话那张一起建好、一起预热:等台词来了再新建图层,会说第一个字之前先闪一下空白。
|
|
1622
1743
|
var speak = document.createElement('div');
|
|
1623
1744
|
speak.className = 'pose';
|
|
1745
|
+
speak.setAttribute('data-frames', SPEAK_POSE.frames || 2);
|
|
1624
1746
|
speak.style.backgroundImage = 'url("' + artUrl(SPEAK_POSE.file) + '")';
|
|
1625
1747
|
speak.style.animationDuration = SPEAK_POSE.flip + 's';
|
|
1626
1748
|
stage.appendChild(speak);
|
|
@@ -1754,6 +1876,18 @@
|
|
|
1754
1876
|
});
|
|
1755
1877
|
}
|
|
1756
1878
|
|
|
1879
|
+
|
|
1880
|
+
/** 手机把审批结论交回电脑。三种结果和上面那条一样:成了 / 晚了 / 没送出去。 */
|
|
1881
|
+
function sendApproval(id, allow) {
|
|
1882
|
+
return api('/mini/api/approval', {
|
|
1883
|
+
method: 'POST',
|
|
1884
|
+
body: JSON.stringify({ id: id, decision: allow ? 'allow' : 'deny' }),
|
|
1885
|
+
})
|
|
1886
|
+
.then(function () { return 'ok'; })
|
|
1887
|
+
.catch(function (err) {
|
|
1888
|
+
return err && err.status === 409 ? 'late' : 'failed';
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1757
1891
|
// ---------------- 排队中的指令 ----------------
|
|
1758
1892
|
//
|
|
1759
1893
|
// 跑着任务时接着发的那几条不会丢,而是排进 agent 的收件箱(`followup()` 的语义
|
|
@@ -1807,9 +1941,15 @@
|
|
|
1807
1941
|
}
|
|
1808
1942
|
|
|
1809
1943
|
$('queue').addEventListener('click', function (e) {
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1944
|
+
// **必须从被点的元素往上找到那个真按钮。** ✗ 是画在按钮里面的 SVG 图形,手指落上去
|
|
1945
|
+
// 时 e.target 是图形本身,读不到按钮身上的编号。用户 2026-09-28 实机报「排队框的 ✗
|
|
1946
|
+
// 点了没反应」就是这么来的:旧写法 `e.target.getAttribute('data-drop')` 拿到 null,
|
|
1947
|
+
// 然后静默什么都不做。按钮只有 24×24、图形 12×12,所以旧写法几乎必然踩中。
|
|
1948
|
+
var hit = e.target && e.target.closest ? e.target.closest('.q-drop') : null;
|
|
1949
|
+
var id = hit ? hit.getAttribute('data-drop') : null;
|
|
1950
|
+
if (id) { requestUnqueue(id); return; }
|
|
1951
|
+
// 找到了按钮却没有编号:这是代码毛病。说一声,别静默——静默失败是最难查的那一类。
|
|
1952
|
+
if (hit) toast('这条撤不了:没带上编号');
|
|
1813
1953
|
});
|
|
1814
1954
|
|
|
1815
1955
|
/** 让「正在执行」那块跟着 state.running 显隐。 */
|
|
@@ -2042,11 +2182,72 @@
|
|
|
2042
2182
|
if (!el || typeof el.getBoundingClientRect !== 'function') return;
|
|
2043
2183
|
if (typeof mainEl.getBoundingClientRect !== 'function') return;
|
|
2044
2184
|
var gap = el.getBoundingClientRect().top - mainEl.getBoundingClientRect().top;
|
|
2185
|
+
// 这里**不用**为顶上那颗「往上滑看更早的」让高度:对齐开头只在「他本来就在底部」
|
|
2186
|
+
// 的时候做(见 scrollChat),而那时候那颗提示是收着的(见 chatNoteVisible)——
|
|
2187
|
+
// 让高度反而会凭空多出一段空白。
|
|
2045
2188
|
mainEl.scrollTop += gap - 12;
|
|
2046
2189
|
}
|
|
2047
2190
|
|
|
2191
|
+
/**
|
|
2192
|
+
* 聊天记录**一次铺多少条**,以及这次已经铺到多少条。
|
|
2193
|
+
*
|
|
2194
|
+
* 用户 2026-09-30 报的:在导航栏点一个没选中的会话,切过去很慢。查下来不是网络、
|
|
2195
|
+
* 也不是服务端取数(本机实测那三个接口 168 / 36 / 107 毫秒),而是这一页**一次把
|
|
2196
|
+
* 推上来的 200 条记录全铺出来**。这 200 条本来就已经在页面手里了——所以「往上多放
|
|
2197
|
+
* 一点」是个纯本地动作,不用再问服务端,也该是瞬时的。
|
|
2198
|
+
*
|
|
2199
|
+
* 40 这个数:一屏通常放得下三到五段长回答,40 条够铺六七屏;首屏只干五分之一的活,
|
|
2200
|
+
* 而往上翻的人一次也就看一屏,翻不到边界。
|
|
2201
|
+
*/
|
|
2202
|
+
var CHAT_PAGE = 40;
|
|
2203
|
+
var chatWindow = CHAT_PAGE; // 从最新那条往回数,这次铺了多少条
|
|
2204
|
+
var chatWindowFor = null; // 上面这个数是哪个会话的(换会话要收回首屏,见 renderChat)
|
|
2205
|
+
var chatLoaded = 0; // 本地一共有多少条可铺(滤掉排队那条之后),由 renderChat 记
|
|
2206
|
+
var chatGrowArmed = true; // 这一轮靠近顶部已经放过一屏没有(见 maybeGrowChat)
|
|
2207
|
+
|
|
2208
|
+
/** 触发线:离顶一屏的四分之一以内。放一屏和那颗提示共用这一条,别写成两个数。 */
|
|
2209
|
+
function chatEdge() {
|
|
2210
|
+
return Math.min(160, Math.max(60, (mainEl.clientHeight || 0) / 4));
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/** 顶上那颗「往上滑看更早的」的元素(没铺出来就没有)。 */
|
|
2214
|
+
function moreNoteEl() {
|
|
2215
|
+
return replyEl.querySelector ? replyEl.querySelector('.more-note') : null;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* 那句提示现在该不该露脸:**近了顶部、又不在底部**。
|
|
2220
|
+
*
|
|
2221
|
+
* 两个条件缺一不可——
|
|
2222
|
+
* · 离顶远了它就没用:再往上滑一下就自动放一屏,用不着它催;
|
|
2223
|
+
* · 在底部时它必须收起来(用户 2026-09-30 真机提的那条):那句话是催人往上滑的,
|
|
2224
|
+
* 人已经在最底下读内容了,它挂在那儿只剩挡视线。
|
|
2225
|
+
*/
|
|
2226
|
+
function chatNoteVisible() {
|
|
2227
|
+
if (atBottom()) return false;
|
|
2228
|
+
return mainEl.scrollTop <= chatEdge();
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* 把提示的露 / 收跟当前位置对齐。
|
|
2233
|
+
*
|
|
2234
|
+
* 这是**滚动时**的那一半。重画只在快照来的时候发生,而用户从中间一路滑到底
|
|
2235
|
+
* 是不会等快照的——不在滚动里收,那条提示就会一直挂在顶上(真机就是这么发现的)。
|
|
2236
|
+
*/
|
|
2237
|
+
function paintChatNote() {
|
|
2238
|
+
var el = moreNoteEl();
|
|
2239
|
+
if (!el || !el.classList || typeof el.classList.toggle !== 'function') return;
|
|
2240
|
+
el.classList.toggle('hide', !chatNoteVisible());
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2048
2243
|
function renderChat() {
|
|
2049
2244
|
mainEl.classList.remove('center');
|
|
2245
|
+
// 换了会话就把窗口收回首屏:上一个会话铺到多少条都不该跟过来——那正是这次要修的慢。
|
|
2246
|
+
// 判据用会话 id 而不是 history 的引用:服务端每次推的都是整份新数组,比引用永远「变了」。
|
|
2247
|
+
if (chatWindowFor !== state.boundSessionId) {
|
|
2248
|
+
chatWindowFor = state.boundSessionId;
|
|
2249
|
+
chatWindow = CHAT_PAGE;
|
|
2250
|
+
}
|
|
2050
2251
|
// **还在排队的那条不算「已发出」,这里要把它从聊天记录里滤掉。**
|
|
2051
2252
|
//
|
|
2052
2253
|
// 用户 2026-09-22 实机提的:「排队的指令在轮到之前不该出现在指令气泡里,
|
|
@@ -2064,6 +2265,9 @@
|
|
|
2064
2265
|
var list = (state.history || []).filter(function (m) {
|
|
2065
2266
|
return !(m.role === 'user' && m.id && queuedIds[m.id]);
|
|
2066
2267
|
});
|
|
2268
|
+
// 先记「本地一共有多少条」再分支:下面那条「还没有对话记录」的路也要把数记成 0,
|
|
2269
|
+
// 不然中途清空一次(服务端正在重读)会留着一个旧的条数,让 maybeGrowChat 白忙一场。
|
|
2270
|
+
chatLoaded = list.length;
|
|
2067
2271
|
if (!list.length && !state.running && !state.live && !(state.queued || []).length) {
|
|
2068
2272
|
// 「正在读」和「确实没有」是两件事,分开说。点开一个插件没看过的会话时,
|
|
2069
2273
|
// 服务端要去 DSH 的会话记录里读它那一段(最大的那条 20 MB、约半秒),
|
|
@@ -2073,6 +2277,10 @@
|
|
|
2073
2277
|
: '<div class="placeholder">还没有对话记录。</div>';
|
|
2074
2278
|
return;
|
|
2075
2279
|
}
|
|
2280
|
+
// **只铺最近那几十条**,剩下的留在手里等用户往上滑(来由见 CHAT_PAGE 那段)。
|
|
2281
|
+
// 切在**滤掉排队那条之后**:不然被滤掉的那条会白占一个名额。
|
|
2282
|
+
var older = Math.max(0, chatLoaded - chatWindow);
|
|
2283
|
+
if (older) list = list.slice(older);
|
|
2076
2284
|
var html = list.map(function (m) {
|
|
2077
2285
|
// 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
|
|
2078
2286
|
// 不套气泡、不给复制按钮(见 commandBlock 的说明)。
|
|
@@ -2097,6 +2305,12 @@
|
|
|
2097
2305
|
+ '<div class="reply-foot">' + copyBtn
|
|
2098
2306
|
+ '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
|
|
2099
2307
|
}).join('');
|
|
2308
|
+
// 「上面还有,往上滑」要贴在**看得到的顶上**,而且**离顶远了就收起来**——
|
|
2309
|
+
// 两处讲究见 .more-note 的样式注释。露 / 收在这里先按当前位置定一次,
|
|
2310
|
+
// 之后由滚动里的 paintChatNote 接着跟。
|
|
2311
|
+
var moreNote = older
|
|
2312
|
+
? '<div class="more-note' + (chatNoteVisible() ? '' : ' hide') + '">往上滑看更早的</div>'
|
|
2313
|
+
: '';
|
|
2100
2314
|
// 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
|
|
2101
2315
|
// 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
|
|
2102
2316
|
if (state.historyTruncated) {
|
|
@@ -2109,10 +2323,57 @@
|
|
|
2109
2323
|
if (state.live) html += liveBlock(state.live);
|
|
2110
2324
|
// 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
|
|
2111
2325
|
var stick = atBottom();
|
|
2112
|
-
replyEl.innerHTML = html;
|
|
2326
|
+
replyEl.innerHTML = moreNote + html;
|
|
2113
2327
|
scrollChat(stick);
|
|
2114
2328
|
}
|
|
2115
2329
|
|
|
2330
|
+
/**
|
|
2331
|
+
* 往上滑到接近顶部,就再放一屏出来。
|
|
2332
|
+
*
|
|
2333
|
+
* 判据是「离顶部一屏的四分之一以内」,不要求真滑到 0:手机上惯性滚动会滑过头,
|
|
2334
|
+
* 卡在 0 反而要等一次回弹才触发。60 是下限(矮屏上四分之一太小,拇指一带就滑过),
|
|
2335
|
+
* 160 是上限(高屏上别放得太早,太早等于提前干了下一屏的活)。
|
|
2336
|
+
*/
|
|
2337
|
+
function maybeGrowChat() {
|
|
2338
|
+
// 单帧模式那边也在往 main 上滚(回答落定要跳到页面顶端),别把那一下当成「他在往上翻」;
|
|
2339
|
+
// 本地都放完了也没什么可放的。
|
|
2340
|
+
if (state.mode !== 'chat' || chatLoaded <= 0) return;
|
|
2341
|
+
if (chatWindow >= chatLoaded) return;
|
|
2342
|
+
if (mainEl.scrollTop > chatEdge()) {
|
|
2343
|
+
// 滑下来了,这一轮重新装填:**一次靠近只放一屏**。
|
|
2344
|
+
// 不装填的话,万一位置校正没顶住(iOS 顶上那截橡皮筋会把人拽回 0),
|
|
2345
|
+
// 一次手势就能把 200 条连着放完——那正好把这个改动白做了。
|
|
2346
|
+
chatGrowArmed = true;
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (!chatGrowArmed) return;
|
|
2350
|
+
chatGrowArmed = false;
|
|
2351
|
+
growChat();
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/** main 每一次滚动:先对齐那句提示的露 / 收,再看要不要再放一屏。 */
|
|
2355
|
+
function onMainScroll() {
|
|
2356
|
+
paintChatNote();
|
|
2357
|
+
maybeGrowChat();
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/**
|
|
2361
|
+
* 往前多放一屏。纯本地动作:那些记录本来就在页面手里,不再问服务端。
|
|
2362
|
+
*
|
|
2363
|
+
* **放完必须立刻把位置校回来**,这是整件事最容易做坏的一步:往前面插内容,
|
|
2364
|
+
* 浏览器不会替你补偿,用户正看着的那一屏会被整个推下去一屏。
|
|
2365
|
+
*
|
|
2366
|
+
* 补偿用「离底部多远」:这次加的东西全在顶上,底部那一截一个字没动,
|
|
2367
|
+
* 所以只要这个距离不变,他眼前那几行就一格都不会走。别去量「原来那条消息」的
|
|
2368
|
+
* 矩形来对齐——重画是整块换 innerHTML,那条消息已经不在文档里了,量不着。
|
|
2369
|
+
*/
|
|
2370
|
+
function growChat() {
|
|
2371
|
+
var fromBottom = mainEl.scrollHeight - mainEl.scrollTop;
|
|
2372
|
+
chatWindow += CHAT_PAGE;
|
|
2373
|
+
renderChat();
|
|
2374
|
+
mainEl.scrollTop = mainEl.scrollHeight - fromBottom;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2116
2377
|
/**
|
|
2117
2378
|
* 聊天模式里「已经为哪一次回答把开头对齐到顶部」。
|
|
2118
2379
|
*
|
|
@@ -2180,7 +2441,8 @@
|
|
|
2180
2441
|
}
|
|
2181
2442
|
|
|
2182
2443
|
function render() {
|
|
2183
|
-
|
|
2444
|
+
$('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
|
|
2445
|
+
if (state.mode === 'chat') renderChat(); else renderMinimal();
|
|
2184
2446
|
}
|
|
2185
2447
|
|
|
2186
2448
|
function applySnapshot(snap) {
|
|
@@ -2502,6 +2764,24 @@
|
|
|
2502
2764
|
});
|
|
2503
2765
|
}
|
|
2504
2766
|
|
|
2767
|
+
// 标题补齐的节奏。服务端只等 250 毫秒就把列表先给出来(超时的标题留给后台),
|
|
2768
|
+
// 所以这里要接着问。**问几次由服务端说了算**,见 toggleWorkspace 里的说明。
|
|
2769
|
+
var TITLE_FOLLOW_MS = 2500; // 每隔这么久问一次
|
|
2770
|
+
var TITLE_FOLLOW_LIMIT_MS = 30000; // 从展开算起,最多追这么久
|
|
2771
|
+
|
|
2772
|
+
/**
|
|
2773
|
+
* 这份列表「还剩几条标题没读出来」。
|
|
2774
|
+
*
|
|
2775
|
+
* 优先用服务端给的 pending:那是事实(它知道还有几条日志没读完)。
|
|
2776
|
+
* **服务端没给这个字段时才退回数空标题**——那是「改了要重启」的半新状态下
|
|
2777
|
+
* (新页面配旧进程)唯一能用的判据。旧页面就是靠它补拉三次的,
|
|
2778
|
+
* 退回去至少有原来的效果,不会因为服务端换了版本就反而不追了。
|
|
2779
|
+
*/
|
|
2780
|
+
function pendingCount(body) {
|
|
2781
|
+
if (body && typeof body.pending === 'number') return body.pending;
|
|
2782
|
+
return ((body && body.sessions) || []).filter(function (s) { return !s.title; }).length;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2505
2785
|
function toggleWorkspace(wsId) {
|
|
2506
2786
|
if (navExpanded[wsId]) {
|
|
2507
2787
|
delete navExpanded[wsId];
|
|
@@ -2511,10 +2791,37 @@
|
|
|
2511
2791
|
navExpanded[wsId] = true;
|
|
2512
2792
|
renderNav();
|
|
2513
2793
|
if (navSessions[wsId]) return;
|
|
2794
|
+
/**
|
|
2795
|
+
* 接着问,直到服务端说「一条都不剩」。
|
|
2796
|
+
*
|
|
2797
|
+
* 原来的写法是**固定补三次**(1.5 秒 / 3 秒 / 3 秒,共 7.5 秒),而一个工作区里
|
|
2798
|
+
* 四五个冷会话实测要 19 秒才读完——三次补拉盖不住,标题就是长不出来,
|
|
2799
|
+
* 用户只能退出去再进来。现在按服务端给的事实问:pending 归零就停。
|
|
2800
|
+
*
|
|
2801
|
+
* 30 秒是总时限:万一有一条日志坏了、永远读不出来,也不该没完没了地问下去。
|
|
2802
|
+
*/
|
|
2803
|
+
function followTitles(cur, deadline) {
|
|
2804
|
+
if (!(pendingCount(cur) > 0) || Date.now() >= deadline) return;
|
|
2805
|
+
setTimeout(function () {
|
|
2806
|
+
// 用户收起了、或者这份数据已经被刷新换掉,就放手:别跟人抢。
|
|
2807
|
+
if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
|
|
2808
|
+
api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
|
|
2809
|
+
.then(function (body) {
|
|
2810
|
+
if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
|
|
2811
|
+
navSessions[wsId] = body;
|
|
2812
|
+
renderNav();
|
|
2813
|
+
followTitles(body, deadline);
|
|
2814
|
+
})
|
|
2815
|
+
.catch(function () {
|
|
2816
|
+
// 追不到就算了:列表本身还在,日期也够用。
|
|
2817
|
+
});
|
|
2818
|
+
}, TITLE_FOLLOW_MS);
|
|
2819
|
+
}
|
|
2514
2820
|
api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
|
|
2515
2821
|
.then(function (body) {
|
|
2516
2822
|
navSessions[wsId] = body;
|
|
2517
2823
|
renderNav();
|
|
2824
|
+
followTitles(body, Date.now() + TITLE_FOLLOW_LIMIT_MS);
|
|
2518
2825
|
})
|
|
2519
2826
|
.catch(function (err) {
|
|
2520
2827
|
navSessions[wsId] = { sessions: [], total: 0 };
|
|
@@ -2781,6 +3088,44 @@
|
|
|
2781
3088
|
if (verdict === 'late') toast('这道题已经交回电脑了,去电脑上答')
|
|
2782
3089
|
})
|
|
2783
3090
|
})
|
|
3091
|
+
// ——— 命令确认(审批)———
|
|
3092
|
+
//
|
|
3093
|
+
// 电脑要做需要拍板的事时,插件推一条 `approval` 过来,点了卡上的按钮再 POST 回
|
|
3094
|
+
// /mini/api/approval。和答题卡同样的三条讲究:没送出去就不收卡片(收掉了就再也
|
|
3095
|
+
// 没地方说这句话,用户看到的只会是"点了没反应");三种结果说三句不同的话;
|
|
3096
|
+
// 只认有 id 的帧,别的形状一律不理。
|
|
3097
|
+
let approve = null
|
|
3098
|
+
function approveShow(payload) {
|
|
3099
|
+
approve = { id: payload.id }
|
|
3100
|
+
$('approveTool').textContent = '它要让「' + (payload.toolName || '某个工具') + '」动手'
|
|
3101
|
+
$('approveReason').textContent = payload.reason || ''
|
|
3102
|
+
$('approveReason').style.display = payload.reason ? '' : 'none'
|
|
3103
|
+
// 命令原文:有就照原样显示。**没有就明说"没取到",不静默藏起来**——
|
|
3104
|
+
// 用户要的就是看见命令,藏起来等于骗他;真没取到时他也一眼看得出是这条链没走通。
|
|
3105
|
+
$('approveCmd').textContent = payload.command || '(这条没取到命令原文)'
|
|
3106
|
+
$('approveCard').classList.add('on')
|
|
3107
|
+
}
|
|
3108
|
+
function approveDecide(allow) {
|
|
3109
|
+
if (!approve) return
|
|
3110
|
+
const id = approve.id
|
|
3111
|
+
sendApproval(id, allow).then(function (verdict) {
|
|
3112
|
+
if (verdict === 'failed') {
|
|
3113
|
+
toast('没送出去,看一眼和电脑的连接,再点一次')
|
|
3114
|
+
return
|
|
3115
|
+
}
|
|
3116
|
+
approve = null
|
|
3117
|
+
$('approveCard').classList.remove('on')
|
|
3118
|
+
if (verdict === 'late') toast('这条已经交回电脑了,去电脑上处理')
|
|
3119
|
+
})
|
|
3120
|
+
}
|
|
3121
|
+
$('approveAllow').addEventListener('click', function () { approveDecide(true) })
|
|
3122
|
+
$('approveDeny').addEventListener('click', function () { approveDecide(false) })
|
|
3123
|
+
es.addEventListener('approval', function (e) {
|
|
3124
|
+
let payload = null
|
|
3125
|
+
try { payload = JSON.parse(e.data) } catch (err) { return }
|
|
3126
|
+
if (!payload || !payload.id) return
|
|
3127
|
+
approveShow(payload)
|
|
3128
|
+
});
|
|
2784
3129
|
es.addEventListener('question', function (e) {
|
|
2785
3130
|
let payload = null
|
|
2786
3131
|
try { payload = JSON.parse(e.data) } catch (err) { return }
|
|
@@ -3306,6 +3651,7 @@
|
|
|
3306
3651
|
Array.prototype.forEach.call($('segMode').children, function (btn) {
|
|
3307
3652
|
btn.addEventListener('click', function () {
|
|
3308
3653
|
state.mode = btn.dataset.mode;
|
|
3654
|
+
$('hintMode').textContent = state.mode === 'chat' ? '聊天:常规问答对话' : '单帧:只留最新一条回复';
|
|
3309
3655
|
localStorage.setItem('dshMiniMode', state.mode);
|
|
3310
3656
|
syncSeg();
|
|
3311
3657
|
render();
|
|
@@ -3569,6 +3915,28 @@
|
|
|
3569
3915
|
connect();
|
|
3570
3916
|
}
|
|
3571
3917
|
|
|
3918
|
+
/**
|
|
3919
|
+
* 服务器换过代码就自己刷新一次。
|
|
3920
|
+
*
|
|
3921
|
+
* 为什么非要有:这个页面是**启动时读进内存**才发出去的(改了要重启 DSH),而立绘是
|
|
3922
|
+
* **每次请求现读盘**的(文件一改就生效)。于是"页面和图不是同一时刻换的"这件事真的
|
|
3923
|
+
* 会发生——2026-09-29 用户在手机上看到「原来的两条腿变成 4 条腿了」:手里那份页面还是
|
|
3924
|
+
* 旧的(写死按两格、244 像素宽去切),硬盘上的图已经是六格的了,122 像素的窗口里于是
|
|
3925
|
+
* 挤进三个压扁的小人,看着就是一堆腿。
|
|
3926
|
+
*
|
|
3927
|
+
* `/mini/api/version` 报的是**服务器现在这份代码**的构建号:和手里这份一样就什么都不做,
|
|
3928
|
+
* 不一样说明页面过期了,刷一次就好(刷新后拿到的新页面自带正确的切帧规则)。
|
|
3929
|
+
* **正在打字就先不刷**——用户没发出去的话不能被吞掉,等下一轮再说。
|
|
3930
|
+
*/
|
|
3931
|
+
function watchBuild() {
|
|
3932
|
+
if (!state.token) return;
|
|
3933
|
+
api('/mini/api/version').then(function (r) {
|
|
3934
|
+
if (!r || !r.build || r.build === BUILD) return;
|
|
3935
|
+
if (inputEl.value && inputEl.value.trim()) return;
|
|
3936
|
+
location.reload();
|
|
3937
|
+
}).catch(function () { /* 读不到就当没这回事,别把页面弄崩 */ });
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3572
3940
|
// 复制按钮:整篇、单条气泡、代码块共用一套。
|
|
3573
3941
|
// 用事件委托——回复区每次都是整块重绘的,逐个绑会漏。
|
|
3574
3942
|
// (原来这里是「点回复区任意位置复制全文」,它直接用了 navigator.clipboard,
|
|
@@ -3581,6 +3949,13 @@
|
|
|
3581
3949
|
copyText(btn.getAttribute('data-copy') || '', btn);
|
|
3582
3950
|
});
|
|
3583
3951
|
|
|
3952
|
+
// main 的滚动:一句提示的露 / 收,和「再放一屏」,都挂在这一根线上(见 onMainScroll)。
|
|
3953
|
+
// **这根线不能省**:逻辑写得再对,没挂上去就是「一点反应都没有」,
|
|
3954
|
+
// 而这种坏光看代码是看不出来的。
|
|
3955
|
+
// 位置也有讲究:绑在这里,不绑在 atBottom 那片函数旁边——页面测试会把那一段
|
|
3956
|
+
// 整块抠出来求值,测试里的假 mainEl 没有 addEventListener,写在那边好几个测试当场报错。
|
|
3957
|
+
mainEl.addEventListener('scroll', onMainScroll);
|
|
3958
|
+
|
|
3584
3959
|
// ---------------- 启动 ----------------
|
|
3585
3960
|
syncSeg();
|
|
3586
3961
|
autoGrow();
|
|
@@ -3614,6 +3989,8 @@
|
|
|
3614
3989
|
|
|
3615
3990
|
var catalog = null // 服务端给的模型目录
|
|
3616
3991
|
var current = null // 服务端记的当前选择 {provider, model, reasoningEffort}
|
|
3992
|
+
// 上下文窗口用量 {used, window},服务端给。取不到就是 null——那时整段不显示。
|
|
3993
|
+
var contextUsage = null
|
|
3617
3994
|
// 刚改完、服务端还没确认的那个值。为什么要多这一个:服务端记的「当前模型」是在
|
|
3618
3995
|
// **下一次请求**时才更新的,所以改完立刻回头去读,读到的还是旧名字。先信
|
|
3619
3996
|
// set-model 的返回值,等服务端追上了再让它接管。
|
|
@@ -3626,6 +4003,21 @@
|
|
|
3626
4003
|
for (var i = 0; i < gs.length; i++) { if (gs[i].id === id) return gs[i].name }
|
|
3627
4004
|
return id || ''
|
|
3628
4005
|
}
|
|
4006
|
+
/**
|
|
4007
|
+
* 上下文用量那一句人话。**不编**:分子分母缺任一个就返回空串,界面据此整段不显示
|
|
4008
|
+
* (和「模型:还没问到」同一条口径——不知道就说不知道,不拿别的数凑)。
|
|
4009
|
+
*
|
|
4010
|
+
* 超过 100% 时按 100% 显示:它是"下一次请求大概要花多少",压不压缩由电脑决定;
|
|
4011
|
+
* 把 105% 端给用户,只会让人以为哪里出错了。
|
|
4012
|
+
*/
|
|
4013
|
+
function contextLabel(used, capacity) {
|
|
4014
|
+
if (typeof used !== 'number' || typeof capacity !== 'number') return ''
|
|
4015
|
+
if (!(used > 0) || !(capacity > 0)) return ''
|
|
4016
|
+
var pct = Math.round(used / capacity * 100)
|
|
4017
|
+
if (pct > 100) pct = 100
|
|
4018
|
+
return pct + '%'
|
|
4019
|
+
}
|
|
4020
|
+
|
|
3629
4021
|
function sel() { return pending || current || null }
|
|
3630
4022
|
|
|
3631
4023
|
function renderBar() {
|
|
@@ -3640,6 +4032,9 @@
|
|
|
3640
4032
|
// 来源名要用 DSH 给的那个(「DeepSeek」「opencode go」「mimo」),不是内部 id
|
|
3641
4033
|
var parts = [groupName(s.provider), s.model]
|
|
3642
4034
|
if (s.reasoningEffort) parts.push('思考 ' + s.reasoningEffort)
|
|
4035
|
+
// 上下文用得怎么样。取不到就什么都不加——不显示 0%,也不写"未知"占位。
|
|
4036
|
+
var ctxText = contextLabel(contextUsage && contextUsage.used, contextUsage && contextUsage.window)
|
|
4037
|
+
if (ctxText) parts.push(ctxText)
|
|
3643
4038
|
text.textContent = parts.join(' · ')
|
|
3644
4039
|
bar.style.display = 'flex'
|
|
3645
4040
|
}
|
|
@@ -3762,6 +4157,9 @@
|
|
|
3762
4157
|
// 目录那个默认值等于「上一次选过的模型」,于是用户切到一个用别的模型的
|
|
3763
4158
|
// 会话,顶栏显示的还是上一个模型——一个像模像样的错答案,比空着坏得多。
|
|
3764
4159
|
current = (d.current && d.current.model) ? d.current : null
|
|
4160
|
+
// 用量跟着模型一起回来(同一条接口)。**不校验内容**:给它什么显示什么,
|
|
4161
|
+
// 拿不准的由下面那个纯函数挡掉。刷新频率就是这一条接口的频率(15 秒一次)。
|
|
4162
|
+
contextUsage = d.context || null
|
|
3765
4163
|
// 服务端追上了就撤掉本地那个临时值,之后一律以服务端的为准
|
|
3766
4164
|
if (pending && current && current.provider === pending.provider
|
|
3767
4165
|
&& current.model === pending.model) pending = null
|
|
@@ -3792,6 +4190,12 @@
|
|
|
3792
4190
|
// 显示一个过期的模型名比不显示更糟——用户会以为自己改的没生效。
|
|
3793
4191
|
setInterval(function () { if (!document.hidden) load() }, 15000)
|
|
3794
4192
|
document.addEventListener('visibilitychange', function () { if (!document.hidden) load() })
|
|
4193
|
+
// 上面那个轮询管"显示的东西新不新",这个管"**手里这份页面**是不是过期了"——
|
|
4194
|
+
// 插件重启过就要刷一次,不然页面会拿着旧的切帧规则去切新的立绘(见 watchBuild 注释)。
|
|
4195
|
+
// 30 秒的间隔够用:它防的是"重启之后一直拿着旧页面",不是实时性。
|
|
4196
|
+
setInterval(watchBuild, 30000)
|
|
4197
|
+
// 手机把页面挂到后台时定时器会被压住,切回来那一刻补一次检查。
|
|
4198
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) watchBuild() })
|
|
3795
4199
|
})();
|
|
3796
4200
|
})();
|
|
3797
4201
|
</script>
|