dsh-mini-remote 0.1.0 → 0.1.4
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 +82 -0
- package/README.en.md +160 -0
- package/README.md +81 -59
- package/client/client.js +148 -1
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/events.js +52 -0
- package/lib/history.js +72 -0
- package/lib/index.js +801 -12
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1699 -36
- package/lib/page.js +21 -0
- package/lib/pairing.js +110 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +365 -0
- package/lib/server.js +465 -6
- package/lib/store.js +191 -8
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +15 -6
- package/README.zh.md +0 -138
- package/client/verify.mjs +0 -514
package/lib/page.html
CHANGED
|
@@ -170,6 +170,28 @@
|
|
|
170
170
|
/* 聊天模式下 AI 的回答**不套气泡**(用户要求「参考单帧模式那样」)。
|
|
171
171
|
用户自己的指令仍然是气泡,一左一右、一个有框一个没框,正好分得清谁说的。 */
|
|
172
172
|
.said { margin-bottom: 18px; }
|
|
173
|
+
/* ---------- 历史里的一条指令(会话做的一个动作,不是谁说的话) ----------
|
|
174
|
+
和上面两种气泡都刻意画得不同:不套框、不给复制按钮,只用左边一条细线起头。
|
|
175
|
+
它是**系统动作**——长得像一句话,就会被当成 AI 的回答去读,而它根本不是回答。 */
|
|
176
|
+
.cmd {
|
|
177
|
+
display: flex; align-items: baseline; gap: 8px;
|
|
178
|
+
width: fit-content; max-width: 100%;
|
|
179
|
+
margin: 0 0 14px; padding: 4px 10px 4px 9px;
|
|
180
|
+
border-left: 2px solid var(--line); border-radius: 0 8px 8px 0;
|
|
181
|
+
background: var(--panel-2); font-size: 13px; line-height: 1.5;
|
|
182
|
+
}
|
|
183
|
+
/* 名字加参数排一行,太长就省略——它是「做了哪件事」的标签,不是正文。 */
|
|
184
|
+
.cmd .cmd-line {
|
|
185
|
+
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
186
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
187
|
+
color: var(--fg);
|
|
188
|
+
}
|
|
189
|
+
.cmd .cmd-res { flex: 0 0 auto; color: var(--muted); }
|
|
190
|
+
.cmd.success { border-left-color: var(--ok); }
|
|
191
|
+
.cmd.success .cmd-res { color: var(--ok); }
|
|
192
|
+
/* 出错的那一种必须是红的:整串历史里只有它需要用户抬眼看一下。 */
|
|
193
|
+
.cmd.error { border-left-color: var(--err); }
|
|
194
|
+
.cmd.error .cmd-res { color: var(--err); }
|
|
173
195
|
/* ---------- 正在流出来的那一段 ---------- */
|
|
174
196
|
/* 和最终答案**刻意画得不一样**:上面一条虚线、末尾一个闪动的竖条、没有复制按钮。
|
|
175
197
|
三样都在说同一句话——「这不是最终答案,还会变」。等答案落定整块被换掉。 */
|
|
@@ -199,10 +221,15 @@
|
|
|
199
221
|
那个 171 = 10 个全角字 × 14.5px,加左右内边距 24、边框 2。
|
|
200
222
|
用 max-content 而不是写死 width:将来真加了更长的词条,这行会自己撑开——
|
|
201
223
|
宁可立绘挪一点,也不能让字顶出屏幕。
|
|
202
|
-
min() 是窄屏退路:屏幕比 299 还窄时,这行跟着屏幕收,别溢出。
|
|
224
|
+
min() 是窄屏退路:屏幕比 299 还窄时,这行跟着屏幕收,别溢出。
|
|
225
|
+
**max-width 那一行是把上面那句话真正落实**(2026-09-27 补):光有
|
|
226
|
+
`max-content` + `min-width` 只有下限没有上限,而气泡的 `max-width:
|
|
227
|
+
calc(100% - 128px)` 里的那个 100% 又是拿这一行自己算的——两头都靠对方,
|
|
228
|
+
谁也没被真正管住。结果是一句长话(尤其我那几句英文思考)把整行撑到屏幕外,
|
|
229
|
+
手机上得横着划才能看见(用户截图报的就是这个)。钉死在这一行的宽度上才算数。 */
|
|
203
230
|
.work-top {
|
|
204
|
-
display: flex; align-items:
|
|
205
|
-
width: max-content; min-width: min(299px, 100%);
|
|
231
|
+
display: flex; align-items: flex-start; gap: 6px;
|
|
232
|
+
width: max-content; min-width: min(299px, 100%); max-width: 100%;
|
|
206
233
|
margin: 0 auto;
|
|
207
234
|
}
|
|
208
235
|
|
|
@@ -240,7 +267,12 @@
|
|
|
240
267
|
它推不动立绘——立绘的位置由上面 .work-top 的固定行宽管着,跟气泡宽窄无关。
|
|
241
268
|
所以「词条每 3.6 秒换一次」这件事不会让立绘左右晃。
|
|
242
269
|
max-width 是窄屏退路:立绘 122 + 间隙 6,剩下的都给气泡,宁可折行也别顶出屏幕。
|
|
243
|
-
|
|
270
|
+
|
|
271
|
+
**竖着不限行数**(2026-09-27 用户报的:一句长话被切掉半行)。原来是
|
|
272
|
+
`-webkit-line-clamp: 3` + `overflow: hidden`——第四行连省略号都没有地截断,
|
|
273
|
+
看着像她话说了一半。现在让气泡自己长高,整页跟着让位(main 本来就能上下滚)。
|
|
274
|
+
留一道 `max-height` + 内部滚动只为兜病态长文(几屏):正常一两句话够不到它。
|
|
275
|
+
`align-self: flex-start` 让气泡顶到行首、和她的头对齐。 */
|
|
244
276
|
.work-bubble {
|
|
245
277
|
position: relative; flex: 0 1 auto;
|
|
246
278
|
max-width: calc(100% - 128px);
|
|
@@ -248,13 +280,31 @@
|
|
|
248
280
|
background: var(--panel-2); border: 1px solid var(--line);
|
|
249
281
|
border-radius: 12px; padding: 9px 12px;
|
|
250
282
|
font-size: 14.5px; line-height: 1.5; color: var(--fg);
|
|
251
|
-
|
|
283
|
+
max-height: 50vh; overflow-y: auto;
|
|
284
|
+
/* 长英文串、URL 不认换行,会把气泡顶宽。中文本来就能任意断行,这条是给它们兜的。 */
|
|
285
|
+
overflow-wrap: anywhere;
|
|
286
|
+
}
|
|
287
|
+
/* hidden 只是 UA 样式表里的一条 display:none,元素自己没写 display 时它才管用——
|
|
288
|
+
原来这里有个 `display: -webkit-box` 把它盖掉了,于是「词条是空串就把气泡收起来」
|
|
289
|
+
根本没生效,跑动那张旁边一直挂着一个没字的空药丸(2026-09-27 用户截图报的)。 */
|
|
290
|
+
.work-bubble[hidden] { display: none; }
|
|
291
|
+
/* 尖角固定在气泡**上部**(对准她的头),不跟着气泡高度跑。原来钉在 50%,气泡一长高,
|
|
292
|
+
尖角就滑到她身子下边去了——长句和短句都得指着头,所以要一个绝对值。 */
|
|
252
293
|
.work-bubble::before, .work-bubble::after {
|
|
253
|
-
content: ''; position: absolute; top:
|
|
294
|
+
content: ''; position: absolute; top: 24px; width: 0; height: 0;
|
|
254
295
|
border: 6px solid transparent; margin-top: -6px;
|
|
255
296
|
}
|
|
256
297
|
.work-bubble::before { left: -12px; border-right-color: var(--line); }
|
|
257
298
|
.work-bubble::after { left: -11px; border-right-color: var(--panel-2); }
|
|
299
|
+
/* 气泡里的话走和回答区同一套行内渲染(`mdInline`):`**加粗**`、`` `代码` ``、链接会变成样式。
|
|
300
|
+
代码的底色特意不用 --panel-2——那是气泡自己的底色,套上去等于看不见。 */
|
|
301
|
+
.work-bubble strong { font-weight: 650; }
|
|
302
|
+
.work-bubble a { color: var(--accent); word-break: break-all; }
|
|
303
|
+
.work-bubble code {
|
|
304
|
+
background: rgba(127, 127, 127, .18); border: 1px solid var(--line); border-radius: 5px;
|
|
305
|
+
padding: .1em .35em; font-size: .88em;
|
|
306
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
307
|
+
}
|
|
258
308
|
|
|
259
309
|
/* 进度条故意做成**不确定进度**的流动条,不是百分比。
|
|
260
310
|
插件按设计只收「指令 + 最终回复」,拿不到 Agent 此刻跑到哪一步,
|
|
@@ -375,6 +425,34 @@
|
|
|
375
425
|
}
|
|
376
426
|
.chip.failed { border-color: var(--danger, #dc2626); }
|
|
377
427
|
|
|
428
|
+
/* ---------- 指令菜单(在输入框里打 / 时弹出来的那个列表) ----------
|
|
429
|
+
排在底栏**上面**,不挡住输入框:手机屏幕就那么大,把它盖住,用户就看不见自己
|
|
430
|
+
正在打的那一行字了——手机上没有悬停,也没有第二个地方能看。 */
|
|
431
|
+
.cmd-menu {
|
|
432
|
+
flex: 0 0 auto; max-height: 42vh; overflow-y: auto;
|
|
433
|
+
-webkit-overflow-scrolling: touch;
|
|
434
|
+
padding: 6px 8px; background: var(--panel); border-top: 1px solid var(--line);
|
|
435
|
+
}
|
|
436
|
+
.cmd-menu[hidden] { display: none; }
|
|
437
|
+
.cmd-menu .cmd-row {
|
|
438
|
+
display: flex; align-items: baseline; gap: 10px; width: 100%;
|
|
439
|
+
padding: 9px 8px; border: none; border-radius: 8px; background: none;
|
|
440
|
+
color: var(--fg); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
|
|
441
|
+
}
|
|
442
|
+
.cmd-menu .cmd-row:active { background: var(--panel-2); }
|
|
443
|
+
.cmd-menu .cmd-name {
|
|
444
|
+
flex: 0 0 auto; color: var(--accent);
|
|
445
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
446
|
+
}
|
|
447
|
+
/* 说明收成一行:这一屏是「扫一眼挑一个」,不是读文档的地方。 */
|
|
448
|
+
.cmd-menu .cmd-desc {
|
|
449
|
+
flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 12.5px;
|
|
450
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
451
|
+
}
|
|
452
|
+
/* 取不到指令时说清原因,**不显示空列表**:空列表看着就像「这个会话没有指令」,
|
|
453
|
+
而真实原因多半是「这个会话现在没在跑」——两件事,用户能做的事也不一样。 */
|
|
454
|
+
.cmd-menu .cmd-note { padding: 10px 8px; color: var(--muted); font-size: 13px; }
|
|
455
|
+
|
|
378
456
|
/* ---------- 左侧导航栏 ---------- */
|
|
379
457
|
#nav {
|
|
380
458
|
position: fixed; inset: 0; z-index: 60; display: none;
|
|
@@ -428,7 +506,71 @@
|
|
|
428
506
|
flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
|
|
429
507
|
background: var(--warn); animation: pulse 1.2s ease-in-out infinite;
|
|
430
508
|
}
|
|
509
|
+
/* 「新建会话」那一行。跟会话条目同宽、同缩进,但用强调色——
|
|
510
|
+
它不是一个会话,是一个动作。长得跟会话一样会让人以为点进去就是那条会话。 */
|
|
511
|
+
.ws-new {
|
|
512
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
513
|
+
padding: 10px 14px 10px 30px; background: none; border: none;
|
|
514
|
+
color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
|
|
515
|
+
}
|
|
516
|
+
.ws-new:active { background: var(--panel-2); }
|
|
517
|
+
.ws-new[disabled] { color: var(--muted); cursor: default; }
|
|
518
|
+
|
|
519
|
+
/* 导航栏最上面那条「新建工作区」。跟工作区条目之间用一条线隔开:
|
|
520
|
+
它是这一屏的入口,不是一个工作区。 */
|
|
521
|
+
.nav-cta {
|
|
522
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
523
|
+
padding: 12px 14px; background: none; border: none; border-bottom: 1px solid var(--line);
|
|
524
|
+
color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
|
|
525
|
+
}
|
|
526
|
+
.nav-cta:active { background: var(--panel-2); }
|
|
527
|
+
|
|
528
|
+
/* 面包屑。横向能划,路径再深也不会把面板撑破。 */
|
|
529
|
+
.crumb {
|
|
530
|
+
display: flex; align-items: center; gap: 2px; padding: 8px 10px;
|
|
531
|
+
overflow-x: auto; white-space: nowrap; border-bottom: 1px solid var(--line);
|
|
532
|
+
}
|
|
533
|
+
.crumb button {
|
|
534
|
+
background: none; border: none; color: var(--accent); font: inherit;
|
|
535
|
+
font-size: 13px; padding: 4px 6px; cursor: pointer; border-radius: 6px;
|
|
536
|
+
}
|
|
537
|
+
.crumb button:active { background: var(--panel-2); }
|
|
538
|
+
.crumb .sep { color: var(--muted); font-size: 12px; }
|
|
539
|
+
.crumb .here { color: var(--fg); font-size: 13px; padding: 4px 6px; }
|
|
540
|
+
|
|
541
|
+
/* 「就选这个目录」——这一屏唯一的主按钮,所以给它实心底。 */
|
|
542
|
+
.pick-here {
|
|
543
|
+
display: block; width: calc(100% - 24px); margin: 10px 12px;
|
|
544
|
+
padding: 11px; border: none; border-radius: var(--radius);
|
|
545
|
+
background: var(--accent); color: #fff; font: inherit; font-size: 14px; cursor: pointer;
|
|
546
|
+
}
|
|
547
|
+
.pick-here:active { opacity: .85; }
|
|
548
|
+
.pick-here[disabled] { background: var(--line); color: var(--muted); cursor: default; }
|
|
549
|
+
|
|
550
|
+
/* 新建文件夹那一行输入。内联而不是 window.prompt:prompt 在微信这类内嵌
|
|
551
|
+
浏览器里会被直接屏蔽,那时候点「新建文件夹」什么都不发生、也没提示。 */
|
|
552
|
+
.mkdir-row { display: flex; gap: 6px; padding: 8px 12px; align-items: center; }
|
|
553
|
+
.mkdir-row input {
|
|
554
|
+
flex: 1 1 auto; min-width: 0; padding: 9px 10px; font: inherit; font-size: 14px;
|
|
555
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
556
|
+
border-radius: 10px; color: var(--fg);
|
|
557
|
+
}
|
|
558
|
+
.mkdir-row button {
|
|
559
|
+
flex: 0 0 auto; padding: 9px 12px; font: inherit; font-size: 13px; cursor: pointer;
|
|
560
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
561
|
+
border-radius: 10px; color: var(--fg);
|
|
562
|
+
}
|
|
563
|
+
.mkdir-row button:active { background: var(--line); }
|
|
564
|
+
|
|
565
|
+
/* 「完全权限」那一档的待确认状态:点一次先变成这样,再点一次才真切。
|
|
566
|
+
跟 .active(当前所在档位)用不同颜色区分——一个是「将要去」,一个是「已在」。 */
|
|
567
|
+
.seg button.armed { background: var(--danger, #c0392b); color: #fff; }
|
|
431
568
|
.nav-note { padding: 10px 16px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
|
569
|
+
/* 「这不是全部」:垫在聊天内容最上面,往上翻到顶才看得见,不抢内容。 */
|
|
570
|
+
.history-note {
|
|
571
|
+
margin: 0 0 14px; padding: 8px 12px; border: 1px dashed var(--line);
|
|
572
|
+
border-radius: 10px; color: var(--muted); font-size: 12.5px; line-height: 1.6;
|
|
573
|
+
}
|
|
432
574
|
|
|
433
575
|
/* ---------- 设置抽屉 ---------- */
|
|
434
576
|
#settings {
|
|
@@ -450,7 +592,10 @@
|
|
|
450
592
|
.row:last-of-type { border-bottom: none; }
|
|
451
593
|
.row .label { font-size: 15px; }
|
|
452
594
|
.row .hint { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
453
|
-
|
|
595
|
+
/* flex-shrink: 0 不能省。它是弹性项,左边的说明文字一长就会被压缩;
|
|
596
|
+
而它自己带着 overflow: hidden,所以被压掉的那一段不是跑到屏幕外,是**被裁掉**——
|
|
597
|
+
2026-09-25 用户截图里那个「关」字就是这么没的。 */
|
|
598
|
+
.seg { display: flex; flex-shrink: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
454
599
|
.seg button {
|
|
455
600
|
padding: 7px 12px; border: none; background: var(--panel-2); color: var(--muted);
|
|
456
601
|
font-size: 13px; cursor: pointer;
|
|
@@ -489,10 +634,49 @@
|
|
|
489
634
|
transition: opacity .2s, transform .2s; z-index: 200; max-width: 86vw; text-align: center;
|
|
490
635
|
}
|
|
491
636
|
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
637
|
+
|
|
638
|
+
/* ——— 回答电脑提问的卡片 ———
|
|
639
|
+
整屏遮住聊天区:手机上一次只专心答一题,答完自动消失。
|
|
640
|
+
之所以遮全屏而不是弹个小条:非技术用户看到"电脑在等你回答"时,
|
|
641
|
+
最怕的是不知道该点哪里;整屏只剩问题本身和按钮,没有第二个选择。 */
|
|
642
|
+
#askCard { position: fixed; inset: 0; z-index: 300; background: #0e1116;
|
|
643
|
+
display: none; flex-direction: column; padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); overflow-y: auto; }
|
|
644
|
+
#askCard.on { display: flex; }
|
|
645
|
+
#askCard .qhead { font-size: 13px; opacity: .6; margin-bottom: 12px; }
|
|
646
|
+
#askCard .qtext { font-size: 18px; line-height: 1.6; margin-bottom: 10px; }
|
|
647
|
+
#askCard .qdetail { font-size: 14px; line-height: 1.65; opacity: .75; margin-bottom: 16px; white-space: pre-wrap; }
|
|
648
|
+
#askCard .opt { display: block; width: 100%; text-align: left; margin: 0 0 10px; padding: 15px 16px;
|
|
649
|
+
font-size: 16px; line-height: 1.5; border-radius: 12px; border: 1px solid #2a3140;
|
|
650
|
+
background: #161b23; color: inherit; font-family: inherit; }
|
|
651
|
+
#askCard .opt.sel { border-color: #3b82f6; background: #17243a; }
|
|
652
|
+
#askCard .opt small { display: block; opacity: .7; font-size: 13px; margin-top: 5px; }
|
|
653
|
+
#askCard textarea { width: 100%; box-sizing: border-box; font-size: 16px; line-height: 1.6;
|
|
654
|
+
border-radius: 12px; border: 1px solid #2a3140; background: #161b23; color: inherit;
|
|
655
|
+
padding: 12px; font-family: inherit; resize: vertical; }
|
|
656
|
+
#askCard .qfoot { margin-top: auto; display: flex; gap: 10px; padding-top: 16px; }
|
|
657
|
+
#askCard .qfoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: 12px;
|
|
658
|
+
border: 1px solid #2a3140; background: #161b23; color: inherit; font-family: inherit; }
|
|
659
|
+
#askCard .qfoot button#askNext { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
492
660
|
</style>
|
|
493
661
|
</head>
|
|
494
662
|
<body>
|
|
495
663
|
|
|
664
|
+
<!-- 电脑提问时弹出来的答题卡片。默认隐藏,收到 question 事件才显示。 -->
|
|
665
|
+
<div id="askCard">
|
|
666
|
+
<div class="qhead" id="askHead"></div>
|
|
667
|
+
<div class="qtext" id="askText"></div>
|
|
668
|
+
<div class="qdetail" id="askDetail"></div>
|
|
669
|
+
<div id="askOpts"></div>
|
|
670
|
+
<div id="askCustomWrap" style="display:none;margin-bottom:10px">
|
|
671
|
+
<textarea id="askCustom" rows="3" placeholder="用你自己的话回答……"></textarea>
|
|
672
|
+
</div>
|
|
673
|
+
<div class="qfoot">
|
|
674
|
+
<button id="askBack" type="button">上一题</button>
|
|
675
|
+
<button id="askOwn" type="button">我自己说</button>
|
|
676
|
+
<button id="askNext" type="button">下一题</button>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
496
680
|
<header>
|
|
497
681
|
<button class="icon-btn" id="btnNav" aria-label="工作区与会话">☰</button>
|
|
498
682
|
<span class="dot" id="dot"></span>
|
|
@@ -501,6 +685,20 @@
|
|
|
501
685
|
<button class="icon-btn" id="btnSettings" aria-label="设置">⚙</button>
|
|
502
686
|
</header>
|
|
503
687
|
|
|
688
|
+
<!-- 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
|
|
689
|
+
|
|
690
|
+
现在两件事都在了:顶部那一行显示当前用的模型,**点它弹出面板可以改**。
|
|
691
|
+
面板里的强度只画当前模型自己声明的档位;模型不支持调强度时那一行不出现。
|
|
692
|
+
|
|
693
|
+
**整段是自包含的**:自己的 script 标签、自己的请求、自己的元素,不碰页面现有的
|
|
694
|
+
任何代码。唯一的对外依赖是 /mini/api/models 那条接口(已经验过真数据)。
|
|
695
|
+
这样即使这段整个坏掉,也不会影响已经能用的部分。
|
|
696
|
+
|
|
697
|
+
没拿到数据之前整条藏着——**宁可什么都不显示,也不显示一个占位的假值**。 -->
|
|
698
|
+
<div id="modelBar" style="display:none;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:var(--dim,#888);border-bottom:1px solid var(--line,#e5e5e5)">
|
|
699
|
+
<span id="modelBarText"></span>
|
|
700
|
+
</div>
|
|
701
|
+
|
|
504
702
|
<main id="main">
|
|
505
703
|
<div id="reply"></div>
|
|
506
704
|
<!-- 「正在执行」是常驻元素,不参与 #reply 的整块重绘。
|
|
@@ -529,6 +727,11 @@
|
|
|
529
727
|
</main>
|
|
530
728
|
|
|
531
729
|
<div class="attach-bar" id="attachBar" hidden></div>
|
|
730
|
+
|
|
731
|
+
<!-- 在输入框里打 / 时弹出来的指令列表。和附件小条一样排在底栏**上面**:
|
|
732
|
+
盖住输入框的话,用户就看不清自己正在打什么了。 -->
|
|
733
|
+
<div class="cmd-menu" id="cmdMenu" hidden></div>
|
|
734
|
+
|
|
532
735
|
<footer>
|
|
533
736
|
<!-- 回形针。input 藏起来,按钮点它——iOS 上直接放一个 file input 样式很难统一。 -->
|
|
534
737
|
<button class="attach" id="btnAttach" type="button" aria-label="上传文件" title="上传文件">
|
|
@@ -565,6 +768,18 @@
|
|
|
565
768
|
</div>
|
|
566
769
|
</div>
|
|
567
770
|
|
|
771
|
+
<!-- 权限档位。整块由脚本填:这台电脑有哪些档位是**运行时读**的,一个都不写死。
|
|
772
|
+
服务拿不到时整块不显示——它是附加能力,缺了遥控本身照常。 -->
|
|
773
|
+
<div id="permBlock" hidden>
|
|
774
|
+
<div class="row">
|
|
775
|
+
<div>
|
|
776
|
+
<div class="label">权限档位</div>
|
|
777
|
+
<div class="hint" id="permHint">作用在当前会话上</div>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
<div class="seg" id="segPerm"></div>
|
|
781
|
+
</div>
|
|
782
|
+
|
|
568
783
|
<!--
|
|
569
784
|
这里原来有两行:「完成后提醒」和「语音输入」。
|
|
570
785
|
|
|
@@ -589,6 +804,38 @@
|
|
|
589
804
|
</div>
|
|
590
805
|
</div>
|
|
591
806
|
|
|
807
|
+
<!--
|
|
808
|
+
「页面在后台时提醒」。**现在不露出来了,藏到底。**
|
|
809
|
+
|
|
810
|
+
2026-09-21 它整行被撤掉过,理由是当时三条连接路径全是明文 http,浏览器不给发
|
|
811
|
+
系统通知,摆出来只是让用户点一下碰一鼻子灰。
|
|
812
|
+
|
|
813
|
+
2026-09-25 白天 Tailscale 那条路接通 HTTPS,它回来过一次;**当晚又撤了**。
|
|
814
|
+
第二次撤的理由和第一次不同,也更硬:**手机锁屏会冻结后台页面**,那条实时连接
|
|
815
|
+
跟着断掉,回复根本到不了页面,也就没人去发通知。这不是"前提没到",是浏览器这条
|
|
816
|
+
路本来就有上限——service worker 也救不了,它是被事件唤醒的,没有事件它就不存在。
|
|
817
|
+
|
|
818
|
+
用户原话:「类似功能我们以后考虑做成 APP 时再加,浏览器里不放了。」
|
|
819
|
+
|
|
820
|
+
**代码全部留着,只是不露出来**(见下面 serviceWorker.register 那段的注释)。
|
|
821
|
+
将来做 App 里的锁屏提醒,这些知识直接能用。真正不怕锁屏的是电脑端那条外部推送
|
|
822
|
+
(Bark/ntfy/Telegram,lib/notify.js),从服务器直接发出去,不经过浏览器。
|
|
823
|
+
|
|
824
|
+
为什么非要有 service worker:手机浏览器上 new Notification() 会直接抛 TypeError,
|
|
825
|
+
唯一能发系统通知的入口是 ServiceWorkerRegistration.showNotification()。
|
|
826
|
+
详见 lib/sw.js。
|
|
827
|
+
-->
|
|
828
|
+
<div class="row" id="rowNotify" style="display:none">
|
|
829
|
+
<div>
|
|
830
|
+
<div class="label">页面在后台时提醒</div>
|
|
831
|
+
<div class="hint" id="notifyHint">回复到了发一条系统通知</div>
|
|
832
|
+
</div>
|
|
833
|
+
<div class="seg" id="segNotify">
|
|
834
|
+
<button data-notify="on">开</button>
|
|
835
|
+
<button data-notify="off">关</button>
|
|
836
|
+
</div>
|
|
837
|
+
</div>
|
|
838
|
+
|
|
592
839
|
<div class="row">
|
|
593
840
|
<div>
|
|
594
841
|
<div class="label">连接</div>
|
|
@@ -634,6 +881,9 @@
|
|
|
634
881
|
token: params.get('token') || localStorage.getItem('dshMiniToken') || '',
|
|
635
882
|
mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
|
|
636
883
|
sound: localStorage.getItem('dshMiniSound') !== 'off',
|
|
884
|
+
// 锁屏也能提醒(系统通知)。默认关着——它要用户主动点一下才会申请权限,
|
|
885
|
+
// 不能替他决定。明文连接下这一项永远是空转的,见下面「锁屏也能提醒」那一段。
|
|
886
|
+
notify: localStorage.getItem('dshMiniNotify') === 'on',
|
|
637
887
|
running: false,
|
|
638
888
|
sessions: [],
|
|
639
889
|
// 排队中的指令([{ id, text, placement }])。跑着任务时接着发的那几条排在这儿,
|
|
@@ -646,7 +896,28 @@
|
|
|
646
896
|
// 只存服务端发回来的那张小票,不存附件本身——真正的东西已经在电脑上了。
|
|
647
897
|
uploads: [],
|
|
648
898
|
// 正在流出来的那一段。空字符串 = 这会儿没有东西要显示。
|
|
649
|
-
live: ''
|
|
899
|
+
live: '',
|
|
900
|
+
/**
|
|
901
|
+
* 模型此刻的自言自语(鲸鱼娘的台词):{ text, at } 或 null。
|
|
902
|
+
*
|
|
903
|
+
* **和 live 严格分开**:live 非空时整块 #work 会让位(那是「回答正在往外冒」的
|
|
904
|
+
* 规矩),台词要是塞进 live,会出现「该说话的时候鲸鱼娘不见了」。
|
|
905
|
+
* 它也不进 #reply——2026-09-22 用户否掉的就是「过程的文字跑进回答区」。
|
|
906
|
+
*/
|
|
907
|
+
thought: null,
|
|
908
|
+
// 服务端正去读这个会话的完整记录(点开一个插件没看过的会话时会有这一下)。
|
|
909
|
+
historyLoading: false,
|
|
910
|
+
// 「这份历史只到最近一段」(服务端读历史有上限时会给这一句)。
|
|
911
|
+
historyTruncated: false,
|
|
912
|
+
historyNote: '',
|
|
913
|
+
// 斜杠指令的列表(在输入框里打 / 时弹出来的那个菜单)。
|
|
914
|
+
// **按绑定的会话缓存**:指令表是每个会话一份的,而这个会话没在跑时服务端一条都
|
|
915
|
+
// 读不到(回 ok:false + 一句话)。所以换了会话必须重取——否则用户点到的是上一个
|
|
916
|
+
// 会话的指令,点下去必然被拒,而他从屏幕上完全看不出为什么。
|
|
917
|
+
commands: null, // null = 还没取到
|
|
918
|
+
commandsError: '', // 取不到时服务端给的那句话,原样显示给用户
|
|
919
|
+
commandsFor: null, // 上面这份数据是哪个会话的
|
|
920
|
+
commandsLoading: false // 正在取(挡住重复请求,见 loadCommands)
|
|
650
921
|
};
|
|
651
922
|
if (params.get('token')) {
|
|
652
923
|
localStorage.setItem('dshMiniToken', params.get('token'));
|
|
@@ -880,7 +1151,13 @@
|
|
|
880
1151
|
opts.headers = Object.assign(authHeaders(!raw), opts.headers || {});
|
|
881
1152
|
return fetch(path, opts).then(function (r) {
|
|
882
1153
|
return r.json().catch(function () { return {}; }).then(function (body) {
|
|
883
|
-
if (!r.ok)
|
|
1154
|
+
if (!r.ok) {
|
|
1155
|
+
var err = new Error(body.error || ('HTTP ' + r.status));
|
|
1156
|
+
// 状态码挂在错上带出去:调用方要区分「服务端说这题已经结束了」(409)和
|
|
1157
|
+
// 「压根没连上」(fetch 直接抛)——靠中文字面去猜太脆。
|
|
1158
|
+
err.status = r.status;
|
|
1159
|
+
throw err;
|
|
1160
|
+
}
|
|
884
1161
|
return body;
|
|
885
1162
|
});
|
|
886
1163
|
});
|
|
@@ -977,10 +1254,50 @@
|
|
|
977
1254
|
var GAP_MS = 3600;
|
|
978
1255
|
var SLOW_MS = 60000;
|
|
979
1256
|
|
|
1257
|
+
/**
|
|
1258
|
+
* 说话那张立绘。**它不进 POSES。**
|
|
1259
|
+
*
|
|
1260
|
+
* POSES 是轮播的节目单,进去就要占一格(还要连带改 posePool 那套按位置认「还在忙」
|
|
1261
|
+
* 的写法)。这张是**按需插播**的:只在宿主侧推来台词时出现,说完就回原来的顺序。
|
|
1262
|
+
* 文件名里的 9 同样只是个名字,跟顺序无关。
|
|
1263
|
+
*/
|
|
1264
|
+
var SPEAK_POSE = { file: 'work-9-talking', flip: 1.1 };
|
|
1265
|
+
/**
|
|
1266
|
+
* 一句台词占多久:**按字数算**,短的有起步时长,长的封顶。
|
|
1267
|
+
*
|
|
1268
|
+
* 用户 2026-09-26 选的是「一直说到它结束(最多 10 秒)」;2026-09-27 他觉得太短,
|
|
1269
|
+
* 问能不能再久一点。原来是一律 10 秒——短句够用,可长句在 2026-09-27 起会**完整显示**
|
|
1270
|
+
* (不再截断),一句 200 字的话念 10 秒根本念不完,她就已经回去干别的了。
|
|
1271
|
+
*
|
|
1272
|
+
* 所以:起步 15 秒,每多一个字多留 0.15 秒(≈ 一分钟 400 字,手机上舒服的默读速度),
|
|
1273
|
+
* 30 秒封顶——封顶是为了防「某一轮她再没说话、画面却一直卡在说话那张」。
|
|
1274
|
+
* 宿主侧只拿得到**整段**(一步一步来的,不是逐字),所以「结束」得自己收尾:
|
|
1275
|
+
* 下一句台词来了就把表往后推,一直没有新的或这一轮结束了,就到点放回轮播。
|
|
1276
|
+
*/
|
|
1277
|
+
var SPEAK_MIN_MS = 15000;
|
|
1278
|
+
var SPEAK_PER_CHAR_MS = 150;
|
|
1279
|
+
var SPEAK_MAX_MS = 30000;
|
|
1280
|
+
|
|
1281
|
+
/** 这句话该占多久(见上面三条)。 */
|
|
1282
|
+
function speakDuration(text) {
|
|
1283
|
+
var n = String(text || '').length;
|
|
1284
|
+
return Math.min(SPEAK_MAX_MS, Math.max(SPEAK_MIN_MS, n * SPEAK_PER_CHAR_MS));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
980
1287
|
var reducedMotion = false;
|
|
981
1288
|
try { reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
|
|
982
1289
|
|
|
983
|
-
var work = {
|
|
1290
|
+
var work = {
|
|
1291
|
+
startedAt: 0, pose: 0, poseTimer: null, tickTimer: null, layers: [],
|
|
1292
|
+
// 说话那张立绘单独一层(不在 POSES 里,见 SPEAK_POSE)。
|
|
1293
|
+
speakLayer: null,
|
|
1294
|
+
// 正在说的那句台词,以及它最晚说到什么时候。
|
|
1295
|
+
speakText: '',
|
|
1296
|
+
speakUntil: 0,
|
|
1297
|
+
// 已经念过的那句话的身份(宿主侧的 at)。快照来得很密,不认这个的话
|
|
1298
|
+
// 同一句话会被反复「重新开始说」,动画一直从头播。
|
|
1299
|
+
speakAt: null,
|
|
1300
|
+
};
|
|
984
1301
|
|
|
985
1302
|
/** 现在该用哪几条姿态:跑够一分钟才把「还在忙」放进来。 */
|
|
986
1303
|
function posePool() {
|
|
@@ -1019,16 +1336,65 @@
|
|
|
1019
1336
|
+ '&v=' + encodeURIComponent(BUILD);
|
|
1020
1337
|
}
|
|
1021
1338
|
|
|
1339
|
+
/**
|
|
1340
|
+
* 把一句话写进气泡。
|
|
1341
|
+
*
|
|
1342
|
+
* 走的是和回答区同一个行内渲染(`mdInline`,见上面那段 Markdown 渲染)。它的顺序是
|
|
1343
|
+
* **先转义、再上标签**:模型原话里的尖括号仍然只是字面上的尖括号,不会被当成标签;
|
|
1344
|
+
* 只有它自己写的 `**粗体**`、`` `代码` ``、链接才会变成样式。留一个口子给"以后不加"。
|
|
1345
|
+
*
|
|
1346
|
+
* 之前这里是 `textContent`(一句纯文本),于是 `**重启把连接掐断**` 连星号一起端到了
|
|
1347
|
+
* 用户面前(2026-09-27 用户报的)。词条那几种是我们自己写的、不含 markdown,也一起走
|
|
1348
|
+
* 这个入口——两条路各写各的,早晚会分叉。
|
|
1349
|
+
*/
|
|
1350
|
+
function paintBubble(line) {
|
|
1351
|
+
var text = line || '';
|
|
1352
|
+
$('workBubble').hidden = !text;
|
|
1353
|
+
$('workBubble').innerHTML = mdInline(text);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1022
1356
|
function paintPose(index) {
|
|
1023
1357
|
for (var i = 0; i < work.layers.length; i += 1) {
|
|
1024
1358
|
work.layers[i].classList.toggle('on', i === index);
|
|
1025
1359
|
}
|
|
1360
|
+
// 轮播回来时把说话那张收掉——它在 POSES 之外,不归上面那个循环管。
|
|
1361
|
+
if (work.speakLayer) work.speakLayer.classList.toggle('on', false);
|
|
1026
1362
|
// 有的姿势不带词条(比如那张冲刺):词条是空串就把整个气泡收起来。
|
|
1027
1363
|
// 留一个空泡泡在那儿比不放气泡更怪——那个尖角还指着她的头,像话没说出来。
|
|
1028
1364
|
// 收起气泡不会让立绘挪位:.work-top 有 min-width 兜着,行宽不变。
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1365
|
+
paintBubble(POSES[index].line);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* 换成说话那张,把台词写进气泡。
|
|
1370
|
+
*
|
|
1371
|
+
* 台词是**模型的原话**(宿主机只从旁白里取,模型自己想的那些不上手机),
|
|
1372
|
+
* 所以这里只做一件事:原样写出来,不加不减——渲染的那一步交给 `paintBubble`。
|
|
1373
|
+
*/
|
|
1374
|
+
function paintSpeak(text) {
|
|
1375
|
+
paintBubble(text);
|
|
1376
|
+
// 说话那张没加载出来时(预热那次报错翻过这个开关):台词照说,**但不动图层**——
|
|
1377
|
+
// 把正常那几张全关掉、换上一张空的,她会当场从屏幕上消失(2026-09-27 立的闸)。
|
|
1378
|
+
if (work.speakReady === false) return;
|
|
1379
|
+
for (var i = 0; i < work.layers.length; i += 1) work.layers[i].classList.toggle('on', false);
|
|
1380
|
+
if (work.speakLayer) work.speakLayer.classList.toggle('on', true);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* 宿主侧送来的新台词要不要现在就说。
|
|
1385
|
+
*
|
|
1386
|
+
* 判据是 at(那句话的身份):**同一句只说一次**。快照广播来得很密,每来一条就重说
|
|
1387
|
+
* 一遍的话,说话这张立绘会一直从头播,看着像卡住不动。
|
|
1388
|
+
*/
|
|
1389
|
+
function maybeSpeak() {
|
|
1390
|
+
var t = state.thought;
|
|
1391
|
+
if (!t || !t.text) return;
|
|
1392
|
+
if (t.at === work.speakAt) return;
|
|
1393
|
+
work.speakAt = t.at;
|
|
1394
|
+
work.speakText = t.text;
|
|
1395
|
+
// 每来一句新的就从这一刻重新算:时长按这句话有多长来(见 speakDuration)。
|
|
1396
|
+
work.speakUntil = Date.now() + speakDuration(t.text);
|
|
1397
|
+
paintSpeak(t.text);
|
|
1032
1398
|
}
|
|
1033
1399
|
|
|
1034
1400
|
function elapsedLabel(ms) {
|
|
@@ -1058,6 +1424,19 @@
|
|
|
1058
1424
|
var warm = document.createElement('img');
|
|
1059
1425
|
warm.src = artUrl(POSES[i].file);
|
|
1060
1426
|
}
|
|
1427
|
+
// 说话那张一起建好、一起预热:等台词来了再新建图层,会说第一个字之前先闪一下空白。
|
|
1428
|
+
var speak = document.createElement('div');
|
|
1429
|
+
speak.className = 'pose';
|
|
1430
|
+
speak.style.backgroundImage = 'url("' + artUrl(SPEAK_POSE.file) + '")';
|
|
1431
|
+
speak.style.animationDuration = SPEAK_POSE.flip + 's';
|
|
1432
|
+
stage.appendChild(speak);
|
|
1433
|
+
work.speakLayer = speak;
|
|
1434
|
+
var warmSpeak = document.createElement('img');
|
|
1435
|
+
// 说话那张要是加载不出来(图还没画好、或者网络半路断了):**别把舞台清空**。
|
|
1436
|
+
// 默认按「图在」处理,只有真报错才翻成 false——正常路径一个字都不变;
|
|
1437
|
+
// 翻成 false 之后就退成「台词照说、人照轮播」,而不是她整个消失(见 paintSpeak)。
|
|
1438
|
+
warmSpeak.onerror = function () { work.speakReady = false; };
|
|
1439
|
+
warmSpeak.src = artUrl(SPEAK_POSE.file);
|
|
1061
1440
|
paintPose(0);
|
|
1062
1441
|
}
|
|
1063
1442
|
|
|
@@ -1069,6 +1448,20 @@
|
|
|
1069
1448
|
* 停止时 stopWork 会把待排的那一次清掉,不会漏跑。
|
|
1070
1449
|
*/
|
|
1071
1450
|
function nextPose() {
|
|
1451
|
+
// 正在说话:这一格让给她,轮播停在这儿等(work.pose 不动,所以恢复时接着的那一格
|
|
1452
|
+
// 就是被打断的那一格,而不是从头来)。到点或来了新台词都由 maybeSpeak 管。
|
|
1453
|
+
if (work.speakUntil) {
|
|
1454
|
+
var left = work.speakUntil - Date.now();
|
|
1455
|
+
if (left > 0) {
|
|
1456
|
+
paintSpeak(work.speakText);
|
|
1457
|
+
// 睡到「该收尾的那一刻」为止,不多不少:按 3.6 秒一格睡的话,
|
|
1458
|
+
// 这个上限会被拖到整格之后,最后那句台词要多挂将近一秒。
|
|
1459
|
+
work.poseTimer = setTimeout(nextPose, Math.min(POSE_MS, left));
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
work.speakUntil = 0;
|
|
1463
|
+
work.speakText = '';
|
|
1464
|
+
}
|
|
1072
1465
|
var seq = poseSequence();
|
|
1073
1466
|
work.pose = (work.pose + 1) % seq.length;
|
|
1074
1467
|
var index = seq[work.pose];
|
|
@@ -1093,6 +1486,11 @@
|
|
|
1093
1486
|
|
|
1094
1487
|
function stopWork() {
|
|
1095
1488
|
work.startedAt = 0;
|
|
1489
|
+
// 说话状态跟着收掉:这一轮的话已经说完了,下一轮不该接着上一句。
|
|
1490
|
+
// **但 speakAt 留着**——那是对「哪句话已经念过」的记忆,清掉的话,万一宿主侧
|
|
1491
|
+
// 还留着上一轮的台词(广播没到),新一轮一开场它就会被当成新话说一遍。
|
|
1492
|
+
work.speakUntil = 0;
|
|
1493
|
+
work.speakText = '';
|
|
1096
1494
|
// 轮播现在是自己排下一次的 setTimeout(见 nextPose),用 clearInterval 清
|
|
1097
1495
|
// setTimeout 在浏览器里也行,但写 clearTimeout 更不容易让人误会。
|
|
1098
1496
|
if (work.poseTimer) { clearTimeout(work.poseTimer); work.poseTimer = null; }
|
|
@@ -1138,6 +1536,30 @@
|
|
|
1138
1536
|
|
|
1139
1537
|
$('btnStop').addEventListener('click', requestStop);
|
|
1140
1538
|
|
|
1539
|
+
/**
|
|
1540
|
+
* 把手机上答的题交回电脑,回一句实话。
|
|
1541
|
+
*
|
|
1542
|
+
* 回的是三种结果,不是一个「成没成」——因为两种失败要说不同的话:
|
|
1543
|
+
* 'ok' 电脑收下了;
|
|
1544
|
+
* 'late' 这题已经交回电脑了(插件回 409),得让用户去电脑上答;
|
|
1545
|
+
* 'failed' 压根没发出去(连不上),卡片留着,手一按还能再试。
|
|
1546
|
+
*
|
|
1547
|
+
* 原来那一段是按下就先把卡片收掉、发失败也咽着不说(当时的理由是「电脑那边等不到
|
|
1548
|
+
* 会自己还给浏览器」)。可电脑那边还在等着,手机上却静悄悄——用户看到的只有
|
|
1549
|
+
* 「我点了,没反应」(2026-09-27 用户报的正是这一幕)。所以这里只做一件事:
|
|
1550
|
+
* 把实情如实报出来,怎么显示由上面那张卡片决定。
|
|
1551
|
+
*/
|
|
1552
|
+
function sendAnswer(id, answers) {
|
|
1553
|
+
return api('/mini/api/answer', {
|
|
1554
|
+
method: 'POST',
|
|
1555
|
+
body: JSON.stringify({ id: id, answers: answers }),
|
|
1556
|
+
})
|
|
1557
|
+
.then(function () { return 'ok'; })
|
|
1558
|
+
.catch(function (err) {
|
|
1559
|
+
return err && err.status === 409 ? 'late' : 'failed';
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1141
1563
|
// ---------------- 排队中的指令 ----------------
|
|
1142
1564
|
//
|
|
1143
1565
|
// 跑着任务时接着发的那几条不会丢,而是排进 agent 的收件箱(`followup()` 的语义
|
|
@@ -1196,7 +1618,12 @@
|
|
|
1196
1618
|
// (比如正在调工具)时,鲸鱼娘还是要留着,那是它唯一该出现的时候。
|
|
1197
1619
|
var busy = (state.running || queued > 0) && !state.live;
|
|
1198
1620
|
el.hidden = !busy;
|
|
1199
|
-
if (busy)
|
|
1621
|
+
if (busy) {
|
|
1622
|
+
startWork();
|
|
1623
|
+
// 台词每次随快照重算一遍。**必须放在 startWork 之后**:startWork 会先画第 0 张,
|
|
1624
|
+
// 反过来的话它会把这句台词盖掉,用户看到的是「收到,这就去办」而不是她的自说自话。
|
|
1625
|
+
maybeSpeak();
|
|
1626
|
+
} else stopWork();
|
|
1200
1627
|
paintQueue();
|
|
1201
1628
|
}
|
|
1202
1629
|
|
|
@@ -1224,6 +1651,24 @@
|
|
|
1224
1651
|
paintWork();
|
|
1225
1652
|
}
|
|
1226
1653
|
|
|
1654
|
+
/**
|
|
1655
|
+
* 单帧模式:「这一条回答」有没有已经带用户滚过顶了。
|
|
1656
|
+
*
|
|
1657
|
+
* **用 timestamp 当身份**,因为它是这里唯一稳定、且只在真推了一条新回答时才变的字段
|
|
1658
|
+
* (`pushReply` 每次都现取 `Date.now()`;重连、SSE 重推、切模式、从后台回来
|
|
1659
|
+
* 都只是把同一条回答重新渲染一遍,timestamp 不变)。
|
|
1660
|
+
*
|
|
1661
|
+
* 用户 2026-09-23 提的:「手机端的单帧模式,回答出现的时候,页面应该跳到顶部,
|
|
1662
|
+
* 方便用户阅读。」不记这个的话:一条长结论渲染出来,用户看到的是最后一行,
|
|
1663
|
+
* 得自己往上翻才读得到开头。
|
|
1664
|
+
*
|
|
1665
|
+
* 记成「已经为哪一条滚过顶」而不是一个「刚跳过」的布尔量,是为了不误伤上面那几种
|
|
1666
|
+
* 重新渲染——那些时候用户可能正读到一半,把他弹回顶部比不跳还烦人。
|
|
1667
|
+
* 初始值 0:开机第一次渲染时任何一条回答的 timestamp 都比它大,于是会跳一次,
|
|
1668
|
+
* 正好是「打开页面从最新那条的开头读起」。
|
|
1669
|
+
*/
|
|
1670
|
+
var scrolledReplyAt = 0;
|
|
1671
|
+
|
|
1227
1672
|
function renderMinimal() {
|
|
1228
1673
|
mainEl.classList.remove('center');
|
|
1229
1674
|
var latest = state.latest;
|
|
@@ -1245,6 +1690,10 @@
|
|
|
1245
1690
|
+ ' data-copy="' + escapeHtml(latest.text) + '">' + ICON_COPY + '</button>'
|
|
1246
1691
|
+ '</div>');
|
|
1247
1692
|
}
|
|
1693
|
+
// 刚跑完的那条指令也要露出来(判据和为什么见 commandTail)。
|
|
1694
|
+
// 「正在执行」那块照旧:它是常驻的 #work,跟这里没关系。
|
|
1695
|
+
var tail = commandTail();
|
|
1696
|
+
if (tail) parts.push(commandBlock(tail));
|
|
1248
1697
|
if (live) parts.push(liveBlock(live));
|
|
1249
1698
|
if (parts.length) {
|
|
1250
1699
|
var stick = atBottom();
|
|
@@ -1252,12 +1701,33 @@
|
|
|
1252
1701
|
// 流式的时候得跟着往下滚,不然新写出来的内容在屏幕外面,看起来像卡住了。
|
|
1253
1702
|
// 但只在用户本来就在底部时才跟——他翻上去看历史的时候别拽他回来。
|
|
1254
1703
|
if (live && stick) mainEl.scrollTop = mainEl.scrollHeight;
|
|
1704
|
+
// 单帧模式专属:**答案落定的那一刻**跳到顶部,让人从第一行开始读。
|
|
1705
|
+
//
|
|
1706
|
+
// 判据是 timestamp 变了,不是「渲染了一次」——重连、SSE 重推、切模式、
|
|
1707
|
+
// 从后台回来都会重新渲染同一条回答,那些时候用户可能正读到一半,
|
|
1708
|
+
// 弹回顶部比不跳还烦人(见 scrolledReplyAt 的说明)。
|
|
1709
|
+
//
|
|
1710
|
+
// 和上面那条黏底不冲突,两者管的是不同时刻:流式那会儿(live 还在)
|
|
1711
|
+
// 答案没落定,该跟着往下走;落定之后才轮到这一条把它拉回开头。
|
|
1712
|
+
// 也别跟下面 send() 里那条「按了发送无条件回底部」搞混——那是用户自己的
|
|
1713
|
+
// 主动动作,他想看自己刚发出去的那条;这条是回答到了,他要读回答。
|
|
1714
|
+
if (latest && latest.timestamp && latest.timestamp !== scrolledReplyAt) {
|
|
1715
|
+
scrolledReplyAt = latest.timestamp;
|
|
1716
|
+
mainEl.scrollTop = 0;
|
|
1717
|
+
}
|
|
1255
1718
|
return;
|
|
1256
1719
|
}
|
|
1257
1720
|
if (state.running) {
|
|
1258
1721
|
replyEl.innerHTML = '';
|
|
1259
1722
|
return;
|
|
1260
1723
|
}
|
|
1724
|
+
// 正在去读这个会话的记录:这时候说「在下面输入一条指令」是不对的——
|
|
1725
|
+
// 这个会话其实有历史,只是还没读完。单帧模式只显示最后一条回复,
|
|
1726
|
+
// 那一条也在这份记录里。
|
|
1727
|
+
if (state.historyLoading) {
|
|
1728
|
+
replyEl.innerHTML = '<div class="placeholder">正在读取这个会话的记录…</div>';
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1261
1731
|
replyEl.innerHTML = '<div class="placeholder">在下面输入一条指令,'
|
|
1262
1732
|
+ '这里只会显示 AI 的最终回复。<br>中间的工具调用和执行过程不会传过来。</div>';
|
|
1263
1733
|
}
|
|
@@ -1279,6 +1749,58 @@
|
|
|
1279
1749
|
return '<div class="live"><div class="md">' + mdToHtml(text) + '</div></div>';
|
|
1280
1750
|
}
|
|
1281
1751
|
|
|
1752
|
+
/**
|
|
1753
|
+
* 历史里的一条指令(`role:'command'`):这个会话执行了 /xxx,跑到哪一步了。
|
|
1754
|
+
*
|
|
1755
|
+
* 为什么单独一种长相:它既不是用户说的话,也不是 AI 说的话,而是**会话做的一个
|
|
1756
|
+
* 动作**。套上气泡就成了一句人说的话,会被当成回答去读;给它复制按钮也没有意义
|
|
1757
|
+
* (「/compact 执行中…」没有拿走的必要)。所以收成一行紧凑的条目:名字用等宽字体,
|
|
1758
|
+
* 后面直接跟结果。
|
|
1759
|
+
*
|
|
1760
|
+
* 三种状态里只有 running 是「还不知道结果」:服务端在这一条上**不报进度**,
|
|
1761
|
+
* 所以只能说「执行中…」——编一个百分比就是撒谎。
|
|
1762
|
+
*
|
|
1763
|
+
* 这两个函数夹在 liveBlock 和 renderChat 中间是有原因的,**别挪**:page.test.mjs
|
|
1764
|
+
* 是按源码里的锚点串切片的——聊天模式那一套从 liveBlock 那一段切到 render 那一段,
|
|
1765
|
+
* 单帧模式那一套从 scrolledReplyAt 那一段切到 renderChat 那一段,两把刀的交集就是
|
|
1766
|
+
* 这里。放到交集外面,总有一套测试一跑就 ReferenceError。
|
|
1767
|
+
* (顺带一句:这段注释里**不能**写出那几个锚点串的完整样子,否则 indexOf 会先命中
|
|
1768
|
+
* 注释、把切片截断,报出来的是「SyntaxError」而不是人话。)
|
|
1769
|
+
*/
|
|
1770
|
+
function commandBlock(m) {
|
|
1771
|
+
// args 是名字后面**原样**的输入(含分隔空格),可能为空串——所以直接拼,
|
|
1772
|
+
// 不自己补一个空格:补了就把「用户到底敲了什么」改样了。
|
|
1773
|
+
var line = '/' + (m.name || '') + (m.args || '');
|
|
1774
|
+
var kind = m.kind === 'error' ? 'error' : (m.kind === 'success' ? 'success' : 'running');
|
|
1775
|
+
var res = kind === 'error' ? (m.text || '没跑起来')
|
|
1776
|
+
: (kind === 'success' ? (m.text || '完成') : '执行中…');
|
|
1777
|
+
return '<div class="cmd ' + kind + '">'
|
|
1778
|
+
+ '<span class="cmd-line">' + escapeHtml(line) + '</span>'
|
|
1779
|
+
+ '<span class="cmd-res">' + escapeHtml(res) + '</span></div>';
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* 单帧模式要跟着显示的那条指令:**历史里最后一条**,而且得真的是一条指令。
|
|
1784
|
+
*
|
|
1785
|
+
* 为什么要单独处理:单帧模式整屏只留最新一条回复,而指令**不产生回复**——用户点一下
|
|
1786
|
+
* /compact,这里要是什么都不画,屏幕上就是一动不动,看着像点了个没反应的按钮。
|
|
1787
|
+
*
|
|
1788
|
+
* 用「最后一条」而不是「最近一条指令」:中间要是夹了别的消息(指令跑完模型又答了
|
|
1789
|
+
* 一句),那条指令已经不是最新的事了,再把它贴在最下面就成了过期通知。
|
|
1790
|
+
*
|
|
1791
|
+
* 最后还有一道时间判断,别删。单帧模式的回答是走 SSE 的 reply 那条路来的,它
|
|
1792
|
+
* **不往 history 里塞东西**(只有聊天模式才塞),所以 history 的尾巴会一直停在那条
|
|
1793
|
+
* 指令上。不加这一道,新回答到了之后底下还挂着一条「执行中…」,看着像它卡住了。
|
|
1794
|
+
*/
|
|
1795
|
+
function commandTail() {
|
|
1796
|
+
var h = state.history || [];
|
|
1797
|
+
var last = h.length ? h[h.length - 1] : null;
|
|
1798
|
+
if (!last || last.role !== 'command') return null;
|
|
1799
|
+
var lt = (state.latest && state.latest.timestamp) || 0;
|
|
1800
|
+
if (lt && last.timestamp && last.timestamp < lt) return null;
|
|
1801
|
+
return last;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1282
1804
|
/**
|
|
1283
1805
|
* 该不该自动滚到底。
|
|
1284
1806
|
*
|
|
@@ -1298,6 +1820,22 @@
|
|
|
1298
1820
|
return mainEl.scrollHeight - mainEl.scrollTop - (mainEl.clientHeight || 0) < 48;
|
|
1299
1821
|
}
|
|
1300
1822
|
|
|
1823
|
+
/**
|
|
1824
|
+
* 把某一块的**开头**对齐到视野顶部(聊天模式用,见 renderChat)。
|
|
1825
|
+
*
|
|
1826
|
+
* 用两个矩形的差值算,不用 `offsetTop`:`main` 上没有 `position: relative`,
|
|
1827
|
+
* `offsetTop` 会一路找到 body 去,算出来是错的。矩形差值不管定位上下文怎么变都对。
|
|
1828
|
+
*
|
|
1829
|
+
* 留 12px 空隙:`main` 自己带 16px 内边距,滚下去之后那段内边距就滚没了,
|
|
1830
|
+
* 不留缝的话回答的第一行会顶在屏幕最上沿,看着像被切掉了。
|
|
1831
|
+
*/
|
|
1832
|
+
function pinToTop(el) {
|
|
1833
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return;
|
|
1834
|
+
if (typeof mainEl.getBoundingClientRect !== 'function') return;
|
|
1835
|
+
var gap = el.getBoundingClientRect().top - mainEl.getBoundingClientRect().top;
|
|
1836
|
+
mainEl.scrollTop += gap - 12;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1301
1839
|
function renderChat() {
|
|
1302
1840
|
mainEl.classList.remove('center');
|
|
1303
1841
|
// **还在排队的那条不算「已发出」,这里要把它从聊天记录里滤掉。**
|
|
@@ -1318,10 +1856,18 @@
|
|
|
1318
1856
|
return !(m.role === 'user' && m.id && queuedIds[m.id]);
|
|
1319
1857
|
});
|
|
1320
1858
|
if (!list.length && !state.running && !state.live && !(state.queued || []).length) {
|
|
1321
|
-
|
|
1859
|
+
// 「正在读」和「确实没有」是两件事,分开说。点开一个插件没看过的会话时,
|
|
1860
|
+
// 服务端要去 DSH 的会话记录里读它那一段(最大的那条 20 MB、约半秒),
|
|
1861
|
+
// 这期间说「还没有对话记录」会让人以为这个会话本来就是空的。
|
|
1862
|
+
replyEl.innerHTML = state.historyLoading
|
|
1863
|
+
? '<div class="placeholder">正在读取这个会话的记录…</div>'
|
|
1864
|
+
: '<div class="placeholder">还没有对话记录。</div>';
|
|
1322
1865
|
return;
|
|
1323
1866
|
}
|
|
1324
1867
|
var html = list.map(function (m) {
|
|
1868
|
+
// 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
|
|
1869
|
+
// 不套气泡、不给复制按钮(见 commandBlock 的说明)。
|
|
1870
|
+
if (m.role === 'command') return commandBlock(m);
|
|
1325
1871
|
// 被按停的那一轮:标一句,别让人以为模型答到一半断了。
|
|
1326
1872
|
var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
|
|
1327
1873
|
// 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
|
|
@@ -1342,13 +1888,86 @@
|
|
|
1342
1888
|
+ '<div class="reply-foot">' + copyBtn
|
|
1343
1889
|
+ '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
|
|
1344
1890
|
}).join('');
|
|
1891
|
+
// 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
|
|
1892
|
+
// 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
|
|
1893
|
+
if (state.historyTruncated) {
|
|
1894
|
+
html = '<div class="history-note">'
|
|
1895
|
+
+ escapeHtml(state.historyNote || '这个会话很大,只显示了最近一段。')
|
|
1896
|
+
+ '</div>' + html;
|
|
1897
|
+
}
|
|
1345
1898
|
// 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
|
|
1346
1899
|
// 两种模式共用同一块,改的时候两边都走一遍。
|
|
1347
1900
|
if (state.live) html += liveBlock(state.live);
|
|
1348
1901
|
// 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
|
|
1349
1902
|
var stick = atBottom();
|
|
1350
1903
|
replyEl.innerHTML = html;
|
|
1351
|
-
|
|
1904
|
+
scrollChat(stick);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* 聊天模式里「已经为哪一次回答把开头对齐到顶部」。
|
|
1909
|
+
*
|
|
1910
|
+
* 取值是 `'live'`(正在流的那一段)或 `'done:<timestamp>'`(落定的那条)。
|
|
1911
|
+
* 记这个是为了**每条回答只对齐一次**——不记的话,用户往下读的时候每次重画
|
|
1912
|
+
* 都会把他拽回开头,比不对齐还烦人。
|
|
1913
|
+
*
|
|
1914
|
+
* 声明放在这里而不是跟 scrolledReplyAt 作伴:page.test.mjs 是从 `liveBlock`
|
|
1915
|
+
* 切到 `render()` 来跑聊天模式的,放前面就切不进来,一跑就是 ReferenceError。
|
|
1916
|
+
*/
|
|
1917
|
+
var pinnedAnswerKey = '';
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* 聊天模式的滚动:**回答的开头才是要读的地方,不是末尾。**
|
|
1921
|
+
*
|
|
1922
|
+
* 用户 2026-09-24 提的:「聊天模式下,回答出现时,页面也应该停留在回答的开头
|
|
1923
|
+
* 而非末尾,这样用户不用往上滚到开头」。
|
|
1924
|
+
*
|
|
1925
|
+
* 和单帧模式那条的区别:单帧只有一条回答,所以它对齐的是 0(页面顶端);
|
|
1926
|
+
* 聊天模式有一串历史,要对齐的是**最新那条回答自己的开头**。
|
|
1927
|
+
*
|
|
1928
|
+
* 回答分两个阶段,各对齐一次(记号记在 pinnedAnswerKey 里):
|
|
1929
|
+
*
|
|
1930
|
+
* · **正在流**(live 非空)——对齐 `.live` 的开头,之后**不再跟底部**。
|
|
1931
|
+
* 这是关键的一条:流式每秒重画好几次,要是继续黏底,用户刚滚到开头
|
|
1932
|
+
* 就被拽回末尾,等于没改。
|
|
1933
|
+
*
|
|
1934
|
+
* · **落定**(timestamp 变了)——对齐最后那条 `.said` 的开头,
|
|
1935
|
+
* 让最终版从第一行开始读。
|
|
1936
|
+
*
|
|
1937
|
+
* 为什么要记 key、而不是「每次都对齐」:用户往下读的时候,每次重画都把他拽回
|
|
1938
|
+
* 开头,比不对齐还烦人。这一点和单帧模式里 scrolledReplyAt 的道理是一样的。
|
|
1939
|
+
*/
|
|
1940
|
+
function scrollChat(stick) {
|
|
1941
|
+
var liveNow = state.live || '';
|
|
1942
|
+
var newest = liveNow
|
|
1943
|
+
? 'live'
|
|
1944
|
+
: (state.latest && state.latest.timestamp ? 'done:' + state.latest.timestamp : '');
|
|
1945
|
+
if (newest && newest !== pinnedAnswerKey) {
|
|
1946
|
+
// 先记账,再决定动不动他。记完就不再重复处理这条回答——
|
|
1947
|
+
// 用户往下读的时候被反复拉回开头,比不对齐还烦人。
|
|
1948
|
+
pinnedAnswerKey = newest;
|
|
1949
|
+
// **只在「他本来就在底部跟着看」的时候才动他。**
|
|
1950
|
+
//
|
|
1951
|
+
// 这一条不能省:用户 2026-09-22 实机提过「有动画的时候手机端页面运动到上面
|
|
1952
|
+
// 都会自动调回底部,这种机制没必要吧」。他要是自己翻上去看历史了,
|
|
1953
|
+
// 我们把他拽到回答开头,和当初拽回底部是同一类冒犯。
|
|
1954
|
+
//
|
|
1955
|
+
// 注意 stick 是**改 innerHTML 之前**问的(见 atBottom 的注释),
|
|
1956
|
+
// 所以它答的是「他刚才在不在底部」,正是这里要问的。
|
|
1957
|
+
if (!stick) return;
|
|
1958
|
+
var target = null;
|
|
1959
|
+
if (liveNow) {
|
|
1960
|
+
target = replyEl.querySelector ? replyEl.querySelector('.live') : null;
|
|
1961
|
+
} else if (replyEl.querySelectorAll) {
|
|
1962
|
+
var saids = replyEl.querySelectorAll('.said');
|
|
1963
|
+
target = saids.length ? saids[saids.length - 1] : null;
|
|
1964
|
+
}
|
|
1965
|
+
pinToTop(target);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
// 没有新回答的时候照旧黏底(历史重排、切会话、用户自己发指令这些),
|
|
1969
|
+
// 但**流式期间不跟**——那正是要停在开头的时候。
|
|
1970
|
+
if (stick && !liveNow) mainEl.scrollTop = mainEl.scrollHeight;
|
|
1352
1971
|
}
|
|
1353
1972
|
|
|
1354
1973
|
function render() {
|
|
@@ -1361,10 +1980,26 @@
|
|
|
1361
1980
|
// 正在流出来的那一段。服务端只在自己决定要显示之后才写它,所以这里是空字符串
|
|
1362
1981
|
// 不代表模型没在写,只代表「还不到显示的时候」。
|
|
1363
1982
|
if ('live' in snap) state.live = snap.live || '';
|
|
1983
|
+
// 模型此刻的自言自语(鲸鱼娘的台词)。null = 这会儿没话说,气泡回到该姿势自己的词条。
|
|
1984
|
+
if ('thought' in snap) state.thought = snap.thought || null;
|
|
1364
1985
|
if (snap.history) state.history = snap.history;
|
|
1986
|
+
// 「服务端正去读这个会话的完整记录」。点开一个没看过的会话时,那一串要读一会儿
|
|
1987
|
+
// 才补上来(最大的那条日志 20 MB、约半秒),这期间不能装作「这个会话是空的」。
|
|
1988
|
+
state.historyLoading = snap.historyLoading === true;
|
|
1989
|
+
// 「这份历史只到最近一段」。服务端读历史有上限(会话太大只读日志尾部),
|
|
1990
|
+
// 所以内容可能是截断的——**这件事必须跟着内容一起传上来**,不能只在服务端知道。
|
|
1991
|
+
state.historyTruncated = snap.historyTruncated === true;
|
|
1992
|
+
state.historyNote = snap.historyNote || '';
|
|
1365
1993
|
if (snap.sessions) state.sessions = snap.sessions;
|
|
1366
1994
|
if (Array.isArray(snap.queued)) state.queued = snap.queued;
|
|
1367
|
-
if ('boundSessionId' in snap)
|
|
1995
|
+
if ('boundSessionId' in snap) {
|
|
1996
|
+
var wasBound = state.boundSessionId;
|
|
1997
|
+
state.boundSessionId = snap.boundSessionId;
|
|
1998
|
+
// 换了会话,指令菜单那份缓存就作废了(指令表是每个会话一份的,这个会话没在跑
|
|
1999
|
+
// 时一条都读不到,见「斜杠指令」那一段)。重画一次,它自己会发现数据不属于
|
|
2000
|
+
// 这个会话、去重取——否则用户点到的是上一个会话的指令,点下去必然被拒。
|
|
2001
|
+
if (state.boundSessionId !== wasBound) paintCmdMenu();
|
|
2002
|
+
}
|
|
1368
2003
|
// running 也在快照里,而且算的是**当前绑定的那个会话**。不接的话,
|
|
1369
2004
|
// 在导航栏切到别的会话时,那个会话会继承上一个会话的「正在执行」。
|
|
1370
2005
|
if ('running' in snap) setRunning(snap.running);
|
|
@@ -1411,20 +2046,27 @@
|
|
|
1411
2046
|
}
|
|
1412
2047
|
|
|
1413
2048
|
function renderNav() {
|
|
2049
|
+
// 目录浏览那一屏接管整个面板。它自己画返回按钮,所以不走下面这套。
|
|
2050
|
+
if (navView === 'browse') { renderBrowse(); return; }
|
|
1414
2051
|
var list = navList || [];
|
|
2052
|
+
// 「新建工作区」常驻在最上面:它是这一屏的入口,不该等有了会话才出现——
|
|
2053
|
+
// 一个工作区都还没有的人,恰恰最需要它。
|
|
2054
|
+
var cta = '<button class="nav-cta" type="button" data-new-ws="1">+ 新建工作区</button>';
|
|
1415
2055
|
if (!list.length) {
|
|
1416
|
-
$('navBody').innerHTML = '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
2056
|
+
$('navBody').innerHTML = cta + '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
1417
2057
|
+ '在电脑上开一个,这里就会出现了。</div>';
|
|
1418
2058
|
return;
|
|
1419
2059
|
}
|
|
1420
|
-
$('navBody').innerHTML = list.map(function (ws) {
|
|
2060
|
+
$('navBody').innerHTML = cta + list.map(function (ws) {
|
|
1421
2061
|
var open = Boolean(navExpanded[ws.id]);
|
|
1422
2062
|
return '<div class="ws' + (open ? ' open' : '') + '" data-ws="' + escapeHtml(ws.id) + '">'
|
|
1423
2063
|
+ '<button class="ws-head" type="button">'
|
|
1424
2064
|
+ '<span class="caret">▶</span>'
|
|
1425
2065
|
+ '<span class="ws-title">' + escapeHtml(ws.title) + '</span>'
|
|
1426
2066
|
+ (ws.running ? '<span class="s-dot"></span>' : '')
|
|
1427
|
-
|
|
2067
|
+
// 空工作区(通常是刚在手机上建的那个)不写「0」,写「还没有会话」:
|
|
2068
|
+
// 一个孤零零的 0 看着像出了错,说清楚反而让人知道下一步该干什么。
|
|
2069
|
+
+ '<span class="ws-count">' + (ws.count || (ws.empty ? '新' : 0)) + '</span>'
|
|
1428
2070
|
+ '</button>'
|
|
1429
2071
|
+ '<div class="ws-body">' + (open ? sessionsHtml(ws.id) : '') + '</div>'
|
|
1430
2072
|
+ '</div>';
|
|
@@ -1432,11 +2074,15 @@
|
|
|
1432
2074
|
}
|
|
1433
2075
|
|
|
1434
2076
|
function sessionsHtml(wsId) {
|
|
2077
|
+
// 「新建会话」永远排在最上面:不管这个工作区有几个会话、甚至一个都没有,
|
|
2078
|
+
// 这一行都在。放在列表末尾的话,一个 452 条会话的工作区要划到底才看得到。
|
|
2079
|
+
var html = '<button class="ws-new" type="button" data-newws="' + escapeHtml(wsId) + '">'
|
|
2080
|
+
+ '+ 新建会话</button>';
|
|
1435
2081
|
var got = navSessions[wsId];
|
|
1436
|
-
if (!got) return '<div class="placeholder">正在读取…</div>';
|
|
1437
|
-
if (!got.sessions.length) return '<div class="placeholder">这个工作区还没有会话。</div>';
|
|
2082
|
+
if (!got) return html + '<div class="placeholder">正在读取…</div>';
|
|
2083
|
+
if (!got.sessions.length) return html + '<div class="placeholder">这个工作区还没有会话。</div>';
|
|
1438
2084
|
var cur = state.boundSessionId;
|
|
1439
|
-
|
|
2085
|
+
html += got.sessions.map(function (s) {
|
|
1440
2086
|
// 没有标题就用日期——原来退到取 id 的前 8 位当名字,而本机真有一个会话的 id
|
|
1441
2087
|
// 以「session-」开头,于是整列都显示成一样的「session-」,等于没有名字。
|
|
1442
2088
|
return '<button class="sess' + (s.id === cur ? ' cur' : '') + '" type="button"'
|
|
@@ -1453,6 +2099,200 @@
|
|
|
1453
2099
|
return html;
|
|
1454
2100
|
}
|
|
1455
2101
|
|
|
2102
|
+
// ---------------- 新建工作区:浏览电脑目录 ----------------
|
|
2103
|
+
//
|
|
2104
|
+
// 面板里有两个「屏」:工作区树(默认)和目录浏览。用一个变量记当前在哪一屏,
|
|
2105
|
+
// 而不是再开一层浮层——手机屏幕就这么大,多一层就多一次「怎么退回去」。
|
|
2106
|
+
//
|
|
2107
|
+
// 起点给「常用位置」(主目录 / 最近用过 / 磁盘),不是直接甩盘符:
|
|
2108
|
+
// 从 C:\ 一层层点进去找一个项目文件夹,是要人自己知道路径长什么样。
|
|
2109
|
+
var navView = 'tree';
|
|
2110
|
+
var browseRoots = null; // 常用位置,开一次取一次
|
|
2111
|
+
var browseAt = null; // 当前所在目录;null = 还在看常用位置
|
|
2112
|
+
var browseData = null; // 当前目录的列表
|
|
2113
|
+
var browseMkdir = false; // 是不是正在新建文件夹(那一行输入框露出来没有)
|
|
2114
|
+
var browseBusy = false; // 防连点
|
|
2115
|
+
|
|
2116
|
+
function dirName(p) {
|
|
2117
|
+
var parts = String(p).split(/[\\/]/).filter(Boolean);
|
|
2118
|
+
return parts.length ? parts[parts.length - 1] : String(p);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function openBrowse() {
|
|
2122
|
+
navView = 'browse';
|
|
2123
|
+
browseAt = null;
|
|
2124
|
+
browseData = null;
|
|
2125
|
+
browseMkdir = false;
|
|
2126
|
+
renderNav();
|
|
2127
|
+
if (browseRoots) return;
|
|
2128
|
+
api('/mini/api/browse/roots').then(function (body) {
|
|
2129
|
+
browseRoots = body;
|
|
2130
|
+
renderNav();
|
|
2131
|
+
}).catch(function (err) {
|
|
2132
|
+
browseRoots = null;
|
|
2133
|
+
$('navBody').innerHTML = '<button class="nav-cta" type="button" data-browse-back="1">← 返回</button>'
|
|
2134
|
+
+ '<div class="placeholder">读不到常用位置:' + escapeHtml(err.message) + '</div>';
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function browseTo(p) {
|
|
2139
|
+
navView = 'browse';
|
|
2140
|
+
browseAt = p;
|
|
2141
|
+
browseData = null;
|
|
2142
|
+
browseMkdir = false;
|
|
2143
|
+
renderNav();
|
|
2144
|
+
api('/mini/api/browse?path=' + encodeURIComponent(p)).then(function (body) {
|
|
2145
|
+
browseData = body;
|
|
2146
|
+
renderNav();
|
|
2147
|
+
}).catch(function (err) {
|
|
2148
|
+
browseData = { ok: false, error: err.message };
|
|
2149
|
+
renderNav();
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** 目录浏览那一屏。路径来自服务端,这里只负责画。 */
|
|
2154
|
+
function renderBrowse() {
|
|
2155
|
+
var html = '<button class="nav-cta" type="button" data-browse-back="1">← 返回</button>';
|
|
2156
|
+
|
|
2157
|
+
if (!browseAt) {
|
|
2158
|
+
if (!browseRoots) {
|
|
2159
|
+
$('navBody').innerHTML = html + '<div class="placeholder">正在读取…</div>';
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
// 顺序有讲究:主目录(最常用)→ 最近用过(第二常用)→ 磁盘(兜底)。
|
|
2163
|
+
// 磁盘排最后,因为「从盘符一层层点」正是我们要避免的那种走法。
|
|
2164
|
+
var rows = [{ name: '主目录', path: browseRoots.home, tag: '常用' }];
|
|
2165
|
+
(browseRoots.recent || []).forEach(function (p) {
|
|
2166
|
+
rows.push({ name: dirName(p), path: p, tag: '最近' });
|
|
2167
|
+
});
|
|
2168
|
+
(browseRoots.drives || []).forEach(function (p) {
|
|
2169
|
+
rows.push({ name: p, path: p, tag: '磁盘' });
|
|
2170
|
+
});
|
|
2171
|
+
html += '<div class="nav-note">挑一个文件夹,登记成工作区。</div>';
|
|
2172
|
+
html += rows.map(function (r) {
|
|
2173
|
+
return '<button class="sess" type="button" data-browse-to="' + escapeHtml(r.path) + '">'
|
|
2174
|
+
+ '<span class="s-title">' + escapeHtml(r.name) + '</span>'
|
|
2175
|
+
+ '<span class="s-meta">' + escapeHtml(r.tag) + '</span>'
|
|
2176
|
+
+ '</button>';
|
|
2177
|
+
}).join('');
|
|
2178
|
+
$('navBody').innerHTML = html;
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
if (!browseData) {
|
|
2183
|
+
$('navBody').innerHTML = html + '<div class="placeholder">正在读取…</div>';
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (!browseData.ok) {
|
|
2187
|
+
// 读不到时**如实说**,不装作空目录——两者在界面上长得一模一样,
|
|
2188
|
+
// 而「没有权限」和「本来就是空的」对用户是两件事。
|
|
2189
|
+
$('navBody').innerHTML = html + '<div class="crumb"><span class="here">'
|
|
2190
|
+
+ escapeHtml(dirName(browseAt)) + '</span></div>'
|
|
2191
|
+
+ '<div class="placeholder">' + escapeHtml(browseData.error || '读不到这个目录。') + '</div>';
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// 面包屑:能一路点回上去。最后一格是当前目录,不再可点。
|
|
2196
|
+
var chain = browseData.ancestors || [];
|
|
2197
|
+
html += '<div class="crumb">' + chain.map(function (a, i) {
|
|
2198
|
+
var last = i === chain.length - 1;
|
|
2199
|
+
var piece = last
|
|
2200
|
+
? '<span class="here">' + escapeHtml(a.name) + '</span>'
|
|
2201
|
+
: '<button type="button" data-browse-to="' + escapeHtml(a.path) + '">'
|
|
2202
|
+
+ escapeHtml(a.name) + '</button><span class="sep">›</span>';
|
|
2203
|
+
return piece;
|
|
2204
|
+
}).join('') + '</div>';
|
|
2205
|
+
|
|
2206
|
+
html += '<button class="pick-here" type="button" data-pick="' + escapeHtml(browseData.path) + '">'
|
|
2207
|
+
+ '就选这个目录</button>';
|
|
2208
|
+
|
|
2209
|
+
if (browseMkdir) {
|
|
2210
|
+
// 内联输入,不用 window.prompt:prompt 在微信这类内嵌浏览器里会被直接
|
|
2211
|
+
// 屏蔽掉,那时候用户点「新建文件夹」什么都不发生,而且没有任何提示。
|
|
2212
|
+
html += '<div class="mkdir-row">'
|
|
2213
|
+
+ '<input id="mkdirName" type="text" placeholder="新文件夹的名字" autocomplete="off">'
|
|
2214
|
+
+ '<button type="button" data-mkdir-ok="1">建</button>'
|
|
2215
|
+
+ '<button type="button" data-mkdir-cancel="1">取消</button>'
|
|
2216
|
+
+ '</div>';
|
|
2217
|
+
} else {
|
|
2218
|
+
html += '<button class="ws-new" type="button" data-mkdir="1">+ 新建文件夹</button>';
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
if (!browseData.dirs.length) {
|
|
2222
|
+
html += '<div class="placeholder">这个目录下面没有子目录。</div>';
|
|
2223
|
+
} else {
|
|
2224
|
+
html += browseData.dirs.map(function (d) {
|
|
2225
|
+
return '<button class="sess" type="button" data-browse-to="' + escapeHtml(d.path) + '">'
|
|
2226
|
+
+ '<span class="s-title">' + escapeHtml(d.name) + '</span>'
|
|
2227
|
+
+ '</button>';
|
|
2228
|
+
}).join('');
|
|
2229
|
+
}
|
|
2230
|
+
if (browseData.truncated) {
|
|
2231
|
+
html += '<div class="nav-note">只列了前 ' + browseData.dirs.length + ' 个,共 '
|
|
2232
|
+
+ browseData.total + ' 个。</div>';
|
|
2233
|
+
}
|
|
2234
|
+
$('navBody').innerHTML = html;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* 「← 返回」:一级一级往外退,退法要跟用户心里的层级对上。
|
|
2239
|
+
* 在某个目录里 → 退回常用位置那一屏(往上几级有面包屑,不用返回键代劳)
|
|
2240
|
+
* 已经在常用位置 → 退回工作区树
|
|
2241
|
+
* 不这么做的话,从深层目录点返回会直接跳回工作区树,用户想换个盘得重新走一遍。
|
|
2242
|
+
*/
|
|
2243
|
+
function closeBrowse() {
|
|
2244
|
+
if (browseAt) {
|
|
2245
|
+
browseAt = null;
|
|
2246
|
+
browseData = null;
|
|
2247
|
+
browseMkdir = false;
|
|
2248
|
+
renderNav();
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
navView = 'tree';
|
|
2252
|
+
renderNav();
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
function registerWorkspace(p) {
|
|
2256
|
+
if (browseBusy) return;
|
|
2257
|
+
browseBusy = true;
|
|
2258
|
+
api('/mini/api/workspaces', { method: 'POST', body: JSON.stringify({ path: p }) })
|
|
2259
|
+
.then(function (res) {
|
|
2260
|
+
browseBusy = false;
|
|
2261
|
+
navView = 'tree';
|
|
2262
|
+
browseAt = null;
|
|
2263
|
+
browseData = null;
|
|
2264
|
+
// 服务端把登记后的最新列表一起带回来了,直接用——不用再补一趟请求,
|
|
2265
|
+
// 也就不会出现「建好了但列表里还没有」。
|
|
2266
|
+
if (res && res.workspaces) navList = res.workspaces;
|
|
2267
|
+
navSessions = {};
|
|
2268
|
+
renderNav();
|
|
2269
|
+
toast(res && res.created
|
|
2270
|
+
? '已登记《' + res.workspace.title + '》'
|
|
2271
|
+
: '《' + res.workspace.title + '》本来就在列表里');
|
|
2272
|
+
})
|
|
2273
|
+
.catch(function (err) {
|
|
2274
|
+
browseBusy = false;
|
|
2275
|
+
toast(err.message);
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
function makeFolder() {
|
|
2280
|
+
var input = $('mkdirName');
|
|
2281
|
+
var name = input ? input.value : '';
|
|
2282
|
+
if (browseBusy) return;
|
|
2283
|
+
browseBusy = true;
|
|
2284
|
+
api('/mini/api/browse/mkdir', { method: 'POST', body: JSON.stringify({ path: browseAt, name: name }) })
|
|
2285
|
+
.then(function (res) {
|
|
2286
|
+
browseBusy = false;
|
|
2287
|
+
browseMkdir = false;
|
|
2288
|
+
browseTo(res.path);
|
|
2289
|
+
})
|
|
2290
|
+
.catch(function (err) {
|
|
2291
|
+
browseBusy = false;
|
|
2292
|
+
toast(err.message);
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
|
|
1456
2296
|
function toggleWorkspace(wsId) {
|
|
1457
2297
|
if (navExpanded[wsId]) {
|
|
1458
2298
|
delete navExpanded[wsId];
|
|
@@ -1474,6 +2314,49 @@
|
|
|
1474
2314
|
});
|
|
1475
2315
|
}
|
|
1476
2316
|
|
|
2317
|
+
// 防连点。建会话**不是幂等的**——点两下就在电脑上真建出两个会话来,
|
|
2318
|
+
// 而手机上什么都看不出来(第二个只是把列表撑长了一行)。所以这里必须自己拦。
|
|
2319
|
+
// 服务端那道闸门拦不住这个:两个请求都是合法的。
|
|
2320
|
+
var creatingSession = false;
|
|
2321
|
+
|
|
2322
|
+
function createSession(wsId) {
|
|
2323
|
+
if (creatingSession) return;
|
|
2324
|
+
creatingSession = true;
|
|
2325
|
+
Array.prototype.forEach.call($('navBody').querySelectorAll('.ws-new'), function (el) {
|
|
2326
|
+
el.disabled = true;
|
|
2327
|
+
});
|
|
2328
|
+
function done() {
|
|
2329
|
+
creatingSession = false;
|
|
2330
|
+
Array.prototype.forEach.call($('navBody').querySelectorAll('.ws-new'), function (el) {
|
|
2331
|
+
el.disabled = false;
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
// POST 到工作区会话那条路径上:GET 是「列出来」,POST 是「建一个」。
|
|
2335
|
+
// 服务端建完会顺手 bind 并把新快照一起返回,所以这里一次请求就完成了
|
|
2336
|
+
// 「建 + 切过去」,不用再补一次 bind。
|
|
2337
|
+
//
|
|
2338
|
+
// **要把 promise 返回出去**:起了异步动作就该让调用方等得到它。
|
|
2339
|
+
// 不返回的话外面拿到的是 undefined,接不上任何后续。
|
|
2340
|
+
return api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions', {
|
|
2341
|
+
method: 'POST',
|
|
2342
|
+
body: '{}',
|
|
2343
|
+
}).then(function (res) {
|
|
2344
|
+
done();
|
|
2345
|
+
// 接口把绑定后的快照一起给了,直接渲染——不这样的话,切过去的一瞬间
|
|
2346
|
+
// 屏幕上还留着上一个会话的正文。
|
|
2347
|
+
if (res && res.state) applySnapshot(res.state);
|
|
2348
|
+
else render();
|
|
2349
|
+
closeNav();
|
|
2350
|
+
toast('已在《' + ((res && res.workspaceTitle) || '工作区') + '》里建好新会话');
|
|
2351
|
+
// 让列表里立刻出现这条新会话。force 是为了绕过服务端那份 30 秒的缓存——
|
|
2352
|
+
// 刚建的会话它还不知道,不绕过的话切回来会「咦刚才那条呢」。
|
|
2353
|
+
loadWorkspaces(true);
|
|
2354
|
+
}).catch(function (err) {
|
|
2355
|
+
done();
|
|
2356
|
+
toast(err.message);
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
|
|
1477
2360
|
function bindSession(id) {
|
|
1478
2361
|
if (!id) return;
|
|
1479
2362
|
api('/mini/api/bind', { method: 'POST', body: JSON.stringify({ sessionId: id }) })
|
|
@@ -1506,8 +2389,32 @@
|
|
|
1506
2389
|
$('navBody').addEventListener('click', function (e) {
|
|
1507
2390
|
var t = e.target;
|
|
1508
2391
|
var sess = t && t.closest ? t.closest('.sess') : null;
|
|
1509
|
-
if (sess) {
|
|
1510
|
-
|
|
2392
|
+
if (sess) {
|
|
2393
|
+
// 目录浏览那一屏里的 .sess 是「进这个目录」,不是「切到这个会话」。
|
|
2394
|
+
// 两屏共用同一个条目样式,所以要先看当前在哪一屏。
|
|
2395
|
+
if (navView === 'browse') { browseTo(sess.getAttribute('data-browse-to')); return; }
|
|
2396
|
+
bindSession(sess.getAttribute('data-sess'));
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
var at = function (sel) { return t && t.closest ? t.closest(sel) : null; };
|
|
2400
|
+
|
|
2401
|
+
var newWs = at('[data-new-ws]');
|
|
2402
|
+
if (newWs) { openBrowse(); return; }
|
|
2403
|
+
var back = at('[data-browse-back]');
|
|
2404
|
+
if (back) { closeBrowse(); return; }
|
|
2405
|
+
var to = at('[data-browse-to]');
|
|
2406
|
+
if (to) { browseTo(to.getAttribute('data-browse-to')); return; }
|
|
2407
|
+
var pick = at('[data-pick]');
|
|
2408
|
+
if (pick) { registerWorkspace(pick.getAttribute('data-pick')); return; }
|
|
2409
|
+
if (at('[data-mkdir]')) { browseMkdir = true; renderNav(); return; }
|
|
2410
|
+
if (at('[data-mkdir-cancel]')) { browseMkdir = false; renderNav(); return; }
|
|
2411
|
+
if (at('[data-mkdir-ok]')) { makeFolder(); return; }
|
|
2412
|
+
|
|
2413
|
+
// 「新建会话」要排在 ws-head 之前判:它在 ws-body 里面、不是 ws-head,
|
|
2414
|
+
// 顺序上无所谓,但放在前面更清楚——这一支是动作,那一支是展开/收起。
|
|
2415
|
+
var fresh = at('.ws-new');
|
|
2416
|
+
if (fresh) { createSession(fresh.getAttribute('data-newws')); return; }
|
|
2417
|
+
var head = at('.ws-head');
|
|
1511
2418
|
if (!head) return;
|
|
1512
2419
|
toggleWorkspace(head.parentNode.getAttribute('data-ws'));
|
|
1513
2420
|
});
|
|
@@ -1550,9 +2457,127 @@
|
|
|
1550
2457
|
}
|
|
1551
2458
|
setRunning(false);
|
|
1552
2459
|
render();
|
|
1553
|
-
if (!document.hidden) {
|
|
1554
|
-
|
|
1555
|
-
|
|
2460
|
+
if (!document.hidden) {
|
|
2461
|
+
playDing(); buzz();
|
|
2462
|
+
} else {
|
|
2463
|
+
// 页面在后台时,响铃和震动都叫不醒人——那两样只在页面看得见的时候有用。
|
|
2464
|
+
//
|
|
2465
|
+
// 原来这里指望系统通知兜底。2026-09-25 试到底了:**锁屏时系统会冻结后台页面**,
|
|
2466
|
+
// 实时连接断掉,这段代码根本轮不到执行。不锁屏的后台(切到别的应用)它倒是能用,
|
|
2467
|
+
// 但用户试过之后决定浏览器里不做这个功能了,理由和处置见下面「页面在后台时提醒」。
|
|
2468
|
+
//
|
|
2469
|
+
// 调用留着:开关不露出来了,state.notify 就永远是 false,这个函数会立刻返回。
|
|
2470
|
+
notifySystem(payload.text);
|
|
2471
|
+
}
|
|
2472
|
+
});
|
|
2473
|
+
// ——— 回答电脑的提问 ———
|
|
2474
|
+
//
|
|
2475
|
+
// 电脑那边提问时,插件把题目推一条 `question` 过来,答完 POST 回 /mini/api/answer。
|
|
2476
|
+
// 三条是用户定下的:一个一个答(答完这题才出下一题)、点错了能回头改、能自己打字。
|
|
2477
|
+
let ask = null
|
|
2478
|
+
function askRender() {
|
|
2479
|
+
if (!ask) return
|
|
2480
|
+
const q = ask.questions[ask.i]
|
|
2481
|
+
$('askHead').textContent = ask.questions.length > 1
|
|
2482
|
+
? '电脑问你第 ' + (ask.i + 1) + ' 题,一共 ' + ask.questions.length + ' 题'
|
|
2483
|
+
: '电脑在问你一件事'
|
|
2484
|
+
$('askText').textContent = q.question || ''
|
|
2485
|
+
$('askDetail').textContent = q.detail || ''
|
|
2486
|
+
$('askDetail').style.display = q.detail ? '' : 'none'
|
|
2487
|
+
const picked = ask.picked[ask.i] || { selected: [], custom: '' }
|
|
2488
|
+
const box = $('askOpts')
|
|
2489
|
+
box.innerHTML = ''
|
|
2490
|
+
const options = Array.isArray(q.options) ? q.options : []
|
|
2491
|
+
options.forEach(function (o) {
|
|
2492
|
+
const btn = document.createElement('button')
|
|
2493
|
+
btn.type = 'button'
|
|
2494
|
+
btn.className = 'opt' + (picked.selected.indexOf(o.label) >= 0 ? ' sel' : '')
|
|
2495
|
+
const name = document.createElement('span')
|
|
2496
|
+
name.textContent = o.label
|
|
2497
|
+
btn.appendChild(name)
|
|
2498
|
+
if (o.description) {
|
|
2499
|
+
const sub = document.createElement('small')
|
|
2500
|
+
sub.textContent = o.description
|
|
2501
|
+
btn.appendChild(sub)
|
|
2502
|
+
}
|
|
2503
|
+
btn.addEventListener('click', function () {
|
|
2504
|
+
// 允许多选的题就点一下选中、再点一下取消;单选的点谁就是谁。
|
|
2505
|
+
if (q.multiSelect) {
|
|
2506
|
+
const at = picked.selected.indexOf(o.label)
|
|
2507
|
+
if (at >= 0) picked.selected.splice(at, 1)
|
|
2508
|
+
else picked.selected.push(o.label)
|
|
2509
|
+
} else {
|
|
2510
|
+
picked.selected = [o.label]
|
|
2511
|
+
}
|
|
2512
|
+
// 选了选项就清掉"我自己说"——两者只留一个,免得电脑收到含糊的答案。
|
|
2513
|
+
picked.custom = ''
|
|
2514
|
+
ask.picked[ask.i] = picked
|
|
2515
|
+
askRender()
|
|
2516
|
+
})
|
|
2517
|
+
box.appendChild(btn)
|
|
2518
|
+
})
|
|
2519
|
+
$('askCustomWrap').style.display = ask.custom ? '' : 'none'
|
|
2520
|
+
$('askCustom').value = picked.custom || ''
|
|
2521
|
+
$('askBack').style.display = ask.i > 0 ? '' : 'none'
|
|
2522
|
+
$('askNext').textContent = ask.i === ask.questions.length - 1 ? '答好了,发回电脑' : '下一题'
|
|
2523
|
+
$('askCard').classList.add('on')
|
|
2524
|
+
}
|
|
2525
|
+
$('askOwn').addEventListener('click', function () {
|
|
2526
|
+
if (!ask) return
|
|
2527
|
+
ask.custom = !ask.custom
|
|
2528
|
+
$('askCustomWrap').style.display = ask.custom ? '' : 'none'
|
|
2529
|
+
if (ask.custom) $('askCustom').focus()
|
|
2530
|
+
})
|
|
2531
|
+
$('askCustom').addEventListener('input', function () {
|
|
2532
|
+
if (!ask) return
|
|
2533
|
+
const picked = ask.picked[ask.i] || { selected: [], custom: '' }
|
|
2534
|
+
picked.custom = $('askCustom').value
|
|
2535
|
+
picked.selected = []
|
|
2536
|
+
ask.picked[ask.i] = picked
|
|
2537
|
+
askRender()
|
|
2538
|
+
})
|
|
2539
|
+
$('askBack').addEventListener('click', function () {
|
|
2540
|
+
if (!ask || ask.i === 0) return
|
|
2541
|
+
ask.i -= 1
|
|
2542
|
+
askRender()
|
|
2543
|
+
})
|
|
2544
|
+
$('askNext').addEventListener('click', function () {
|
|
2545
|
+
if (!ask) return
|
|
2546
|
+
const picked = ask.picked[ask.i] || { selected: [], custom: '' }
|
|
2547
|
+
if (!picked.selected.length && !(picked.custom || '').trim()) {
|
|
2548
|
+
toast('先选一个,或者点「我自己说」写一句')
|
|
2549
|
+
return
|
|
2550
|
+
}
|
|
2551
|
+
if (ask.i < ask.questions.length - 1) {
|
|
2552
|
+
ask.i += 1
|
|
2553
|
+
askRender()
|
|
2554
|
+
return
|
|
2555
|
+
}
|
|
2556
|
+
// 最后一题答完:把所有题的答案一起交回去。
|
|
2557
|
+
const answers = ask.questions.map(function (q, k) {
|
|
2558
|
+
const one = ask.picked[k] || { selected: [], custom: '' }
|
|
2559
|
+
const answer = { id: q.id, selected: one.selected.slice() }
|
|
2560
|
+
if ((one.custom || '').trim()) answer.custom = one.custom.trim()
|
|
2561
|
+
return answer
|
|
2562
|
+
})
|
|
2563
|
+
// 三种结果说三句不同的话。**没成事就不收卡片**:收掉了就再也没地方说这句话,
|
|
2564
|
+
// 用户看到的只会是「点了没反应」。
|
|
2565
|
+
sendAnswer(ask.id, answers).then(function (verdict) {
|
|
2566
|
+
if (verdict === 'failed') {
|
|
2567
|
+
toast('没送出去,看一眼和电脑的连接,再点一次')
|
|
2568
|
+
return
|
|
2569
|
+
}
|
|
2570
|
+
ask = null
|
|
2571
|
+
$('askCard').classList.remove('on')
|
|
2572
|
+
if (verdict === 'late') toast('这道题已经交回电脑了,去电脑上答')
|
|
2573
|
+
})
|
|
2574
|
+
})
|
|
2575
|
+
es.addEventListener('question', function (e) {
|
|
2576
|
+
let payload = null
|
|
2577
|
+
try { payload = JSON.parse(e.data) } catch (err) { return }
|
|
2578
|
+
if (!payload || !payload.id || !Array.isArray(payload.questions) || !payload.questions.length) return
|
|
2579
|
+
ask = { id: payload.id, questions: payload.questions, i: 0, picked: {}, custom: false }
|
|
2580
|
+
askRender()
|
|
1556
2581
|
});
|
|
1557
2582
|
es.addEventListener('status', function (e) {
|
|
1558
2583
|
try { setRunning(JSON.parse(e.data).running); } catch (err) {}
|
|
@@ -1571,6 +2596,183 @@
|
|
|
1571
2596
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';
|
|
1572
2597
|
}
|
|
1573
2598
|
|
|
2599
|
+
// ---------------- 斜杠指令(在输入框里打 / 时弹出来的菜单) ----------------
|
|
2600
|
+
//
|
|
2601
|
+
// 手机上敲 `/` 要能看见这个会话有哪些指令、点一下就执行。这一块管三件事:
|
|
2602
|
+
// 菜单怎么出(paintCmdMenu)、点一条怎么处理(pickCommand)、执行本身
|
|
2603
|
+
// (runCommand,下面 send 那条路也用它)。
|
|
2604
|
+
//
|
|
2605
|
+
// 数据**按会话缓存**,理由见 state 里 commands* 那几个字段。
|
|
2606
|
+
//
|
|
2607
|
+
// 输入框本来的提示语。选了一条「要参数」的指令时它会被换成那句 hint,发出去之后
|
|
2608
|
+
// 要换回来——留着上一个指令的参数提示,下一个普通问题就顶着一句不相干的话。
|
|
2609
|
+
var INPUT_HINT = inputEl.placeholder || '';
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* 输入框里那个「还没打完的指令」。
|
|
2613
|
+
*
|
|
2614
|
+
* 命中时返回 `/` 后面那一段(只打了一个 `/` 就是空串),没命中返回 null。
|
|
2615
|
+
* 判据收得很紧:**整行**必须正好是「/ 加一串小写字母数字下划线横线」。出现空格、
|
|
2616
|
+
* 前面多了别的字、用了大写,都算没命中——菜单收起来,交给 send 里那条分流规则。
|
|
2617
|
+
*
|
|
2618
|
+
* 只认小写是因为 DSH 的指令名就是小写:把大写也当命中,用户会看到「打了没反应」
|
|
2619
|
+
* (过滤出零条 → 菜单收起),而真实原因只是他开着大写。
|
|
2620
|
+
*/
|
|
2621
|
+
function slashQuery() {
|
|
2622
|
+
var m = /^\/([a-z0-9_-]*)$/.exec(inputEl.value);
|
|
2623
|
+
return m ? m[1] : null;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function hideCmdMenu() {
|
|
2627
|
+
var el = $('cmdMenu');
|
|
2628
|
+
if (!el) return;
|
|
2629
|
+
el.hidden = true;
|
|
2630
|
+
el.innerHTML = '';
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
/** 这个会话的指令表是不是还没有(或者需要重取)。 */
|
|
2634
|
+
function needCommands() {
|
|
2635
|
+
// 换了会话就得重取,见 state 里 commands* 的说明。
|
|
2636
|
+
if (state.commandsFor !== state.boundSessionId) return true;
|
|
2637
|
+
// 这个会话还什么都没拿到、也没失败过——第一次用到它的时候去取。
|
|
2638
|
+
return !state.commands && !state.commandsError;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function loadCommands() {
|
|
2642
|
+
// 正在取就别再发一次:菜单是跟着输入框每敲一个字重画的,不拦着的话
|
|
2643
|
+
// 打「/compact」会连发八个请求。
|
|
2644
|
+
if (state.commandsLoading) return;
|
|
2645
|
+
state.commandsLoading = true;
|
|
2646
|
+
// 记下这份数据是**给哪个会话**取的,而不是「请求是什么时候发的」——
|
|
2647
|
+
// 请求飞在路上时用户完全可能已经切走了,回来那一份就不作数了。
|
|
2648
|
+
var forSession = state.boundSessionId;
|
|
2649
|
+
state.commands = null;
|
|
2650
|
+
state.commandsError = '';
|
|
2651
|
+
api('/mini/api/commands').then(function (res) {
|
|
2652
|
+
state.commandsLoading = false;
|
|
2653
|
+
if (forSession !== state.boundSessionId) return; // 不是这个会话的,扔掉
|
|
2654
|
+
state.commandsFor = forSession;
|
|
2655
|
+
if (res && res.ok) {
|
|
2656
|
+
state.commands = res.commands || [];
|
|
2657
|
+
state.commandsError = '';
|
|
2658
|
+
} else {
|
|
2659
|
+
// 服务端给了原因(多半是「这个会话现在没在跑」)就原样留着显示。
|
|
2660
|
+
// **不能显示成空列表**:那等于说「这个会话没有指令」,是另一回事。
|
|
2661
|
+
state.commandsError = (res && res.error) || '读不到指令列表';
|
|
2662
|
+
}
|
|
2663
|
+
paintCmdMenu();
|
|
2664
|
+
}).catch(function (err) {
|
|
2665
|
+
state.commandsLoading = false;
|
|
2666
|
+
if (forSession !== state.boundSessionId) return;
|
|
2667
|
+
state.commandsFor = forSession;
|
|
2668
|
+
state.commands = null;
|
|
2669
|
+
state.commandsError = err.message || '读不到指令列表';
|
|
2670
|
+
paintCmdMenu();
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/**
|
|
2675
|
+
* 画(或收起)那只菜单。
|
|
2676
|
+
*
|
|
2677
|
+
* 它**每回都按输入框重算**,自己不记「现在是开着还是关着」:这样就不会出现
|
|
2678
|
+
* 「菜单和输入框对不上」的状态——比如程序清空输入框(点菜单、发完之后)不会触发
|
|
2679
|
+
* input 事件,那种时候没人替我们收菜单,只能自己出面对齐。
|
|
2680
|
+
*/
|
|
2681
|
+
function paintCmdMenu() {
|
|
2682
|
+
var el = $('cmdMenu');
|
|
2683
|
+
if (!el) return;
|
|
2684
|
+
var prefix = slashQuery();
|
|
2685
|
+
if (prefix === null) { hideCmdMenu(); return; }
|
|
2686
|
+
var wasOpen = !el.hidden;
|
|
2687
|
+
// 上次没取到时,**每次重新打开可以再试一次**(关着→打开这一下才重取,不是每敲一个
|
|
2688
|
+
// 字都取)。这一条是必须的:失败的原因多半是「这个会话现在没在跑」,而用户完全可能
|
|
2689
|
+
// 随后就发了条消息让它跑起来——那时再打 /,看到的却是上一句已经过时的话,
|
|
2690
|
+
// 他会以为这个功能坏了。
|
|
2691
|
+
if (needCommands() || (!wasOpen && state.commandsError)) loadCommands();
|
|
2692
|
+
el.hidden = false;
|
|
2693
|
+
if (state.commandsError) {
|
|
2694
|
+
el.innerHTML = '<div class="cmd-note">' + escapeHtml(state.commandsError) + '</div>';
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
if (!state.commands) {
|
|
2698
|
+
// 取回来之前先占个位——不然敲了 / 什么都不发生,看着像坏了。
|
|
2699
|
+
el.innerHTML = '<div class="cmd-note">正在读取可用的指令…</div>';
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
var rows = state.commands.filter(function (c) {
|
|
2703
|
+
return String(c.name || '').indexOf(prefix) === 0;
|
|
2704
|
+
});
|
|
2705
|
+
// 一条都对不上就收起来,**不写「没有匹配的指令」**:用户还在打字,打到一半没有
|
|
2706
|
+
// 匹配是常事,插一句提示只是噪音。真打错了指令,按发送时会被明确拒绝并说明。
|
|
2707
|
+
if (!rows.length) { hideCmdMenu(); return; }
|
|
2708
|
+
el.innerHTML = rows.map(function (c) {
|
|
2709
|
+
return '<button class="cmd-row" type="button" data-cmd="' + escapeHtml(c.name) + '">'
|
|
2710
|
+
+ '<span class="cmd-name">/' + escapeHtml(c.name) + '</span>'
|
|
2711
|
+
+ '<span class="cmd-desc">' + escapeHtml(c.description || '') + '</span></button>';
|
|
2712
|
+
}).join('');
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/**
|
|
2716
|
+
* 菜单里点了一条。
|
|
2717
|
+
*
|
|
2718
|
+
* 分两路,判据是服务端给的 hint(这条指令要不要参数):
|
|
2719
|
+
* · 不要参数 —— 直接执行。让用户再按一次发送是白白多一步。
|
|
2720
|
+
* · 要参数 —— 把 `/名字 ` 填进输入框,末尾留一个空格、光标停在后面,并把
|
|
2721
|
+
* hint 那句话挂到输入框的 placeholder 上。
|
|
2722
|
+
* **不自己编一句「请输入参数」**:只有服务端知道这条指令要什么,
|
|
2723
|
+
* 编一句就等于让用户照着一个错的提示去填。
|
|
2724
|
+
*/
|
|
2725
|
+
function pickCommand(name) {
|
|
2726
|
+
var list = state.commands || [];
|
|
2727
|
+
var cmd = null;
|
|
2728
|
+
for (var i = 0; i < list.length; i++) { if (list[i].name === name) cmd = list[i]; }
|
|
2729
|
+
if (!cmd) return;
|
|
2730
|
+
hideCmdMenu();
|
|
2731
|
+
// 先把提示语还原,再按这一条决定要不要挂 hint——不然上一次点的那条「要参数」的
|
|
2732
|
+
// 指令留下的提示,会跟到这一条(不需要参数的)头上。
|
|
2733
|
+
inputEl.placeholder = INPUT_HINT;
|
|
2734
|
+
if (cmd.hint) {
|
|
2735
|
+
inputEl.value = '/' + cmd.name + ' ';
|
|
2736
|
+
inputEl.placeholder = cmd.hint;
|
|
2737
|
+
inputEl.focus();
|
|
2738
|
+
// 光标停在最后:停在名字中间的话,用户接着打的字会插进名字里去。
|
|
2739
|
+
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
|
|
2740
|
+
autoGrow();
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
inputEl.value = '';
|
|
2744
|
+
autoGrow();
|
|
2745
|
+
runCommand('/' + cmd.name);
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* 执行一条斜杠指令。
|
|
2750
|
+
*
|
|
2751
|
+
* 和普通消息走的是**两条不同的接口**:/mini/api/command 收的是完整的一行(含 /
|
|
2752
|
+
* 和参数),/mini/api/send 收的是「发给模型的一句话」。手机这边不替用户猜他打的是
|
|
2753
|
+
* 哪一个——猜错的代价是一次真花掉额度的提问,而且电脑端对不存在的指令是明确拒绝的,
|
|
2754
|
+
* 两边行为得一致。
|
|
2755
|
+
*
|
|
2756
|
+
* 接口只回一个 ok:这条指令**存不存在、跑得怎么样,都不在这里**。开始和结果由会话
|
|
2757
|
+
* 事件推回来(历史里出现 role:'command' 的条目,kind 从 running 变 success/error)。
|
|
2758
|
+
* 所以这里既不做乐观显示(会跟推来的那条重一份),也不报喜——只把「当场被拒」
|
|
2759
|
+
* 这一种情况说出来。
|
|
2760
|
+
*/
|
|
2761
|
+
function runCommand(line) {
|
|
2762
|
+
api('/mini/api/command', { method: 'POST', body: JSON.stringify({ line: line }) })
|
|
2763
|
+
.then(function (res) {
|
|
2764
|
+
if (res && res.ok) return;
|
|
2765
|
+
// 被拒(没有这条指令 / 语法不对)的原因必须让用户看见,不能悄悄咽掉。
|
|
2766
|
+
toast((res && res.error) || '这条指令没跑起来');
|
|
2767
|
+
})
|
|
2768
|
+
.catch(function (err) { toast(err.message || '这条指令没跑起来'); });
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
$('cmdMenu').addEventListener('click', function (e) {
|
|
2772
|
+
var btn = e.target && e.target.closest ? e.target.closest('[data-cmd]') : null;
|
|
2773
|
+
if (btn) pickCommand(btn.getAttribute('data-cmd'));
|
|
2774
|
+
});
|
|
2775
|
+
|
|
1574
2776
|
// ---------------- 上传附件 ----------------
|
|
1575
2777
|
/** 把字节数说成人话。 */
|
|
1576
2778
|
function humanSize(n) {
|
|
@@ -1654,6 +2856,27 @@
|
|
|
1654
2856
|
mainEl.scrollTop = mainEl.scrollHeight;
|
|
1655
2857
|
inputEl.value = '';
|
|
1656
2858
|
autoGrow();
|
|
2859
|
+
// 输入框空了,菜单得当场收起来:清空是程序改的 value,不会触发 input 事件,
|
|
2860
|
+
// 没人替我们重算,菜单就会挂在那儿配着一个空输入框。
|
|
2861
|
+
hideCmdMenu();
|
|
2862
|
+
inputEl.placeholder = INPUT_HINT;
|
|
2863
|
+
|
|
2864
|
+
// **以 / 打头的走指令那条路,不再当普通消息发。**
|
|
2865
|
+
//
|
|
2866
|
+
// 这是用户拍板的:手机上打了一条不存在的指令,要跟电脑端一样被明确拒绝并说明原因,
|
|
2867
|
+
// 不能悄悄当成一次提问发给模型——那既让两边行为不一致,又等于把一次敲错变成一次
|
|
2868
|
+
// 真花掉额度的提问。
|
|
2869
|
+
//
|
|
2870
|
+
// 存不存在由服务端判(这边的指令表可能是缓存的旧值),所以这里不看列表,
|
|
2871
|
+
// 只认「trim 之后第一个字符是不是 /」。
|
|
2872
|
+
if (text.charAt(0) === '/') {
|
|
2873
|
+
// 附件跟不过去:指令接口收的只有那一行(见 runCommand 的说明)。
|
|
2874
|
+
// 这里**不动 state.uploads**——清掉就等于把用户刚传上来的文件悄悄扔了,
|
|
2875
|
+
// 而他还以为跟着发出去了。留在小条上看得见,想发就配一句话再发一次。
|
|
2876
|
+
if (files.length) toast('指令带不了附件,附件还留在下面');
|
|
2877
|
+
runCommand(text);
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
1657
2880
|
|
|
1658
2881
|
var uploadIds = files.map(function (f) { return f.uploadId; });
|
|
1659
2882
|
// 手机上先把附件收起来:小票已经交出去了,留着会让用户以为还能再发一次。
|
|
@@ -1726,6 +2949,9 @@
|
|
|
1726
2949
|
|
|
1727
2950
|
$('btnSend').addEventListener('click', send);
|
|
1728
2951
|
inputEl.addEventListener('input', autoGrow);
|
|
2952
|
+
// 每敲一个字重算一次菜单。它自己会先看「是不是一个没打完的 / 名字」再决定动不动,
|
|
2953
|
+
// 所以这里不用加判据。
|
|
2954
|
+
inputEl.addEventListener('input', paintCmdMenu);
|
|
1729
2955
|
inputEl.addEventListener('keydown', function (e) {
|
|
1730
2956
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
1731
2957
|
e.preventDefault();
|
|
@@ -1756,6 +2982,118 @@
|
|
|
1756
2982
|
$('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
|
|
1757
2983
|
sheet.addEventListener('click', function (e) { if (e.target === sheet) sheet.classList.remove('open'); });
|
|
1758
2984
|
|
|
2985
|
+
// ---------------- 权限档位 ----------------
|
|
2986
|
+
//
|
|
2987
|
+
// 这是把 PC 端那道授权闸门搬到了手机上:**手机从此能指挥 Agent 把电脑提到
|
|
2988
|
+
// 完全权限**。闸门还在(要 token / 密码),但它守的那扇门换了一扇——
|
|
2989
|
+
// 拿到手机的人等于拿到了这台电脑的写权限。这是功能本身的代价,不是瑕疵。
|
|
2990
|
+
//
|
|
2991
|
+
// 所以「完全权限」那一档要点两次。用页面内的两步确认、不用 window.confirm:
|
|
2992
|
+
// 那种弹窗在微信这类内嵌浏览器里会被直接屏蔽,而在屏蔽的情况下万一返回
|
|
2993
|
+
// 真值就等于确认被跳过了——不安全的方向上不能赌。
|
|
2994
|
+
//
|
|
2995
|
+
// 档位名和标签都是**服务端读出来再给过来的**:有哪些档位、什么顺序、哪个要
|
|
2996
|
+
// 确认,这一侧一个都不写死。服务端那边负责把标识符(read-only 这类 DSH 自己的
|
|
2997
|
+
// 键名)翻成中文——服务给的 optionOf() 只回键名,三个中文是 DSH 网页界面里的
|
|
2998
|
+
// 客户端翻译,服务里没有,所以那张表放在 lib/permissions.js。
|
|
2999
|
+
// 映射不到时会退回标识符本身,所以这里拿到的一定是个真名字,不会是空格。
|
|
3000
|
+
// 页面只管显示拿到的东西。
|
|
3001
|
+
var permOptions = null; // null = 还没读到(或这台电脑没这个能力)
|
|
3002
|
+
var permArmed = ''; // 哪一档正在等「再点一次」
|
|
3003
|
+
|
|
3004
|
+
function loadPermissions() {
|
|
3005
|
+
api('/mini/api/permissions').then(function (body) {
|
|
3006
|
+
if (!body || !body.ok) { $('permBlock').hidden = true; return; }
|
|
3007
|
+
permOptions = body;
|
|
3008
|
+
renderPerms();
|
|
3009
|
+
}).catch(function () {
|
|
3010
|
+
// 读不到就整块不显示。附加能力缺了,不该影响发指令。
|
|
3011
|
+
permOptions = null;
|
|
3012
|
+
$('permBlock').hidden = true;
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
function renderPerms() {
|
|
3017
|
+
if (!permOptions) return;
|
|
3018
|
+
$('permBlock').hidden = false;
|
|
3019
|
+
var seg = $('segPerm');
|
|
3020
|
+
seg.innerHTML = (permOptions.options || []).map(function (o) {
|
|
3021
|
+
var cls = [];
|
|
3022
|
+
if (o.value === permOptions.currentValue) cls.push('active');
|
|
3023
|
+
if (o.value === permArmed) cls.push('armed');
|
|
3024
|
+
return '<button type="button" data-perm="' + escapeHtml(o.value) + '"'
|
|
3025
|
+
+ (cls.length ? ' class="' + cls.join(' ') + '"' : '') + '>'
|
|
3026
|
+
+ escapeHtml(o.value === permArmed ? '再点一次:' + o.name : o.name)
|
|
3027
|
+
+ '</button>';
|
|
3028
|
+
}).join('');
|
|
3029
|
+
$('permHint').textContent = permHintText();
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
/**
|
|
3033
|
+
* 提示语。
|
|
3034
|
+
*
|
|
3035
|
+
* 服务没给说明文字时**不硬编一句**——本机三个档位的 description 全是空的。
|
|
3036
|
+
* 那就在这一行显示出当前档位的名字(这个名字是真的,来自服务端),
|
|
3037
|
+
* 而不是编一段「Agent 只能读」这种我没法保证准确的话。
|
|
3038
|
+
* 「现在在哪一档」主要由按钮的高亮表示,这一行只是补充。
|
|
3039
|
+
*/
|
|
3040
|
+
function permHintText() {
|
|
3041
|
+
if (!permOptions) return '';
|
|
3042
|
+
var cur = (permOptions.options || []).filter(function (o) {
|
|
3043
|
+
return o.value === permOptions.currentValue;
|
|
3044
|
+
})[0];
|
|
3045
|
+
if (!cur) return '作用在当前会话上';
|
|
3046
|
+
return cur.description || ('当前:' + cur.name);
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
function tapPerm(name) {
|
|
3050
|
+
if (!permOptions) return;
|
|
3051
|
+
// 点的就是当前这一档:什么都不做。不这么做的话,一次误触会往会话日志里
|
|
3052
|
+
// 记一条没有意义的切换。
|
|
3053
|
+
if (name === permOptions.currentValue) return;
|
|
3054
|
+
var opt = (permOptions.options || []).filter(function (o) { return o.value === name; })[0];
|
|
3055
|
+
if (!opt) return;
|
|
3056
|
+
|
|
3057
|
+
if (opt.dangerous && permArmed !== name) {
|
|
3058
|
+
permArmed = name;
|
|
3059
|
+
renderPerms();
|
|
3060
|
+
toast('再点一次「' + opt.name + '」就切过去');
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
permArmed = '';
|
|
3064
|
+
api('/mini/api/permissions', { method: 'POST', body: JSON.stringify({ name: name }) })
|
|
3065
|
+
.then(function (body) {
|
|
3066
|
+
if (!body || !body.ok) return;
|
|
3067
|
+
// 服务端把切换后的最新状态一起回来了,直接重画,不用再补一趟。
|
|
3068
|
+
permOptions = body;
|
|
3069
|
+
renderPerms();
|
|
3070
|
+
toast('已切到「' + opt.name + '」');
|
|
3071
|
+
})
|
|
3072
|
+
.catch(function (err) {
|
|
3073
|
+
permArmed = '';
|
|
3074
|
+
renderPerms();
|
|
3075
|
+
toast(err.message);
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
loadPermissions();
|
|
3080
|
+
|
|
3081
|
+
// 档位按钮的点击。
|
|
3082
|
+
//
|
|
3083
|
+
// 监听挂在**容器**上,不挂在按钮上——`renderPerms()` 每次都整块换 innerHTML,
|
|
3084
|
+
// 挂按钮上的监听会被下一次重画冲掉。挂在容器上就不受影响。
|
|
3085
|
+
//
|
|
3086
|
+
// 这个坑真机踩到过:三档看得见、点下去毫无反应,因为 `tapPerm` 一次都没被调用。
|
|
3087
|
+
// 单元测试没抓住它,是因为测试直接调 `tapPerm` 验逻辑,而真机上缺的是**接线**——
|
|
3088
|
+
// 逻辑对、没人喊它。
|
|
3089
|
+
$('segPerm').addEventListener('click', function (e) {
|
|
3090
|
+
var btn = e.target;
|
|
3091
|
+
// 按钮里现在只有文字,点到的就是按钮本身;这个循环是防将来往里塞图标。
|
|
3092
|
+
while (btn && btn !== this && !(btn.dataset && btn.dataset.perm)) btn = btn.parentNode;
|
|
3093
|
+
if (!btn || btn === this) return;
|
|
3094
|
+
tapPerm(btn.dataset.perm);
|
|
3095
|
+
});
|
|
3096
|
+
|
|
1759
3097
|
Array.prototype.forEach.call($('segMode').children, function (btn) {
|
|
1760
3098
|
btn.addEventListener('click', function () {
|
|
1761
3099
|
state.mode = btn.dataset.mode;
|
|
@@ -1783,18 +3121,145 @@
|
|
|
1783
3121
|
|
|
1784
3122
|
// 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
|
|
1785
3123
|
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
3124
|
+
// ---------------- 锁屏也能提醒(系统通知) ----------------
|
|
3125
|
+
//
|
|
3126
|
+
// 页内的响铃和震动只在页面看得见的时候有用。人把手机放下、屏幕锁了,那两样都
|
|
3127
|
+
// 叫不醒他——要叫醒他只能发系统通知。
|
|
3128
|
+
//
|
|
3129
|
+
// 这条路以前是关着的,三层原因叠在一起:
|
|
3130
|
+
// 1. 通知权限只能在「安全上下文」(HTTPS 或 localhost)里申请。当时三条连接
|
|
3131
|
+
// 路径全是明文 HTTP,浏览器连这个 API 都不给;
|
|
1789
3132
|
// 2. 就算拿到权限,new Notification() 在大多数移动浏览器上会抛 TypeError,
|
|
1790
|
-
//
|
|
3133
|
+
// 移动端唯一正确的入口是 service worker 的 showNotification;
|
|
1791
3134
|
// 3. service worker 同样要求 HTTPS。
|
|
3135
|
+
// 2026-09-25 Tailscale 那条路接上 HTTPS,第 1、3 条同时成立,这条路才通。
|
|
1792
3136
|
//
|
|
1793
|
-
//
|
|
1794
|
-
//
|
|
1795
|
-
// 电脑端的外部推送(Bark / ntfy),它是从服务器发出去的,不受浏览器这条规矩限制。
|
|
3137
|
+
// **所以这一行只在加密连接下露出来。** 明文下露出来,用户点一下只会碰一鼻子灰
|
|
3138
|
+
// ——那正是 2026-09-21 把它整行撤掉的理由,那条理由现在仍然成立。
|
|
1796
3139
|
//
|
|
1797
|
-
//
|
|
3140
|
+
// 电脑端的外部推送(Bark / ntfy / Telegram)是另一条路:从服务器发出去,不经
|
|
3141
|
+
// 浏览器,明文连接下也照常能用。两者不冲突,各管一段。
|
|
3142
|
+
var NOTIFY_OK = window.isSecureContext
|
|
3143
|
+
&& 'serviceWorker' in navigator
|
|
3144
|
+
&& 'Notification' in window;
|
|
3145
|
+
|
|
3146
|
+
function notifySystem(text) {
|
|
3147
|
+
if (!state.notify || !NOTIFY_OK) return;
|
|
3148
|
+
if (Notification.permission !== 'granted') return;
|
|
3149
|
+
navigator.serviceWorker.ready.then(function (reg) {
|
|
3150
|
+
return reg.showNotification('回复到了', {
|
|
3151
|
+
body: String(text || '').replace(/\s+/g, ' ').trim().slice(0, 120),
|
|
3152
|
+
// 带上 tag:同一条回复重复到达时替换掉旧的,别堆一屏。
|
|
3153
|
+
tag: 'dsh-reply',
|
|
3154
|
+
renotify: true,
|
|
3155
|
+
// 地址由页面给。不能拿 service worker 自己的 location 拼——那是那个 .js
|
|
3156
|
+
// 文件的地址,用户点通知会打开一份源码。
|
|
3157
|
+
data: { url: location.href },
|
|
3158
|
+
});
|
|
3159
|
+
}).catch(function () { /* 发不出去就算了,不该影响主流程 */ });
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
function syncNotify() {
|
|
3163
|
+
Array.prototype.forEach.call($('segNotify').children, function (b) {
|
|
3164
|
+
b.classList.toggle('active', (b.dataset.notify === 'on') === state.notify);
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
if (NOTIFY_OK) {
|
|
3169
|
+
// 先注册 service worker。**这一步不申请权限**——权限要用户主动点一下才给,
|
|
3170
|
+
// 页面一加载就弹窗会被浏览器直接拒掉,而且很讨厌。
|
|
3171
|
+
//
|
|
3172
|
+
// 注册成功才把那一行露出来。注册不上就继续藏着:露一个点了没用的开关,
|
|
3173
|
+
// 比没有更让人困惑。
|
|
3174
|
+
//
|
|
3175
|
+
// **2026-09-25 用户裁决:这一行不露出来了。**
|
|
3176
|
+
//
|
|
3177
|
+
// 试到底了:手机锁屏后系统冻结后台页面,那条实时连接跟着断掉,回复根本到不了页面,
|
|
3178
|
+
// 也就没人去发通知。这是手机系统的调度,插件绕不过去;service worker 也救不了——
|
|
3179
|
+
// 它是被事件唤醒的,没有事件它就不存在。
|
|
3180
|
+
//
|
|
3181
|
+
// 用户原话:「类似功能我们以后考虑做成 APP 时再加,浏览器里不放了。」
|
|
3182
|
+
//
|
|
3183
|
+
// **代码全部留着,只是不露出来。** service worker 照常注册(已经验证过在加密连接下
|
|
3184
|
+
// 能正常工作),通知那段逻辑照常挂在回复到达的分支里。将来做 App 里的锁屏提醒,
|
|
3185
|
+
// 这里的知识直接能用。
|
|
3186
|
+
//
|
|
3187
|
+
// 要重新打开:把下面两行取消注释。**但先把名字想清楚**——别再用「锁屏也能提醒」
|
|
3188
|
+
// 这种它做不到的说法,那是这一轮踩过的坑。
|
|
3189
|
+
navigator.serviceWorker.register('/mini/sw.js?v=' + BUILD).catch(function () {});
|
|
3190
|
+
// $('rowNotify').style.display = '';
|
|
3191
|
+
// $('notifyHint').textContent = notifyHintText();
|
|
3192
|
+
// syncNotify();
|
|
3193
|
+
|
|
3194
|
+
// 浏览器现在给的是什么状态,就说什么话。
|
|
3195
|
+
//
|
|
3196
|
+
// **不能只说「打开时会问一次」**——2026-09-25 用户点了「开」,浏览器什么都没问,
|
|
3197
|
+
// 界面上也一点变化都没有,看上去就是「点了没用」。真实原因是这个网站的通知权限
|
|
3198
|
+
// 早就被拒过,而 requestPermission() 在这种情况下**不会再弹**,直接回 denied。
|
|
3199
|
+
// 所以每一种状态都得有对应的一句话,用户才能自己判断下一步该干什么。
|
|
3200
|
+
function notifyHintText() {
|
|
3201
|
+
var p = Notification.permission;
|
|
3202
|
+
if (p === 'granted') return '回复到了发一条系统通知';
|
|
3203
|
+
if (p === 'denied') return '权限被拒过,浏览器不会再问。去浏览器设置里允许,或清掉本站数据后重新扫码';
|
|
3204
|
+
return '打开时浏览器会问一次要不要允许';
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
// 申请通知权限。**两种写法都要兼容**:新浏览器返回 Promise,老一点的和一些厂商
|
|
3208
|
+
// 定制内核只认回调式——直接 .then() 会抛,表现同样是「点了没反应」。
|
|
3209
|
+
// 回调式在所有支持这个 API 的浏览器上都会被调用(包括那些同时支持 Promise 的),
|
|
3210
|
+
// 所以以它为主,Promise 只当兜底。
|
|
3211
|
+
function askNotifyPermission() {
|
|
3212
|
+
var settled = false;
|
|
3213
|
+
function settle(perm) {
|
|
3214
|
+
if (settled) return;
|
|
3215
|
+
settled = true;
|
|
3216
|
+
if (perm !== 'granted') {
|
|
3217
|
+
$('notifyHint').textContent = '浏览器没给通知权限(现在是「' + (perm || '没反应')
|
|
3218
|
+
+ '」)。去浏览器设置里允许,或清掉本站数据后重新扫码。';
|
|
3219
|
+
syncNotify();
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
3222
|
+
state.notify = true;
|
|
3223
|
+
localStorage.setItem('dshMiniNotify', 'on');
|
|
3224
|
+
$('notifyHint').textContent = notifyHintText();
|
|
3225
|
+
syncNotify();
|
|
3226
|
+
}
|
|
3227
|
+
try {
|
|
3228
|
+
var r = Notification.requestPermission(settle);
|
|
3229
|
+
if (r && typeof r.then === 'function') r.then(settle).catch(function () {});
|
|
3230
|
+
} catch (err) {
|
|
3231
|
+
$('notifyHint').textContent = '这个浏览器问不出通知权限。要去浏览器的设置里手动开。';
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
// 有些内核两条路都不回来。等三秒给个交代,别让用户对着一个没反应的开关发呆。
|
|
3235
|
+
setTimeout(function () {
|
|
3236
|
+
if (!settled) {
|
|
3237
|
+
$('notifyHint').textContent = '浏览器没弹权限询问(现在是「' + Notification.permission
|
|
3238
|
+
+ '」)。去浏览器设置里允许,或清掉本站数据后重新扫码。';
|
|
3239
|
+
}
|
|
3240
|
+
}, 3000);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
$('segNotify').addEventListener('click', function (e) {
|
|
3244
|
+
var btn = e.target.closest('button');
|
|
3245
|
+
if (!btn) return;
|
|
3246
|
+
if (btn.dataset.notify !== 'on') {
|
|
3247
|
+
state.notify = false;
|
|
3248
|
+
localStorage.setItem('dshMiniNotify', 'off');
|
|
3249
|
+
syncNotify();
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
// 已经允许过了就别再问——问了也不会弹,只会让用户以为坏了。
|
|
3253
|
+
if (Notification.permission === 'granted') {
|
|
3254
|
+
state.notify = true;
|
|
3255
|
+
localStorage.setItem('dshMiniNotify', 'on');
|
|
3256
|
+
$('notifyHint').textContent = notifyHintText();
|
|
3257
|
+
syncNotify();
|
|
3258
|
+
return;
|
|
3259
|
+
}
|
|
3260
|
+
askNotifyPermission();
|
|
3261
|
+
});
|
|
3262
|
+
}
|
|
1798
3263
|
|
|
1799
3264
|
$('btnReconnect').addEventListener('click', function () {
|
|
1800
3265
|
connect();
|
|
@@ -1885,6 +3350,204 @@
|
|
|
1885
3350
|
// 任何一次触摸都顺手解锁音频,避免用户第一次收到回复时没声音。
|
|
1886
3351
|
document.addEventListener('touchstart', unlockAudio, { once: true });
|
|
1887
3352
|
document.addEventListener('click', unlockAudio, { once: true });
|
|
3353
|
+
|
|
3354
|
+
// -------------------------------------------------------------------------
|
|
3355
|
+
// 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)
|
|
3356
|
+
//
|
|
3357
|
+
// 顶栏那一行显示当前用的模型,点它弹出面板可以改。强度按当前模型自己声明的
|
|
3358
|
+
// 档位长;模型不支持调强度时那一行不出现。
|
|
3359
|
+
//
|
|
3360
|
+
// **位置很重要**:必须写在这个外层函数**里面**——`state` 是它内部的变量,
|
|
3361
|
+
// 写到 `})();` 后面就看不见了,结果会是「永远不显示」而且一句错都不报。
|
|
3362
|
+
// 另外**不能再开一个 script 块**:这个页面的测试用贪婪正则抽取脚本,
|
|
3363
|
+
// 多一个块就会把中间的 HTML 整段当成 JS 解析。这个坑踩过一次,别再来一次。
|
|
3364
|
+
// -------------------------------------------------------------------------
|
|
3365
|
+
;(function () {
|
|
3366
|
+
var bar = document.getElementById('modelBar')
|
|
3367
|
+
var text = document.getElementById('modelBarText')
|
|
3368
|
+
if (!bar || !text) return
|
|
3369
|
+
|
|
3370
|
+
var catalog = null // 服务端给的模型目录
|
|
3371
|
+
var current = null // 服务端记的当前选择 {provider, model, reasoningEffort}
|
|
3372
|
+
// 刚改完、服务端还没确认的那个值。为什么要多这一个:服务端记的「当前模型」是在
|
|
3373
|
+
// **下一次请求**时才更新的,所以改完立刻回头去读,读到的还是旧名字。先信
|
|
3374
|
+
// set-model 的返回值,等服务端追上了再让它接管。
|
|
3375
|
+
var pending = null
|
|
3376
|
+
var busy = false // 防止连点两下发出两个互相打架的请求
|
|
3377
|
+
|
|
3378
|
+
// ---- 顶栏 ------------------------------------------------------------
|
|
3379
|
+
function groupName(id) {
|
|
3380
|
+
var gs = (catalog && catalog.groups) || []
|
|
3381
|
+
for (var i = 0; i < gs.length; i++) { if (gs[i].id === id) return gs[i].name }
|
|
3382
|
+
return id || ''
|
|
3383
|
+
}
|
|
3384
|
+
function sel() { return pending || current || null }
|
|
3385
|
+
|
|
3386
|
+
function renderBar() {
|
|
3387
|
+
var s = sel()
|
|
3388
|
+
if (!s || !s.model) {
|
|
3389
|
+
// 问不到这个会话用哪个模型时,如实说一句——既不装作没这个功能,也不编一个。
|
|
3390
|
+
// 点它还照样能打开面板挑一个,挑完就写得进去。
|
|
3391
|
+
text.textContent = '模型:还没问到'
|
|
3392
|
+
bar.style.display = 'flex'
|
|
3393
|
+
return
|
|
3394
|
+
}
|
|
3395
|
+
// 来源名要用 DSH 给的那个(「DeepSeek」「opencode go」「mimo」),不是内部 id
|
|
3396
|
+
var parts = [groupName(s.provider), s.model]
|
|
3397
|
+
if (s.reasoningEffort) parts.push('思考 ' + s.reasoningEffort)
|
|
3398
|
+
text.textContent = parts.join(' · ')
|
|
3399
|
+
bar.style.display = 'flex'
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// ---- 选择面板 --------------------------------------------------------
|
|
3403
|
+
function el(tag, css, txt) {
|
|
3404
|
+
var e = document.createElement(tag)
|
|
3405
|
+
if (css) e.style.cssText = css
|
|
3406
|
+
if (txt != null) e.textContent = txt
|
|
3407
|
+
return e
|
|
3408
|
+
}
|
|
3409
|
+
var ROW = 'display:block;width:100%;text-align:left;background:none;border:0;font:inherit;'
|
|
3410
|
+
+ 'color:inherit;padding:11px 2px;border-bottom:1px solid var(--line,#333)'
|
|
3411
|
+
var HEAD = 'padding:14px 2px 4px;font-size:12px;color:var(--dim,#888)'
|
|
3412
|
+
var CHIP = 'margin:0 6px 6px 0;padding:5px 13px;border:1px solid var(--line,#444);'
|
|
3413
|
+
+ 'border-radius:999px;background:none;font:inherit;color:inherit;font-size:13px'
|
|
3414
|
+
|
|
3415
|
+
// 面板用代码建出来、挂在 body 末尾,而不是写进页面结构里——这样这一整块的
|
|
3416
|
+
// 改动全部落在脚本内部,不用动 HTML,也就不会碰到别的部分。
|
|
3417
|
+
var mask = el('div', 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:69;display:none')
|
|
3418
|
+
var sheet = el('div', 'position:fixed;left:0;right:0;bottom:0;z-index:70;display:none;'
|
|
3419
|
+
+ 'max-height:72vh;overflow-y:auto;background:var(--bg,#1b1b1b);'
|
|
3420
|
+
+ 'border-top:1px solid var(--line,#444);border-radius:14px 14px 0 0;'
|
|
3421
|
+
+ 'padding:10px 14px calc(16px + env(safe-area-inset-bottom))')
|
|
3422
|
+
document.body.appendChild(mask)
|
|
3423
|
+
document.body.appendChild(sheet)
|
|
3424
|
+
|
|
3425
|
+
function renderSheet() {
|
|
3426
|
+
sheet.textContent = ''
|
|
3427
|
+
var s = sel() || {}
|
|
3428
|
+
sheet.appendChild(el('div', 'font-size:13px;color:var(--dim,#888);padding:2px 2px 6px',
|
|
3429
|
+
'这个会话用哪个模型'))
|
|
3430
|
+
if (!catalog) { sheet.appendChild(el('div', 'padding:12px 2px', '还没读到模型列表。')); return }
|
|
3431
|
+
|
|
3432
|
+
var groups = catalog.groups || []
|
|
3433
|
+
for (var gi = 0; gi < groups.length; gi++) {
|
|
3434
|
+
var grp = groups[gi]
|
|
3435
|
+
sheet.appendChild(el('div', HEAD, grp.name || grp.id))
|
|
3436
|
+
var models = grp.models || []
|
|
3437
|
+
for (var mi = 0; mi < models.length; mi++) {
|
|
3438
|
+
var mod = models[mi]
|
|
3439
|
+
var isCur = s.provider === grp.id && s.model === mod.id
|
|
3440
|
+
var b = el('button', ROW, (isCur ? '✓ ' : '') + (mod.name || mod.id))
|
|
3441
|
+
if (isCur) b.style.fontWeight = '600'
|
|
3442
|
+
b.addEventListener('click', picker(grp.id, mod.id))
|
|
3443
|
+
sheet.appendChild(b)
|
|
3444
|
+
|
|
3445
|
+
// 强度只在**当前这个模型**下面出现,而且只画它自己声明的档位——DeepSeek 是
|
|
3446
|
+
// off/low/high/max,opencode 和 mimo 是 minimal…max,两套不一样,不能合并成
|
|
3447
|
+
// 一个统一的说法。模型没声明强度时(五十个里有四十三个)这里什么都不画,
|
|
3448
|
+
// 不给一个拖了没反应的控件。
|
|
3449
|
+
var r = mod.reasoning
|
|
3450
|
+
if (isCur && r && r.efforts && r.efforts.length) {
|
|
3451
|
+
var box = el('div', 'padding:8px 2px 12px;border-bottom:1px solid var(--line,#333)')
|
|
3452
|
+
for (var ei = 0; ei < r.efforts.length; ei++) {
|
|
3453
|
+
var ef = r.efforts[ei]
|
|
3454
|
+
var on = s.reasoningEffort === ef.id
|
|
3455
|
+
var c = el('button', CHIP + (on ? ';border-color:currentColor;font-weight:600' : ''),
|
|
3456
|
+
(on ? '✓ ' : '') + (ef.name || ef.id))
|
|
3457
|
+
c.addEventListener('click', picker(grp.id, mod.id, ef.id))
|
|
3458
|
+
box.appendChild(c)
|
|
3459
|
+
}
|
|
3460
|
+
sheet.appendChild(box)
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// 某个来源读不到时如实说出来,不要假装整份目录都是全的
|
|
3466
|
+
var fails = catalog.failures || []
|
|
3467
|
+
for (var fi = 0; fi < fails.length; fi++) {
|
|
3468
|
+
sheet.appendChild(el('div', 'padding:10px 2px;font-size:13px;color:var(--dim,#888)',
|
|
3469
|
+
'⚠ ' + (fails[fi].name || fails[fi].id) + ':' + (fails[fi].message || '读不到')))
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
var close = el('button', ROW + ';border-bottom:0;text-align:center;color:var(--dim,#888)', '关闭')
|
|
3473
|
+
close.addEventListener('click', closeSheet)
|
|
3474
|
+
sheet.appendChild(close)
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
// 把参数用闭包封起来。循环里直接写回调会全都指向最后一项——这是老坑了。
|
|
3478
|
+
function picker(provider, model, effort) {
|
|
3479
|
+
return function () { if (!busy) pick(provider, model, effort) }
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
function pick(provider, model, effort) {
|
|
3483
|
+
var body = { provider: provider, model: model }
|
|
3484
|
+
if (effort) body.reasoningEffort = effort
|
|
3485
|
+
busy = true
|
|
3486
|
+
fetch('/mini/api/set-model?token=' + encodeURIComponent(state.token), {
|
|
3487
|
+
method: 'POST',
|
|
3488
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3489
|
+
body: JSON.stringify(body)
|
|
3490
|
+
})
|
|
3491
|
+
.then(function (r) { return r.json().catch(function () { return null }) })
|
|
3492
|
+
.then(function (d) {
|
|
3493
|
+
busy = false
|
|
3494
|
+
if (!d || !d.ok) { alert('没改成:' + ((d && d.error) || '电脑那边没答应')); return }
|
|
3495
|
+
var v = d.selected || body
|
|
3496
|
+
pending = { provider: v.provider, model: v.model, reasoningEffort: v.reasoningEffort || null }
|
|
3497
|
+
renderBar()
|
|
3498
|
+
renderSheet()
|
|
3499
|
+
})
|
|
3500
|
+
.catch(function () { busy = false; alert('没改成:连不上电脑。') })
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
function openSheet() { renderSheet(); mask.style.display = 'block'; sheet.style.display = 'block' }
|
|
3504
|
+
function closeSheet() { mask.style.display = 'none'; sheet.style.display = 'none' }
|
|
3505
|
+
|
|
3506
|
+
// ---- 读目录 ----------------------------------------------------------
|
|
3507
|
+
function load() {
|
|
3508
|
+
if (!state.token) return
|
|
3509
|
+
fetch('/mini/api/models?token=' + encodeURIComponent(state.token), { cache: 'no-store' })
|
|
3510
|
+
.then(function (r) { return r.ok ? r.json() : null })
|
|
3511
|
+
.then(function (d) {
|
|
3512
|
+
if (!d || !d.ok) return
|
|
3513
|
+
catalog = d
|
|
3514
|
+
// **问不到就说问不到,绝不用目录默认值兜底。**
|
|
3515
|
+
//
|
|
3516
|
+
// 上一版这里写的是「没记下就显示目录默认值」,看着体贴,实际是编的:
|
|
3517
|
+
// 目录那个默认值等于「上一次选过的模型」,于是用户切到一个用别的模型的
|
|
3518
|
+
// 会话,顶栏显示的还是上一个模型——一个像模像样的错答案,比空着坏得多。
|
|
3519
|
+
current = (d.current && d.current.model) ? d.current : null
|
|
3520
|
+
// 服务端追上了就撤掉本地那个临时值,之后一律以服务端的为准
|
|
3521
|
+
if (pending && current && current.provider === pending.provider
|
|
3522
|
+
&& current.model === pending.model) pending = null
|
|
3523
|
+
renderBar()
|
|
3524
|
+
if (sheet.style.display !== 'none') renderSheet()
|
|
3525
|
+
})
|
|
3526
|
+
.catch(function () { /* 读不到就保持原样,不显示错的东西 */ })
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
bar.style.cursor = 'pointer'
|
|
3530
|
+
bar.addEventListener('click', openSheet)
|
|
3531
|
+
mask.addEventListener('click', closeSheet)
|
|
3532
|
+
|
|
3533
|
+
// 手机切了会话要**立刻**跟上,不能等下面那个 15 秒的轮询——那段时间里顶栏显示的
|
|
3534
|
+
// 是上一个会话的模型,看上去就像新会话用错了模型。页面自己把「现在是哪个会话」
|
|
3535
|
+
// 存在 state.boundSessionId 里(状态广播一到就更新),盯着它很便宜。
|
|
3536
|
+
var lastSession = state.boundSessionId || null
|
|
3537
|
+
setInterval(function () {
|
|
3538
|
+
var id = state.boundSessionId || null
|
|
3539
|
+
if (id === lastSession) return
|
|
3540
|
+
lastSession = id
|
|
3541
|
+
pending = null // 上一个会话身上那个临时值,跟这个会话没关系,必须扔掉
|
|
3542
|
+
load()
|
|
3543
|
+
}, 1000)
|
|
3544
|
+
|
|
3545
|
+
load()
|
|
3546
|
+
// 在电脑上改了模型,手机这边也要跟上。**轮询很土,但这里是诚实的做法**:
|
|
3547
|
+
// 显示一个过期的模型名比不显示更糟——用户会以为自己改的没生效。
|
|
3548
|
+
setInterval(function () { if (!document.hidden) load() }, 15000)
|
|
3549
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) load() })
|
|
3550
|
+
})();
|
|
1888
3551
|
})();
|
|
1889
3552
|
</script>
|
|
1890
3553
|
</body>
|