dsh-mini-remote 0.1.0 → 0.1.3
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 +65 -0
- package/README.en.md +154 -0
- package/README.md +75 -59
- package/client/client.js +144 -1
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/history.js +72 -0
- package/lib/index.js +644 -5
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1488 -24
- package/lib/page.js +21 -0
- package/lib/pairing.js +103 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +358 -0
- package/lib/server.js +429 -6
- package/lib/store.js +134 -2
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +6 -5
- 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
|
三样都在说同一句话——「这不是最终答案,还会变」。等答案落定整块被换掉。 */
|
|
@@ -375,6 +397,34 @@
|
|
|
375
397
|
}
|
|
376
398
|
.chip.failed { border-color: var(--danger, #dc2626); }
|
|
377
399
|
|
|
400
|
+
/* ---------- 指令菜单(在输入框里打 / 时弹出来的那个列表) ----------
|
|
401
|
+
排在底栏**上面**,不挡住输入框:手机屏幕就那么大,把它盖住,用户就看不见自己
|
|
402
|
+
正在打的那一行字了——手机上没有悬停,也没有第二个地方能看。 */
|
|
403
|
+
.cmd-menu {
|
|
404
|
+
flex: 0 0 auto; max-height: 42vh; overflow-y: auto;
|
|
405
|
+
-webkit-overflow-scrolling: touch;
|
|
406
|
+
padding: 6px 8px; background: var(--panel); border-top: 1px solid var(--line);
|
|
407
|
+
}
|
|
408
|
+
.cmd-menu[hidden] { display: none; }
|
|
409
|
+
.cmd-menu .cmd-row {
|
|
410
|
+
display: flex; align-items: baseline; gap: 10px; width: 100%;
|
|
411
|
+
padding: 9px 8px; border: none; border-radius: 8px; background: none;
|
|
412
|
+
color: var(--fg); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
|
|
413
|
+
}
|
|
414
|
+
.cmd-menu .cmd-row:active { background: var(--panel-2); }
|
|
415
|
+
.cmd-menu .cmd-name {
|
|
416
|
+
flex: 0 0 auto; color: var(--accent);
|
|
417
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
418
|
+
}
|
|
419
|
+
/* 说明收成一行:这一屏是「扫一眼挑一个」,不是读文档的地方。 */
|
|
420
|
+
.cmd-menu .cmd-desc {
|
|
421
|
+
flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 12.5px;
|
|
422
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
423
|
+
}
|
|
424
|
+
/* 取不到指令时说清原因,**不显示空列表**:空列表看着就像「这个会话没有指令」,
|
|
425
|
+
而真实原因多半是「这个会话现在没在跑」——两件事,用户能做的事也不一样。 */
|
|
426
|
+
.cmd-menu .cmd-note { padding: 10px 8px; color: var(--muted); font-size: 13px; }
|
|
427
|
+
|
|
378
428
|
/* ---------- 左侧导航栏 ---------- */
|
|
379
429
|
#nav {
|
|
380
430
|
position: fixed; inset: 0; z-index: 60; display: none;
|
|
@@ -428,7 +478,71 @@
|
|
|
428
478
|
flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
|
|
429
479
|
background: var(--warn); animation: pulse 1.2s ease-in-out infinite;
|
|
430
480
|
}
|
|
481
|
+
/* 「新建会话」那一行。跟会话条目同宽、同缩进,但用强调色——
|
|
482
|
+
它不是一个会话,是一个动作。长得跟会话一样会让人以为点进去就是那条会话。 */
|
|
483
|
+
.ws-new {
|
|
484
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
485
|
+
padding: 10px 14px 10px 30px; background: none; border: none;
|
|
486
|
+
color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
|
|
487
|
+
}
|
|
488
|
+
.ws-new:active { background: var(--panel-2); }
|
|
489
|
+
.ws-new[disabled] { color: var(--muted); cursor: default; }
|
|
490
|
+
|
|
491
|
+
/* 导航栏最上面那条「新建工作区」。跟工作区条目之间用一条线隔开:
|
|
492
|
+
它是这一屏的入口,不是一个工作区。 */
|
|
493
|
+
.nav-cta {
|
|
494
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
495
|
+
padding: 12px 14px; background: none; border: none; border-bottom: 1px solid var(--line);
|
|
496
|
+
color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
|
|
497
|
+
}
|
|
498
|
+
.nav-cta:active { background: var(--panel-2); }
|
|
499
|
+
|
|
500
|
+
/* 面包屑。横向能划,路径再深也不会把面板撑破。 */
|
|
501
|
+
.crumb {
|
|
502
|
+
display: flex; align-items: center; gap: 2px; padding: 8px 10px;
|
|
503
|
+
overflow-x: auto; white-space: nowrap; border-bottom: 1px solid var(--line);
|
|
504
|
+
}
|
|
505
|
+
.crumb button {
|
|
506
|
+
background: none; border: none; color: var(--accent); font: inherit;
|
|
507
|
+
font-size: 13px; padding: 4px 6px; cursor: pointer; border-radius: 6px;
|
|
508
|
+
}
|
|
509
|
+
.crumb button:active { background: var(--panel-2); }
|
|
510
|
+
.crumb .sep { color: var(--muted); font-size: 12px; }
|
|
511
|
+
.crumb .here { color: var(--fg); font-size: 13px; padding: 4px 6px; }
|
|
512
|
+
|
|
513
|
+
/* 「就选这个目录」——这一屏唯一的主按钮,所以给它实心底。 */
|
|
514
|
+
.pick-here {
|
|
515
|
+
display: block; width: calc(100% - 24px); margin: 10px 12px;
|
|
516
|
+
padding: 11px; border: none; border-radius: var(--radius);
|
|
517
|
+
background: var(--accent); color: #fff; font: inherit; font-size: 14px; cursor: pointer;
|
|
518
|
+
}
|
|
519
|
+
.pick-here:active { opacity: .85; }
|
|
520
|
+
.pick-here[disabled] { background: var(--line); color: var(--muted); cursor: default; }
|
|
521
|
+
|
|
522
|
+
/* 新建文件夹那一行输入。内联而不是 window.prompt:prompt 在微信这类内嵌
|
|
523
|
+
浏览器里会被直接屏蔽,那时候点「新建文件夹」什么都不发生、也没提示。 */
|
|
524
|
+
.mkdir-row { display: flex; gap: 6px; padding: 8px 12px; align-items: center; }
|
|
525
|
+
.mkdir-row input {
|
|
526
|
+
flex: 1 1 auto; min-width: 0; padding: 9px 10px; font: inherit; font-size: 14px;
|
|
527
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
528
|
+
border-radius: 10px; color: var(--fg);
|
|
529
|
+
}
|
|
530
|
+
.mkdir-row button {
|
|
531
|
+
flex: 0 0 auto; padding: 9px 12px; font: inherit; font-size: 13px; cursor: pointer;
|
|
532
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
533
|
+
border-radius: 10px; color: var(--fg);
|
|
534
|
+
}
|
|
535
|
+
.mkdir-row button:active { background: var(--line); }
|
|
536
|
+
|
|
537
|
+
/* 「完全权限」那一档的待确认状态:点一次先变成这样,再点一次才真切。
|
|
538
|
+
跟 .active(当前所在档位)用不同颜色区分——一个是「将要去」,一个是「已在」。 */
|
|
539
|
+
.seg button.armed { background: var(--danger, #c0392b); color: #fff; }
|
|
431
540
|
.nav-note { padding: 10px 16px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
|
541
|
+
/* 「这不是全部」:垫在聊天内容最上面,往上翻到顶才看得见,不抢内容。 */
|
|
542
|
+
.history-note {
|
|
543
|
+
margin: 0 0 14px; padding: 8px 12px; border: 1px dashed var(--line);
|
|
544
|
+
border-radius: 10px; color: var(--muted); font-size: 12.5px; line-height: 1.6;
|
|
545
|
+
}
|
|
432
546
|
|
|
433
547
|
/* ---------- 设置抽屉 ---------- */
|
|
434
548
|
#settings {
|
|
@@ -450,7 +564,10 @@
|
|
|
450
564
|
.row:last-of-type { border-bottom: none; }
|
|
451
565
|
.row .label { font-size: 15px; }
|
|
452
566
|
.row .hint { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
453
|
-
|
|
567
|
+
/* flex-shrink: 0 不能省。它是弹性项,左边的说明文字一长就会被压缩;
|
|
568
|
+
而它自己带着 overflow: hidden,所以被压掉的那一段不是跑到屏幕外,是**被裁掉**——
|
|
569
|
+
2026-09-25 用户截图里那个「关」字就是这么没的。 */
|
|
570
|
+
.seg { display: flex; flex-shrink: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
454
571
|
.seg button {
|
|
455
572
|
padding: 7px 12px; border: none; background: var(--panel-2); color: var(--muted);
|
|
456
573
|
font-size: 13px; cursor: pointer;
|
|
@@ -489,10 +606,49 @@
|
|
|
489
606
|
transition: opacity .2s, transform .2s; z-index: 200; max-width: 86vw; text-align: center;
|
|
490
607
|
}
|
|
491
608
|
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
609
|
+
|
|
610
|
+
/* ——— 回答电脑提问的卡片 ———
|
|
611
|
+
整屏遮住聊天区:手机上一次只专心答一题,答完自动消失。
|
|
612
|
+
之所以遮全屏而不是弹个小条:非技术用户看到"电脑在等你回答"时,
|
|
613
|
+
最怕的是不知道该点哪里;整屏只剩问题本身和按钮,没有第二个选择。 */
|
|
614
|
+
#askCard { position: fixed; inset: 0; z-index: 300; background: #0e1116;
|
|
615
|
+
display: none; flex-direction: column; padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); overflow-y: auto; }
|
|
616
|
+
#askCard.on { display: flex; }
|
|
617
|
+
#askCard .qhead { font-size: 13px; opacity: .6; margin-bottom: 12px; }
|
|
618
|
+
#askCard .qtext { font-size: 18px; line-height: 1.6; margin-bottom: 10px; }
|
|
619
|
+
#askCard .qdetail { font-size: 14px; line-height: 1.65; opacity: .75; margin-bottom: 16px; white-space: pre-wrap; }
|
|
620
|
+
#askCard .opt { display: block; width: 100%; text-align: left; margin: 0 0 10px; padding: 15px 16px;
|
|
621
|
+
font-size: 16px; line-height: 1.5; border-radius: 12px; border: 1px solid #2a3140;
|
|
622
|
+
background: #161b23; color: inherit; font-family: inherit; }
|
|
623
|
+
#askCard .opt.sel { border-color: #3b82f6; background: #17243a; }
|
|
624
|
+
#askCard .opt small { display: block; opacity: .7; font-size: 13px; margin-top: 5px; }
|
|
625
|
+
#askCard textarea { width: 100%; box-sizing: border-box; font-size: 16px; line-height: 1.6;
|
|
626
|
+
border-radius: 12px; border: 1px solid #2a3140; background: #161b23; color: inherit;
|
|
627
|
+
padding: 12px; font-family: inherit; resize: vertical; }
|
|
628
|
+
#askCard .qfoot { margin-top: auto; display: flex; gap: 10px; padding-top: 16px; }
|
|
629
|
+
#askCard .qfoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: 12px;
|
|
630
|
+
border: 1px solid #2a3140; background: #161b23; color: inherit; font-family: inherit; }
|
|
631
|
+
#askCard .qfoot button#askNext { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
492
632
|
</style>
|
|
493
633
|
</head>
|
|
494
634
|
<body>
|
|
495
635
|
|
|
636
|
+
<!-- 电脑提问时弹出来的答题卡片。默认隐藏,收到 question 事件才显示。 -->
|
|
637
|
+
<div id="askCard">
|
|
638
|
+
<div class="qhead" id="askHead"></div>
|
|
639
|
+
<div class="qtext" id="askText"></div>
|
|
640
|
+
<div class="qdetail" id="askDetail"></div>
|
|
641
|
+
<div id="askOpts"></div>
|
|
642
|
+
<div id="askCustomWrap" style="display:none;margin-bottom:10px">
|
|
643
|
+
<textarea id="askCustom" rows="3" placeholder="用你自己的话回答……"></textarea>
|
|
644
|
+
</div>
|
|
645
|
+
<div class="qfoot">
|
|
646
|
+
<button id="askBack" type="button">上一题</button>
|
|
647
|
+
<button id="askOwn" type="button">我自己说</button>
|
|
648
|
+
<button id="askNext" type="button">下一题</button>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
|
|
496
652
|
<header>
|
|
497
653
|
<button class="icon-btn" id="btnNav" aria-label="工作区与会话">☰</button>
|
|
498
654
|
<span class="dot" id="dot"></span>
|
|
@@ -501,6 +657,20 @@
|
|
|
501
657
|
<button class="icon-btn" id="btnSettings" aria-label="设置">⚙</button>
|
|
502
658
|
</header>
|
|
503
659
|
|
|
660
|
+
<!-- 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
|
|
661
|
+
|
|
662
|
+
现在两件事都在了:顶部那一行显示当前用的模型,**点它弹出面板可以改**。
|
|
663
|
+
面板里的强度只画当前模型自己声明的档位;模型不支持调强度时那一行不出现。
|
|
664
|
+
|
|
665
|
+
**整段是自包含的**:自己的 script 标签、自己的请求、自己的元素,不碰页面现有的
|
|
666
|
+
任何代码。唯一的对外依赖是 /mini/api/models 那条接口(已经验过真数据)。
|
|
667
|
+
这样即使这段整个坏掉,也不会影响已经能用的部分。
|
|
668
|
+
|
|
669
|
+
没拿到数据之前整条藏着——**宁可什么都不显示,也不显示一个占位的假值**。 -->
|
|
670
|
+
<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)">
|
|
671
|
+
<span id="modelBarText"></span>
|
|
672
|
+
</div>
|
|
673
|
+
|
|
504
674
|
<main id="main">
|
|
505
675
|
<div id="reply"></div>
|
|
506
676
|
<!-- 「正在执行」是常驻元素,不参与 #reply 的整块重绘。
|
|
@@ -529,6 +699,11 @@
|
|
|
529
699
|
</main>
|
|
530
700
|
|
|
531
701
|
<div class="attach-bar" id="attachBar" hidden></div>
|
|
702
|
+
|
|
703
|
+
<!-- 在输入框里打 / 时弹出来的指令列表。和附件小条一样排在底栏**上面**:
|
|
704
|
+
盖住输入框的话,用户就看不清自己正在打什么了。 -->
|
|
705
|
+
<div class="cmd-menu" id="cmdMenu" hidden></div>
|
|
706
|
+
|
|
532
707
|
<footer>
|
|
533
708
|
<!-- 回形针。input 藏起来,按钮点它——iOS 上直接放一个 file input 样式很难统一。 -->
|
|
534
709
|
<button class="attach" id="btnAttach" type="button" aria-label="上传文件" title="上传文件">
|
|
@@ -565,6 +740,18 @@
|
|
|
565
740
|
</div>
|
|
566
741
|
</div>
|
|
567
742
|
|
|
743
|
+
<!-- 权限档位。整块由脚本填:这台电脑有哪些档位是**运行时读**的,一个都不写死。
|
|
744
|
+
服务拿不到时整块不显示——它是附加能力,缺了遥控本身照常。 -->
|
|
745
|
+
<div id="permBlock" hidden>
|
|
746
|
+
<div class="row">
|
|
747
|
+
<div>
|
|
748
|
+
<div class="label">权限档位</div>
|
|
749
|
+
<div class="hint" id="permHint">作用在当前会话上</div>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
<div class="seg" id="segPerm"></div>
|
|
753
|
+
</div>
|
|
754
|
+
|
|
568
755
|
<!--
|
|
569
756
|
这里原来有两行:「完成后提醒」和「语音输入」。
|
|
570
757
|
|
|
@@ -589,6 +776,38 @@
|
|
|
589
776
|
</div>
|
|
590
777
|
</div>
|
|
591
778
|
|
|
779
|
+
<!--
|
|
780
|
+
「页面在后台时提醒」。**现在不露出来了,藏到底。**
|
|
781
|
+
|
|
782
|
+
2026-09-21 它整行被撤掉过,理由是当时三条连接路径全是明文 http,浏览器不给发
|
|
783
|
+
系统通知,摆出来只是让用户点一下碰一鼻子灰。
|
|
784
|
+
|
|
785
|
+
2026-09-25 白天 Tailscale 那条路接通 HTTPS,它回来过一次;**当晚又撤了**。
|
|
786
|
+
第二次撤的理由和第一次不同,也更硬:**手机锁屏会冻结后台页面**,那条实时连接
|
|
787
|
+
跟着断掉,回复根本到不了页面,也就没人去发通知。这不是"前提没到",是浏览器这条
|
|
788
|
+
路本来就有上限——service worker 也救不了,它是被事件唤醒的,没有事件它就不存在。
|
|
789
|
+
|
|
790
|
+
用户原话:「类似功能我们以后考虑做成 APP 时再加,浏览器里不放了。」
|
|
791
|
+
|
|
792
|
+
**代码全部留着,只是不露出来**(见下面 serviceWorker.register 那段的注释)。
|
|
793
|
+
将来做 App 里的锁屏提醒,这些知识直接能用。真正不怕锁屏的是电脑端那条外部推送
|
|
794
|
+
(Bark/ntfy/Telegram,lib/notify.js),从服务器直接发出去,不经过浏览器。
|
|
795
|
+
|
|
796
|
+
为什么非要有 service worker:手机浏览器上 new Notification() 会直接抛 TypeError,
|
|
797
|
+
唯一能发系统通知的入口是 ServiceWorkerRegistration.showNotification()。
|
|
798
|
+
详见 lib/sw.js。
|
|
799
|
+
-->
|
|
800
|
+
<div class="row" id="rowNotify" style="display:none">
|
|
801
|
+
<div>
|
|
802
|
+
<div class="label">页面在后台时提醒</div>
|
|
803
|
+
<div class="hint" id="notifyHint">回复到了发一条系统通知</div>
|
|
804
|
+
</div>
|
|
805
|
+
<div class="seg" id="segNotify">
|
|
806
|
+
<button data-notify="on">开</button>
|
|
807
|
+
<button data-notify="off">关</button>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
|
|
592
811
|
<div class="row">
|
|
593
812
|
<div>
|
|
594
813
|
<div class="label">连接</div>
|
|
@@ -634,6 +853,9 @@
|
|
|
634
853
|
token: params.get('token') || localStorage.getItem('dshMiniToken') || '',
|
|
635
854
|
mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
|
|
636
855
|
sound: localStorage.getItem('dshMiniSound') !== 'off',
|
|
856
|
+
// 锁屏也能提醒(系统通知)。默认关着——它要用户主动点一下才会申请权限,
|
|
857
|
+
// 不能替他决定。明文连接下这一项永远是空转的,见下面「锁屏也能提醒」那一段。
|
|
858
|
+
notify: localStorage.getItem('dshMiniNotify') === 'on',
|
|
637
859
|
running: false,
|
|
638
860
|
sessions: [],
|
|
639
861
|
// 排队中的指令([{ id, text, placement }])。跑着任务时接着发的那几条排在这儿,
|
|
@@ -646,7 +868,20 @@
|
|
|
646
868
|
// 只存服务端发回来的那张小票,不存附件本身——真正的东西已经在电脑上了。
|
|
647
869
|
uploads: [],
|
|
648
870
|
// 正在流出来的那一段。空字符串 = 这会儿没有东西要显示。
|
|
649
|
-
live: ''
|
|
871
|
+
live: '',
|
|
872
|
+
// 服务端正去读这个会话的完整记录(点开一个插件没看过的会话时会有这一下)。
|
|
873
|
+
historyLoading: false,
|
|
874
|
+
// 「这份历史只到最近一段」(服务端读历史有上限时会给这一句)。
|
|
875
|
+
historyTruncated: false,
|
|
876
|
+
historyNote: '',
|
|
877
|
+
// 斜杠指令的列表(在输入框里打 / 时弹出来的那个菜单)。
|
|
878
|
+
// **按绑定的会话缓存**:指令表是每个会话一份的,而这个会话没在跑时服务端一条都
|
|
879
|
+
// 读不到(回 ok:false + 一句话)。所以换了会话必须重取——否则用户点到的是上一个
|
|
880
|
+
// 会话的指令,点下去必然被拒,而他从屏幕上完全看不出为什么。
|
|
881
|
+
commands: null, // null = 还没取到
|
|
882
|
+
commandsError: '', // 取不到时服务端给的那句话,原样显示给用户
|
|
883
|
+
commandsFor: null, // 上面这份数据是哪个会话的
|
|
884
|
+
commandsLoading: false // 正在取(挡住重复请求,见 loadCommands)
|
|
650
885
|
};
|
|
651
886
|
if (params.get('token')) {
|
|
652
887
|
localStorage.setItem('dshMiniToken', params.get('token'));
|
|
@@ -1224,6 +1459,24 @@
|
|
|
1224
1459
|
paintWork();
|
|
1225
1460
|
}
|
|
1226
1461
|
|
|
1462
|
+
/**
|
|
1463
|
+
* 单帧模式:「这一条回答」有没有已经带用户滚过顶了。
|
|
1464
|
+
*
|
|
1465
|
+
* **用 timestamp 当身份**,因为它是这里唯一稳定、且只在真推了一条新回答时才变的字段
|
|
1466
|
+
* (`pushReply` 每次都现取 `Date.now()`;重连、SSE 重推、切模式、从后台回来
|
|
1467
|
+
* 都只是把同一条回答重新渲染一遍,timestamp 不变)。
|
|
1468
|
+
*
|
|
1469
|
+
* 用户 2026-09-23 提的:「手机端的单帧模式,回答出现的时候,页面应该跳到顶部,
|
|
1470
|
+
* 方便用户阅读。」不记这个的话:一条长结论渲染出来,用户看到的是最后一行,
|
|
1471
|
+
* 得自己往上翻才读得到开头。
|
|
1472
|
+
*
|
|
1473
|
+
* 记成「已经为哪一条滚过顶」而不是一个「刚跳过」的布尔量,是为了不误伤上面那几种
|
|
1474
|
+
* 重新渲染——那些时候用户可能正读到一半,把他弹回顶部比不跳还烦人。
|
|
1475
|
+
* 初始值 0:开机第一次渲染时任何一条回答的 timestamp 都比它大,于是会跳一次,
|
|
1476
|
+
* 正好是「打开页面从最新那条的开头读起」。
|
|
1477
|
+
*/
|
|
1478
|
+
var scrolledReplyAt = 0;
|
|
1479
|
+
|
|
1227
1480
|
function renderMinimal() {
|
|
1228
1481
|
mainEl.classList.remove('center');
|
|
1229
1482
|
var latest = state.latest;
|
|
@@ -1245,6 +1498,10 @@
|
|
|
1245
1498
|
+ ' data-copy="' + escapeHtml(latest.text) + '">' + ICON_COPY + '</button>'
|
|
1246
1499
|
+ '</div>');
|
|
1247
1500
|
}
|
|
1501
|
+
// 刚跑完的那条指令也要露出来(判据和为什么见 commandTail)。
|
|
1502
|
+
// 「正在执行」那块照旧:它是常驻的 #work,跟这里没关系。
|
|
1503
|
+
var tail = commandTail();
|
|
1504
|
+
if (tail) parts.push(commandBlock(tail));
|
|
1248
1505
|
if (live) parts.push(liveBlock(live));
|
|
1249
1506
|
if (parts.length) {
|
|
1250
1507
|
var stick = atBottom();
|
|
@@ -1252,12 +1509,33 @@
|
|
|
1252
1509
|
// 流式的时候得跟着往下滚,不然新写出来的内容在屏幕外面,看起来像卡住了。
|
|
1253
1510
|
// 但只在用户本来就在底部时才跟——他翻上去看历史的时候别拽他回来。
|
|
1254
1511
|
if (live && stick) mainEl.scrollTop = mainEl.scrollHeight;
|
|
1512
|
+
// 单帧模式专属:**答案落定的那一刻**跳到顶部,让人从第一行开始读。
|
|
1513
|
+
//
|
|
1514
|
+
// 判据是 timestamp 变了,不是「渲染了一次」——重连、SSE 重推、切模式、
|
|
1515
|
+
// 从后台回来都会重新渲染同一条回答,那些时候用户可能正读到一半,
|
|
1516
|
+
// 弹回顶部比不跳还烦人(见 scrolledReplyAt 的说明)。
|
|
1517
|
+
//
|
|
1518
|
+
// 和上面那条黏底不冲突,两者管的是不同时刻:流式那会儿(live 还在)
|
|
1519
|
+
// 答案没落定,该跟着往下走;落定之后才轮到这一条把它拉回开头。
|
|
1520
|
+
// 也别跟下面 send() 里那条「按了发送无条件回底部」搞混——那是用户自己的
|
|
1521
|
+
// 主动动作,他想看自己刚发出去的那条;这条是回答到了,他要读回答。
|
|
1522
|
+
if (latest && latest.timestamp && latest.timestamp !== scrolledReplyAt) {
|
|
1523
|
+
scrolledReplyAt = latest.timestamp;
|
|
1524
|
+
mainEl.scrollTop = 0;
|
|
1525
|
+
}
|
|
1255
1526
|
return;
|
|
1256
1527
|
}
|
|
1257
1528
|
if (state.running) {
|
|
1258
1529
|
replyEl.innerHTML = '';
|
|
1259
1530
|
return;
|
|
1260
1531
|
}
|
|
1532
|
+
// 正在去读这个会话的记录:这时候说「在下面输入一条指令」是不对的——
|
|
1533
|
+
// 这个会话其实有历史,只是还没读完。单帧模式只显示最后一条回复,
|
|
1534
|
+
// 那一条也在这份记录里。
|
|
1535
|
+
if (state.historyLoading) {
|
|
1536
|
+
replyEl.innerHTML = '<div class="placeholder">正在读取这个会话的记录…</div>';
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1261
1539
|
replyEl.innerHTML = '<div class="placeholder">在下面输入一条指令,'
|
|
1262
1540
|
+ '这里只会显示 AI 的最终回复。<br>中间的工具调用和执行过程不会传过来。</div>';
|
|
1263
1541
|
}
|
|
@@ -1279,6 +1557,58 @@
|
|
|
1279
1557
|
return '<div class="live"><div class="md">' + mdToHtml(text) + '</div></div>';
|
|
1280
1558
|
}
|
|
1281
1559
|
|
|
1560
|
+
/**
|
|
1561
|
+
* 历史里的一条指令(`role:'command'`):这个会话执行了 /xxx,跑到哪一步了。
|
|
1562
|
+
*
|
|
1563
|
+
* 为什么单独一种长相:它既不是用户说的话,也不是 AI 说的话,而是**会话做的一个
|
|
1564
|
+
* 动作**。套上气泡就成了一句人说的话,会被当成回答去读;给它复制按钮也没有意义
|
|
1565
|
+
* (「/compact 执行中…」没有拿走的必要)。所以收成一行紧凑的条目:名字用等宽字体,
|
|
1566
|
+
* 后面直接跟结果。
|
|
1567
|
+
*
|
|
1568
|
+
* 三种状态里只有 running 是「还不知道结果」:服务端在这一条上**不报进度**,
|
|
1569
|
+
* 所以只能说「执行中…」——编一个百分比就是撒谎。
|
|
1570
|
+
*
|
|
1571
|
+
* 这两个函数夹在 liveBlock 和 renderChat 中间是有原因的,**别挪**:page.test.mjs
|
|
1572
|
+
* 是按源码里的锚点串切片的——聊天模式那一套从 liveBlock 那一段切到 render 那一段,
|
|
1573
|
+
* 单帧模式那一套从 scrolledReplyAt 那一段切到 renderChat 那一段,两把刀的交集就是
|
|
1574
|
+
* 这里。放到交集外面,总有一套测试一跑就 ReferenceError。
|
|
1575
|
+
* (顺带一句:这段注释里**不能**写出那几个锚点串的完整样子,否则 indexOf 会先命中
|
|
1576
|
+
* 注释、把切片截断,报出来的是「SyntaxError」而不是人话。)
|
|
1577
|
+
*/
|
|
1578
|
+
function commandBlock(m) {
|
|
1579
|
+
// args 是名字后面**原样**的输入(含分隔空格),可能为空串——所以直接拼,
|
|
1580
|
+
// 不自己补一个空格:补了就把「用户到底敲了什么」改样了。
|
|
1581
|
+
var line = '/' + (m.name || '') + (m.args || '');
|
|
1582
|
+
var kind = m.kind === 'error' ? 'error' : (m.kind === 'success' ? 'success' : 'running');
|
|
1583
|
+
var res = kind === 'error' ? (m.text || '没跑起来')
|
|
1584
|
+
: (kind === 'success' ? (m.text || '完成') : '执行中…');
|
|
1585
|
+
return '<div class="cmd ' + kind + '">'
|
|
1586
|
+
+ '<span class="cmd-line">' + escapeHtml(line) + '</span>'
|
|
1587
|
+
+ '<span class="cmd-res">' + escapeHtml(res) + '</span></div>';
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* 单帧模式要跟着显示的那条指令:**历史里最后一条**,而且得真的是一条指令。
|
|
1592
|
+
*
|
|
1593
|
+
* 为什么要单独处理:单帧模式整屏只留最新一条回复,而指令**不产生回复**——用户点一下
|
|
1594
|
+
* /compact,这里要是什么都不画,屏幕上就是一动不动,看着像点了个没反应的按钮。
|
|
1595
|
+
*
|
|
1596
|
+
* 用「最后一条」而不是「最近一条指令」:中间要是夹了别的消息(指令跑完模型又答了
|
|
1597
|
+
* 一句),那条指令已经不是最新的事了,再把它贴在最下面就成了过期通知。
|
|
1598
|
+
*
|
|
1599
|
+
* 最后还有一道时间判断,别删。单帧模式的回答是走 SSE 的 reply 那条路来的,它
|
|
1600
|
+
* **不往 history 里塞东西**(只有聊天模式才塞),所以 history 的尾巴会一直停在那条
|
|
1601
|
+
* 指令上。不加这一道,新回答到了之后底下还挂着一条「执行中…」,看着像它卡住了。
|
|
1602
|
+
*/
|
|
1603
|
+
function commandTail() {
|
|
1604
|
+
var h = state.history || [];
|
|
1605
|
+
var last = h.length ? h[h.length - 1] : null;
|
|
1606
|
+
if (!last || last.role !== 'command') return null;
|
|
1607
|
+
var lt = (state.latest && state.latest.timestamp) || 0;
|
|
1608
|
+
if (lt && last.timestamp && last.timestamp < lt) return null;
|
|
1609
|
+
return last;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1282
1612
|
/**
|
|
1283
1613
|
* 该不该自动滚到底。
|
|
1284
1614
|
*
|
|
@@ -1298,6 +1628,22 @@
|
|
|
1298
1628
|
return mainEl.scrollHeight - mainEl.scrollTop - (mainEl.clientHeight || 0) < 48;
|
|
1299
1629
|
}
|
|
1300
1630
|
|
|
1631
|
+
/**
|
|
1632
|
+
* 把某一块的**开头**对齐到视野顶部(聊天模式用,见 renderChat)。
|
|
1633
|
+
*
|
|
1634
|
+
* 用两个矩形的差值算,不用 `offsetTop`:`main` 上没有 `position: relative`,
|
|
1635
|
+
* `offsetTop` 会一路找到 body 去,算出来是错的。矩形差值不管定位上下文怎么变都对。
|
|
1636
|
+
*
|
|
1637
|
+
* 留 12px 空隙:`main` 自己带 16px 内边距,滚下去之后那段内边距就滚没了,
|
|
1638
|
+
* 不留缝的话回答的第一行会顶在屏幕最上沿,看着像被切掉了。
|
|
1639
|
+
*/
|
|
1640
|
+
function pinToTop(el) {
|
|
1641
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return;
|
|
1642
|
+
if (typeof mainEl.getBoundingClientRect !== 'function') return;
|
|
1643
|
+
var gap = el.getBoundingClientRect().top - mainEl.getBoundingClientRect().top;
|
|
1644
|
+
mainEl.scrollTop += gap - 12;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1301
1647
|
function renderChat() {
|
|
1302
1648
|
mainEl.classList.remove('center');
|
|
1303
1649
|
// **还在排队的那条不算「已发出」,这里要把它从聊天记录里滤掉。**
|
|
@@ -1318,10 +1664,18 @@
|
|
|
1318
1664
|
return !(m.role === 'user' && m.id && queuedIds[m.id]);
|
|
1319
1665
|
});
|
|
1320
1666
|
if (!list.length && !state.running && !state.live && !(state.queued || []).length) {
|
|
1321
|
-
|
|
1667
|
+
// 「正在读」和「确实没有」是两件事,分开说。点开一个插件没看过的会话时,
|
|
1668
|
+
// 服务端要去 DSH 的会话记录里读它那一段(最大的那条 20 MB、约半秒),
|
|
1669
|
+
// 这期间说「还没有对话记录」会让人以为这个会话本来就是空的。
|
|
1670
|
+
replyEl.innerHTML = state.historyLoading
|
|
1671
|
+
? '<div class="placeholder">正在读取这个会话的记录…</div>'
|
|
1672
|
+
: '<div class="placeholder">还没有对话记录。</div>';
|
|
1322
1673
|
return;
|
|
1323
1674
|
}
|
|
1324
1675
|
var html = list.map(function (m) {
|
|
1676
|
+
// 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
|
|
1677
|
+
// 不套气泡、不给复制按钮(见 commandBlock 的说明)。
|
|
1678
|
+
if (m.role === 'command') return commandBlock(m);
|
|
1325
1679
|
// 被按停的那一轮:标一句,别让人以为模型答到一半断了。
|
|
1326
1680
|
var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
|
|
1327
1681
|
// 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
|
|
@@ -1342,13 +1696,86 @@
|
|
|
1342
1696
|
+ '<div class="reply-foot">' + copyBtn
|
|
1343
1697
|
+ '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
|
|
1344
1698
|
}).join('');
|
|
1699
|
+
// 「这不是全部」要说在**内容前面**:用户往上翻到顶时,第一眼看到的就是它,
|
|
1700
|
+
// 免得以为这个会话只有这么点内容。读历史有上限(见服务端 HISTORY_MAX_*)。
|
|
1701
|
+
if (state.historyTruncated) {
|
|
1702
|
+
html = '<div class="history-note">'
|
|
1703
|
+
+ escapeHtml(state.historyNote || '这个会话很大,只显示了最近一段。')
|
|
1704
|
+
+ '</div>' + html;
|
|
1705
|
+
}
|
|
1345
1706
|
// 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
|
|
1346
1707
|
// 两种模式共用同一块,改的时候两边都走一遍。
|
|
1347
1708
|
if (state.live) html += liveBlock(state.live);
|
|
1348
1709
|
// 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
|
|
1349
1710
|
var stick = atBottom();
|
|
1350
1711
|
replyEl.innerHTML = html;
|
|
1351
|
-
|
|
1712
|
+
scrollChat(stick);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* 聊天模式里「已经为哪一次回答把开头对齐到顶部」。
|
|
1717
|
+
*
|
|
1718
|
+
* 取值是 `'live'`(正在流的那一段)或 `'done:<timestamp>'`(落定的那条)。
|
|
1719
|
+
* 记这个是为了**每条回答只对齐一次**——不记的话,用户往下读的时候每次重画
|
|
1720
|
+
* 都会把他拽回开头,比不对齐还烦人。
|
|
1721
|
+
*
|
|
1722
|
+
* 声明放在这里而不是跟 scrolledReplyAt 作伴:page.test.mjs 是从 `liveBlock`
|
|
1723
|
+
* 切到 `render()` 来跑聊天模式的,放前面就切不进来,一跑就是 ReferenceError。
|
|
1724
|
+
*/
|
|
1725
|
+
var pinnedAnswerKey = '';
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* 聊天模式的滚动:**回答的开头才是要读的地方,不是末尾。**
|
|
1729
|
+
*
|
|
1730
|
+
* 用户 2026-09-24 提的:「聊天模式下,回答出现时,页面也应该停留在回答的开头
|
|
1731
|
+
* 而非末尾,这样用户不用往上滚到开头」。
|
|
1732
|
+
*
|
|
1733
|
+
* 和单帧模式那条的区别:单帧只有一条回答,所以它对齐的是 0(页面顶端);
|
|
1734
|
+
* 聊天模式有一串历史,要对齐的是**最新那条回答自己的开头**。
|
|
1735
|
+
*
|
|
1736
|
+
* 回答分两个阶段,各对齐一次(记号记在 pinnedAnswerKey 里):
|
|
1737
|
+
*
|
|
1738
|
+
* · **正在流**(live 非空)——对齐 `.live` 的开头,之后**不再跟底部**。
|
|
1739
|
+
* 这是关键的一条:流式每秒重画好几次,要是继续黏底,用户刚滚到开头
|
|
1740
|
+
* 就被拽回末尾,等于没改。
|
|
1741
|
+
*
|
|
1742
|
+
* · **落定**(timestamp 变了)——对齐最后那条 `.said` 的开头,
|
|
1743
|
+
* 让最终版从第一行开始读。
|
|
1744
|
+
*
|
|
1745
|
+
* 为什么要记 key、而不是「每次都对齐」:用户往下读的时候,每次重画都把他拽回
|
|
1746
|
+
* 开头,比不对齐还烦人。这一点和单帧模式里 scrolledReplyAt 的道理是一样的。
|
|
1747
|
+
*/
|
|
1748
|
+
function scrollChat(stick) {
|
|
1749
|
+
var liveNow = state.live || '';
|
|
1750
|
+
var newest = liveNow
|
|
1751
|
+
? 'live'
|
|
1752
|
+
: (state.latest && state.latest.timestamp ? 'done:' + state.latest.timestamp : '');
|
|
1753
|
+
if (newest && newest !== pinnedAnswerKey) {
|
|
1754
|
+
// 先记账,再决定动不动他。记完就不再重复处理这条回答——
|
|
1755
|
+
// 用户往下读的时候被反复拉回开头,比不对齐还烦人。
|
|
1756
|
+
pinnedAnswerKey = newest;
|
|
1757
|
+
// **只在「他本来就在底部跟着看」的时候才动他。**
|
|
1758
|
+
//
|
|
1759
|
+
// 这一条不能省:用户 2026-09-22 实机提过「有动画的时候手机端页面运动到上面
|
|
1760
|
+
// 都会自动调回底部,这种机制没必要吧」。他要是自己翻上去看历史了,
|
|
1761
|
+
// 我们把他拽到回答开头,和当初拽回底部是同一类冒犯。
|
|
1762
|
+
//
|
|
1763
|
+
// 注意 stick 是**改 innerHTML 之前**问的(见 atBottom 的注释),
|
|
1764
|
+
// 所以它答的是「他刚才在不在底部」,正是这里要问的。
|
|
1765
|
+
if (!stick) return;
|
|
1766
|
+
var target = null;
|
|
1767
|
+
if (liveNow) {
|
|
1768
|
+
target = replyEl.querySelector ? replyEl.querySelector('.live') : null;
|
|
1769
|
+
} else if (replyEl.querySelectorAll) {
|
|
1770
|
+
var saids = replyEl.querySelectorAll('.said');
|
|
1771
|
+
target = saids.length ? saids[saids.length - 1] : null;
|
|
1772
|
+
}
|
|
1773
|
+
pinToTop(target);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
// 没有新回答的时候照旧黏底(历史重排、切会话、用户自己发指令这些),
|
|
1777
|
+
// 但**流式期间不跟**——那正是要停在开头的时候。
|
|
1778
|
+
if (stick && !liveNow) mainEl.scrollTop = mainEl.scrollHeight;
|
|
1352
1779
|
}
|
|
1353
1780
|
|
|
1354
1781
|
function render() {
|
|
@@ -1362,9 +1789,23 @@
|
|
|
1362
1789
|
// 不代表模型没在写,只代表「还不到显示的时候」。
|
|
1363
1790
|
if ('live' in snap) state.live = snap.live || '';
|
|
1364
1791
|
if (snap.history) state.history = snap.history;
|
|
1792
|
+
// 「服务端正去读这个会话的完整记录」。点开一个没看过的会话时,那一串要读一会儿
|
|
1793
|
+
// 才补上来(最大的那条日志 20 MB、约半秒),这期间不能装作「这个会话是空的」。
|
|
1794
|
+
state.historyLoading = snap.historyLoading === true;
|
|
1795
|
+
// 「这份历史只到最近一段」。服务端读历史有上限(会话太大只读日志尾部),
|
|
1796
|
+
// 所以内容可能是截断的——**这件事必须跟着内容一起传上来**,不能只在服务端知道。
|
|
1797
|
+
state.historyTruncated = snap.historyTruncated === true;
|
|
1798
|
+
state.historyNote = snap.historyNote || '';
|
|
1365
1799
|
if (snap.sessions) state.sessions = snap.sessions;
|
|
1366
1800
|
if (Array.isArray(snap.queued)) state.queued = snap.queued;
|
|
1367
|
-
if ('boundSessionId' in snap)
|
|
1801
|
+
if ('boundSessionId' in snap) {
|
|
1802
|
+
var wasBound = state.boundSessionId;
|
|
1803
|
+
state.boundSessionId = snap.boundSessionId;
|
|
1804
|
+
// 换了会话,指令菜单那份缓存就作废了(指令表是每个会话一份的,这个会话没在跑
|
|
1805
|
+
// 时一条都读不到,见「斜杠指令」那一段)。重画一次,它自己会发现数据不属于
|
|
1806
|
+
// 这个会话、去重取——否则用户点到的是上一个会话的指令,点下去必然被拒。
|
|
1807
|
+
if (state.boundSessionId !== wasBound) paintCmdMenu();
|
|
1808
|
+
}
|
|
1368
1809
|
// running 也在快照里,而且算的是**当前绑定的那个会话**。不接的话,
|
|
1369
1810
|
// 在导航栏切到别的会话时,那个会话会继承上一个会话的「正在执行」。
|
|
1370
1811
|
if ('running' in snap) setRunning(snap.running);
|
|
@@ -1411,20 +1852,27 @@
|
|
|
1411
1852
|
}
|
|
1412
1853
|
|
|
1413
1854
|
function renderNav() {
|
|
1855
|
+
// 目录浏览那一屏接管整个面板。它自己画返回按钮,所以不走下面这套。
|
|
1856
|
+
if (navView === 'browse') { renderBrowse(); return; }
|
|
1414
1857
|
var list = navList || [];
|
|
1858
|
+
// 「新建工作区」常驻在最上面:它是这一屏的入口,不该等有了会话才出现——
|
|
1859
|
+
// 一个工作区都还没有的人,恰恰最需要它。
|
|
1860
|
+
var cta = '<button class="nav-cta" type="button" data-new-ws="1">+ 新建工作区</button>';
|
|
1415
1861
|
if (!list.length) {
|
|
1416
|
-
$('navBody').innerHTML = '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
1862
|
+
$('navBody').innerHTML = cta + '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
1417
1863
|
+ '在电脑上开一个,这里就会出现了。</div>';
|
|
1418
1864
|
return;
|
|
1419
1865
|
}
|
|
1420
|
-
$('navBody').innerHTML = list.map(function (ws) {
|
|
1866
|
+
$('navBody').innerHTML = cta + list.map(function (ws) {
|
|
1421
1867
|
var open = Boolean(navExpanded[ws.id]);
|
|
1422
1868
|
return '<div class="ws' + (open ? ' open' : '') + '" data-ws="' + escapeHtml(ws.id) + '">'
|
|
1423
1869
|
+ '<button class="ws-head" type="button">'
|
|
1424
1870
|
+ '<span class="caret">▶</span>'
|
|
1425
1871
|
+ '<span class="ws-title">' + escapeHtml(ws.title) + '</span>'
|
|
1426
1872
|
+ (ws.running ? '<span class="s-dot"></span>' : '')
|
|
1427
|
-
|
|
1873
|
+
// 空工作区(通常是刚在手机上建的那个)不写「0」,写「还没有会话」:
|
|
1874
|
+
// 一个孤零零的 0 看着像出了错,说清楚反而让人知道下一步该干什么。
|
|
1875
|
+
+ '<span class="ws-count">' + (ws.count || (ws.empty ? '新' : 0)) + '</span>'
|
|
1428
1876
|
+ '</button>'
|
|
1429
1877
|
+ '<div class="ws-body">' + (open ? sessionsHtml(ws.id) : '') + '</div>'
|
|
1430
1878
|
+ '</div>';
|
|
@@ -1432,11 +1880,15 @@
|
|
|
1432
1880
|
}
|
|
1433
1881
|
|
|
1434
1882
|
function sessionsHtml(wsId) {
|
|
1883
|
+
// 「新建会话」永远排在最上面:不管这个工作区有几个会话、甚至一个都没有,
|
|
1884
|
+
// 这一行都在。放在列表末尾的话,一个 452 条会话的工作区要划到底才看得到。
|
|
1885
|
+
var html = '<button class="ws-new" type="button" data-newws="' + escapeHtml(wsId) + '">'
|
|
1886
|
+
+ '+ 新建会话</button>';
|
|
1435
1887
|
var got = navSessions[wsId];
|
|
1436
|
-
if (!got) return '<div class="placeholder">正在读取…</div>';
|
|
1437
|
-
if (!got.sessions.length) return '<div class="placeholder">这个工作区还没有会话。</div>';
|
|
1888
|
+
if (!got) return html + '<div class="placeholder">正在读取…</div>';
|
|
1889
|
+
if (!got.sessions.length) return html + '<div class="placeholder">这个工作区还没有会话。</div>';
|
|
1438
1890
|
var cur = state.boundSessionId;
|
|
1439
|
-
|
|
1891
|
+
html += got.sessions.map(function (s) {
|
|
1440
1892
|
// 没有标题就用日期——原来退到取 id 的前 8 位当名字,而本机真有一个会话的 id
|
|
1441
1893
|
// 以「session-」开头,于是整列都显示成一样的「session-」,等于没有名字。
|
|
1442
1894
|
return '<button class="sess' + (s.id === cur ? ' cur' : '') + '" type="button"'
|
|
@@ -1453,6 +1905,200 @@
|
|
|
1453
1905
|
return html;
|
|
1454
1906
|
}
|
|
1455
1907
|
|
|
1908
|
+
// ---------------- 新建工作区:浏览电脑目录 ----------------
|
|
1909
|
+
//
|
|
1910
|
+
// 面板里有两个「屏」:工作区树(默认)和目录浏览。用一个变量记当前在哪一屏,
|
|
1911
|
+
// 而不是再开一层浮层——手机屏幕就这么大,多一层就多一次「怎么退回去」。
|
|
1912
|
+
//
|
|
1913
|
+
// 起点给「常用位置」(主目录 / 最近用过 / 磁盘),不是直接甩盘符:
|
|
1914
|
+
// 从 C:\ 一层层点进去找一个项目文件夹,是要人自己知道路径长什么样。
|
|
1915
|
+
var navView = 'tree';
|
|
1916
|
+
var browseRoots = null; // 常用位置,开一次取一次
|
|
1917
|
+
var browseAt = null; // 当前所在目录;null = 还在看常用位置
|
|
1918
|
+
var browseData = null; // 当前目录的列表
|
|
1919
|
+
var browseMkdir = false; // 是不是正在新建文件夹(那一行输入框露出来没有)
|
|
1920
|
+
var browseBusy = false; // 防连点
|
|
1921
|
+
|
|
1922
|
+
function dirName(p) {
|
|
1923
|
+
var parts = String(p).split(/[\\/]/).filter(Boolean);
|
|
1924
|
+
return parts.length ? parts[parts.length - 1] : String(p);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function openBrowse() {
|
|
1928
|
+
navView = 'browse';
|
|
1929
|
+
browseAt = null;
|
|
1930
|
+
browseData = null;
|
|
1931
|
+
browseMkdir = false;
|
|
1932
|
+
renderNav();
|
|
1933
|
+
if (browseRoots) return;
|
|
1934
|
+
api('/mini/api/browse/roots').then(function (body) {
|
|
1935
|
+
browseRoots = body;
|
|
1936
|
+
renderNav();
|
|
1937
|
+
}).catch(function (err) {
|
|
1938
|
+
browseRoots = null;
|
|
1939
|
+
$('navBody').innerHTML = '<button class="nav-cta" type="button" data-browse-back="1">← 返回</button>'
|
|
1940
|
+
+ '<div class="placeholder">读不到常用位置:' + escapeHtml(err.message) + '</div>';
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function browseTo(p) {
|
|
1945
|
+
navView = 'browse';
|
|
1946
|
+
browseAt = p;
|
|
1947
|
+
browseData = null;
|
|
1948
|
+
browseMkdir = false;
|
|
1949
|
+
renderNav();
|
|
1950
|
+
api('/mini/api/browse?path=' + encodeURIComponent(p)).then(function (body) {
|
|
1951
|
+
browseData = body;
|
|
1952
|
+
renderNav();
|
|
1953
|
+
}).catch(function (err) {
|
|
1954
|
+
browseData = { ok: false, error: err.message };
|
|
1955
|
+
renderNav();
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** 目录浏览那一屏。路径来自服务端,这里只负责画。 */
|
|
1960
|
+
function renderBrowse() {
|
|
1961
|
+
var html = '<button class="nav-cta" type="button" data-browse-back="1">← 返回</button>';
|
|
1962
|
+
|
|
1963
|
+
if (!browseAt) {
|
|
1964
|
+
if (!browseRoots) {
|
|
1965
|
+
$('navBody').innerHTML = html + '<div class="placeholder">正在读取…</div>';
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
// 顺序有讲究:主目录(最常用)→ 最近用过(第二常用)→ 磁盘(兜底)。
|
|
1969
|
+
// 磁盘排最后,因为「从盘符一层层点」正是我们要避免的那种走法。
|
|
1970
|
+
var rows = [{ name: '主目录', path: browseRoots.home, tag: '常用' }];
|
|
1971
|
+
(browseRoots.recent || []).forEach(function (p) {
|
|
1972
|
+
rows.push({ name: dirName(p), path: p, tag: '最近' });
|
|
1973
|
+
});
|
|
1974
|
+
(browseRoots.drives || []).forEach(function (p) {
|
|
1975
|
+
rows.push({ name: p, path: p, tag: '磁盘' });
|
|
1976
|
+
});
|
|
1977
|
+
html += '<div class="nav-note">挑一个文件夹,登记成工作区。</div>';
|
|
1978
|
+
html += rows.map(function (r) {
|
|
1979
|
+
return '<button class="sess" type="button" data-browse-to="' + escapeHtml(r.path) + '">'
|
|
1980
|
+
+ '<span class="s-title">' + escapeHtml(r.name) + '</span>'
|
|
1981
|
+
+ '<span class="s-meta">' + escapeHtml(r.tag) + '</span>'
|
|
1982
|
+
+ '</button>';
|
|
1983
|
+
}).join('');
|
|
1984
|
+
$('navBody').innerHTML = html;
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (!browseData) {
|
|
1989
|
+
$('navBody').innerHTML = html + '<div class="placeholder">正在读取…</div>';
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
if (!browseData.ok) {
|
|
1993
|
+
// 读不到时**如实说**,不装作空目录——两者在界面上长得一模一样,
|
|
1994
|
+
// 而「没有权限」和「本来就是空的」对用户是两件事。
|
|
1995
|
+
$('navBody').innerHTML = html + '<div class="crumb"><span class="here">'
|
|
1996
|
+
+ escapeHtml(dirName(browseAt)) + '</span></div>'
|
|
1997
|
+
+ '<div class="placeholder">' + escapeHtml(browseData.error || '读不到这个目录。') + '</div>';
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// 面包屑:能一路点回上去。最后一格是当前目录,不再可点。
|
|
2002
|
+
var chain = browseData.ancestors || [];
|
|
2003
|
+
html += '<div class="crumb">' + chain.map(function (a, i) {
|
|
2004
|
+
var last = i === chain.length - 1;
|
|
2005
|
+
var piece = last
|
|
2006
|
+
? '<span class="here">' + escapeHtml(a.name) + '</span>'
|
|
2007
|
+
: '<button type="button" data-browse-to="' + escapeHtml(a.path) + '">'
|
|
2008
|
+
+ escapeHtml(a.name) + '</button><span class="sep">›</span>';
|
|
2009
|
+
return piece;
|
|
2010
|
+
}).join('') + '</div>';
|
|
2011
|
+
|
|
2012
|
+
html += '<button class="pick-here" type="button" data-pick="' + escapeHtml(browseData.path) + '">'
|
|
2013
|
+
+ '就选这个目录</button>';
|
|
2014
|
+
|
|
2015
|
+
if (browseMkdir) {
|
|
2016
|
+
// 内联输入,不用 window.prompt:prompt 在微信这类内嵌浏览器里会被直接
|
|
2017
|
+
// 屏蔽掉,那时候用户点「新建文件夹」什么都不发生,而且没有任何提示。
|
|
2018
|
+
html += '<div class="mkdir-row">'
|
|
2019
|
+
+ '<input id="mkdirName" type="text" placeholder="新文件夹的名字" autocomplete="off">'
|
|
2020
|
+
+ '<button type="button" data-mkdir-ok="1">建</button>'
|
|
2021
|
+
+ '<button type="button" data-mkdir-cancel="1">取消</button>'
|
|
2022
|
+
+ '</div>';
|
|
2023
|
+
} else {
|
|
2024
|
+
html += '<button class="ws-new" type="button" data-mkdir="1">+ 新建文件夹</button>';
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
if (!browseData.dirs.length) {
|
|
2028
|
+
html += '<div class="placeholder">这个目录下面没有子目录。</div>';
|
|
2029
|
+
} else {
|
|
2030
|
+
html += browseData.dirs.map(function (d) {
|
|
2031
|
+
return '<button class="sess" type="button" data-browse-to="' + escapeHtml(d.path) + '">'
|
|
2032
|
+
+ '<span class="s-title">' + escapeHtml(d.name) + '</span>'
|
|
2033
|
+
+ '</button>';
|
|
2034
|
+
}).join('');
|
|
2035
|
+
}
|
|
2036
|
+
if (browseData.truncated) {
|
|
2037
|
+
html += '<div class="nav-note">只列了前 ' + browseData.dirs.length + ' 个,共 '
|
|
2038
|
+
+ browseData.total + ' 个。</div>';
|
|
2039
|
+
}
|
|
2040
|
+
$('navBody').innerHTML = html;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* 「← 返回」:一级一级往外退,退法要跟用户心里的层级对上。
|
|
2045
|
+
* 在某个目录里 → 退回常用位置那一屏(往上几级有面包屑,不用返回键代劳)
|
|
2046
|
+
* 已经在常用位置 → 退回工作区树
|
|
2047
|
+
* 不这么做的话,从深层目录点返回会直接跳回工作区树,用户想换个盘得重新走一遍。
|
|
2048
|
+
*/
|
|
2049
|
+
function closeBrowse() {
|
|
2050
|
+
if (browseAt) {
|
|
2051
|
+
browseAt = null;
|
|
2052
|
+
browseData = null;
|
|
2053
|
+
browseMkdir = false;
|
|
2054
|
+
renderNav();
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
navView = 'tree';
|
|
2058
|
+
renderNav();
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function registerWorkspace(p) {
|
|
2062
|
+
if (browseBusy) return;
|
|
2063
|
+
browseBusy = true;
|
|
2064
|
+
api('/mini/api/workspaces', { method: 'POST', body: JSON.stringify({ path: p }) })
|
|
2065
|
+
.then(function (res) {
|
|
2066
|
+
browseBusy = false;
|
|
2067
|
+
navView = 'tree';
|
|
2068
|
+
browseAt = null;
|
|
2069
|
+
browseData = null;
|
|
2070
|
+
// 服务端把登记后的最新列表一起带回来了,直接用——不用再补一趟请求,
|
|
2071
|
+
// 也就不会出现「建好了但列表里还没有」。
|
|
2072
|
+
if (res && res.workspaces) navList = res.workspaces;
|
|
2073
|
+
navSessions = {};
|
|
2074
|
+
renderNav();
|
|
2075
|
+
toast(res && res.created
|
|
2076
|
+
? '已登记《' + res.workspace.title + '》'
|
|
2077
|
+
: '《' + res.workspace.title + '》本来就在列表里');
|
|
2078
|
+
})
|
|
2079
|
+
.catch(function (err) {
|
|
2080
|
+
browseBusy = false;
|
|
2081
|
+
toast(err.message);
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function makeFolder() {
|
|
2086
|
+
var input = $('mkdirName');
|
|
2087
|
+
var name = input ? input.value : '';
|
|
2088
|
+
if (browseBusy) return;
|
|
2089
|
+
browseBusy = true;
|
|
2090
|
+
api('/mini/api/browse/mkdir', { method: 'POST', body: JSON.stringify({ path: browseAt, name: name }) })
|
|
2091
|
+
.then(function (res) {
|
|
2092
|
+
browseBusy = false;
|
|
2093
|
+
browseMkdir = false;
|
|
2094
|
+
browseTo(res.path);
|
|
2095
|
+
})
|
|
2096
|
+
.catch(function (err) {
|
|
2097
|
+
browseBusy = false;
|
|
2098
|
+
toast(err.message);
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1456
2102
|
function toggleWorkspace(wsId) {
|
|
1457
2103
|
if (navExpanded[wsId]) {
|
|
1458
2104
|
delete navExpanded[wsId];
|
|
@@ -1474,6 +2120,49 @@
|
|
|
1474
2120
|
});
|
|
1475
2121
|
}
|
|
1476
2122
|
|
|
2123
|
+
// 防连点。建会话**不是幂等的**——点两下就在电脑上真建出两个会话来,
|
|
2124
|
+
// 而手机上什么都看不出来(第二个只是把列表撑长了一行)。所以这里必须自己拦。
|
|
2125
|
+
// 服务端那道闸门拦不住这个:两个请求都是合法的。
|
|
2126
|
+
var creatingSession = false;
|
|
2127
|
+
|
|
2128
|
+
function createSession(wsId) {
|
|
2129
|
+
if (creatingSession) return;
|
|
2130
|
+
creatingSession = true;
|
|
2131
|
+
Array.prototype.forEach.call($('navBody').querySelectorAll('.ws-new'), function (el) {
|
|
2132
|
+
el.disabled = true;
|
|
2133
|
+
});
|
|
2134
|
+
function done() {
|
|
2135
|
+
creatingSession = false;
|
|
2136
|
+
Array.prototype.forEach.call($('navBody').querySelectorAll('.ws-new'), function (el) {
|
|
2137
|
+
el.disabled = false;
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
// POST 到工作区会话那条路径上:GET 是「列出来」,POST 是「建一个」。
|
|
2141
|
+
// 服务端建完会顺手 bind 并把新快照一起返回,所以这里一次请求就完成了
|
|
2142
|
+
// 「建 + 切过去」,不用再补一次 bind。
|
|
2143
|
+
//
|
|
2144
|
+
// **要把 promise 返回出去**:起了异步动作就该让调用方等得到它。
|
|
2145
|
+
// 不返回的话外面拿到的是 undefined,接不上任何后续。
|
|
2146
|
+
return api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions', {
|
|
2147
|
+
method: 'POST',
|
|
2148
|
+
body: '{}',
|
|
2149
|
+
}).then(function (res) {
|
|
2150
|
+
done();
|
|
2151
|
+
// 接口把绑定后的快照一起给了,直接渲染——不这样的话,切过去的一瞬间
|
|
2152
|
+
// 屏幕上还留着上一个会话的正文。
|
|
2153
|
+
if (res && res.state) applySnapshot(res.state);
|
|
2154
|
+
else render();
|
|
2155
|
+
closeNav();
|
|
2156
|
+
toast('已在《' + ((res && res.workspaceTitle) || '工作区') + '》里建好新会话');
|
|
2157
|
+
// 让列表里立刻出现这条新会话。force 是为了绕过服务端那份 30 秒的缓存——
|
|
2158
|
+
// 刚建的会话它还不知道,不绕过的话切回来会「咦刚才那条呢」。
|
|
2159
|
+
loadWorkspaces(true);
|
|
2160
|
+
}).catch(function (err) {
|
|
2161
|
+
done();
|
|
2162
|
+
toast(err.message);
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
1477
2166
|
function bindSession(id) {
|
|
1478
2167
|
if (!id) return;
|
|
1479
2168
|
api('/mini/api/bind', { method: 'POST', body: JSON.stringify({ sessionId: id }) })
|
|
@@ -1506,8 +2195,32 @@
|
|
|
1506
2195
|
$('navBody').addEventListener('click', function (e) {
|
|
1507
2196
|
var t = e.target;
|
|
1508
2197
|
var sess = t && t.closest ? t.closest('.sess') : null;
|
|
1509
|
-
if (sess) {
|
|
1510
|
-
|
|
2198
|
+
if (sess) {
|
|
2199
|
+
// 目录浏览那一屏里的 .sess 是「进这个目录」,不是「切到这个会话」。
|
|
2200
|
+
// 两屏共用同一个条目样式,所以要先看当前在哪一屏。
|
|
2201
|
+
if (navView === 'browse') { browseTo(sess.getAttribute('data-browse-to')); return; }
|
|
2202
|
+
bindSession(sess.getAttribute('data-sess'));
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
var at = function (sel) { return t && t.closest ? t.closest(sel) : null; };
|
|
2206
|
+
|
|
2207
|
+
var newWs = at('[data-new-ws]');
|
|
2208
|
+
if (newWs) { openBrowse(); return; }
|
|
2209
|
+
var back = at('[data-browse-back]');
|
|
2210
|
+
if (back) { closeBrowse(); return; }
|
|
2211
|
+
var to = at('[data-browse-to]');
|
|
2212
|
+
if (to) { browseTo(to.getAttribute('data-browse-to')); return; }
|
|
2213
|
+
var pick = at('[data-pick]');
|
|
2214
|
+
if (pick) { registerWorkspace(pick.getAttribute('data-pick')); return; }
|
|
2215
|
+
if (at('[data-mkdir]')) { browseMkdir = true; renderNav(); return; }
|
|
2216
|
+
if (at('[data-mkdir-cancel]')) { browseMkdir = false; renderNav(); return; }
|
|
2217
|
+
if (at('[data-mkdir-ok]')) { makeFolder(); return; }
|
|
2218
|
+
|
|
2219
|
+
// 「新建会话」要排在 ws-head 之前判:它在 ws-body 里面、不是 ws-head,
|
|
2220
|
+
// 顺序上无所谓,但放在前面更清楚——这一支是动作,那一支是展开/收起。
|
|
2221
|
+
var fresh = at('.ws-new');
|
|
2222
|
+
if (fresh) { createSession(fresh.getAttribute('data-newws')); return; }
|
|
2223
|
+
var head = at('.ws-head');
|
|
1511
2224
|
if (!head) return;
|
|
1512
2225
|
toggleWorkspace(head.parentNode.getAttribute('data-ws'));
|
|
1513
2226
|
});
|
|
@@ -1550,9 +2263,122 @@
|
|
|
1550
2263
|
}
|
|
1551
2264
|
setRunning(false);
|
|
1552
2265
|
render();
|
|
1553
|
-
if (!document.hidden) {
|
|
1554
|
-
|
|
1555
|
-
|
|
2266
|
+
if (!document.hidden) {
|
|
2267
|
+
playDing(); buzz();
|
|
2268
|
+
} else {
|
|
2269
|
+
// 页面在后台时,响铃和震动都叫不醒人——那两样只在页面看得见的时候有用。
|
|
2270
|
+
//
|
|
2271
|
+
// 原来这里指望系统通知兜底。2026-09-25 试到底了:**锁屏时系统会冻结后台页面**,
|
|
2272
|
+
// 实时连接断掉,这段代码根本轮不到执行。不锁屏的后台(切到别的应用)它倒是能用,
|
|
2273
|
+
// 但用户试过之后决定浏览器里不做这个功能了,理由和处置见下面「页面在后台时提醒」。
|
|
2274
|
+
//
|
|
2275
|
+
// 调用留着:开关不露出来了,state.notify 就永远是 false,这个函数会立刻返回。
|
|
2276
|
+
notifySystem(payload.text);
|
|
2277
|
+
}
|
|
2278
|
+
});
|
|
2279
|
+
// ——— 回答电脑的提问 ———
|
|
2280
|
+
//
|
|
2281
|
+
// 电脑那边提问时,插件把题目推一条 `question` 过来,答完 POST 回 /mini/api/answer。
|
|
2282
|
+
// 三条是用户定下的:一个一个答(答完这题才出下一题)、点错了能回头改、能自己打字。
|
|
2283
|
+
let ask = null
|
|
2284
|
+
function askRender() {
|
|
2285
|
+
if (!ask) return
|
|
2286
|
+
const q = ask.questions[ask.i]
|
|
2287
|
+
$('askHead').textContent = ask.questions.length > 1
|
|
2288
|
+
? '电脑问你第 ' + (ask.i + 1) + ' 题,一共 ' + ask.questions.length + ' 题'
|
|
2289
|
+
: '电脑在问你一件事'
|
|
2290
|
+
$('askText').textContent = q.question || ''
|
|
2291
|
+
$('askDetail').textContent = q.detail || ''
|
|
2292
|
+
$('askDetail').style.display = q.detail ? '' : 'none'
|
|
2293
|
+
const picked = ask.picked[ask.i] || { selected: [], custom: '' }
|
|
2294
|
+
const box = $('askOpts')
|
|
2295
|
+
box.innerHTML = ''
|
|
2296
|
+
const options = Array.isArray(q.options) ? q.options : []
|
|
2297
|
+
options.forEach(function (o) {
|
|
2298
|
+
const btn = document.createElement('button')
|
|
2299
|
+
btn.type = 'button'
|
|
2300
|
+
btn.className = 'opt' + (picked.selected.indexOf(o.label) >= 0 ? ' sel' : '')
|
|
2301
|
+
const name = document.createElement('span')
|
|
2302
|
+
name.textContent = o.label
|
|
2303
|
+
btn.appendChild(name)
|
|
2304
|
+
if (o.description) {
|
|
2305
|
+
const sub = document.createElement('small')
|
|
2306
|
+
sub.textContent = o.description
|
|
2307
|
+
btn.appendChild(sub)
|
|
2308
|
+
}
|
|
2309
|
+
btn.addEventListener('click', function () {
|
|
2310
|
+
// 允许多选的题就点一下选中、再点一下取消;单选的点谁就是谁。
|
|
2311
|
+
if (q.multiSelect) {
|
|
2312
|
+
const at = picked.selected.indexOf(o.label)
|
|
2313
|
+
if (at >= 0) picked.selected.splice(at, 1)
|
|
2314
|
+
else picked.selected.push(o.label)
|
|
2315
|
+
} else {
|
|
2316
|
+
picked.selected = [o.label]
|
|
2317
|
+
}
|
|
2318
|
+
// 选了选项就清掉"我自己说"——两者只留一个,免得电脑收到含糊的答案。
|
|
2319
|
+
picked.custom = ''
|
|
2320
|
+
ask.picked[ask.i] = picked
|
|
2321
|
+
askRender()
|
|
2322
|
+
})
|
|
2323
|
+
box.appendChild(btn)
|
|
2324
|
+
})
|
|
2325
|
+
$('askCustomWrap').style.display = ask.custom ? '' : 'none'
|
|
2326
|
+
$('askCustom').value = picked.custom || ''
|
|
2327
|
+
$('askBack').style.display = ask.i > 0 ? '' : 'none'
|
|
2328
|
+
$('askNext').textContent = ask.i === ask.questions.length - 1 ? '答好了,发回电脑' : '下一题'
|
|
2329
|
+
$('askCard').classList.add('on')
|
|
2330
|
+
}
|
|
2331
|
+
$('askOwn').addEventListener('click', function () {
|
|
2332
|
+
if (!ask) return
|
|
2333
|
+
ask.custom = !ask.custom
|
|
2334
|
+
$('askCustomWrap').style.display = ask.custom ? '' : 'none'
|
|
2335
|
+
if (ask.custom) $('askCustom').focus()
|
|
2336
|
+
})
|
|
2337
|
+
$('askCustom').addEventListener('input', function () {
|
|
2338
|
+
if (!ask) return
|
|
2339
|
+
const picked = ask.picked[ask.i] || { selected: [], custom: '' }
|
|
2340
|
+
picked.custom = $('askCustom').value
|
|
2341
|
+
picked.selected = []
|
|
2342
|
+
ask.picked[ask.i] = picked
|
|
2343
|
+
askRender()
|
|
2344
|
+
})
|
|
2345
|
+
$('askBack').addEventListener('click', function () {
|
|
2346
|
+
if (!ask || ask.i === 0) return
|
|
2347
|
+
ask.i -= 1
|
|
2348
|
+
askRender()
|
|
2349
|
+
})
|
|
2350
|
+
$('askNext').addEventListener('click', function () {
|
|
2351
|
+
if (!ask) return
|
|
2352
|
+
const picked = ask.picked[ask.i] || { selected: [], custom: '' }
|
|
2353
|
+
if (!picked.selected.length && !(picked.custom || '').trim()) {
|
|
2354
|
+
toast('先选一个,或者点「我自己说」写一句')
|
|
2355
|
+
return
|
|
2356
|
+
}
|
|
2357
|
+
if (ask.i < ask.questions.length - 1) {
|
|
2358
|
+
ask.i += 1
|
|
2359
|
+
askRender()
|
|
2360
|
+
return
|
|
2361
|
+
}
|
|
2362
|
+
// 最后一题答完:把所有题的答案一起交回去。
|
|
2363
|
+
const answers = ask.questions.map(function (q, k) {
|
|
2364
|
+
const one = ask.picked[k] || { selected: [], custom: '' }
|
|
2365
|
+
const answer = { id: q.id, selected: one.selected.slice() }
|
|
2366
|
+
if ((one.custom || '').trim()) answer.custom = one.custom.trim()
|
|
2367
|
+
return answer
|
|
2368
|
+
})
|
|
2369
|
+
const id = ask.id
|
|
2370
|
+
ask = null
|
|
2371
|
+
$('askCard').classList.remove('on')
|
|
2372
|
+
// 发失败也不弹窗打扰:电脑那边等不到答案会自己还给浏览器,用户不会卡住。
|
|
2373
|
+
api('/mini/api/answer', { method: 'POST', body: JSON.stringify({ id: id, answers: answers }) })
|
|
2374
|
+
.catch(function () {})
|
|
2375
|
+
})
|
|
2376
|
+
es.addEventListener('question', function (e) {
|
|
2377
|
+
let payload = null
|
|
2378
|
+
try { payload = JSON.parse(e.data) } catch (err) { return }
|
|
2379
|
+
if (!payload || !payload.id || !Array.isArray(payload.questions) || !payload.questions.length) return
|
|
2380
|
+
ask = { id: payload.id, questions: payload.questions, i: 0, picked: {}, custom: false }
|
|
2381
|
+
askRender()
|
|
1556
2382
|
});
|
|
1557
2383
|
es.addEventListener('status', function (e) {
|
|
1558
2384
|
try { setRunning(JSON.parse(e.data).running); } catch (err) {}
|
|
@@ -1571,6 +2397,183 @@
|
|
|
1571
2397
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';
|
|
1572
2398
|
}
|
|
1573
2399
|
|
|
2400
|
+
// ---------------- 斜杠指令(在输入框里打 / 时弹出来的菜单) ----------------
|
|
2401
|
+
//
|
|
2402
|
+
// 手机上敲 `/` 要能看见这个会话有哪些指令、点一下就执行。这一块管三件事:
|
|
2403
|
+
// 菜单怎么出(paintCmdMenu)、点一条怎么处理(pickCommand)、执行本身
|
|
2404
|
+
// (runCommand,下面 send 那条路也用它)。
|
|
2405
|
+
//
|
|
2406
|
+
// 数据**按会话缓存**,理由见 state 里 commands* 那几个字段。
|
|
2407
|
+
//
|
|
2408
|
+
// 输入框本来的提示语。选了一条「要参数」的指令时它会被换成那句 hint,发出去之后
|
|
2409
|
+
// 要换回来——留着上一个指令的参数提示,下一个普通问题就顶着一句不相干的话。
|
|
2410
|
+
var INPUT_HINT = inputEl.placeholder || '';
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* 输入框里那个「还没打完的指令」。
|
|
2414
|
+
*
|
|
2415
|
+
* 命中时返回 `/` 后面那一段(只打了一个 `/` 就是空串),没命中返回 null。
|
|
2416
|
+
* 判据收得很紧:**整行**必须正好是「/ 加一串小写字母数字下划线横线」。出现空格、
|
|
2417
|
+
* 前面多了别的字、用了大写,都算没命中——菜单收起来,交给 send 里那条分流规则。
|
|
2418
|
+
*
|
|
2419
|
+
* 只认小写是因为 DSH 的指令名就是小写:把大写也当命中,用户会看到「打了没反应」
|
|
2420
|
+
* (过滤出零条 → 菜单收起),而真实原因只是他开着大写。
|
|
2421
|
+
*/
|
|
2422
|
+
function slashQuery() {
|
|
2423
|
+
var m = /^\/([a-z0-9_-]*)$/.exec(inputEl.value);
|
|
2424
|
+
return m ? m[1] : null;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function hideCmdMenu() {
|
|
2428
|
+
var el = $('cmdMenu');
|
|
2429
|
+
if (!el) return;
|
|
2430
|
+
el.hidden = true;
|
|
2431
|
+
el.innerHTML = '';
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
/** 这个会话的指令表是不是还没有(或者需要重取)。 */
|
|
2435
|
+
function needCommands() {
|
|
2436
|
+
// 换了会话就得重取,见 state 里 commands* 的说明。
|
|
2437
|
+
if (state.commandsFor !== state.boundSessionId) return true;
|
|
2438
|
+
// 这个会话还什么都没拿到、也没失败过——第一次用到它的时候去取。
|
|
2439
|
+
return !state.commands && !state.commandsError;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function loadCommands() {
|
|
2443
|
+
// 正在取就别再发一次:菜单是跟着输入框每敲一个字重画的,不拦着的话
|
|
2444
|
+
// 打「/compact」会连发八个请求。
|
|
2445
|
+
if (state.commandsLoading) return;
|
|
2446
|
+
state.commandsLoading = true;
|
|
2447
|
+
// 记下这份数据是**给哪个会话**取的,而不是「请求是什么时候发的」——
|
|
2448
|
+
// 请求飞在路上时用户完全可能已经切走了,回来那一份就不作数了。
|
|
2449
|
+
var forSession = state.boundSessionId;
|
|
2450
|
+
state.commands = null;
|
|
2451
|
+
state.commandsError = '';
|
|
2452
|
+
api('/mini/api/commands').then(function (res) {
|
|
2453
|
+
state.commandsLoading = false;
|
|
2454
|
+
if (forSession !== state.boundSessionId) return; // 不是这个会话的,扔掉
|
|
2455
|
+
state.commandsFor = forSession;
|
|
2456
|
+
if (res && res.ok) {
|
|
2457
|
+
state.commands = res.commands || [];
|
|
2458
|
+
state.commandsError = '';
|
|
2459
|
+
} else {
|
|
2460
|
+
// 服务端给了原因(多半是「这个会话现在没在跑」)就原样留着显示。
|
|
2461
|
+
// **不能显示成空列表**:那等于说「这个会话没有指令」,是另一回事。
|
|
2462
|
+
state.commandsError = (res && res.error) || '读不到指令列表';
|
|
2463
|
+
}
|
|
2464
|
+
paintCmdMenu();
|
|
2465
|
+
}).catch(function (err) {
|
|
2466
|
+
state.commandsLoading = false;
|
|
2467
|
+
if (forSession !== state.boundSessionId) return;
|
|
2468
|
+
state.commandsFor = forSession;
|
|
2469
|
+
state.commands = null;
|
|
2470
|
+
state.commandsError = err.message || '读不到指令列表';
|
|
2471
|
+
paintCmdMenu();
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/**
|
|
2476
|
+
* 画(或收起)那只菜单。
|
|
2477
|
+
*
|
|
2478
|
+
* 它**每回都按输入框重算**,自己不记「现在是开着还是关着」:这样就不会出现
|
|
2479
|
+
* 「菜单和输入框对不上」的状态——比如程序清空输入框(点菜单、发完之后)不会触发
|
|
2480
|
+
* input 事件,那种时候没人替我们收菜单,只能自己出面对齐。
|
|
2481
|
+
*/
|
|
2482
|
+
function paintCmdMenu() {
|
|
2483
|
+
var el = $('cmdMenu');
|
|
2484
|
+
if (!el) return;
|
|
2485
|
+
var prefix = slashQuery();
|
|
2486
|
+
if (prefix === null) { hideCmdMenu(); return; }
|
|
2487
|
+
var wasOpen = !el.hidden;
|
|
2488
|
+
// 上次没取到时,**每次重新打开可以再试一次**(关着→打开这一下才重取,不是每敲一个
|
|
2489
|
+
// 字都取)。这一条是必须的:失败的原因多半是「这个会话现在没在跑」,而用户完全可能
|
|
2490
|
+
// 随后就发了条消息让它跑起来——那时再打 /,看到的却是上一句已经过时的话,
|
|
2491
|
+
// 他会以为这个功能坏了。
|
|
2492
|
+
if (needCommands() || (!wasOpen && state.commandsError)) loadCommands();
|
|
2493
|
+
el.hidden = false;
|
|
2494
|
+
if (state.commandsError) {
|
|
2495
|
+
el.innerHTML = '<div class="cmd-note">' + escapeHtml(state.commandsError) + '</div>';
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
if (!state.commands) {
|
|
2499
|
+
// 取回来之前先占个位——不然敲了 / 什么都不发生,看着像坏了。
|
|
2500
|
+
el.innerHTML = '<div class="cmd-note">正在读取可用的指令…</div>';
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
var rows = state.commands.filter(function (c) {
|
|
2504
|
+
return String(c.name || '').indexOf(prefix) === 0;
|
|
2505
|
+
});
|
|
2506
|
+
// 一条都对不上就收起来,**不写「没有匹配的指令」**:用户还在打字,打到一半没有
|
|
2507
|
+
// 匹配是常事,插一句提示只是噪音。真打错了指令,按发送时会被明确拒绝并说明。
|
|
2508
|
+
if (!rows.length) { hideCmdMenu(); return; }
|
|
2509
|
+
el.innerHTML = rows.map(function (c) {
|
|
2510
|
+
return '<button class="cmd-row" type="button" data-cmd="' + escapeHtml(c.name) + '">'
|
|
2511
|
+
+ '<span class="cmd-name">/' + escapeHtml(c.name) + '</span>'
|
|
2512
|
+
+ '<span class="cmd-desc">' + escapeHtml(c.description || '') + '</span></button>';
|
|
2513
|
+
}).join('');
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
/**
|
|
2517
|
+
* 菜单里点了一条。
|
|
2518
|
+
*
|
|
2519
|
+
* 分两路,判据是服务端给的 hint(这条指令要不要参数):
|
|
2520
|
+
* · 不要参数 —— 直接执行。让用户再按一次发送是白白多一步。
|
|
2521
|
+
* · 要参数 —— 把 `/名字 ` 填进输入框,末尾留一个空格、光标停在后面,并把
|
|
2522
|
+
* hint 那句话挂到输入框的 placeholder 上。
|
|
2523
|
+
* **不自己编一句「请输入参数」**:只有服务端知道这条指令要什么,
|
|
2524
|
+
* 编一句就等于让用户照着一个错的提示去填。
|
|
2525
|
+
*/
|
|
2526
|
+
function pickCommand(name) {
|
|
2527
|
+
var list = state.commands || [];
|
|
2528
|
+
var cmd = null;
|
|
2529
|
+
for (var i = 0; i < list.length; i++) { if (list[i].name === name) cmd = list[i]; }
|
|
2530
|
+
if (!cmd) return;
|
|
2531
|
+
hideCmdMenu();
|
|
2532
|
+
// 先把提示语还原,再按这一条决定要不要挂 hint——不然上一次点的那条「要参数」的
|
|
2533
|
+
// 指令留下的提示,会跟到这一条(不需要参数的)头上。
|
|
2534
|
+
inputEl.placeholder = INPUT_HINT;
|
|
2535
|
+
if (cmd.hint) {
|
|
2536
|
+
inputEl.value = '/' + cmd.name + ' ';
|
|
2537
|
+
inputEl.placeholder = cmd.hint;
|
|
2538
|
+
inputEl.focus();
|
|
2539
|
+
// 光标停在最后:停在名字中间的话,用户接着打的字会插进名字里去。
|
|
2540
|
+
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
|
|
2541
|
+
autoGrow();
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
inputEl.value = '';
|
|
2545
|
+
autoGrow();
|
|
2546
|
+
runCommand('/' + cmd.name);
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/**
|
|
2550
|
+
* 执行一条斜杠指令。
|
|
2551
|
+
*
|
|
2552
|
+
* 和普通消息走的是**两条不同的接口**:/mini/api/command 收的是完整的一行(含 /
|
|
2553
|
+
* 和参数),/mini/api/send 收的是「发给模型的一句话」。手机这边不替用户猜他打的是
|
|
2554
|
+
* 哪一个——猜错的代价是一次真花掉额度的提问,而且电脑端对不存在的指令是明确拒绝的,
|
|
2555
|
+
* 两边行为得一致。
|
|
2556
|
+
*
|
|
2557
|
+
* 接口只回一个 ok:这条指令**存不存在、跑得怎么样,都不在这里**。开始和结果由会话
|
|
2558
|
+
* 事件推回来(历史里出现 role:'command' 的条目,kind 从 running 变 success/error)。
|
|
2559
|
+
* 所以这里既不做乐观显示(会跟推来的那条重一份),也不报喜——只把「当场被拒」
|
|
2560
|
+
* 这一种情况说出来。
|
|
2561
|
+
*/
|
|
2562
|
+
function runCommand(line) {
|
|
2563
|
+
api('/mini/api/command', { method: 'POST', body: JSON.stringify({ line: line }) })
|
|
2564
|
+
.then(function (res) {
|
|
2565
|
+
if (res && res.ok) return;
|
|
2566
|
+
// 被拒(没有这条指令 / 语法不对)的原因必须让用户看见,不能悄悄咽掉。
|
|
2567
|
+
toast((res && res.error) || '这条指令没跑起来');
|
|
2568
|
+
})
|
|
2569
|
+
.catch(function (err) { toast(err.message || '这条指令没跑起来'); });
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
$('cmdMenu').addEventListener('click', function (e) {
|
|
2573
|
+
var btn = e.target && e.target.closest ? e.target.closest('[data-cmd]') : null;
|
|
2574
|
+
if (btn) pickCommand(btn.getAttribute('data-cmd'));
|
|
2575
|
+
});
|
|
2576
|
+
|
|
1574
2577
|
// ---------------- 上传附件 ----------------
|
|
1575
2578
|
/** 把字节数说成人话。 */
|
|
1576
2579
|
function humanSize(n) {
|
|
@@ -1654,6 +2657,27 @@
|
|
|
1654
2657
|
mainEl.scrollTop = mainEl.scrollHeight;
|
|
1655
2658
|
inputEl.value = '';
|
|
1656
2659
|
autoGrow();
|
|
2660
|
+
// 输入框空了,菜单得当场收起来:清空是程序改的 value,不会触发 input 事件,
|
|
2661
|
+
// 没人替我们重算,菜单就会挂在那儿配着一个空输入框。
|
|
2662
|
+
hideCmdMenu();
|
|
2663
|
+
inputEl.placeholder = INPUT_HINT;
|
|
2664
|
+
|
|
2665
|
+
// **以 / 打头的走指令那条路,不再当普通消息发。**
|
|
2666
|
+
//
|
|
2667
|
+
// 这是用户拍板的:手机上打了一条不存在的指令,要跟电脑端一样被明确拒绝并说明原因,
|
|
2668
|
+
// 不能悄悄当成一次提问发给模型——那既让两边行为不一致,又等于把一次敲错变成一次
|
|
2669
|
+
// 真花掉额度的提问。
|
|
2670
|
+
//
|
|
2671
|
+
// 存不存在由服务端判(这边的指令表可能是缓存的旧值),所以这里不看列表,
|
|
2672
|
+
// 只认「trim 之后第一个字符是不是 /」。
|
|
2673
|
+
if (text.charAt(0) === '/') {
|
|
2674
|
+
// 附件跟不过去:指令接口收的只有那一行(见 runCommand 的说明)。
|
|
2675
|
+
// 这里**不动 state.uploads**——清掉就等于把用户刚传上来的文件悄悄扔了,
|
|
2676
|
+
// 而他还以为跟着发出去了。留在小条上看得见,想发就配一句话再发一次。
|
|
2677
|
+
if (files.length) toast('指令带不了附件,附件还留在下面');
|
|
2678
|
+
runCommand(text);
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
1657
2681
|
|
|
1658
2682
|
var uploadIds = files.map(function (f) { return f.uploadId; });
|
|
1659
2683
|
// 手机上先把附件收起来:小票已经交出去了,留着会让用户以为还能再发一次。
|
|
@@ -1726,6 +2750,9 @@
|
|
|
1726
2750
|
|
|
1727
2751
|
$('btnSend').addEventListener('click', send);
|
|
1728
2752
|
inputEl.addEventListener('input', autoGrow);
|
|
2753
|
+
// 每敲一个字重算一次菜单。它自己会先看「是不是一个没打完的 / 名字」再决定动不动,
|
|
2754
|
+
// 所以这里不用加判据。
|
|
2755
|
+
inputEl.addEventListener('input', paintCmdMenu);
|
|
1729
2756
|
inputEl.addEventListener('keydown', function (e) {
|
|
1730
2757
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
1731
2758
|
e.preventDefault();
|
|
@@ -1756,6 +2783,118 @@
|
|
|
1756
2783
|
$('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
|
|
1757
2784
|
sheet.addEventListener('click', function (e) { if (e.target === sheet) sheet.classList.remove('open'); });
|
|
1758
2785
|
|
|
2786
|
+
// ---------------- 权限档位 ----------------
|
|
2787
|
+
//
|
|
2788
|
+
// 这是把 PC 端那道授权闸门搬到了手机上:**手机从此能指挥 Agent 把电脑提到
|
|
2789
|
+
// 完全权限**。闸门还在(要 token / 密码),但它守的那扇门换了一扇——
|
|
2790
|
+
// 拿到手机的人等于拿到了这台电脑的写权限。这是功能本身的代价,不是瑕疵。
|
|
2791
|
+
//
|
|
2792
|
+
// 所以「完全权限」那一档要点两次。用页面内的两步确认、不用 window.confirm:
|
|
2793
|
+
// 那种弹窗在微信这类内嵌浏览器里会被直接屏蔽,而在屏蔽的情况下万一返回
|
|
2794
|
+
// 真值就等于确认被跳过了——不安全的方向上不能赌。
|
|
2795
|
+
//
|
|
2796
|
+
// 档位名和标签都是**服务端读出来再给过来的**:有哪些档位、什么顺序、哪个要
|
|
2797
|
+
// 确认,这一侧一个都不写死。服务端那边负责把标识符(read-only 这类 DSH 自己的
|
|
2798
|
+
// 键名)翻成中文——服务给的 optionOf() 只回键名,三个中文是 DSH 网页界面里的
|
|
2799
|
+
// 客户端翻译,服务里没有,所以那张表放在 lib/permissions.js。
|
|
2800
|
+
// 映射不到时会退回标识符本身,所以这里拿到的一定是个真名字,不会是空格。
|
|
2801
|
+
// 页面只管显示拿到的东西。
|
|
2802
|
+
var permOptions = null; // null = 还没读到(或这台电脑没这个能力)
|
|
2803
|
+
var permArmed = ''; // 哪一档正在等「再点一次」
|
|
2804
|
+
|
|
2805
|
+
function loadPermissions() {
|
|
2806
|
+
api('/mini/api/permissions').then(function (body) {
|
|
2807
|
+
if (!body || !body.ok) { $('permBlock').hidden = true; return; }
|
|
2808
|
+
permOptions = body;
|
|
2809
|
+
renderPerms();
|
|
2810
|
+
}).catch(function () {
|
|
2811
|
+
// 读不到就整块不显示。附加能力缺了,不该影响发指令。
|
|
2812
|
+
permOptions = null;
|
|
2813
|
+
$('permBlock').hidden = true;
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function renderPerms() {
|
|
2818
|
+
if (!permOptions) return;
|
|
2819
|
+
$('permBlock').hidden = false;
|
|
2820
|
+
var seg = $('segPerm');
|
|
2821
|
+
seg.innerHTML = (permOptions.options || []).map(function (o) {
|
|
2822
|
+
var cls = [];
|
|
2823
|
+
if (o.value === permOptions.currentValue) cls.push('active');
|
|
2824
|
+
if (o.value === permArmed) cls.push('armed');
|
|
2825
|
+
return '<button type="button" data-perm="' + escapeHtml(o.value) + '"'
|
|
2826
|
+
+ (cls.length ? ' class="' + cls.join(' ') + '"' : '') + '>'
|
|
2827
|
+
+ escapeHtml(o.value === permArmed ? '再点一次:' + o.name : o.name)
|
|
2828
|
+
+ '</button>';
|
|
2829
|
+
}).join('');
|
|
2830
|
+
$('permHint').textContent = permHintText();
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
/**
|
|
2834
|
+
* 提示语。
|
|
2835
|
+
*
|
|
2836
|
+
* 服务没给说明文字时**不硬编一句**——本机三个档位的 description 全是空的。
|
|
2837
|
+
* 那就在这一行显示出当前档位的名字(这个名字是真的,来自服务端),
|
|
2838
|
+
* 而不是编一段「Agent 只能读」这种我没法保证准确的话。
|
|
2839
|
+
* 「现在在哪一档」主要由按钮的高亮表示,这一行只是补充。
|
|
2840
|
+
*/
|
|
2841
|
+
function permHintText() {
|
|
2842
|
+
if (!permOptions) return '';
|
|
2843
|
+
var cur = (permOptions.options || []).filter(function (o) {
|
|
2844
|
+
return o.value === permOptions.currentValue;
|
|
2845
|
+
})[0];
|
|
2846
|
+
if (!cur) return '作用在当前会话上';
|
|
2847
|
+
return cur.description || ('当前:' + cur.name);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
function tapPerm(name) {
|
|
2851
|
+
if (!permOptions) return;
|
|
2852
|
+
// 点的就是当前这一档:什么都不做。不这么做的话,一次误触会往会话日志里
|
|
2853
|
+
// 记一条没有意义的切换。
|
|
2854
|
+
if (name === permOptions.currentValue) return;
|
|
2855
|
+
var opt = (permOptions.options || []).filter(function (o) { return o.value === name; })[0];
|
|
2856
|
+
if (!opt) return;
|
|
2857
|
+
|
|
2858
|
+
if (opt.dangerous && permArmed !== name) {
|
|
2859
|
+
permArmed = name;
|
|
2860
|
+
renderPerms();
|
|
2861
|
+
toast('再点一次「' + opt.name + '」就切过去');
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
permArmed = '';
|
|
2865
|
+
api('/mini/api/permissions', { method: 'POST', body: JSON.stringify({ name: name }) })
|
|
2866
|
+
.then(function (body) {
|
|
2867
|
+
if (!body || !body.ok) return;
|
|
2868
|
+
// 服务端把切换后的最新状态一起回来了,直接重画,不用再补一趟。
|
|
2869
|
+
permOptions = body;
|
|
2870
|
+
renderPerms();
|
|
2871
|
+
toast('已切到「' + opt.name + '」');
|
|
2872
|
+
})
|
|
2873
|
+
.catch(function (err) {
|
|
2874
|
+
permArmed = '';
|
|
2875
|
+
renderPerms();
|
|
2876
|
+
toast(err.message);
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
loadPermissions();
|
|
2881
|
+
|
|
2882
|
+
// 档位按钮的点击。
|
|
2883
|
+
//
|
|
2884
|
+
// 监听挂在**容器**上,不挂在按钮上——`renderPerms()` 每次都整块换 innerHTML,
|
|
2885
|
+
// 挂按钮上的监听会被下一次重画冲掉。挂在容器上就不受影响。
|
|
2886
|
+
//
|
|
2887
|
+
// 这个坑真机踩到过:三档看得见、点下去毫无反应,因为 `tapPerm` 一次都没被调用。
|
|
2888
|
+
// 单元测试没抓住它,是因为测试直接调 `tapPerm` 验逻辑,而真机上缺的是**接线**——
|
|
2889
|
+
// 逻辑对、没人喊它。
|
|
2890
|
+
$('segPerm').addEventListener('click', function (e) {
|
|
2891
|
+
var btn = e.target;
|
|
2892
|
+
// 按钮里现在只有文字,点到的就是按钮本身;这个循环是防将来往里塞图标。
|
|
2893
|
+
while (btn && btn !== this && !(btn.dataset && btn.dataset.perm)) btn = btn.parentNode;
|
|
2894
|
+
if (!btn || btn === this) return;
|
|
2895
|
+
tapPerm(btn.dataset.perm);
|
|
2896
|
+
});
|
|
2897
|
+
|
|
1759
2898
|
Array.prototype.forEach.call($('segMode').children, function (btn) {
|
|
1760
2899
|
btn.addEventListener('click', function () {
|
|
1761
2900
|
state.mode = btn.dataset.mode;
|
|
@@ -1783,18 +2922,145 @@
|
|
|
1783
2922
|
|
|
1784
2923
|
// 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
|
|
1785
2924
|
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
2925
|
+
// ---------------- 锁屏也能提醒(系统通知) ----------------
|
|
2926
|
+
//
|
|
2927
|
+
// 页内的响铃和震动只在页面看得见的时候有用。人把手机放下、屏幕锁了,那两样都
|
|
2928
|
+
// 叫不醒他——要叫醒他只能发系统通知。
|
|
2929
|
+
//
|
|
2930
|
+
// 这条路以前是关着的,三层原因叠在一起:
|
|
2931
|
+
// 1. 通知权限只能在「安全上下文」(HTTPS 或 localhost)里申请。当时三条连接
|
|
2932
|
+
// 路径全是明文 HTTP,浏览器连这个 API 都不给;
|
|
1789
2933
|
// 2. 就算拿到权限,new Notification() 在大多数移动浏览器上会抛 TypeError,
|
|
1790
|
-
//
|
|
2934
|
+
// 移动端唯一正确的入口是 service worker 的 showNotification;
|
|
1791
2935
|
// 3. service worker 同样要求 HTTPS。
|
|
2936
|
+
// 2026-09-25 Tailscale 那条路接上 HTTPS,第 1、3 条同时成立,这条路才通。
|
|
1792
2937
|
//
|
|
1793
|
-
//
|
|
1794
|
-
//
|
|
1795
|
-
// 电脑端的外部推送(Bark / ntfy),它是从服务器发出去的,不受浏览器这条规矩限制。
|
|
2938
|
+
// **所以这一行只在加密连接下露出来。** 明文下露出来,用户点一下只会碰一鼻子灰
|
|
2939
|
+
// ——那正是 2026-09-21 把它整行撤掉的理由,那条理由现在仍然成立。
|
|
1796
2940
|
//
|
|
1797
|
-
//
|
|
2941
|
+
// 电脑端的外部推送(Bark / ntfy / Telegram)是另一条路:从服务器发出去,不经
|
|
2942
|
+
// 浏览器,明文连接下也照常能用。两者不冲突,各管一段。
|
|
2943
|
+
var NOTIFY_OK = window.isSecureContext
|
|
2944
|
+
&& 'serviceWorker' in navigator
|
|
2945
|
+
&& 'Notification' in window;
|
|
2946
|
+
|
|
2947
|
+
function notifySystem(text) {
|
|
2948
|
+
if (!state.notify || !NOTIFY_OK) return;
|
|
2949
|
+
if (Notification.permission !== 'granted') return;
|
|
2950
|
+
navigator.serviceWorker.ready.then(function (reg) {
|
|
2951
|
+
return reg.showNotification('回复到了', {
|
|
2952
|
+
body: String(text || '').replace(/\s+/g, ' ').trim().slice(0, 120),
|
|
2953
|
+
// 带上 tag:同一条回复重复到达时替换掉旧的,别堆一屏。
|
|
2954
|
+
tag: 'dsh-reply',
|
|
2955
|
+
renotify: true,
|
|
2956
|
+
// 地址由页面给。不能拿 service worker 自己的 location 拼——那是那个 .js
|
|
2957
|
+
// 文件的地址,用户点通知会打开一份源码。
|
|
2958
|
+
data: { url: location.href },
|
|
2959
|
+
});
|
|
2960
|
+
}).catch(function () { /* 发不出去就算了,不该影响主流程 */ });
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function syncNotify() {
|
|
2964
|
+
Array.prototype.forEach.call($('segNotify').children, function (b) {
|
|
2965
|
+
b.classList.toggle('active', (b.dataset.notify === 'on') === state.notify);
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
if (NOTIFY_OK) {
|
|
2970
|
+
// 先注册 service worker。**这一步不申请权限**——权限要用户主动点一下才给,
|
|
2971
|
+
// 页面一加载就弹窗会被浏览器直接拒掉,而且很讨厌。
|
|
2972
|
+
//
|
|
2973
|
+
// 注册成功才把那一行露出来。注册不上就继续藏着:露一个点了没用的开关,
|
|
2974
|
+
// 比没有更让人困惑。
|
|
2975
|
+
//
|
|
2976
|
+
// **2026-09-25 用户裁决:这一行不露出来了。**
|
|
2977
|
+
//
|
|
2978
|
+
// 试到底了:手机锁屏后系统冻结后台页面,那条实时连接跟着断掉,回复根本到不了页面,
|
|
2979
|
+
// 也就没人去发通知。这是手机系统的调度,插件绕不过去;service worker 也救不了——
|
|
2980
|
+
// 它是被事件唤醒的,没有事件它就不存在。
|
|
2981
|
+
//
|
|
2982
|
+
// 用户原话:「类似功能我们以后考虑做成 APP 时再加,浏览器里不放了。」
|
|
2983
|
+
//
|
|
2984
|
+
// **代码全部留着,只是不露出来。** service worker 照常注册(已经验证过在加密连接下
|
|
2985
|
+
// 能正常工作),通知那段逻辑照常挂在回复到达的分支里。将来做 App 里的锁屏提醒,
|
|
2986
|
+
// 这里的知识直接能用。
|
|
2987
|
+
//
|
|
2988
|
+
// 要重新打开:把下面两行取消注释。**但先把名字想清楚**——别再用「锁屏也能提醒」
|
|
2989
|
+
// 这种它做不到的说法,那是这一轮踩过的坑。
|
|
2990
|
+
navigator.serviceWorker.register('/mini/sw.js?v=' + BUILD).catch(function () {});
|
|
2991
|
+
// $('rowNotify').style.display = '';
|
|
2992
|
+
// $('notifyHint').textContent = notifyHintText();
|
|
2993
|
+
// syncNotify();
|
|
2994
|
+
|
|
2995
|
+
// 浏览器现在给的是什么状态,就说什么话。
|
|
2996
|
+
//
|
|
2997
|
+
// **不能只说「打开时会问一次」**——2026-09-25 用户点了「开」,浏览器什么都没问,
|
|
2998
|
+
// 界面上也一点变化都没有,看上去就是「点了没用」。真实原因是这个网站的通知权限
|
|
2999
|
+
// 早就被拒过,而 requestPermission() 在这种情况下**不会再弹**,直接回 denied。
|
|
3000
|
+
// 所以每一种状态都得有对应的一句话,用户才能自己判断下一步该干什么。
|
|
3001
|
+
function notifyHintText() {
|
|
3002
|
+
var p = Notification.permission;
|
|
3003
|
+
if (p === 'granted') return '回复到了发一条系统通知';
|
|
3004
|
+
if (p === 'denied') return '权限被拒过,浏览器不会再问。去浏览器设置里允许,或清掉本站数据后重新扫码';
|
|
3005
|
+
return '打开时浏览器会问一次要不要允许';
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
// 申请通知权限。**两种写法都要兼容**:新浏览器返回 Promise,老一点的和一些厂商
|
|
3009
|
+
// 定制内核只认回调式——直接 .then() 会抛,表现同样是「点了没反应」。
|
|
3010
|
+
// 回调式在所有支持这个 API 的浏览器上都会被调用(包括那些同时支持 Promise 的),
|
|
3011
|
+
// 所以以它为主,Promise 只当兜底。
|
|
3012
|
+
function askNotifyPermission() {
|
|
3013
|
+
var settled = false;
|
|
3014
|
+
function settle(perm) {
|
|
3015
|
+
if (settled) return;
|
|
3016
|
+
settled = true;
|
|
3017
|
+
if (perm !== 'granted') {
|
|
3018
|
+
$('notifyHint').textContent = '浏览器没给通知权限(现在是「' + (perm || '没反应')
|
|
3019
|
+
+ '」)。去浏览器设置里允许,或清掉本站数据后重新扫码。';
|
|
3020
|
+
syncNotify();
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
state.notify = true;
|
|
3024
|
+
localStorage.setItem('dshMiniNotify', 'on');
|
|
3025
|
+
$('notifyHint').textContent = notifyHintText();
|
|
3026
|
+
syncNotify();
|
|
3027
|
+
}
|
|
3028
|
+
try {
|
|
3029
|
+
var r = Notification.requestPermission(settle);
|
|
3030
|
+
if (r && typeof r.then === 'function') r.then(settle).catch(function () {});
|
|
3031
|
+
} catch (err) {
|
|
3032
|
+
$('notifyHint').textContent = '这个浏览器问不出通知权限。要去浏览器的设置里手动开。';
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
// 有些内核两条路都不回来。等三秒给个交代,别让用户对着一个没反应的开关发呆。
|
|
3036
|
+
setTimeout(function () {
|
|
3037
|
+
if (!settled) {
|
|
3038
|
+
$('notifyHint').textContent = '浏览器没弹权限询问(现在是「' + Notification.permission
|
|
3039
|
+
+ '」)。去浏览器设置里允许,或清掉本站数据后重新扫码。';
|
|
3040
|
+
}
|
|
3041
|
+
}, 3000);
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
$('segNotify').addEventListener('click', function (e) {
|
|
3045
|
+
var btn = e.target.closest('button');
|
|
3046
|
+
if (!btn) return;
|
|
3047
|
+
if (btn.dataset.notify !== 'on') {
|
|
3048
|
+
state.notify = false;
|
|
3049
|
+
localStorage.setItem('dshMiniNotify', 'off');
|
|
3050
|
+
syncNotify();
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
// 已经允许过了就别再问——问了也不会弹,只会让用户以为坏了。
|
|
3054
|
+
if (Notification.permission === 'granted') {
|
|
3055
|
+
state.notify = true;
|
|
3056
|
+
localStorage.setItem('dshMiniNotify', 'on');
|
|
3057
|
+
$('notifyHint').textContent = notifyHintText();
|
|
3058
|
+
syncNotify();
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
askNotifyPermission();
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
1798
3064
|
|
|
1799
3065
|
$('btnReconnect').addEventListener('click', function () {
|
|
1800
3066
|
connect();
|
|
@@ -1885,6 +3151,204 @@
|
|
|
1885
3151
|
// 任何一次触摸都顺手解锁音频,避免用户第一次收到回复时没声音。
|
|
1886
3152
|
document.addEventListener('touchstart', unlockAudio, { once: true });
|
|
1887
3153
|
document.addEventListener('click', unlockAudio, { once: true });
|
|
3154
|
+
|
|
3155
|
+
// -------------------------------------------------------------------------
|
|
3156
|
+
// 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)
|
|
3157
|
+
//
|
|
3158
|
+
// 顶栏那一行显示当前用的模型,点它弹出面板可以改。强度按当前模型自己声明的
|
|
3159
|
+
// 档位长;模型不支持调强度时那一行不出现。
|
|
3160
|
+
//
|
|
3161
|
+
// **位置很重要**:必须写在这个外层函数**里面**——`state` 是它内部的变量,
|
|
3162
|
+
// 写到 `})();` 后面就看不见了,结果会是「永远不显示」而且一句错都不报。
|
|
3163
|
+
// 另外**不能再开一个 script 块**:这个页面的测试用贪婪正则抽取脚本,
|
|
3164
|
+
// 多一个块就会把中间的 HTML 整段当成 JS 解析。这个坑踩过一次,别再来一次。
|
|
3165
|
+
// -------------------------------------------------------------------------
|
|
3166
|
+
;(function () {
|
|
3167
|
+
var bar = document.getElementById('modelBar')
|
|
3168
|
+
var text = document.getElementById('modelBarText')
|
|
3169
|
+
if (!bar || !text) return
|
|
3170
|
+
|
|
3171
|
+
var catalog = null // 服务端给的模型目录
|
|
3172
|
+
var current = null // 服务端记的当前选择 {provider, model, reasoningEffort}
|
|
3173
|
+
// 刚改完、服务端还没确认的那个值。为什么要多这一个:服务端记的「当前模型」是在
|
|
3174
|
+
// **下一次请求**时才更新的,所以改完立刻回头去读,读到的还是旧名字。先信
|
|
3175
|
+
// set-model 的返回值,等服务端追上了再让它接管。
|
|
3176
|
+
var pending = null
|
|
3177
|
+
var busy = false // 防止连点两下发出两个互相打架的请求
|
|
3178
|
+
|
|
3179
|
+
// ---- 顶栏 ------------------------------------------------------------
|
|
3180
|
+
function groupName(id) {
|
|
3181
|
+
var gs = (catalog && catalog.groups) || []
|
|
3182
|
+
for (var i = 0; i < gs.length; i++) { if (gs[i].id === id) return gs[i].name }
|
|
3183
|
+
return id || ''
|
|
3184
|
+
}
|
|
3185
|
+
function sel() { return pending || current || null }
|
|
3186
|
+
|
|
3187
|
+
function renderBar() {
|
|
3188
|
+
var s = sel()
|
|
3189
|
+
if (!s || !s.model) {
|
|
3190
|
+
// 问不到这个会话用哪个模型时,如实说一句——既不装作没这个功能,也不编一个。
|
|
3191
|
+
// 点它还照样能打开面板挑一个,挑完就写得进去。
|
|
3192
|
+
text.textContent = '模型:还没问到'
|
|
3193
|
+
bar.style.display = 'flex'
|
|
3194
|
+
return
|
|
3195
|
+
}
|
|
3196
|
+
// 来源名要用 DSH 给的那个(「DeepSeek」「opencode go」「mimo」),不是内部 id
|
|
3197
|
+
var parts = [groupName(s.provider), s.model]
|
|
3198
|
+
if (s.reasoningEffort) parts.push('思考 ' + s.reasoningEffort)
|
|
3199
|
+
text.textContent = parts.join(' · ')
|
|
3200
|
+
bar.style.display = 'flex'
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// ---- 选择面板 --------------------------------------------------------
|
|
3204
|
+
function el(tag, css, txt) {
|
|
3205
|
+
var e = document.createElement(tag)
|
|
3206
|
+
if (css) e.style.cssText = css
|
|
3207
|
+
if (txt != null) e.textContent = txt
|
|
3208
|
+
return e
|
|
3209
|
+
}
|
|
3210
|
+
var ROW = 'display:block;width:100%;text-align:left;background:none;border:0;font:inherit;'
|
|
3211
|
+
+ 'color:inherit;padding:11px 2px;border-bottom:1px solid var(--line,#333)'
|
|
3212
|
+
var HEAD = 'padding:14px 2px 4px;font-size:12px;color:var(--dim,#888)'
|
|
3213
|
+
var CHIP = 'margin:0 6px 6px 0;padding:5px 13px;border:1px solid var(--line,#444);'
|
|
3214
|
+
+ 'border-radius:999px;background:none;font:inherit;color:inherit;font-size:13px'
|
|
3215
|
+
|
|
3216
|
+
// 面板用代码建出来、挂在 body 末尾,而不是写进页面结构里——这样这一整块的
|
|
3217
|
+
// 改动全部落在脚本内部,不用动 HTML,也就不会碰到别的部分。
|
|
3218
|
+
var mask = el('div', 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:69;display:none')
|
|
3219
|
+
var sheet = el('div', 'position:fixed;left:0;right:0;bottom:0;z-index:70;display:none;'
|
|
3220
|
+
+ 'max-height:72vh;overflow-y:auto;background:var(--bg,#1b1b1b);'
|
|
3221
|
+
+ 'border-top:1px solid var(--line,#444);border-radius:14px 14px 0 0;'
|
|
3222
|
+
+ 'padding:10px 14px calc(16px + env(safe-area-inset-bottom))')
|
|
3223
|
+
document.body.appendChild(mask)
|
|
3224
|
+
document.body.appendChild(sheet)
|
|
3225
|
+
|
|
3226
|
+
function renderSheet() {
|
|
3227
|
+
sheet.textContent = ''
|
|
3228
|
+
var s = sel() || {}
|
|
3229
|
+
sheet.appendChild(el('div', 'font-size:13px;color:var(--dim,#888);padding:2px 2px 6px',
|
|
3230
|
+
'这个会话用哪个模型'))
|
|
3231
|
+
if (!catalog) { sheet.appendChild(el('div', 'padding:12px 2px', '还没读到模型列表。')); return }
|
|
3232
|
+
|
|
3233
|
+
var groups = catalog.groups || []
|
|
3234
|
+
for (var gi = 0; gi < groups.length; gi++) {
|
|
3235
|
+
var grp = groups[gi]
|
|
3236
|
+
sheet.appendChild(el('div', HEAD, grp.name || grp.id))
|
|
3237
|
+
var models = grp.models || []
|
|
3238
|
+
for (var mi = 0; mi < models.length; mi++) {
|
|
3239
|
+
var mod = models[mi]
|
|
3240
|
+
var isCur = s.provider === grp.id && s.model === mod.id
|
|
3241
|
+
var b = el('button', ROW, (isCur ? '✓ ' : '') + (mod.name || mod.id))
|
|
3242
|
+
if (isCur) b.style.fontWeight = '600'
|
|
3243
|
+
b.addEventListener('click', picker(grp.id, mod.id))
|
|
3244
|
+
sheet.appendChild(b)
|
|
3245
|
+
|
|
3246
|
+
// 强度只在**当前这个模型**下面出现,而且只画它自己声明的档位——DeepSeek 是
|
|
3247
|
+
// off/low/high/max,opencode 和 mimo 是 minimal…max,两套不一样,不能合并成
|
|
3248
|
+
// 一个统一的说法。模型没声明强度时(五十个里有四十三个)这里什么都不画,
|
|
3249
|
+
// 不给一个拖了没反应的控件。
|
|
3250
|
+
var r = mod.reasoning
|
|
3251
|
+
if (isCur && r && r.efforts && r.efforts.length) {
|
|
3252
|
+
var box = el('div', 'padding:8px 2px 12px;border-bottom:1px solid var(--line,#333)')
|
|
3253
|
+
for (var ei = 0; ei < r.efforts.length; ei++) {
|
|
3254
|
+
var ef = r.efforts[ei]
|
|
3255
|
+
var on = s.reasoningEffort === ef.id
|
|
3256
|
+
var c = el('button', CHIP + (on ? ';border-color:currentColor;font-weight:600' : ''),
|
|
3257
|
+
(on ? '✓ ' : '') + (ef.name || ef.id))
|
|
3258
|
+
c.addEventListener('click', picker(grp.id, mod.id, ef.id))
|
|
3259
|
+
box.appendChild(c)
|
|
3260
|
+
}
|
|
3261
|
+
sheet.appendChild(box)
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
// 某个来源读不到时如实说出来,不要假装整份目录都是全的
|
|
3267
|
+
var fails = catalog.failures || []
|
|
3268
|
+
for (var fi = 0; fi < fails.length; fi++) {
|
|
3269
|
+
sheet.appendChild(el('div', 'padding:10px 2px;font-size:13px;color:var(--dim,#888)',
|
|
3270
|
+
'⚠ ' + (fails[fi].name || fails[fi].id) + ':' + (fails[fi].message || '读不到')))
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
var close = el('button', ROW + ';border-bottom:0;text-align:center;color:var(--dim,#888)', '关闭')
|
|
3274
|
+
close.addEventListener('click', closeSheet)
|
|
3275
|
+
sheet.appendChild(close)
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// 把参数用闭包封起来。循环里直接写回调会全都指向最后一项——这是老坑了。
|
|
3279
|
+
function picker(provider, model, effort) {
|
|
3280
|
+
return function () { if (!busy) pick(provider, model, effort) }
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
function pick(provider, model, effort) {
|
|
3284
|
+
var body = { provider: provider, model: model }
|
|
3285
|
+
if (effort) body.reasoningEffort = effort
|
|
3286
|
+
busy = true
|
|
3287
|
+
fetch('/mini/api/set-model?token=' + encodeURIComponent(state.token), {
|
|
3288
|
+
method: 'POST',
|
|
3289
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3290
|
+
body: JSON.stringify(body)
|
|
3291
|
+
})
|
|
3292
|
+
.then(function (r) { return r.json().catch(function () { return null }) })
|
|
3293
|
+
.then(function (d) {
|
|
3294
|
+
busy = false
|
|
3295
|
+
if (!d || !d.ok) { alert('没改成:' + ((d && d.error) || '电脑那边没答应')); return }
|
|
3296
|
+
var v = d.selected || body
|
|
3297
|
+
pending = { provider: v.provider, model: v.model, reasoningEffort: v.reasoningEffort || null }
|
|
3298
|
+
renderBar()
|
|
3299
|
+
renderSheet()
|
|
3300
|
+
})
|
|
3301
|
+
.catch(function () { busy = false; alert('没改成:连不上电脑。') })
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
function openSheet() { renderSheet(); mask.style.display = 'block'; sheet.style.display = 'block' }
|
|
3305
|
+
function closeSheet() { mask.style.display = 'none'; sheet.style.display = 'none' }
|
|
3306
|
+
|
|
3307
|
+
// ---- 读目录 ----------------------------------------------------------
|
|
3308
|
+
function load() {
|
|
3309
|
+
if (!state.token) return
|
|
3310
|
+
fetch('/mini/api/models?token=' + encodeURIComponent(state.token), { cache: 'no-store' })
|
|
3311
|
+
.then(function (r) { return r.ok ? r.json() : null })
|
|
3312
|
+
.then(function (d) {
|
|
3313
|
+
if (!d || !d.ok) return
|
|
3314
|
+
catalog = d
|
|
3315
|
+
// **问不到就说问不到,绝不用目录默认值兜底。**
|
|
3316
|
+
//
|
|
3317
|
+
// 上一版这里写的是「没记下就显示目录默认值」,看着体贴,实际是编的:
|
|
3318
|
+
// 目录那个默认值等于「上一次选过的模型」,于是用户切到一个用别的模型的
|
|
3319
|
+
// 会话,顶栏显示的还是上一个模型——一个像模像样的错答案,比空着坏得多。
|
|
3320
|
+
current = (d.current && d.current.model) ? d.current : null
|
|
3321
|
+
// 服务端追上了就撤掉本地那个临时值,之后一律以服务端的为准
|
|
3322
|
+
if (pending && current && current.provider === pending.provider
|
|
3323
|
+
&& current.model === pending.model) pending = null
|
|
3324
|
+
renderBar()
|
|
3325
|
+
if (sheet.style.display !== 'none') renderSheet()
|
|
3326
|
+
})
|
|
3327
|
+
.catch(function () { /* 读不到就保持原样,不显示错的东西 */ })
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
bar.style.cursor = 'pointer'
|
|
3331
|
+
bar.addEventListener('click', openSheet)
|
|
3332
|
+
mask.addEventListener('click', closeSheet)
|
|
3333
|
+
|
|
3334
|
+
// 手机切了会话要**立刻**跟上,不能等下面那个 15 秒的轮询——那段时间里顶栏显示的
|
|
3335
|
+
// 是上一个会话的模型,看上去就像新会话用错了模型。页面自己把「现在是哪个会话」
|
|
3336
|
+
// 存在 state.boundSessionId 里(状态广播一到就更新),盯着它很便宜。
|
|
3337
|
+
var lastSession = state.boundSessionId || null
|
|
3338
|
+
setInterval(function () {
|
|
3339
|
+
var id = state.boundSessionId || null
|
|
3340
|
+
if (id === lastSession) return
|
|
3341
|
+
lastSession = id
|
|
3342
|
+
pending = null // 上一个会话身上那个临时值,跟这个会话没关系,必须扔掉
|
|
3343
|
+
load()
|
|
3344
|
+
}, 1000)
|
|
3345
|
+
|
|
3346
|
+
load()
|
|
3347
|
+
// 在电脑上改了模型,手机这边也要跟上。**轮询很土,但这里是诚实的做法**:
|
|
3348
|
+
// 显示一个过期的模型名比不显示更糟——用户会以为自己改的没生效。
|
|
3349
|
+
setInterval(function () { if (!document.hidden) load() }, 15000)
|
|
3350
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) load() })
|
|
3351
|
+
})();
|
|
1888
3352
|
})();
|
|
1889
3353
|
</script>
|
|
1890
3354
|
</body>
|