@yfwu2020/dsh-selection-explain 0.7.1 → 0.9.0
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/README.md +112 -5
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1734 -77
- package/lib/index.js +513 -32
- package/lib/index.js.map +1 -1
- package/lib/settings.js +228 -0
- package/lib/settings.js.map +1 -0
- package/lib/types/index.d.ts +37 -6
- package/lib/types/settings.d.ts +125 -0
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +333 -1
- package/scripts/smoke-voice.mjs +3 -1
- package/scripts/test-bridge.mjs +4 -0
- package/scripts/test-client.mjs +661 -10
- package/scripts/test-model-follow.mjs +193 -0
- package/scripts/test-route-cache.mjs +138 -0
- package/scripts/test-settings-behavior.mjs +188 -0
- package/scripts/test-settings-host.mjs +48 -0
- package/scripts/test-settings.mjs +329 -0
- package/src/client/index.js +1734 -77
- package/src/index.ts +549 -28
- package/src/settings.ts +310 -0
package/lib/client.js
CHANGED
|
@@ -29,9 +29,20 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
var PROMOTE = '/selection-explain/api/promote'
|
|
30
30
|
/** 胶囊拖到别处后的位置(记在 host 上:拖到别处→存,吸附回右下角→清,挂载→读回来)。 */
|
|
31
31
|
var PILL_POS = '/selection-explain/api/pill'
|
|
32
|
+
/** 小窗的**尺寸**(改过大小之后记在 host 上,刷新后回到那个大小)。位置不记。 */
|
|
33
|
+
var PANEL_SIZE = '/selection-explain/api/panel'
|
|
32
34
|
var HISTORY = '/selection-explain/api/history'
|
|
33
35
|
/** 模型清单(带每个模型支持的推理等级)。 */
|
|
34
36
|
var MODELS = '/selection-explain/api/models'
|
|
37
|
+
/**
|
|
38
|
+
* 小窗设置:GET 拿 spec + 当前值,POST 写回。
|
|
39
|
+
*
|
|
40
|
+
* spec(分组 / 标题 / 说明 / 边界 / 单位)由 host 下发 —— 客户端**不做业务判断**,
|
|
41
|
+
* 免得 UI 的边界和 host 的 clamp 各写一份、时间久了互相漂(见 src/settings.ts 顶部注释)。
|
|
42
|
+
*/
|
|
43
|
+
var SETTINGS = '/selection-explain/api/settings'
|
|
44
|
+
/** 极轻的"这一轮用哪个模型"查询:设置页开着时按秒轮询它,让括号里的名字实时跟着变。 */
|
|
45
|
+
var ROUTE = '/selection-explain/api/route'
|
|
35
46
|
/**
|
|
36
47
|
* 语音输入(小窗麦克风):识别在 host 上做,浏览器只负责录一段规范 WAV 送过去。
|
|
37
48
|
* · 目录接口说清"能不能录、要录多久、要不要先准备模型";
|
|
@@ -321,11 +332,47 @@ window.__ModuleLoader__.load({
|
|
|
321
332
|
'.dsh-sel-layer[data-theme=dark]{--sel-a1:#4ecdc4;--sel-a1-text:#6fe3d8;--sel-a2:#4ecdc4;--sel-a2-text:#6fe3d8;',
|
|
322
333
|
'--sel-fill:#4ecdc4;--sel-fill-fg:#052b26;',
|
|
323
334
|
'--sel-a2-soft:color-mix(in srgb,var(--sel-a2) 16%,transparent);--sel-a1-soft:color-mix(in srgb,var(--sel-a1) 16%,transparent)}',
|
|
324
|
-
|
|
325
|
-
|
|
335
|
+
// ⚠️ 声明块必须在**同一段字符串里收尾**。
|
|
336
|
+
// 以前这里是 `...max-height:...;` 后面直接接了一条完整规则 `[data-stage=translation]{...}`,
|
|
337
|
+
// `.dsh-sel-panel{` 的 `}` 于是永远没出现 —— 那条阶段规则被当成块内的**非法声明**整条丢弃,
|
|
338
|
+
// "首轮收窄到 440" 从来没生效过(实测计算宽度一直是 540)。后面几段的 border-radius /
|
|
339
|
+
// overflow 之所以没事,是因为 CSS 出错恢复会跳过坏声明继续往下读。
|
|
340
|
+
// 单测只断言了 data-stage 这个**属性**,而桩 DOM 没有布局,所以一直没暴露。
|
|
341
|
+
// box-sizing:border-box 是**必须**的:拖角改大小时我们把 getBoundingClientRect()(含边框)
|
|
342
|
+
// 的宽高写回 style.width/height,而它们默认按 content-box 解释 —— 差一个边框宽(1px),
|
|
343
|
+
// 于是"存进去 → 读回来"每来一轮就胖 1px,拖几次就明显走形(实测 584 存进去、读回来 586)。
|
|
344
|
+
'.dsh-sel-panel{position:fixed;display:none;flex-direction:column;box-sizing:border-box;width:min(540px,calc(100vw - 20px));max-height:min(78vh,720px);',
|
|
326
345
|
'border-radius:16px;overflow:hidden;background:var(--dsw-alias-bg-layer-1,Canvas);color:var(--dsw-alias-label-primary,CanvasText);',
|
|
327
346
|
'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));box-shadow:0 20px 52px rgba(0,0,0,.3);',
|
|
328
347
|
'font:400 13.5px/1.7 ' + FONT + '}',
|
|
348
|
+
// 这里曾经有一条 `.dsh-sel-panel[data-stage=translation]{width:440px}`:
|
|
349
|
+
// "首轮只出翻译时收窄一点"。它因为上面那个漏掉的 `}` 一直是死代码,修好闭合之后才真正生效,
|
|
350
|
+
// 一生效就暴露了问题 —— 宽度会**在追问那一刻从 440 跳到 540**(见 syncPanelStage 的判定),
|
|
351
|
+
// 用户看到同一个窗口自己变宽,问"怎么有追问和没追问的宽度不一样"。
|
|
352
|
+
// 决定:**统一成 540,宽度不随阶段变**。少一档就没这档子事,也没有"什么时候会跳"的心智负担。
|
|
353
|
+
// (data-stage 本身保留 —— 它还决定追问输入框显不显示,见 renderPanel。)
|
|
354
|
+
// 设置抽屉开着时只补**高度**下限,**不动宽度**。
|
|
355
|
+
//
|
|
356
|
+
// 抽屉是 inset:0 贴在面板上的:面板多宽,抽屉就多宽 —— 这是要的(设置窗口与小窗等宽,
|
|
357
|
+
// 开抽屉时小窗不该"胖一圈")。但高度不能不管:面板高度是由"解读内容有多长"撑出来的,
|
|
358
|
+
// 刚开窗(只出了翻译)时面板才 ~115px 高,设置列表只剩 17px 可视高度(实测)。
|
|
359
|
+
// 所以只给 min-height;宽度交给面板自己的规则(首轮 440 / 其余 540),抽屉自动跟随。
|
|
360
|
+
// min-height 用 min() 与 max-height 同源,视口矮时两者相等,不会顶出屏幕。
|
|
361
|
+
// 只在**用户没自己改过尺寸**时才补这个下限:他拉过大小就按他的来(min-height 会压过 inline height)
|
|
362
|
+
'.dsh-sel-panel[data-settings="1"]:not([data-resized="1"]){min-height:min(560px,78vh)}',
|
|
363
|
+
// 改大小手柄:右下角一个角标 + 右边/下边各一条(细,不抢视线;hover/拖动时才明显)
|
|
364
|
+
// z-index 比设置抽屉(6)高 —— 但抽屉开着时整体隐藏(见下面的规则):
|
|
365
|
+
// 抽屉页脚右侧就是「关闭」按钮,角标压上去会误点。
|
|
366
|
+
'.dsh-sel-grip{position:absolute;z-index:7;background:transparent;touch-action:none}',
|
|
367
|
+
'.dsh-sel-grip-corner{right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize}',
|
|
368
|
+
'.dsh-sel-grip-r{right:0;top:12px;bottom:16px;width:6px;cursor:ew-resize}',
|
|
369
|
+
'.dsh-sel-grip-b{left:12px;right:16px;bottom:0;height:6px;cursor:ns-resize}',
|
|
370
|
+
// 手柄**不画任何常驻标识**:以前角上有一道小斜纹(两条 1.5px 边框拼的直角),
|
|
371
|
+
// 在浅色面板上看着像多出来的一笔,用户明确要求去掉。
|
|
372
|
+
// 保留 hover 时边缘的高亮(只在鼠标移上去时出现,不占视觉)——
|
|
373
|
+
// 加上 cursor: nwse-resize / ew-resize / ns-resize,可发现性还在。
|
|
374
|
+
// 抽屉开着时收起手柄(页脚右侧是「关闭」按钮,别让角标压上去)
|
|
375
|
+
'.dsh-sel-panel[data-settings="1"] .dsh-sel-grip{display:none}',
|
|
329
376
|
'.dsh-sel-head{display:flex;align-items:center;gap:8px;padding:10px 10px 10px 14px;cursor:grab;user-select:none;',
|
|
330
377
|
'border-bottom:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22))}',
|
|
331
378
|
'.dsh-sel-head:active{cursor:grabbing}',
|
|
@@ -342,6 +389,11 @@ window.__ModuleLoader__.load({
|
|
|
342
389
|
'background:transparent;color:inherit;font:inherit;font-size:11.5px;line-height:1;opacity:.85}',
|
|
343
390
|
'.dsh-sel-action:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.16));opacity:1}',
|
|
344
391
|
'.dsh-sel-action:disabled{opacity:.4;cursor:default}',
|
|
392
|
+
// 统一图标(头部 / 工具行):尺寸由 SVG 的 width/height 属性给,这里只钉住显示方式
|
|
393
|
+
'.dsh-sel-ic{display:block;flex:none}',
|
|
394
|
+
// 带文字的图标键里的图标:和文字之间留一点呼吸,别贴在一起
|
|
395
|
+
'.dsh-sel-action .dsh-sel-ic{margin-right:1px}',
|
|
396
|
+
'.dsh-sel-icon .dsh-sel-ic{margin:0}',
|
|
345
397
|
// 选中文字quote:左侧强调条 + 略暗底
|
|
346
398
|
// 选中文字条:在滚动区里(跟着消息一起滚),左右边距交给 body 的 padding 所以与内容对齐。
|
|
347
399
|
// 可以自动换行、超过 84px 内部滚动,但**不显示滚动条**。
|
|
@@ -556,7 +608,11 @@ window.__ModuleLoader__.load({
|
|
|
556
608
|
'.dsh-sel-pickerrow:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
|
|
557
609
|
'.dsh-sel-pickerrow[data-on="1"]{font-weight:600}',
|
|
558
610
|
'.dsh-sel-pickerrow .dsh-sel-pickercheck{width:12px;flex:0 0 auto;color:var(--sel-a1-text,#0f766e);font-weight:700}',
|
|
559
|
-
|
|
611
|
+
// 名字占掉剩下的宽度、挤不下就省略号 —— 保证一行里塞得下 勾 + 名字 + 「跟随」小标 + provider
|
|
612
|
+
'.dsh-sel-pickerrow .dsh-sel-pickername{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
613
|
+
'.dsh-sel-pickerrow .dsh-sel-pickerfollow{flex:0 0 auto;font-size:10px;line-height:14px;padding:0 5px;border-radius:6px;font-weight:600;opacity:.85;',
|
|
614
|
+
'background:color-mix(in srgb,var(--sel-a1,#0d9488) 16%,transparent)}',
|
|
615
|
+
'.dsh-sel-pickerrow .dsh-sel-pickerprov{flex:0 0 auto;margin-left:auto;font-size:10.5px;opacity:.55}',
|
|
560
616
|
'.dsh-sel-pickersplit{height:1px;margin:6px 4px;background:var(--dsw-alias-border-l4,rgba(140,140,140,.22))}',
|
|
561
617
|
'.dsh-sel-tiers{display:flex;gap:6px;padding:4px 6px 6px}',
|
|
562
618
|
'.dsh-sel-tierbtn{flex:1 1 0;height:26px;padding:0;border-radius:9px;cursor:pointer;font:inherit;font-size:11.5px;',
|
|
@@ -748,6 +804,136 @@ window.__ModuleLoader__.load({
|
|
|
748
804
|
'.dsh-sel-err{color:var(--dsw-alias-state-error-primary,#e5484d);font-size:12.5px;white-space:pre-wrap}',
|
|
749
805
|
'@media (prefers-reduced-motion:reduce){.dsh-sel-sk{animation:none}.dsh-sel-dots i{animation:none;opacity:.6}',
|
|
750
806
|
'.dsh-sel-btn{transition:none}.dsh-sel-btn[data-pop="1"]{animation:none}}',
|
|
807
|
+
// ────────────────────── 设置页(抽屉) ──────────────────────
|
|
808
|
+
// 一个抽屉,不另开窗口:小窗本身就是窄容器,再弹一个浮窗只会互相打架。
|
|
809
|
+
// 从面板右侧滑入、盖住正文,面板尺寸一个像素都不变。
|
|
810
|
+
'.dsh-sel-sheet{position:absolute;top:0;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;',
|
|
811
|
+
'background:var(--dsw-alias-bg-layer-1,Canvas);color:var(--dsw-alias-label-primary,CanvasText);',
|
|
812
|
+
// ⚠️ 值必须带引号:[data-open=1] 是**非法选择器**(CSS 标识符不能以数字开头),
|
|
813
|
+
// 浏览器会整条丢弃 —— 表现为"属性明明设了、抽屉却纹丝不动"(实测踩过)。
|
|
814
|
+
'transform:translateX(100%);opacity:0;pointer-events:none;box-shadow:-14px 0 30px rgba(0,0,0,.16);',
|
|
815
|
+
'transition:transform .34s cubic-bezier(.22,.8,.3,1),opacity .22s ease}',
|
|
816
|
+
'.dsh-sel-sheet[data-open="1"]{transform:translateX(0);opacity:1;pointer-events:auto}',
|
|
817
|
+
// 抽屉后面压一层薄纱:不遮死正文(用户还得看得出上下文),只把层次分开
|
|
818
|
+
'.dsh-sel-scrim{position:absolute;top:0;left:0;right:0;bottom:0;z-index:5;background:rgba(0,0,0,.06);',
|
|
819
|
+
'opacity:0;pointer-events:none;transition:opacity .28s ease}',
|
|
820
|
+
'.dsh-sel-scrim[data-open="1"]{opacity:1}',
|
|
821
|
+
'.dsh-sel-layer[data-theme=dark] .dsh-sel-scrim{background:rgba(0,0,0,.32)}',
|
|
822
|
+
'@media (prefers-reduced-motion:reduce){.dsh-sel-sheet{transition:opacity .16s ease;transform:none}',
|
|
823
|
+
'.dsh-sel-scrim{transition:none}}',
|
|
824
|
+
'.dsh-sel-sheethead{display:flex;align-items:center;gap:8px;padding:10px 10px 10px 12px;flex:0 0 auto;',
|
|
825
|
+
'border-bottom:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22))}',
|
|
826
|
+
'.dsh-sel-sheetback{flex:0 0 auto;width:24px;height:24px;display:grid;place-items:center;border:0;border-radius:8px;',
|
|
827
|
+
'cursor:pointer;background:transparent;color:inherit;opacity:.75;padding:0}',
|
|
828
|
+
'.dsh-sel-sheetback:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.16));opacity:1}',
|
|
829
|
+
'.dsh-sel-sheettitle{font-weight:600;font-size:12.5px;letter-spacing:.2px}',
|
|
830
|
+
'.dsh-sel-sheetscope{margin-left:auto;font-size:11px;opacity:.5;padding-right:4px;white-space:nowrap}',
|
|
831
|
+
// 左栏分类 + 右栏内容
|
|
832
|
+
'.dsh-sel-sheetmain{flex:1 1 auto;display:flex;min-height:0}',
|
|
833
|
+
'.dsh-sel-rail{flex:0 0 96px;border-right:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22));',
|
|
834
|
+
'padding:8px 6px;display:flex;flex-direction:column;gap:2px;overflow:auto}',
|
|
835
|
+
'.dsh-sel-railbtn{border:0;background:transparent;text-align:left;padding:6px 8px;border-radius:8px;cursor:pointer;',
|
|
836
|
+
'font:inherit;font-size:12px;color:inherit;opacity:.72;white-space:nowrap}',
|
|
837
|
+
'.dsh-sel-railbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12));opacity:1}',
|
|
838
|
+
'.dsh-sel-railbtn[aria-current="true"]{background:var(--sel-a1-soft,rgba(13,148,136,.12));color:var(--sel-a1-text,#0f766e);',
|
|
839
|
+
'font-weight:600;opacity:1}',
|
|
840
|
+
'.dsh-sel-sheetbody{flex:1 1 auto;overflow:auto;padding:2px 12px 14px;min-width:0;',
|
|
841
|
+
'scrollbar-width:thin;scrollbar-color:rgba(140,140,140,.35) transparent}',
|
|
842
|
+
'.dsh-sel-sheetbody::-webkit-scrollbar{width:9px;height:9px}',
|
|
843
|
+
'.dsh-sel-sheetbody::-webkit-scrollbar-thumb{background:rgba(140,140,140,.3);border-radius:9px;border:2px solid transparent;background-clip:content-box}',
|
|
844
|
+
'.dsh-sel-grp{padding:12px 0 4px;border-bottom:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.16))}',
|
|
845
|
+
'.dsh-sel-grp:last-child{border-bottom:0;padding-bottom:0}',
|
|
846
|
+
'.dsh-sel-grph{font-size:11px;font-weight:600;opacity:.5;letter-spacing:.4px;margin-bottom:8px}',
|
|
847
|
+
'.dsh-sel-ssection+.dsh-sel-ssection{margin-top:16px;padding-top:16px;border-top:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.16))}',
|
|
848
|
+
'.dsh-sel-ssectionh{font-size:14px;font-weight:600;line-height:1.5;margin:0 0 6px}',
|
|
849
|
+
'.dsh-sel-ssectionhint,.dsh-sel-ssectionnote{font-size:11.5px;opacity:.6;line-height:1.7;margin:3px 0 8px}',
|
|
850
|
+
'.dsh-sel-ssectionnote{margin:8px 0 0}',
|
|
851
|
+
'.dsh-sel-ssection>.dsh-sel-srow+.dsh-sel-srow{border-top:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.16))}',
|
|
852
|
+
'.dsh-sel-srow[data-disabled="1"]{opacity:.5}',
|
|
853
|
+
'.dsh-sel-srow input:disabled,.dsh-sel-srow button:disabled{cursor:default}',
|
|
854
|
+
'.dsh-sel-sheet textarea.dsh-sel-stext{resize:vertical;line-height:1.6}',
|
|
855
|
+
|
|
856
|
+
'.dsh-sel-srow{display:flex;align-items:flex-start;gap:14px;padding:8px 0}',
|
|
857
|
+
'.dsh-sel-srowlab{flex:1 1 auto;min-width:0}',
|
|
858
|
+
'.dsh-sel-srowt{font-size:13px;line-height:1.5}',
|
|
859
|
+
'.dsh-sel-srowd{font-size:11.5px;opacity:.55;line-height:1.6;margin-top:3px;max-width:46ch}',
|
|
860
|
+
'.dsh-sel-srowctl{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding-top:2px;min-width:0}',
|
|
861
|
+
// 宽输入框(provider / model / 工具清单)所在行:说明在上、输入框独占一行。
|
|
862
|
+
// 抽屉是嵌在小窗里的窄容器,视口再宽也帮不上忙,所以按"行里有没有宽输入框"来判。
|
|
863
|
+
'.dsh-sel-srow[data-wide="1"]{flex-wrap:wrap}',
|
|
864
|
+
'.dsh-sel-srow[data-wide="1"] .dsh-sel-srowlab{flex:1 1 100%}',
|
|
865
|
+
'.dsh-sel-srow[data-wide="1"] .dsh-sel-srowctl{flex:1 1 100%;padding-top:6px}',
|
|
866
|
+
'.dsh-sel-srow[data-wide="1"] .dsh-sel-stext{width:100%;max-width:none}',
|
|
867
|
+
'.dsh-sel-srow[data-wide="1"] .dsh-sel-spick{width:100%;max-width:none}',
|
|
868
|
+
// 控件:开关(38×22,触屏也点得住)
|
|
869
|
+
'.dsh-sel-sws{flex:none;width:38px;height:22px;border-radius:11px;position:relative;cursor:pointer;padding:0;',
|
|
870
|
+
'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));background:var(--dsw-alias-bg-layer-2,rgba(140,140,140,.12));',
|
|
871
|
+
'transition:background .18s ease}',
|
|
872
|
+
'.dsh-sel-sws:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
|
|
873
|
+
'background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 3px rgba(0,0,0,.28);',
|
|
874
|
+
'transition:transform .2s cubic-bezier(.34,1.4,.64,1)}',
|
|
875
|
+
'.dsh-sel-sws[aria-checked="true"]{background:var(--sel-fill,#0f766e);border-color:var(--sel-fill,#0f766e)}',
|
|
876
|
+
'.dsh-sel-sws[aria-checked="true"]:after{transform:translateX(16px);background:var(--sel-fill-fg,#fff)}',
|
|
877
|
+
// 控件:数字 / 文本
|
|
878
|
+
'.dsh-sel-snum{width:74px;border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));border-radius:8px;',
|
|
879
|
+
'padding:5px 8px;background:transparent;color:inherit;font:inherit;font-size:12.5px;text-align:right;',
|
|
880
|
+
'font-variant-numeric:tabular-nums;box-sizing:border-box}',
|
|
881
|
+
'.dsh-sel-stext{width:170px;border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));border-radius:8px;',
|
|
882
|
+
'padding:5px 8px;background:transparent;color:inherit;font:inherit;font-size:12px;box-sizing:border-box}',
|
|
883
|
+
'.dsh-sel-snum:focus-visible,.dsh-sel-stext:focus-visible,.dsh-sel-sws:focus-visible,.dsh-sel-segcell:focus-visible,',
|
|
884
|
+
'.dsh-sel-sheetback:focus-visible,.dsh-sel-railbtn:focus-visible,.dsh-sel-sbtn:focus-visible,',
|
|
885
|
+
'.dsh-sel-action:focus-visible,.dsh-sel-icon:focus-visible{outline:2px solid var(--sel-a1,#0d9488);outline-offset:2px}',
|
|
886
|
+
'.dsh-sel-sunit{font-size:11.5px;opacity:.5;white-space:nowrap}',
|
|
887
|
+
// 模型选择:**自绘下拉**,不用原生 <select>。
|
|
888
|
+
// 原生 select 的弹出层由浏览器画,42 个模型时会顶出可视区且拿不到滚动条(用户报的)。
|
|
889
|
+
// 自绘的列表自己带 max-height + overflow:auto,滚动条是确定的;样式也和 composer 那枚模型菜单一致。
|
|
890
|
+
'.dsh-sel-spick{width:100%;display:flex;align-items:center;gap:8px;border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));',
|
|
891
|
+
'border-radius:8px;padding:5px 8px;background:transparent;color:inherit;font:inherit;font-size:12.5px;',
|
|
892
|
+
'box-sizing:border-box;cursor:pointer;text-align:left}',
|
|
893
|
+
'.dsh-sel-spick:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
|
|
894
|
+
'.dsh-sel-spickname{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
895
|
+
'.dsh-sel-spickcaret{flex:none;font-size:9px;opacity:.6}',
|
|
896
|
+
// 菜单挂在**抽屉**上(不是行里):行的祖先链上有 overflow:auto 的滚动区,浮层会被它裁掉
|
|
897
|
+
'.dsh-sel-spickmenu{position:absolute;z-index:30;display:none;flex-direction:column;padding:6px;border-radius:12px;',
|
|
898
|
+
'box-sizing:border-box;background:var(--dsw-alias-bg-layer-2,#232427);',
|
|
899
|
+
'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));box-shadow:0 14px 34px rgba(0,0,0,.35)}',
|
|
900
|
+
'.dsh-sel-spickmenu[data-open="1"]{display:flex}',
|
|
901
|
+
'.dsh-sel-spickgroup{padding:2px 8px 5px;font-size:10.5px;opacity:.6;flex:none}',
|
|
902
|
+
// 列表自己滚:max-height 由 JS 按"抽屉底部还剩多少"给,这里只兜底
|
|
903
|
+
'.dsh-sel-spicklist{flex:1 1 auto;min-height:0;max-height:260px;overflow-y:auto;overflow-x:hidden;',
|
|
904
|
+
'display:flex;flex-direction:column;gap:1px;scrollbar-width:thin;scrollbar-color:rgba(140,140,140,.45) transparent}',
|
|
905
|
+
'.dsh-sel-spicklist::-webkit-scrollbar{width:9px}',
|
|
906
|
+
'.dsh-sel-spicklist::-webkit-scrollbar-thumb{background:rgba(140,140,140,.4);border-radius:9px;border:2px solid transparent;background-clip:content-box}',
|
|
907
|
+
'.dsh-sel-spickrow{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer;',
|
|
908
|
+
'font:inherit;font-size:12px;text-align:left;border:0;background:transparent;color:inherit;flex:none}',
|
|
909
|
+
'.dsh-sel-spickrow:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
|
|
910
|
+
'.dsh-sel-spickrow[data-on="1"]{font-weight:600}',
|
|
911
|
+
'.dsh-sel-spickcheck{width:12px;flex:0 0 auto;color:var(--sel-a1-text,#0f766e);font-weight:700}',
|
|
912
|
+
'.dsh-sel-spickrowname{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
913
|
+
'.dsh-sel-spickprov{flex:none;font-size:10.5px;opacity:.55;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
914
|
+
'.dsh-sel-spickempty{padding:8px;font-size:11.5px;opacity:.55}',
|
|
915
|
+
// 控件:分段(档位)
|
|
916
|
+
'.dsh-sel-sheet .dsh-sel-seg{display:inline-flex;background:var(--dsw-alias-bg-layer-2,rgba(140,140,140,.1));',
|
|
917
|
+
'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.3));border-radius:9px;padding:2px;position:relative}',
|
|
918
|
+
'.dsh-sel-sheet .dsh-sel-segcell{border:0;background:transparent;cursor:pointer;font:inherit;font-size:11.5px;width:auto;height:26px;min-width:34px;padding:4px 9px;',
|
|
919
|
+
'border-radius:7px;color:inherit;position:relative;z-index:1;white-space:nowrap;opacity:.8}',
|
|
920
|
+
'.dsh-sel-sheet .dsh-sel-segcell[aria-checked="true"]{color:var(--sel-fill-fg,#fff);font-weight:600;opacity:1}',
|
|
921
|
+
'.dsh-sel-sheet .dsh-sel-segpill{position:absolute;top:2px;bottom:2px;height:auto;border-radius:7px;background:var(--sel-fill,#0f766e);z-index:0;',
|
|
922
|
+
'transition:left .22s cubic-bezier(.34,1.3,.64,1),width .22s cubic-bezier(.34,1.3,.64,1)}',
|
|
923
|
+
// 底部动作栏:常驻;不脏时写"改动会自动保存",脏了才报数
|
|
924
|
+
'.dsh-sel-sheetfoot{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:9px 12px;',
|
|
925
|
+
'border-top:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22));background:var(--dsw-alias-bg-layer-2,rgba(140,140,140,.05))}',
|
|
926
|
+
'.dsh-sel-sfootnote{font-size:11.5px;opacity:.55;margin-right:auto;display:flex;align-items:center;gap:6px}',
|
|
927
|
+
'.dsh-sel-sfootnote i{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-error-primary,#e5484d);display:none}',
|
|
928
|
+
'.dsh-sel-sfootnote[data-dirty="1"]{opacity:.9}',
|
|
929
|
+
'.dsh-sel-sfootnote[data-dirty="1"] i{display:block}',
|
|
930
|
+
'.dsh-sel-sbtn{border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));border-radius:8px;padding:5px 11px;',
|
|
931
|
+
'background:transparent;color:inherit;cursor:pointer;font:inherit;font-size:12px;white-space:nowrap}',
|
|
932
|
+
'.dsh-sel-sbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
|
|
933
|
+
'.dsh-sel-sbtn:disabled{opacity:.4;cursor:default}',
|
|
934
|
+
'.dsh-sel-sbtn[data-danger="1"]{color:var(--dsw-alias-state-error-primary,#e5484d);',
|
|
935
|
+
'border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e5484d) 45%,transparent)}',
|
|
936
|
+
'.dsh-sel-sempty{font-size:12px;opacity:.55;padding:14px 2px}',
|
|
751
937
|
].join('')
|
|
752
938
|
|
|
753
939
|
// ────────────────────── Markdown 轻渲染(全 DOM,无 innerHTML) ──────────────────────
|
|
@@ -1635,7 +1821,8 @@ window.__ModuleLoader__.load({
|
|
|
1635
1821
|
window.__dshSelBridge = 1
|
|
1636
1822
|
var WINDOW_CHARS = 1500
|
|
1637
1823
|
var KEY_CHARS = 300
|
|
1638
|
-
|
|
1824
|
+
// 帧内只守设置允许的最高边界;父页面按当前设置决定是否显示入口。
|
|
1825
|
+
var MAX_SELECTION = 20000
|
|
1639
1826
|
/** 鼠标按着超过这么久还没等到 mouseup,就当松手落在帧外了(别永久卡住上报)。 */
|
|
1640
1827
|
var STALE_PRESS_MS = 6000
|
|
1641
1828
|
/** 轮询连续这么多拍都读不到选区,才认为选区真没了(约 1.5s;瞬时读不到不清)。 */
|
|
@@ -2138,15 +2325,17 @@ window.__ModuleLoader__.load({
|
|
|
2138
2325
|
// 撑开用:把右侧三个按钮(最近/升格/✕)顶到最右。
|
|
2139
2326
|
// 以前这一步是"模型名标签"的 margin-left:auto 兼任的,标签一删按钮就贴到标题后面了。
|
|
2140
2327
|
head.appendChild(el('span', 'dsh-sel-headspace'))
|
|
2141
|
-
var historyButton =
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2328
|
+
var historyButton = actionButton('最近聊过的划词(点一条把那段对话调回来)', '最近', headerHistoryIcon)
|
|
2329
|
+
var promoteButton = actionButton('升格为正式会话(建在同一个项目下,带着这段文字与上面的讨论)', '升格', headerPromoteIcon)
|
|
2330
|
+
// 设置:与「最近 / 升格」**同款**(同边框、同高、同圆角、同 hover),图标 + 文字。
|
|
2331
|
+
// 用户要求"图标旁加上设置两字" —— 一排文字键,视觉上才是同一套东西。
|
|
2332
|
+
var settingsButton = actionButton('设置(模型 / 解读 / 背景 / 联网 / 界面 / 数据)', '设置', headerSettingsIcon)
|
|
2333
|
+
settingsButton.setAttribute('aria-haspopup', 'dialog')
|
|
2334
|
+
settingsButton.setAttribute('aria-pressed', 'false')
|
|
2335
|
+
var closeButton = iconButton('关闭(Esc)', headerCloseIcon)
|
|
2148
2336
|
head.appendChild(historyButton)
|
|
2149
2337
|
head.appendChild(promoteButton)
|
|
2338
|
+
head.appendChild(settingsButton)
|
|
2150
2339
|
head.appendChild(closeButton)
|
|
2151
2340
|
var quote = el('div', 'dsh-sel-quote')
|
|
2152
2341
|
var body = el('div', 'dsh-sel-body')
|
|
@@ -2288,10 +2477,938 @@ window.__ModuleLoader__.load({
|
|
|
2288
2477
|
var retryButton = el('button', 'dsh-sel-retry', '重新生成')
|
|
2289
2478
|
retryButton.type = 'button'
|
|
2290
2479
|
retryButton.style.display = 'none'
|
|
2480
|
+
// ══════════════════ 设置页(抽屉) ══════════════════
|
|
2481
|
+
// 挂在面板**内部**:面板是 position:fixed(也算定位祖先)且 overflow:hidden,
|
|
2482
|
+
// 于是"从右侧滑入"的那一帧会被圆角裁掉,不会溢出到面板外面去。
|
|
2483
|
+
var settingsScrim = el('div', 'dsh-sel-scrim')
|
|
2484
|
+
settingsScrim.setAttribute('data-open', '0')
|
|
2485
|
+
var settingsSheet = el('div', 'dsh-sel-sheet')
|
|
2486
|
+
settingsSheet.setAttribute('data-open', '0')
|
|
2487
|
+
settingsSheet.setAttribute('role', 'dialog')
|
|
2488
|
+
settingsSheet.setAttribute('aria-label', '划词解读设置')
|
|
2489
|
+
var settingsHead = el('div', 'dsh-sel-sheethead')
|
|
2490
|
+
var settingsBack = el('button', 'dsh-sel-sheetback')
|
|
2491
|
+
settingsBack.type = 'button'
|
|
2492
|
+
settingsBack.title = '返回(Esc)'
|
|
2493
|
+
settingsBack.setAttribute('aria-label', '返回')
|
|
2494
|
+
settingsBack.appendChild(backIcon())
|
|
2495
|
+
settingsHead.appendChild(settingsBack)
|
|
2496
|
+
settingsHead.appendChild(el('span', 'dsh-sel-sheettitle', '设置'))
|
|
2497
|
+
settingsHead.appendChild(el('span', 'dsh-sel-sheetscope', '仅本插件 · 即时生效'))
|
|
2498
|
+
var settingsMain = el('div', 'dsh-sel-sheetmain')
|
|
2499
|
+
var settingsRail = el('div', 'dsh-sel-rail')
|
|
2500
|
+
settingsRail.setAttribute('role', 'tablist')
|
|
2501
|
+
settingsRail.setAttribute('aria-orientation', 'vertical')
|
|
2502
|
+
var settingsBody = el('div', 'dsh-sel-sheetbody')
|
|
2503
|
+
settingsMain.appendChild(settingsRail)
|
|
2504
|
+
settingsMain.appendChild(settingsBody)
|
|
2505
|
+
var settingsFoot = el('div', 'dsh-sel-sheetfoot')
|
|
2506
|
+
var settingsNote = el('span', 'dsh-sel-sfootnote')
|
|
2507
|
+
settingsNote.setAttribute('data-dirty', '0')
|
|
2508
|
+
settingsNote.appendChild(el('i'))
|
|
2509
|
+
var settingsNoteText = el('span', null, '改动会自动保存')
|
|
2510
|
+
settingsNote.appendChild(settingsNoteText)
|
|
2511
|
+
var settingsReset = el('button', 'dsh-sel-sbtn', '恢复默认')
|
|
2512
|
+
settingsReset.type = 'button'
|
|
2513
|
+
settingsReset.title = '把所有设置写回 cordis.patch.yml / 出厂默认'
|
|
2514
|
+
var settingsClose = el('button', 'dsh-sel-sbtn', '关闭')
|
|
2515
|
+
settingsClose.type = 'button'
|
|
2516
|
+
settingsFoot.appendChild(settingsNote)
|
|
2517
|
+
settingsFoot.appendChild(settingsReset)
|
|
2518
|
+
settingsFoot.appendChild(settingsClose)
|
|
2519
|
+
settingsSheet.appendChild(settingsHead)
|
|
2520
|
+
settingsSheet.appendChild(settingsMain)
|
|
2521
|
+
settingsSheet.appendChild(settingsFoot)
|
|
2522
|
+
// ── 改大小手柄(右下角 + 右边缘 + 下边缘)──
|
|
2523
|
+
// 小窗的尺寸/位置都由这里驱动;拖完记在 host 上(见 savePanelBox)。
|
|
2524
|
+
var gripCorner = el('div', 'dsh-sel-grip dsh-sel-grip-corner')
|
|
2525
|
+
var gripRight = el('div', 'dsh-sel-grip dsh-sel-grip-r')
|
|
2526
|
+
var gripBottom = el('div', 'dsh-sel-grip dsh-sel-grip-b')
|
|
2527
|
+
gripCorner.title = '拖动改大小(双击恢复默认)'
|
|
2528
|
+
panel.appendChild(gripCorner)
|
|
2529
|
+
panel.appendChild(gripRight)
|
|
2530
|
+
panel.appendChild(gripBottom)
|
|
2531
|
+
panel.appendChild(settingsScrim)
|
|
2532
|
+
panel.appendChild(settingsSheet)
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* 设置页状态。
|
|
2536
|
+
*
|
|
2537
|
+
* 刻意**不查 DOM**(不用 querySelectorAll / innerHTML):插件通篇是"拿住节点引用"的写法,
|
|
2538
|
+
* 而且单测的 DOM 桩也没有这两个接口。分组、行、分段格的节点都存下来,重绘时直接按引用操作。
|
|
2539
|
+
*
|
|
2540
|
+
* spec —— host 下发的字段声明(客户端不硬编码任何字段与边界)
|
|
2541
|
+
* saved —— host 已落盘的值(算"改了几项"、也是「恢复默认」后的对照)
|
|
2542
|
+
* live —— 控件上的当前值
|
|
2543
|
+
* byKey —— key → 控件条目,保存时按 key 取值
|
|
2544
|
+
*/
|
|
2545
|
+
var settingsOpen = false
|
|
2546
|
+
var settingsSpec = null
|
|
2547
|
+
var settingsSaved = {}
|
|
2548
|
+
var settingsLive = {}
|
|
2549
|
+
var settingsByKey = {}
|
|
2550
|
+
var settingsRailButtons = []
|
|
2551
|
+
var settingsPanes = []
|
|
2552
|
+
var settingsSegs = []
|
|
2553
|
+
/** 自绘下拉的菜单节点(挂在抽屉上,重渲染时要一起清掉,否则会叠出第二份浮层)。 */
|
|
2554
|
+
var settingsMenus = []
|
|
2555
|
+
var settingsFetching = false
|
|
2556
|
+
var settingsSaveTimer = 0
|
|
2557
|
+
var settingsSaveSeq = 0
|
|
2558
|
+
var settingsSaving = false
|
|
2559
|
+
var settingsSaveRequest = null
|
|
2560
|
+
var settingsResetting = false
|
|
2561
|
+
|
|
2562
|
+
/** 清空一个节点的子元素(替代 innerHTML='',两者在桩里都没有)。 */
|
|
2563
|
+
function clearNode(node) {
|
|
2564
|
+
while (node.firstChild) node.removeChild(node.firstChild)
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* 造一个 <option>(原生 select 用;设置页已改成自绘下拉,这里留给别处复用)。
|
|
2569
|
+
*/
|
|
2570
|
+
function modelOption(value, label) {
|
|
2571
|
+
var option = el('option', null, label)
|
|
2572
|
+
option.value = value
|
|
2573
|
+
return option
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* 一个模型下拉行"选中了什么"。
|
|
2578
|
+
*
|
|
2579
|
+
* 每个模型行管**两个**配置字段(spec.keys,如 ['provider','model'] 或 ['chatProvider','chatModel'])。
|
|
2580
|
+
* 两个都有才算选定;否则这一行就是"跟随"状态。
|
|
2581
|
+
*/
|
|
2582
|
+
function pairOf(entry) {
|
|
2583
|
+
var keys = entry.spec.keys || []
|
|
2584
|
+
var provider = settingsLive[keys[0]]
|
|
2585
|
+
var model = settingsLive[keys[1]]
|
|
2586
|
+
if (!provider || !model) return null
|
|
2587
|
+
return { provider: String(provider), model: String(model) }
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
/** 一行"跟随"时该写什么字(两行语义不同:首轮/次轮跟主会话,追问跟首轮/次轮)。 */
|
|
2591
|
+
function followLabelOf(entry) {
|
|
2592
|
+
return '跟随主会话'
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
/**
|
|
2596
|
+
* 下拉按钮上的文字。
|
|
2597
|
+
*
|
|
2598
|
+
* · 选定过 → 显示模型名(确定无疑)
|
|
2599
|
+
* · 没选定 → 「跟随…(当前:X)」—— X 是本行**实际会用**的那个模型:
|
|
2600
|
+
* 首轮/次轮行取 /route,追问行取 /route?chat=1。两个会话用不同模型时各报各的。
|
|
2601
|
+
*/
|
|
2602
|
+
function paintModelPick(entry) {
|
|
2603
|
+
if (!entry.label) return
|
|
2604
|
+
var pair = pairOf(entry)
|
|
2605
|
+
if (pair) {
|
|
2606
|
+
entry.label.textContent = modelLabel(pair.provider, pair.model, entry.catalog)
|
|
2607
|
+
return
|
|
2608
|
+
}
|
|
2609
|
+
var base = followLabelOf(entry)
|
|
2610
|
+
var route = entry.route
|
|
2611
|
+
entry.label.textContent = route && route.provider && route.model
|
|
2612
|
+
? base + '(当前:' + modelLabel(route.provider, route.model, entry.catalog) + ')'
|
|
2613
|
+
: base
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
/** 关掉所有模型下拉(同一时刻只允许开一个)。 */
|
|
2617
|
+
function closeModelPicks() {
|
|
2618
|
+
Object.keys(settingsByKey).forEach(function (key) {
|
|
2619
|
+
var entry = settingsByKey[key]
|
|
2620
|
+
if (entry && entry.menu) {
|
|
2621
|
+
entry.menu.setAttribute('data-open', '0')
|
|
2622
|
+
if (entry.button) entry.button.setAttribute('aria-expanded', 'false')
|
|
2623
|
+
}
|
|
2624
|
+
})
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* 取"这一行现在实际会用哪个模型"(轻量端点,可高频轮询)。
|
|
2629
|
+
*
|
|
2630
|
+
* 用 /route 而不是 /models:后者要列 provider 与全部模型(42 个 + 端点发现),
|
|
2631
|
+
* 按秒轮询太重。这里只做一次会话模型解析。
|
|
2632
|
+
*/
|
|
2633
|
+
function fetchRoute(sessionId, isChat) {
|
|
2634
|
+
var query = []
|
|
2635
|
+
if (sessionId) query.push('sessionId=' + encodeURIComponent(sessionId))
|
|
2636
|
+
if (isChat) query.push('chat=1')
|
|
2637
|
+
return fetch(ROUTE + (query.length ? '?' + query.join('&') : ''), { headers: { accept: 'application/json' } })
|
|
2638
|
+
.then(function (response) { return response.json() })
|
|
2639
|
+
.then(function (data) {
|
|
2640
|
+
if (!data || data.ok === false) return null
|
|
2641
|
+
return { provider: String(data.provider || ''), model: String(data.model || '') }
|
|
2642
|
+
})
|
|
2643
|
+
.catch(function () { return null })
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* 把"跟随主会话"解析出来的路由贴到界面上。
|
|
2648
|
+
*
|
|
2649
|
+
* 跟随态有两个地方显示它,**两处都要贴**:
|
|
2650
|
+
* · 抽屉里那一行的「跟随主会话(当前:…)」
|
|
2651
|
+
* · **输入框那枚模型胶囊**(跟随态它显示的就是这个值)
|
|
2652
|
+
* 分开贴是有原因的:以前胶囊的更新写在抽屉那行的 `if (!item.entry) return` 之后 ——
|
|
2653
|
+
* 设置没渲染出来(/settings 失败)时胶囊就永远不跟着变;这里各管各的。
|
|
2654
|
+
*/
|
|
2655
|
+
function applyFollowRoute(route) {
|
|
2656
|
+
if (!route || !route.provider || !route.model) return
|
|
2657
|
+
// 选了具体模型时胶囊显示的是那个模型,不显示"跟随(当前:…)"
|
|
2658
|
+
if (!state.modelChoice) {
|
|
2659
|
+
var before = modelCatalog.current
|
|
2660
|
+
if (!before || before.provider !== route.provider || before.model !== route.model) {
|
|
2661
|
+
modelCatalog.current = route
|
|
2662
|
+
paintModelPill()
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
var entry = settingsByKey.modelChoice
|
|
2666
|
+
if (!entry) return
|
|
2667
|
+
var old = entry.route
|
|
2668
|
+
if (old && old.provider === route.provider && old.model === route.model) return
|
|
2669
|
+
entry.route = route
|
|
2670
|
+
paintModelPick(entry)
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/**
|
|
2674
|
+
* 刷新一个模型行:清单(带缓存)+ 本行当前值(每次都新取)→ 画按钮、必要时重建列表。
|
|
2675
|
+
*
|
|
2676
|
+
* `forceRoute` 为真时连清单也强制重取(开抽屉那一次用)。
|
|
2677
|
+
*/
|
|
2678
|
+
function refreshModelRow(entry, forceRoute) {
|
|
2679
|
+
if (!entry) return
|
|
2680
|
+
var isChat = false
|
|
2681
|
+
var sessionId = panelSessionId || currentSessionId()
|
|
2682
|
+
loadModelCatalog(!!forceRoute, sessionId).then(function (catalog) {
|
|
2683
|
+
// **快照**,不是直接拿 modelCatalog 那个单例:
|
|
2684
|
+
// 另一行稍后会带着自己的会话再拉一次,那会把单例上的字段改掉。
|
|
2685
|
+
entry.catalog = {
|
|
2686
|
+
current: catalog.current,
|
|
2687
|
+
items: catalog.items,
|
|
2688
|
+
sessionId: sessionId,
|
|
2689
|
+
error: catalog.error,
|
|
2690
|
+
}
|
|
2691
|
+
if (entry.filledSession !== sessionId) {
|
|
2692
|
+
entry.filledSession = sessionId
|
|
2693
|
+
entry.filled = false
|
|
2694
|
+
entry.rows = []
|
|
2695
|
+
}
|
|
2696
|
+
if (forceRoute) entry.filled = false
|
|
2697
|
+
fillModelMenu(entry, entry.catalog)
|
|
2698
|
+
paintModelPick(entry)
|
|
2699
|
+
})
|
|
2700
|
+
fetchRoute(sessionId, isChat).then(applyFollowRoute)
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/** 按清单把下拉列表填好(清单是异步来的,可能晚于渲染)。 */
|
|
2704
|
+
function fillModelMenu(entry, catalog) {
|
|
2705
|
+
if (!entry.menu || entry.filled) return
|
|
2706
|
+
entry.filled = true
|
|
2707
|
+
var list = entry.list
|
|
2708
|
+
clearNode(list)
|
|
2709
|
+
entry.rows = []
|
|
2710
|
+
var items = (catalog && catalog.items) || []
|
|
2711
|
+
if (items.length === 0) {
|
|
2712
|
+
list.appendChild(el('div', 'dsh-sel-spickempty',
|
|
2713
|
+
catalog && catalog.error ? '读取失败:' + catalog.error : '没有取到可用模型清单'))
|
|
2714
|
+
return
|
|
2715
|
+
}
|
|
2716
|
+
// 第一项永远是"跟随":它不是一个模型,而是"清空这一行的两个字段"
|
|
2717
|
+
list.appendChild(modelPickRow(entry, null))
|
|
2718
|
+
for (var i = 0; i < items.length; i += 1) list.appendChild(modelPickRow(entry, items[i]))
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/** 一行模型(item 为 null = 「跟随…」)。 */
|
|
2722
|
+
function modelPickRow(entry, item) {
|
|
2723
|
+
var row = el('button', 'dsh-sel-spickrow')
|
|
2724
|
+
row.type = 'button'
|
|
2725
|
+
row.setAttribute('role', 'option')
|
|
2726
|
+
var pair = pairOf(entry)
|
|
2727
|
+
var on = item
|
|
2728
|
+
? !!pair && pair.provider === item.provider && pair.model === item.model
|
|
2729
|
+
: !pair
|
|
2730
|
+
row.setAttribute('data-on', on ? '1' : '0')
|
|
2731
|
+
row.setAttribute('aria-selected', on ? 'true' : 'false')
|
|
2732
|
+
row.appendChild(el('span', 'dsh-sel-spickcheck', on ? '✓' : ''))
|
|
2733
|
+
row.appendChild(el('span', 'dsh-sel-spickrowname', item ? String(item.name || item.model) : followLabelOf(entry)))
|
|
2734
|
+
if (item) row.appendChild(el('span', 'dsh-sel-spickprov', String(item.providerName || item.provider || '')))
|
|
2735
|
+
listen(row, 'click', function (event) {
|
|
2736
|
+
event.preventDefault()
|
|
2737
|
+
event.stopPropagation()
|
|
2738
|
+
var keys = entry.spec.keys || []
|
|
2739
|
+
if (item) {
|
|
2740
|
+
settingsLive[keys[0]] = String(item.provider || '')
|
|
2741
|
+
settingsLive[keys[1]] = String(item.model || '')
|
|
2742
|
+
} else {
|
|
2743
|
+
settingsLive[keys[0]] = ''
|
|
2744
|
+
settingsLive[keys[1]] = ''
|
|
2745
|
+
}
|
|
2746
|
+
repaintModelMenu(entry)
|
|
2747
|
+
paintModelPick(entry)
|
|
2748
|
+
closeModelPicks()
|
|
2749
|
+
syncComposerModel()
|
|
2750
|
+
renderSettingsNote('')
|
|
2751
|
+
scheduleSettingsSave()
|
|
2752
|
+
})
|
|
2753
|
+
if (item) entry.rows.push(item)
|
|
2754
|
+
return row
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** 就地重画这一行列表的勾选(不重建节点,省得丢焦点/滚动位置)。 */
|
|
2758
|
+
function repaintModelMenu(entry) {
|
|
2759
|
+
if (!entry.list) return
|
|
2760
|
+
var pair = pairOf(entry)
|
|
2761
|
+
var rows = entry.list.children
|
|
2762
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
2763
|
+
var item = i === 0 ? null : (entry.rows[i - 1] || null)
|
|
2764
|
+
var on = item
|
|
2765
|
+
? !!pair && pair.provider === item.provider && pair.model === item.model
|
|
2766
|
+
: !pair
|
|
2767
|
+
rows[i].setAttribute('data-on', on ? '1' : '0')
|
|
2768
|
+
rows[i].setAttribute('aria-selected', on ? 'true' : 'false')
|
|
2769
|
+
var check = rows[i].firstChild
|
|
2770
|
+
if (check) check.textContent = on ? '✓' : ''
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
/**
|
|
2775
|
+
* 开 / 关模型下拉。
|
|
2776
|
+
*
|
|
2777
|
+
* 菜单挂在**抽屉**上而不是行里:行的祖先链上有 `overflow:auto` 的滚动区,
|
|
2778
|
+
* 浮层放在行里会被那个滚动区裁掉(这正是"看不到滚动条"的一种成因)。
|
|
2779
|
+
* 位置按按钮的实测矩形算,高度按"抽屉底部还剩多少"给 —— 于是列表一定滚得动。
|
|
2780
|
+
*/
|
|
2781
|
+
function toggleModelMenu(entry) {
|
|
2782
|
+
if (!entry.menu) return
|
|
2783
|
+
var wasOpen = entry.menu.getAttribute('data-open') === '1'
|
|
2784
|
+
closeModelPicks()
|
|
2785
|
+
if (wasOpen) return
|
|
2786
|
+
var sheetRect = settingsSheet.getBoundingClientRect()
|
|
2787
|
+
var btnRect = entry.button.getBoundingClientRect()
|
|
2788
|
+
var left = btnRect.left - sheetRect.left
|
|
2789
|
+
var width = btnRect.width
|
|
2790
|
+
entry.menu.style.left = String(Math.max(6, left)) + 'px'
|
|
2791
|
+
entry.menu.style.width = String(Math.max(140, width)) + 'px'
|
|
2792
|
+
// 优先向下弹;下方放不下(窗口矮 / 行贴着底)就翻到按钮**上方** ——
|
|
2793
|
+
// 否则浮层会被面板的 overflow:hidden 切掉一截,看起来又像"没有滚动条"。
|
|
2794
|
+
var roomBelow = sheetRect.bottom - btnRect.bottom - 18
|
|
2795
|
+
if (roomBelow >= 140) {
|
|
2796
|
+
entry.menu.style.top = String(btnRect.bottom - sheetRect.top + 6) + 'px'
|
|
2797
|
+
entry.menu.style.bottom = ''
|
|
2798
|
+
entry.menu.style.maxHeight = String(roomBelow) + 'px'
|
|
2799
|
+
} else {
|
|
2800
|
+
var roomAbove = btnRect.top - sheetRect.top - 18
|
|
2801
|
+
entry.menu.style.top = ''
|
|
2802
|
+
entry.menu.style.bottom = String(sheetRect.bottom - btnRect.top + 6) + 'px'
|
|
2803
|
+
entry.menu.style.maxHeight = String(Math.max(140, roomAbove)) + 'px'
|
|
2804
|
+
}
|
|
2805
|
+
entry.menu.setAttribute('data-open', '1')
|
|
2806
|
+
entry.button.setAttribute('aria-expanded', 'true')
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/**
|
|
2810
|
+
* 模型在下拉里的显示名。
|
|
2811
|
+
*
|
|
2812
|
+
* 优先用 host 给的人类可读名(`name`),没有就退回 `provider / model` ——
|
|
2813
|
+
* 清单里 42 个模型跨好几个 provider,只写模型名会分不清同名项。
|
|
2814
|
+
*/
|
|
2815
|
+
function modelLabel(provider, model, catalog) {
|
|
2816
|
+
var items = (catalog && catalog.items) || modelCatalog.items || []
|
|
2817
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
2818
|
+
if (items[i].provider === provider && items[i].model === model) {
|
|
2819
|
+
var name = String(items[i].name || items[i].model || '')
|
|
2820
|
+
var pname = String(items[i].providerName || provider || '')
|
|
2821
|
+
return items[i].providerName && items[i].providerName !== name ? pname + ' · ' + name : name
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
return (provider ? provider + ' / ' : '') + model
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function settingsDirtyCount() {
|
|
2828
|
+
var n = 0
|
|
2829
|
+
Object.keys(settingsLive).forEach(function (key) {
|
|
2830
|
+
if (settingsSaved[key] !== settingsLive[key]) n += 1
|
|
2831
|
+
})
|
|
2832
|
+
return n
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
function renderSettingsNote(state) {
|
|
2836
|
+
var n = settingsDirtyCount()
|
|
2837
|
+
settingsNote.setAttribute('data-dirty', state === 'error' || n > 0 ? '1' : '0')
|
|
2838
|
+
if (state === 'saving') settingsNoteText.textContent = '保存中…'
|
|
2839
|
+
else if (state === 'error') settingsNoteText.textContent = '保存失败,改动未生效'
|
|
2840
|
+
else if (n > 0) settingsNoteText.textContent = '未保存 · ' + String(n) + ' 项'
|
|
2841
|
+
else settingsNoteText.textContent = '改动会自动保存'
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/**
|
|
2845
|
+
* 把服务端返回的一组值铺到**所有**控件上(保存成功、恢复默认都走它)。
|
|
2846
|
+
*
|
|
2847
|
+
* 为什么不能只按返回的键逐个 setControlValue:模型行的条目 key 是虚拟的
|
|
2848
|
+
* (modelChoice),而服务端返回的是它底下的 provider/model ——
|
|
2849
|
+
* 按后者查条目**查不到**,于是"恢复默认"之后模型下拉纹丝不动(用户报的问题)。
|
|
2850
|
+
* 所以这里先整体更新 settingsLive/settingsSaved,再按**条目**重画一遍。
|
|
2851
|
+
*/
|
|
2852
|
+
function applyServerValues(values, sent) {
|
|
2853
|
+
Object.keys(values || {}).forEach(function (key) {
|
|
2854
|
+
settingsSaved[key] = values[key]
|
|
2855
|
+
if (!sent || settingsLive[key] === sent[key]) settingsLive[key] = values[key]
|
|
2856
|
+
})
|
|
2857
|
+
Object.keys(settingsByKey).forEach(function (key) {
|
|
2858
|
+
var entry = settingsByKey[key]
|
|
2859
|
+
if (!entry) return
|
|
2860
|
+
if (entry.spec.kind === 'model') {
|
|
2861
|
+
paintModelPick(entry)
|
|
2862
|
+
repaintModelMenu(entry)
|
|
2863
|
+
} else {
|
|
2864
|
+
setControlValue(key, settingsLive[key])
|
|
2865
|
+
}
|
|
2866
|
+
})
|
|
2867
|
+
updateSettingDependencies()
|
|
2868
|
+
syncComposerModel()
|
|
2869
|
+
applySelectionLimit(settingsLive)
|
|
2870
|
+
if (!state.modelChoice) refreshModelRow(settingsByKey.modelChoice, true)
|
|
2871
|
+
bridgeOn = settingsLive.bridgeSidebarPreview !== false
|
|
2872
|
+
if (bridgeOn) scheduleBridgeScan()
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
function applySelectionLimit(values) {
|
|
2876
|
+
var max = Number(values && values.maxSelectionChars)
|
|
2877
|
+
if (isFinite(max) && max > 0) MAX_SELECTION = max
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
function syncComposerModel() {
|
|
2881
|
+
var provider = settingsLive.provider
|
|
2882
|
+
var model = settingsLive.model
|
|
2883
|
+
state.modelChoice = provider && model ? { provider: String(provider), model: String(model) } : null
|
|
2884
|
+
paintModelPill()
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
function chooseSharedModel(choice) {
|
|
2888
|
+
settingsLive.provider = choice ? String(choice.provider || '') : ''
|
|
2889
|
+
settingsLive.model = choice ? String(choice.model || '') : ''
|
|
2890
|
+
syncComposerModel()
|
|
2891
|
+
var entry = settingsByKey.modelChoice
|
|
2892
|
+
if (entry) { paintModelPick(entry); repaintModelMenu(entry) }
|
|
2893
|
+
loadSettings(false)
|
|
2894
|
+
renderSettingsNote('')
|
|
2895
|
+
scheduleSettingsSave()
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function updateSettingDependencies() {
|
|
2899
|
+
Object.keys(settingsByKey).forEach(function (key) {
|
|
2900
|
+
var entry = settingsByKey[key]
|
|
2901
|
+
var disabled = !!entry.spec.enabledBy && settingsLive[entry.spec.enabledBy] === false
|
|
2902
|
+
if (entry.row) entry.row.setAttribute('data-disabled', disabled ? '1' : '0')
|
|
2903
|
+
if (entry.input) entry.input.disabled = disabled
|
|
2904
|
+
})
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
/** 把一个值写进控件(首渲染与「恢复默认」共用)。 */
|
|
2908
|
+
function setControlValue(key, value) {
|
|
2909
|
+
var entry = settingsByKey[key]
|
|
2910
|
+
if (!entry) return
|
|
2911
|
+
if (entry.spec.kind === 'switch') {
|
|
2912
|
+
entry.input.setAttribute('aria-checked', value === true ? 'true' : 'false')
|
|
2913
|
+
return
|
|
2914
|
+
}
|
|
2915
|
+
if (entry.spec.kind === 'seg') {
|
|
2916
|
+
entry.seg.setAttribute('data-value', value === undefined || value === null ? '' : String(value))
|
|
2917
|
+
paintSegPill(entry)
|
|
2918
|
+
return
|
|
2919
|
+
}
|
|
2920
|
+
if (entry.spec.kind === 'model') {
|
|
2921
|
+
// 按钮文字与列表勾选都跟着 settingsLive 走
|
|
2922
|
+
paintModelPick(entry)
|
|
2923
|
+
repaintModelMenu(entry)
|
|
2924
|
+
return
|
|
2925
|
+
}
|
|
2926
|
+
// 配置单位 → 显示单位(时间统一按秒显示,见 SettingSpec.scale)
|
|
2927
|
+
var shown = value
|
|
2928
|
+
var scale = entry.spec.scale || 1
|
|
2929
|
+
if (scale !== 1 && typeof value === 'number' && isFinite(value)) shown = value / scale
|
|
2930
|
+
entry.input.value = shown === undefined || shown === null ? '' : String(shown)
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
|
|
2935
|
+
/**
|
|
2936
|
+
* 分段控件的滑块:量当前选中格的位置再摆。
|
|
2937
|
+
*
|
|
2938
|
+
* 格子是**隐藏时量不到宽度**的(display:none 的父节点里 offsetWidth 是 0),
|
|
2939
|
+
* 所以切页之后要重摆一次(见 showSettingsTab)。
|
|
2940
|
+
*/
|
|
2941
|
+
function paintSegPill(entry) {
|
|
2942
|
+
if (!entry.seg || !entry.pill) return
|
|
2943
|
+
var value = entry.seg.getAttribute('data-value')
|
|
2944
|
+
var on = null
|
|
2945
|
+
for (var i = 0; i < entry.cells.length; i += 1) {
|
|
2946
|
+
var picked = entry.cells[i].getAttribute('data-value') === value
|
|
2947
|
+
entry.cells[i].setAttribute('aria-checked', picked ? 'true' : 'false')
|
|
2948
|
+
if (picked) on = entry.cells[i]
|
|
2949
|
+
}
|
|
2950
|
+
if (!on) return
|
|
2951
|
+
entry.pill.style.left = String(on.offsetLeft) + 'px'
|
|
2952
|
+
entry.pill.style.width = String(on.offsetWidth) + 'px'
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
/** 一行设置(按 spec.kind 造对应控件)。 */
|
|
2956
|
+
function buildSettingRow(spec) {
|
|
2957
|
+
var row = el('div', 'dsh-sel-srow')
|
|
2958
|
+
var lab = el('div', 'dsh-sel-srowlab')
|
|
2959
|
+
lab.appendChild(el('div', 'dsh-sel-srowt', spec.label))
|
|
2960
|
+
if (spec.hint) lab.appendChild(el('div', 'dsh-sel-srowd', spec.hint))
|
|
2961
|
+
if (spec.kind === 'model') lab.style.display = 'none'
|
|
2962
|
+
var ctl = el('div', 'dsh-sel-srowctl')
|
|
2963
|
+
row.appendChild(lab)
|
|
2964
|
+
row.appendChild(ctl)
|
|
2965
|
+
|
|
2966
|
+
if (spec.kind === 'model') {
|
|
2967
|
+
// 模型选择:一个下拉同时定 provider + model(见 settings.ts 的 keys)。
|
|
2968
|
+
// 直接列 host 已有的模型清单 —— 用户不必记住 provider id 与 model id 怎么拼。
|
|
2969
|
+
row.setAttribute('data-wide', '1')
|
|
2970
|
+
var pick = el('button', 'dsh-sel-spick')
|
|
2971
|
+
pick.type = 'button'
|
|
2972
|
+
pick.setAttribute('aria-label', spec.label)
|
|
2973
|
+
pick.setAttribute('aria-haspopup', 'listbox')
|
|
2974
|
+
pick.setAttribute('aria-expanded', 'false')
|
|
2975
|
+
var pickName = el('span', 'dsh-sel-spickname', '跟随主会话')
|
|
2976
|
+
pick.appendChild(pickName)
|
|
2977
|
+
pick.appendChild(el('span', 'dsh-sel-spickcaret', '▾'))
|
|
2978
|
+
// 菜单挂到**抽屉**上(不是行里):行在 overflow:auto 的滚动区里,浮层会被裁掉
|
|
2979
|
+
var pickMenu = el('div', 'dsh-sel-spickmenu')
|
|
2980
|
+
pickMenu.setAttribute('data-open', '0')
|
|
2981
|
+
pickMenu.setAttribute('role', 'listbox')
|
|
2982
|
+
pickMenu.setAttribute('aria-label', spec.label)
|
|
2983
|
+
var pickList = el('div', 'dsh-sel-spicklist')
|
|
2984
|
+
pickMenu.appendChild(el('div', 'dsh-sel-spickgroup', '模型'))
|
|
2985
|
+
pickMenu.appendChild(pickList)
|
|
2986
|
+
settingsSheet.appendChild(pickMenu)
|
|
2987
|
+
settingsMenus.push(pickMenu)
|
|
2988
|
+
var entryModel = {
|
|
2989
|
+
spec: spec, row: row, button: pick, label: pickName,
|
|
2990
|
+
list: pickList, menu: pickMenu, filled: false, catalog: null, rows: [],
|
|
2991
|
+
}
|
|
2992
|
+
listen(pick, 'click', function (event) {
|
|
2993
|
+
event.preventDefault()
|
|
2994
|
+
event.stopPropagation()
|
|
2995
|
+
toggleModelMenu(entryModel)
|
|
2996
|
+
})
|
|
2997
|
+
ctl.appendChild(pick)
|
|
2998
|
+
settingsByKey[spec.key] = entryModel
|
|
2999
|
+
// 拉清单 + 取本行当前值,并按**本小窗所属会话**画按钮文字。
|
|
3000
|
+
// 开抽屉时还会再跑一遍(见 openSettings),轮询也会持续刷新。
|
|
3001
|
+
refreshModelRow(entryModel, false)
|
|
3002
|
+
return row
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
if (spec.kind === 'switch') {
|
|
3006
|
+
var sw = el('button', 'dsh-sel-sws')
|
|
3007
|
+
sw.type = 'button'
|
|
3008
|
+
sw.setAttribute('role', 'switch')
|
|
3009
|
+
sw.setAttribute('aria-label', spec.label)
|
|
3010
|
+
sw.setAttribute('aria-checked', 'false')
|
|
3011
|
+
listen(sw, 'click', function () {
|
|
3012
|
+
var on = sw.getAttribute('aria-checked') === 'true'
|
|
3013
|
+
sw.setAttribute('aria-checked', on ? 'false' : 'true')
|
|
3014
|
+
settingsLive[spec.key] = !on
|
|
3015
|
+
updateSettingDependencies()
|
|
3016
|
+
renderSettingsNote('')
|
|
3017
|
+
scheduleSettingsSave()
|
|
3018
|
+
})
|
|
3019
|
+
ctl.appendChild(sw)
|
|
3020
|
+
settingsByKey[spec.key] = { spec: spec, row: row, input: sw }
|
|
3021
|
+
return row
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
if (spec.kind === 'seg') {
|
|
3025
|
+
var seg = el('span', 'dsh-sel-seg')
|
|
3026
|
+
seg.setAttribute('role', 'radiogroup')
|
|
3027
|
+
seg.setAttribute('aria-label', spec.label)
|
|
3028
|
+
seg.setAttribute('data-value', '')
|
|
3029
|
+
var pill = el('span', 'dsh-sel-segpill')
|
|
3030
|
+
seg.appendChild(pill)
|
|
3031
|
+
var entry = { spec: spec, row: row, seg: seg, pill: pill, cells: [] }
|
|
3032
|
+
var options = spec.options || []
|
|
3033
|
+
options.forEach(function (option) {
|
|
3034
|
+
var cell = el('button', 'dsh-sel-segcell', { off: '关', low: '低', high: '高', max: '最高' }[option] || option)
|
|
3035
|
+
cell.type = 'button'
|
|
3036
|
+
cell.setAttribute('role', 'radio')
|
|
3037
|
+
cell.setAttribute('data-value', option)
|
|
3038
|
+
cell.setAttribute('aria-checked', 'false')
|
|
3039
|
+
listen(cell, 'click', function () {
|
|
3040
|
+
seg.setAttribute('data-value', option)
|
|
3041
|
+
paintSegPill(entry)
|
|
3042
|
+
settingsLive[spec.key] = option
|
|
3043
|
+
if (spec.key === 'chatReasoningEffort') { state.effort = option; writeStore(EFFORT_KEY, option); paintModelPill() }
|
|
3044
|
+
renderSettingsNote('')
|
|
3045
|
+
scheduleSettingsSave()
|
|
3046
|
+
})
|
|
3047
|
+
seg.appendChild(cell)
|
|
3048
|
+
entry.cells.push(cell)
|
|
3049
|
+
})
|
|
3050
|
+
ctl.appendChild(seg)
|
|
3051
|
+
settingsByKey[spec.key] = entry
|
|
3052
|
+
settingsSegs.push(entry)
|
|
3053
|
+
return row
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
var input = el(spec.key === 'toolNames' ? 'textarea' : 'input', spec.kind === 'number' ? 'dsh-sel-snum' : 'dsh-sel-stext')
|
|
3057
|
+
if (spec.key === 'toolNames') input.setAttribute('rows', '2')
|
|
3058
|
+
var numScale = spec.scale || 1
|
|
3059
|
+
if (spec.kind === 'number') {
|
|
3060
|
+
input.type = 'number'
|
|
3061
|
+
// 边界按**显示单位**给(配置里是毫秒,界面按秒),换算只在这一处做
|
|
3062
|
+
if (spec.min !== undefined) input.setAttribute('min', String(spec.min / numScale))
|
|
3063
|
+
if (spec.max !== undefined) input.setAttribute('max', String(spec.max / numScale))
|
|
3064
|
+
if (spec.step !== undefined) input.setAttribute('step', String(spec.step / numScale))
|
|
3065
|
+
} else {
|
|
3066
|
+
input.type = 'text'
|
|
3067
|
+
// 宽文本行必须让输入框独占一行,否则值会被裁掉(抽屉是嵌在小窗里的窄容器)
|
|
3068
|
+
row.setAttribute('data-wide', '1')
|
|
3069
|
+
if (spec.placeholder) input.setAttribute('placeholder', spec.placeholder)
|
|
3070
|
+
}
|
|
3071
|
+
input.setAttribute('aria-label', spec.label)
|
|
3072
|
+
listen(input, 'input', function () {
|
|
3073
|
+
if (spec.kind === 'number') {
|
|
3074
|
+
// 空串(用户正在清空重打)先记成 undefined,别把 NaN 送去 host
|
|
3075
|
+
var text = String(input.value || '').trim()
|
|
3076
|
+
// 显示单位 → 配置单位(秒 → 毫秒);取整避免 5.000000000001 这种浮点尾巴
|
|
3077
|
+
settingsLive[spec.key] = text === '' ? undefined
|
|
3078
|
+
: (numScale === 1 ? Number(text) : Math.round(Number(text) * numScale))
|
|
3079
|
+
} else {
|
|
3080
|
+
settingsLive[spec.key] = input.value
|
|
3081
|
+
}
|
|
3082
|
+
renderSettingsNote('')
|
|
3083
|
+
scheduleSettingsSave()
|
|
3084
|
+
})
|
|
3085
|
+
// 数字框失焦时把值夹回边界再回显:否则界面上留着用户敲的越界数,
|
|
3086
|
+
// 而真正生效的是 host 夹过的值,两边对不上(用户会以为没保存成功)
|
|
3087
|
+
listen(input, 'blur', function () {
|
|
3088
|
+
if (spec.kind !== 'number') return
|
|
3089
|
+
var text = String(input.value || '').trim()
|
|
3090
|
+
if (text === '') return
|
|
3091
|
+
var num = Number(text)
|
|
3092
|
+
if (!isFinite(num)) return
|
|
3093
|
+
var min = spec.min === undefined ? -Infinity : spec.min / numScale
|
|
3094
|
+
var max = spec.max === undefined ? Infinity : spec.max / numScale
|
|
3095
|
+
var fixed = Math.min(max, Math.max(min, num))
|
|
3096
|
+
input.value = String(fixed)
|
|
3097
|
+
settingsLive[spec.key] = numScale === 1 ? fixed : Math.round(fixed * numScale)
|
|
3098
|
+
renderSettingsNote('')
|
|
3099
|
+
scheduleSettingsSave()
|
|
3100
|
+
})
|
|
3101
|
+
ctl.appendChild(input)
|
|
3102
|
+
if (spec.unit) ctl.appendChild(el('span', 'dsh-sel-sunit', spec.unit))
|
|
3103
|
+
settingsByKey[spec.key] = { spec: spec, row: row, input: input }
|
|
3104
|
+
return row
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/** 按 spec 画整页(拿到 spec 后调用一次)。 */
|
|
3108
|
+
function renderSettings(spec, values) {
|
|
3109
|
+
settingsSpec = spec
|
|
3110
|
+
settingsByKey = {}
|
|
3111
|
+
settingsRailButtons = []
|
|
3112
|
+
settingsPanes = []
|
|
3113
|
+
settingsSegs = []
|
|
3114
|
+
for (var m = 0; m < settingsMenus.length; m += 1) {
|
|
3115
|
+
if (settingsMenus[m].parentNode) settingsMenus[m].parentNode.removeChild(settingsMenus[m])
|
|
3116
|
+
}
|
|
3117
|
+
settingsMenus = []
|
|
3118
|
+
clearNode(settingsRail)
|
|
3119
|
+
clearNode(settingsBody)
|
|
3120
|
+
var groups = spec.groups || []
|
|
3121
|
+
groups.forEach(function (group, index) {
|
|
3122
|
+
var railBtn = el('button', 'dsh-sel-railbtn', group.label)
|
|
3123
|
+
railBtn.type = 'button'
|
|
3124
|
+
railBtn.setAttribute('role', 'tab')
|
|
3125
|
+
railBtn.setAttribute('data-tab', group.id)
|
|
3126
|
+
railBtn.setAttribute('aria-current', index === 0 ? 'true' : 'false')
|
|
3127
|
+
listen(railBtn, 'click', function () { showSettingsTab(group.id) })
|
|
3128
|
+
settingsRail.appendChild(railBtn)
|
|
3129
|
+
settingsRailButtons.push({ id: group.id, node: railBtn })
|
|
3130
|
+
|
|
3131
|
+
var section = el('section', 'dsh-sel-grp')
|
|
3132
|
+
section.setAttribute('data-pane', group.id)
|
|
3133
|
+
var parts = group.sections || [{ label: group.label, keys: group.items.map(function (item) { return item.key }) }]
|
|
3134
|
+
parts.forEach(function (part) {
|
|
3135
|
+
var block = el('section', 'dsh-sel-ssection')
|
|
3136
|
+
block.appendChild(el('h3', 'dsh-sel-ssectionh', part.label))
|
|
3137
|
+
if (part.hint) block.appendChild(el('p', 'dsh-sel-ssectionhint', part.hint))
|
|
3138
|
+
part.keys.forEach(function (key) {
|
|
3139
|
+
var item = group.items.find(function (candidate) { return candidate.key === key })
|
|
3140
|
+
if (item) block.appendChild(buildSettingRow(item))
|
|
3141
|
+
})
|
|
3142
|
+
if (part.note) block.appendChild(el('p', 'dsh-sel-ssectionnote', part.note))
|
|
3143
|
+
section.appendChild(block)
|
|
3144
|
+
})
|
|
3145
|
+
settingsBody.appendChild(section)
|
|
3146
|
+
settingsPanes.push({ id: group.id, node: section })
|
|
3147
|
+
})
|
|
3148
|
+
Object.keys(values || {}).forEach(function (key) {
|
|
3149
|
+
setControlValue(key, values[key])
|
|
3150
|
+
})
|
|
3151
|
+
updateSettingDependencies()
|
|
3152
|
+
syncComposerModel()
|
|
3153
|
+
applySelectionLimit(values)
|
|
3154
|
+
showSettingsTab(groups.length ? groups[0].id : '')
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
function showSettingsTab(id) {
|
|
3158
|
+
var i
|
|
3159
|
+
for (i = 0; i < settingsRailButtons.length; i += 1) {
|
|
3160
|
+
settingsRailButtons[i].node.setAttribute('aria-current', settingsRailButtons[i].id === id ? 'true' : 'false')
|
|
3161
|
+
}
|
|
3162
|
+
for (i = 0; i < settingsPanes.length; i += 1) {
|
|
3163
|
+
settingsPanes[i].node.style.display = settingsPanes[i].id === id ? '' : 'none'
|
|
3164
|
+
}
|
|
3165
|
+
settingsBody.scrollTop = 0
|
|
3166
|
+
closeModelPicks() // 切分类时把模型下拉收掉(浮层挂在抽屉上,不跟着分类走)
|
|
3167
|
+
// 隐藏时量不到 offsetWidth,切页后重摆一次滑块(否则滑块停在 0 宽)
|
|
3168
|
+
for (i = 0; i < settingsSegs.length; i += 1) {
|
|
3169
|
+
if (settingsSegs[i].seg.offsetParent !== null) paintSegPill(settingsSegs[i])
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
/** 拉一次 spec + 当前值。失败就在正文里给出说明(重开抽屉即可重试)。 */
|
|
3174
|
+
function loadSettings(force) {
|
|
3175
|
+
if (settingsFetching) return
|
|
3176
|
+
if (settingsSpec && !force) return
|
|
3177
|
+
settingsFetching = true
|
|
3178
|
+
fetch(SETTINGS, { headers: { accept: 'application/json' } })
|
|
3179
|
+
.then(function (response) { return response.json() })
|
|
3180
|
+
.then(function (payload) {
|
|
3181
|
+
settingsFetching = false
|
|
3182
|
+
if (!payload || payload.ok === false || !payload.groups) {
|
|
3183
|
+
throw new Error((payload && payload.error) || '设置读取失败')
|
|
3184
|
+
}
|
|
3185
|
+
var pending = settingsSpec ? {} : Object.assign({}, settingsLive)
|
|
3186
|
+
settingsSaved = Object.assign({}, payload.values || {})
|
|
3187
|
+
settingsLive = Object.assign({}, payload.values || {}, pending)
|
|
3188
|
+
renderSettings({ groups: payload.groups }, settingsLive)
|
|
3189
|
+
renderSettingsNote('')
|
|
3190
|
+
if (settingsDirtyCount() > 0) scheduleSettingsSave()
|
|
3191
|
+
})
|
|
3192
|
+
.catch(function (error) {
|
|
3193
|
+
settingsFetching = false
|
|
3194
|
+
clearNode(settingsBody)
|
|
3195
|
+
settingsBody.appendChild(el('div', 'dsh-sel-sempty',
|
|
3196
|
+
'设置读取失败:' + String((error && error.message) || error) + '(点「关闭」后重开可重试)'))
|
|
3197
|
+
})
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
/**
|
|
3201
|
+
* 保存(防抖 400ms)。
|
|
3202
|
+
*
|
|
3203
|
+
* 只发改过的键:补丁越小,越不容易和"另一个窗口同时改了别的项"撞车
|
|
3204
|
+
* (host 侧是**合并**而不是覆盖,两者配合才不会互相抹掉)。
|
|
3205
|
+
*/
|
|
3206
|
+
function scheduleSettingsSave() {
|
|
3207
|
+
if (settingsSaveTimer) clearTimeout(settingsSaveTimer)
|
|
3208
|
+
settingsSaveTimer = setTimeout(function () {
|
|
3209
|
+
settingsSaveTimer = 0
|
|
3210
|
+
flushSettingsSave()
|
|
3211
|
+
}, 400)
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
function flushSettingsSave() {
|
|
3215
|
+
if (settingsSaving || settingsResetting || !settingsSpec) return settingsSaveRequest
|
|
3216
|
+
var patch = {}
|
|
3217
|
+
Object.keys(settingsLive).forEach(function (key) {
|
|
3218
|
+
var value = settingsLive[key]
|
|
3219
|
+
if (value === undefined) return
|
|
3220
|
+
if (settingsSaved[key] === value) return
|
|
3221
|
+
patch[key] = value
|
|
3222
|
+
})
|
|
3223
|
+
if (Object.keys(patch).length === 0) {
|
|
3224
|
+
renderSettingsNote('')
|
|
3225
|
+
return
|
|
3226
|
+
}
|
|
3227
|
+
var seq = ++settingsSaveSeq
|
|
3228
|
+
var sent = Object.assign({}, settingsLive)
|
|
3229
|
+
var accepted = false
|
|
3230
|
+
settingsSaving = true
|
|
3231
|
+
renderSettingsNote('saving')
|
|
3232
|
+
settingsSaveRequest = fetch(SETTINGS, {
|
|
3233
|
+
method: 'POST',
|
|
3234
|
+
headers: { 'content-type': 'application/json' },
|
|
3235
|
+
body: JSON.stringify({ values: patch }),
|
|
3236
|
+
})
|
|
3237
|
+
.then(function (response) { return response.json() })
|
|
3238
|
+
.then(function (payload) {
|
|
3239
|
+
if (seq !== settingsSaveSeq) return // 有更新的保存在路上,别用旧结果覆盖
|
|
3240
|
+
if (!payload || payload.ok === false) throw new Error((payload && payload.error) || '保存失败')
|
|
3241
|
+
// 以 host 归一后的值为准(clamp 过的数字要和界面显示一致)
|
|
3242
|
+
applyServerValues(payload.values, sent)
|
|
3243
|
+
// 胶囊偏好立刻生效(不等下次刷新)—— 设置页承诺"即时生效"
|
|
3244
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'pillEnabled') ||
|
|
3245
|
+
Object.prototype.hasOwnProperty.call(patch, 'pillIdleMs')) {
|
|
3246
|
+
applyPillPrefs({ enabled: settingsLive.pillEnabled, idleMs: settingsLive.pillIdleMs })
|
|
3247
|
+
}
|
|
3248
|
+
if (Array.isArray(payload.rejected) && payload.rejected.length > 0) {
|
|
3249
|
+
// 有字段被拒:只提示、不回滚 —— 同一批里其它字段已经生效了
|
|
3250
|
+
settingsNote.setAttribute('data-dirty', '1')
|
|
3251
|
+
settingsNoteText.textContent = '有 ' + String(payload.rejected.length) + ' 项未接受'
|
|
3252
|
+
return
|
|
3253
|
+
}
|
|
3254
|
+
accepted = true
|
|
3255
|
+
renderSettingsNote('')
|
|
3256
|
+
})
|
|
3257
|
+
.catch(function () {
|
|
3258
|
+
if (seq !== settingsSaveSeq) return
|
|
3259
|
+
renderSettingsNote('error')
|
|
3260
|
+
})
|
|
3261
|
+
.finally(function () {
|
|
3262
|
+
settingsSaving = false
|
|
3263
|
+
if (accepted && !settingsResetting && settingsDirtyCount() > 0 && settingsSaveSeq === seq) scheduleSettingsSave()
|
|
3264
|
+
})
|
|
3265
|
+
return settingsSaveRequest
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
/**
|
|
3269
|
+
* 把胶囊的两个偏好落到界面上。
|
|
3270
|
+
*
|
|
3271
|
+
* ping(启动时)与设置保存后**都走这里** —— 设置页写着"即时生效",
|
|
3272
|
+
* 如果只在启动时读一次,用户关掉胶囊后要刷新页面才见效,那就是假承诺。
|
|
3273
|
+
*/
|
|
3274
|
+
function applyPillPrefs(prefs) {
|
|
3275
|
+
if (!prefs) return
|
|
3276
|
+
var idle = Number(prefs.idleMs)
|
|
3277
|
+
if (isFinite(idle) && idle >= 2000) PILL_BALL_IDLE_MS = idle
|
|
3278
|
+
if (prefs.enabled === false) {
|
|
3279
|
+
pillHidden = true
|
|
3280
|
+
pill.style.display = 'none'
|
|
3281
|
+
} else if (prefs.enabled === true) {
|
|
3282
|
+
pillHidden = false
|
|
3283
|
+
pill.style.display = ''
|
|
3284
|
+
placePill()
|
|
3285
|
+
}
|
|
3286
|
+
if (pillHidden) {
|
|
3287
|
+
if (pillIdleId) clearTimeout(pillIdleId)
|
|
3288
|
+
pillIdleId = 0
|
|
3289
|
+
} else {
|
|
3290
|
+
schedulePillIdle()
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
/** 轮询句柄(抽屉和小窗都收了必须清掉,不然会一直打接口)。 */
|
|
3295
|
+
var routeWatchTimer = 0
|
|
3296
|
+
/** 轮询间隔:够快看得出"实时",又不至于把接口打爆(这条接口只做一次会话解析,很轻)。 */
|
|
3297
|
+
var ROUTE_WATCH_MS = 2000
|
|
3298
|
+
|
|
3299
|
+
/**
|
|
3300
|
+
* 盯着"当前会话在用的模型"。
|
|
3301
|
+
*
|
|
3302
|
+
* 主会话里随时可以换模型,而插件这边**没有**可订阅的变更事件(模型选择是会话侧的),
|
|
3303
|
+
* 所以只能轮询那条轻量 /route:值一变就把「跟随主会话(当前:…)」和输入框那枚胶囊改掉。
|
|
3304
|
+
*
|
|
3305
|
+
* **小窗开着就要盯,不只是抽屉开着**:跟随态下输入框那枚胶囊报的就是这个值 ——
|
|
3306
|
+
* 以前只在抽屉开着时轮询,于是"小窗开着、主会话换了模型"要等下一次开/关设置页才更新
|
|
3307
|
+
* (用户报的"没有及时刷新")。抽屉和小窗都收起来即停。
|
|
3308
|
+
*/
|
|
3309
|
+
function startRouteWatch() {
|
|
3310
|
+
stopRouteWatch()
|
|
3311
|
+
routeWatchTimer = setInterval(function () {
|
|
3312
|
+
if (!settingsOpen && !panelOpen) { stopRouteWatch(); return }
|
|
3313
|
+
// 选了具体模型:胶囊与括号显示的都不是"跟随"的值,不必再问
|
|
3314
|
+
if (state.modelChoice) return
|
|
3315
|
+
fetchRoute(panelSessionId || currentSessionId(), false).then(applyFollowRoute)
|
|
3316
|
+
}, ROUTE_WATCH_MS)
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
function stopRouteWatch() {
|
|
3320
|
+
if (routeWatchTimer) {
|
|
3321
|
+
clearInterval(routeWatchTimer)
|
|
3322
|
+
routeWatchTimer = 0
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
/** 抽屉或小窗还开着就继续轮询;两个都收了才停(开着时重复调用不重启计时)。 */
|
|
3327
|
+
function syncRouteWatch() {
|
|
3328
|
+
if (!settingsOpen && !panelOpen) { stopRouteWatch(); return }
|
|
3329
|
+
if (routeWatchTimer) return
|
|
3330
|
+
startRouteWatch()
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
/** 先对齐一次"跟随"的值(开窗/开抽屉那一刻,不等第一拍轮询)。 */
|
|
3334
|
+
function refreshFollowRoute() {
|
|
3335
|
+
if (state.modelChoice) return
|
|
3336
|
+
fetchRoute(panelSessionId || currentSessionId(), false).then(applyFollowRoute)
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
function openSettings() {
|
|
3340
|
+
if (settingsOpen) return
|
|
3341
|
+
settingsOpen = true
|
|
3342
|
+
// 抽屉贴着面板(inset:0),所以给面板一个合适的尺寸下限 —— 否则内容短时抽屉被压成一条
|
|
3343
|
+
panel.setAttribute('data-settings', '1')
|
|
3344
|
+
settingsSheet.setAttribute('data-open', '1')
|
|
3345
|
+
settingsScrim.setAttribute('data-open', '1')
|
|
3346
|
+
settingsButton.setAttribute('aria-pressed', 'true')
|
|
3347
|
+
loadSettings(false)
|
|
3348
|
+
settingsSegs.forEach(function (entry) { paintSegPill(entry) })
|
|
3349
|
+
// 每次开抽屉都重取一次模型(强制,绕过缓存):
|
|
3350
|
+
// 会话换了、或主会话中途换了模型,括号里的名字都要跟着变。
|
|
3351
|
+
refreshModelRow(settingsByKey.modelChoice, true)
|
|
3352
|
+
syncRouteWatch()
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
function closeSettings() {
|
|
3356
|
+
if (!settingsOpen) return
|
|
3357
|
+
settingsOpen = false
|
|
3358
|
+
panel.removeAttribute('data-settings')
|
|
3359
|
+
closeModelPicks()
|
|
3360
|
+
settingsSheet.setAttribute('data-open', '0')
|
|
3361
|
+
settingsScrim.setAttribute('data-open', '0')
|
|
3362
|
+
settingsButton.setAttribute('aria-pressed', 'false')
|
|
3363
|
+
// 关抽屉前把还没落盘的改动补发一次,别让"关掉就走"丢掉刚改的值
|
|
3364
|
+
if (settingsSaveTimer) {
|
|
3365
|
+
clearTimeout(settingsSaveTimer)
|
|
3366
|
+
settingsSaveTimer = 0
|
|
3367
|
+
flushSettingsSave()
|
|
3368
|
+
}
|
|
3369
|
+
// 小窗还开着就继续盯(跟随态下输入框那枚胶囊还指着会话的模型)
|
|
3370
|
+
syncRouteWatch()
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
function resetSettings() {
|
|
3374
|
+
if (settingsResetting) return
|
|
3375
|
+
settingsResetting = true
|
|
3376
|
+
if (settingsSaveTimer) { clearTimeout(settingsSaveTimer); settingsSaveTimer = 0 }
|
|
3377
|
+
var sent
|
|
3378
|
+
renderSettingsNote('saving')
|
|
3379
|
+
// 等正在保存的请求结束再恢复,避免晚到的保存把默认值覆盖掉。
|
|
3380
|
+
Promise.resolve(settingsSaveRequest).then(function () {
|
|
3381
|
+
sent = Object.assign({}, settingsLive)
|
|
3382
|
+
return fetch(SETTINGS, {
|
|
3383
|
+
method: 'POST',
|
|
3384
|
+
headers: { 'content-type': 'application/json' },
|
|
3385
|
+
body: JSON.stringify({ reset: true }),
|
|
3386
|
+
})
|
|
3387
|
+
}).then(function (response) { return response.json() })
|
|
3388
|
+
.then(function (payload) {
|
|
3389
|
+
if (!payload || payload.ok === false) throw new Error('恢复默认失败')
|
|
3390
|
+
applyServerValues(payload.values, sent)
|
|
3391
|
+
state.effort = settingsLive.chatReasoningEffort || ''
|
|
3392
|
+
writeStore(EFFORT_KEY, state.effort)
|
|
3393
|
+
applyPillPrefs({ enabled: settingsLive.pillEnabled, idleMs: settingsLive.pillIdleMs })
|
|
3394
|
+
bridgeOn = settingsLive.bridgeSidebarPreview !== false
|
|
3395
|
+
if (bridgeOn) scheduleBridgeScan()
|
|
3396
|
+
refreshModelRow(settingsByKey.modelChoice, true)
|
|
3397
|
+
paintModelPill()
|
|
3398
|
+
renderSettingsNote('')
|
|
3399
|
+
})
|
|
3400
|
+
.catch(function () { renderSettingsNote('error') })
|
|
3401
|
+
.finally(function () {
|
|
3402
|
+
settingsResetting = false
|
|
3403
|
+
if (settingsDirtyCount() > 0 && settingsNoteText.textContent !== '保存失败,改动未生效') scheduleSettingsSave()
|
|
3404
|
+
})
|
|
3405
|
+
}
|
|
3406
|
+
|
|
2291
3407
|
panel.appendChild(head)
|
|
2292
3408
|
panel.appendChild(body)
|
|
2293
3409
|
panel.appendChild(askRow)
|
|
2294
3410
|
layer.appendChild(panel)
|
|
3411
|
+
|
|
2295
3412
|
// 「最近聊过的」是**独立的浮层**(贴面板侧边),不和消息共用一个滚动区
|
|
2296
3413
|
var historyList = el('div', 'dsh-sel-history')
|
|
2297
3414
|
historyList.style.display = 'none'
|
|
@@ -2334,6 +3451,14 @@ window.__ModuleLoader__.load({
|
|
|
2334
3451
|
}
|
|
2335
3452
|
|
|
2336
3453
|
var panelOpen = false
|
|
3454
|
+
/**
|
|
3455
|
+
* **本小窗所属的会话**(划词那一刻所在的那个)。
|
|
3456
|
+
*
|
|
3457
|
+
* 不能直接用 currentSessionId():那是"当前正在看的会话",用户切走之后它立刻变了。
|
|
3458
|
+
* 小窗的模型、背景、升格都该按**它自己那个会话**算 ——
|
|
3459
|
+
* 否则切到别的会话再打开设置,括号里会报成另一个会话的模型(用户报的问题)。
|
|
3460
|
+
*/
|
|
3461
|
+
var panelSessionId = ''
|
|
2337
3462
|
var rafPending = false
|
|
2338
3463
|
/** 流式重绘限速(毫秒):见 scheduleTurnsRender / schedulePaint。 */
|
|
2339
3464
|
var STREAM_RENDER_MS = 90
|
|
@@ -2355,6 +3480,8 @@ window.__ModuleLoader__.load({
|
|
|
2355
3480
|
* · pillAnchorWidth:本次量到的费用胶囊宽度(0 = 没量到)—— 决定"跟随"还是"兜底",
|
|
2356
3481
|
* 也是"能不能收球"的前提(用户定的:只有没有费用胶囊时才收成小球)。
|
|
2357
3482
|
*/
|
|
3483
|
+
/** 胶囊总开关:host 的 pillEnabled 说关就关(见下面 ping 分支)。 */
|
|
3484
|
+
var pillHidden = false
|
|
2358
3485
|
var pillDock = 'anchor'
|
|
2359
3486
|
var pillFreeRight = null
|
|
2360
3487
|
var pillBall = false
|
|
@@ -3153,7 +4280,7 @@ window.__ModuleLoader__.load({
|
|
|
3153
4280
|
*/
|
|
3154
4281
|
var offBridgeMessage = listen(window, 'message', function (event) {
|
|
3155
4282
|
var data = event && event.data
|
|
3156
|
-
if (!data || data.__dshSel !== 1) return
|
|
4283
|
+
if (!bridgeOn || !data || data.__dshSel !== 1) return
|
|
3157
4284
|
var frame = frameBySource(event.source)
|
|
3158
4285
|
if (!frame) return
|
|
3159
4286
|
if (data.kind === 'press') {
|
|
@@ -3234,13 +4361,34 @@ window.__ModuleLoader__.load({
|
|
|
3234
4361
|
// —— 面板 ——
|
|
3235
4362
|
|
|
3236
4363
|
/** 当前会话 id(客户端 sessions 快照;取不到就留空,host 会跳过会话背景)。 */
|
|
4364
|
+
/**
|
|
4365
|
+
* 小窗相关请求该带的会话 id。
|
|
4366
|
+
*
|
|
4367
|
+
* 用 `panelSessionId`(划词那一刻的会话)而不是 `currentSessionId()`(当前正在看的会话):
|
|
4368
|
+
* 小窗开着时用户可能切走,若还用后者,解读/追问/升格都会跑到**另一个会话**上 ——
|
|
4369
|
+
* 背景取错、模型也跟着换成另一个会话的(设置页括号里显示的也会与实际用的对不上)。
|
|
4370
|
+
* 还没划过词(panelSessionId 为空,比如启动时的 ping)自然落回当前会话。
|
|
4371
|
+
*/
|
|
4372
|
+
function panelOrCurrentSessionId() {
|
|
4373
|
+
return panelSessionId || currentSessionId()
|
|
4374
|
+
}
|
|
4375
|
+
|
|
3237
4376
|
function currentSessionId() {
|
|
3238
4377
|
try {
|
|
4378
|
+
// 新版 DSH 的列表只保存目录;导航身份在 uiSession 的主视图绑定中。
|
|
4379
|
+
var uiSession = ctx.get('uiSession')
|
|
4380
|
+
var bindingSource = uiSession && uiSession.adapter && uiSession.adapter.current
|
|
4381
|
+
if (bindingSource && typeof bindingSource.getSnapshot === 'function') {
|
|
4382
|
+
var binding = bindingSource.getSnapshot()
|
|
4383
|
+
var boundId = binding && binding.props && binding.props.sessionId
|
|
4384
|
+
return typeof boundId === 'string' ? boundId : ''
|
|
4385
|
+
}
|
|
4386
|
+
// 兼容尚未提供主视图绑定的旧版 DSH。
|
|
3239
4387
|
var sessions = ctx.get('sessions')
|
|
3240
4388
|
var store = sessions && sessions.list
|
|
3241
4389
|
var snapshot = store && typeof store.getSnapshot === 'function' ? store.getSnapshot() : null
|
|
3242
4390
|
var id = snapshot && snapshot.current
|
|
3243
|
-
return id ?
|
|
4391
|
+
return typeof id === 'string' ? id : ''
|
|
3244
4392
|
} catch (error) {
|
|
3245
4393
|
return ''
|
|
3246
4394
|
}
|
|
@@ -3316,13 +4464,12 @@ window.__ModuleLoader__.load({
|
|
|
3316
4464
|
label: label,
|
|
3317
4465
|
title: document.title,
|
|
3318
4466
|
url: location.href,
|
|
3319
|
-
sessionId:
|
|
4467
|
+
sessionId: panelOrCurrentSessionId(),
|
|
3320
4468
|
kind: selectionKind(text, label),
|
|
3321
4469
|
stage: stage === 'detail' ? 'detail' : 'translation',
|
|
3322
4470
|
...(refresh ? { refresh: true } : {}),
|
|
3323
|
-
//
|
|
4471
|
+
// 模型对三个阶段一致;此处不携带追问思考档位。
|
|
3324
4472
|
...(state.modelChoice ? { provider: state.modelChoice.provider, model: state.modelChoice.model } : {}),
|
|
3325
|
-
...(state.effort ? { effort: state.effort } : {}),
|
|
3326
4473
|
}
|
|
3327
4474
|
}
|
|
3328
4475
|
|
|
@@ -3372,6 +4519,8 @@ window.__ModuleLoader__.load({
|
|
|
3372
4519
|
* 否则钩子会绕过缓存(缓存行为只能靠人工点选才能验证)。
|
|
3373
4520
|
*/
|
|
3374
4521
|
function openPanelWith(text, context, label, anchor, keyContext) {
|
|
4522
|
+
// 记下"这次划词发生在哪个会话"——小窗后续所有按会话解析的东西都用它
|
|
4523
|
+
panelSessionId = currentSessionId()
|
|
3375
4524
|
state.payload = buildPayload(text, context, label)
|
|
3376
4525
|
state.kind = state.payload.kind
|
|
3377
4526
|
hideButton()
|
|
@@ -3636,6 +4785,8 @@ window.__ModuleLoader__.load({
|
|
|
3636
4785
|
* 宽度和上限三种处境都一样算(拖动不该顺手改宽度),只有位置分家。
|
|
3637
4786
|
*/
|
|
3638
4787
|
function placePill() {
|
|
4788
|
+
// 关掉胶囊时不做任何定位工作:它已经 display:none,量位置纯属白费(而且会一直轮询)
|
|
4789
|
+
if (pillHidden) return
|
|
3639
4790
|
var anchor = measurePillAnchor()
|
|
3640
4791
|
pillAnchorWidth = anchor.width
|
|
3641
4792
|
// 费用胶囊**出现**了:小球没有存在的意义(收球的前提就是"没有可跟随的胶囊")→ 立刻展开
|
|
@@ -4124,6 +5275,209 @@ window.__ModuleLoader__.load({
|
|
|
4124
5275
|
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height }
|
|
4125
5276
|
}
|
|
4126
5277
|
|
|
5278
|
+
/**
|
|
5279
|
+
* 小窗的尺寸(用户拖角改过之后才有值;null = 用 CSS 里的默认尺寸)。
|
|
5280
|
+
*
|
|
5281
|
+
* **只记尺寸,不记位置**:小窗每次开窗都必须按选区重新锚定(见 showPanel),
|
|
5282
|
+
* 这是既有的设计 —— 位置一旦被记住,开窗就不再跟着划词的地方走,等于把那套锚定架空了。
|
|
5283
|
+
* 尺寸是用户明确调过的东西,跨开窗保留才合理。
|
|
5284
|
+
*/
|
|
5285
|
+
var panelSize = null
|
|
5286
|
+
var panelSizeAsked = false
|
|
5287
|
+
var panelSizeTimer = 0
|
|
5288
|
+
/** 正在拖手柄 / 拖标题栏时,别让 window resize 的夹取插一脚。 */
|
|
5289
|
+
var panelInteracting = false
|
|
5290
|
+
/**
|
|
5291
|
+
* 尺寸下限:比"设置页要放得下"低一点就行。真实约束在 CSS/JS 里:
|
|
5292
|
+
* 太窄了模型下拉和提示文案会挤成一团,太矮了正文没得看。
|
|
5293
|
+
*/
|
|
5294
|
+
var PANEL_MIN_W = 320
|
|
5295
|
+
var PANEL_MIN_H = 240
|
|
5296
|
+
|
|
5297
|
+
/**
|
|
5298
|
+
* 开窗时应用"记住的尺寸"。
|
|
5299
|
+
*
|
|
5300
|
+
* ⚠️ 宽度是**固定值**,高度只能是**上限**(max-height)。
|
|
5301
|
+
*
|
|
5302
|
+
* 高度要是写成固定值,新开一个会话(内容只有一小段翻译)时面板会硬撑到上次那么大,
|
|
5303
|
+
* 下面留一大片空白 —— 那是回退。以前高度一直是内容撑的(`max-height` 只是个天花板),
|
|
5304
|
+
* 这个手感必须保住:内容短就短,内容长才长到上限、再滚动。
|
|
5305
|
+
*
|
|
5306
|
+
* 所以每次开窗都把 `height` **清掉**(回到自适应),只把用户调过的高度当作新的天花板。
|
|
5307
|
+
* 用户当场拖出来的尺寸在**本次**打开里照旧生效(拖动时直接写 inline height),
|
|
5308
|
+
* 只是下次开窗会重新按内容自适应 —— 既没有空白,拖动也看得见反馈。
|
|
5309
|
+
*/
|
|
5310
|
+
function applyPanelSizeOnOpen() {
|
|
5311
|
+
// 先还原成"内容撑高 + CSS 默认天花板"
|
|
5312
|
+
panel.style.height = ''
|
|
5313
|
+
panel.style.maxHeight = ''
|
|
5314
|
+
if (!panelSize) {
|
|
5315
|
+
panel.style.width = ''
|
|
5316
|
+
panel.removeAttribute('data-resized')
|
|
5317
|
+
return
|
|
5318
|
+
}
|
|
5319
|
+
panel.style.width = Math.round(panelSize.w) + 'px'
|
|
5320
|
+
panel.style.maxHeight = Math.round(panelSize.h) + 'px'
|
|
5321
|
+
// 打过标记:设置页那条约 560 的 min-height 就别再插手了(用户已经自己定了高)
|
|
5322
|
+
panel.setAttribute('data-resized', '1')
|
|
5323
|
+
}
|
|
5324
|
+
|
|
5325
|
+
/** 当前尺寸(读实际渲染值,比拿变量算更稳 —— 拖拽过程中变量会漂)。 */
|
|
5326
|
+
function currentPanelSize() {
|
|
5327
|
+
var rect = panel.getBoundingClientRect()
|
|
5328
|
+
return { w: rect.width, h: rect.height }
|
|
5329
|
+
}
|
|
5330
|
+
|
|
5331
|
+
/** 夹进视口:改大小/窗口变化后都调,保证手柄还在屏幕里、小窗不会超出可视区。 */
|
|
5332
|
+
function clampPanelSize(size) {
|
|
5333
|
+
var maxW = Math.max(PANEL_MIN_W, window.innerWidth - 8)
|
|
5334
|
+
var maxH = Math.max(PANEL_MIN_H, window.innerHeight - 8)
|
|
5335
|
+
return {
|
|
5336
|
+
w: Math.min(maxW, Math.max(PANEL_MIN_W, size.w)),
|
|
5337
|
+
h: Math.min(maxH, Math.max(PANEL_MIN_H, size.h)),
|
|
5338
|
+
}
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
/**
|
|
5342
|
+
* 把面板夹回可视区。
|
|
5343
|
+
*
|
|
5344
|
+
* **每次尺寸变化之后都要调**:位置是当初按旧尺寸算好的,尺寸一变就可能把面板顶出屏幕。
|
|
5345
|
+
* 用户报的就是这个 —— 小窗贴着屏幕边缘时双击角标恢复默认尺寸,一部分直接跑到屏幕外。
|
|
5346
|
+
* 优先保住左上角(那是用户看到的锚点),右边/下边放不下才往回收。
|
|
5347
|
+
*/
|
|
5348
|
+
function clampPanelPosition() {
|
|
5349
|
+
var rect = panel.getBoundingClientRect()
|
|
5350
|
+
var maxLeft = Math.max(4, window.innerWidth - rect.width - 4)
|
|
5351
|
+
var maxTop = Math.max(4, window.innerHeight - rect.height - 4)
|
|
5352
|
+
panel.style.left = Math.round(Math.min(Math.max(rect.left, 4), maxLeft)) + 'px'
|
|
5353
|
+
panel.style.top = Math.round(Math.min(Math.max(rect.top, 4), maxTop)) + 'px'
|
|
5354
|
+
if (historyList.style.display === 'flex') placeHistoryList()
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5357
|
+
/** 存到 host(防抖:拖动过程中每帧都存会把接口打爆)。 */
|
|
5358
|
+
function savePanelSize() {
|
|
5359
|
+
if (panelSizeTimer) clearTimeout(panelSizeTimer)
|
|
5360
|
+
panelSizeTimer = setTimeout(function () {
|
|
5361
|
+
panelSizeTimer = 0
|
|
5362
|
+
var size = clampPanelSize(currentPanelSize())
|
|
5363
|
+
panelSize = size
|
|
5364
|
+
fetch(PANEL_SIZE, {
|
|
5365
|
+
method: 'POST',
|
|
5366
|
+
headers: { 'content-type': 'application/json' },
|
|
5367
|
+
body: JSON.stringify({ size: size }),
|
|
5368
|
+
}).catch(function () { /* 存不上不影响使用,下次再改还会存 */ })
|
|
5369
|
+
}, 300)
|
|
5370
|
+
}
|
|
5371
|
+
|
|
5372
|
+
/** 挂载时问一次(异步,回来再摆;用户已经动过就以用户的为准)。 */
|
|
5373
|
+
function loadPanelSize() {
|
|
5374
|
+
if (panelSizeAsked) return
|
|
5375
|
+
panelSizeAsked = true
|
|
5376
|
+
fetch(PANEL_SIZE)
|
|
5377
|
+
.then(function (res) { return res.json() })
|
|
5378
|
+
.then(function (data) {
|
|
5379
|
+
var size = data && data.size
|
|
5380
|
+
if (!size || panelInteracting) return
|
|
5381
|
+
if (typeof size.w !== 'number' || typeof size.h !== 'number') return
|
|
5382
|
+
panelSize = clampPanelSize(size)
|
|
5383
|
+
if (panelOpen) {
|
|
5384
|
+
applyPanelSizeOnOpen()
|
|
5385
|
+
clampPanelPosition()
|
|
5386
|
+
}
|
|
5387
|
+
})
|
|
5388
|
+
.catch(function () {})
|
|
5389
|
+
}
|
|
5390
|
+
|
|
5391
|
+
/**
|
|
5392
|
+
* 恢复默认尺寸(双击右下角)。
|
|
5393
|
+
*
|
|
5394
|
+
* 和"高度自适应"是**同一个动作**:把整份存档清掉 ——
|
|
5395
|
+
* 宽度回到 CSS 默认(540 / 首轮阶段 440),高度清成 auto 由内容撑(也就是自适应)。
|
|
5396
|
+
*
|
|
5397
|
+
* ⚠️ 必须走 applyPanelSizeOnOpen() 来清,而不是手写两行 `style.width = ''`:
|
|
5398
|
+
* 高度上限是写在 `style.maxHeight` 上的,只清 width/height 会把它留下 ——
|
|
5399
|
+
* 用户先前把窗口**拉小**过的话,双击之后高度仍被旧上限卡着,看着就不像"恢复默认"。
|
|
5400
|
+
*/
|
|
5401
|
+
function resetPanelSize() {
|
|
5402
|
+
panelSize = null
|
|
5403
|
+
// ⚠️ 先掐掉还没落盘的那次保存。
|
|
5404
|
+
// savePanelSize 是防抖的,而且**在定时器触发时**才去读当前尺寸 ——
|
|
5405
|
+
// 刚拖完角标马上双击还原时,这一次来不及取消的保存会在 300ms 后读到
|
|
5406
|
+
// "已经还原好的默认尺寸",又把它当成"用户选的尺寸"写回去:
|
|
5407
|
+
// 还原被无声撤销,宽度还被钉死在那一刻(连带把按阶段收窄那套也废掉)。
|
|
5408
|
+
if (panelSizeTimer) {
|
|
5409
|
+
clearTimeout(panelSizeTimer)
|
|
5410
|
+
panelSizeTimer = 0
|
|
5411
|
+
}
|
|
5412
|
+
applyPanelSizeOnOpen() // 宽度 / 高度 / 高度上限 一次全还原
|
|
5413
|
+
fetch(PANEL_SIZE, {
|
|
5414
|
+
method: 'POST',
|
|
5415
|
+
headers: { 'content-type': 'application/json' },
|
|
5416
|
+
body: JSON.stringify({ clear: true }),
|
|
5417
|
+
}).catch(function () {})
|
|
5418
|
+
// 宽度/高度都变了 → 位置得重夹,否则贴着屏幕边缘时会被顶出去
|
|
5419
|
+
clampPanelPosition()
|
|
5420
|
+
autoGrowAskBox()
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
/**
|
|
5424
|
+
* 拖手柄改大小。
|
|
5425
|
+
*
|
|
5426
|
+
* edge 决定这次动哪几个方向:'se' 角标两个方向都能动,'e' 只动宽,'s' 只动高。
|
|
5427
|
+
* 用 pointer 事件(不是 mouse):触控板/触屏一样能拖,而且能 setPointerCapture,
|
|
5428
|
+
* 鼠标划出窗口也不会丢掉这一轮(mouse 版本会卡在中途)。
|
|
5429
|
+
*/
|
|
5430
|
+
function bindResizeGrip(node, edge) {
|
|
5431
|
+
listen(node, 'pointerdown', function (event) {
|
|
5432
|
+
if (event.button !== undefined && event.button !== 0) return
|
|
5433
|
+
event.preventDefault()
|
|
5434
|
+
event.stopPropagation()
|
|
5435
|
+
var startSize = currentPanelSize()
|
|
5436
|
+
var startX = event.clientX
|
|
5437
|
+
var startY = event.clientY
|
|
5438
|
+
panelInteracting = true
|
|
5439
|
+
var layer = panel.parentNode
|
|
5440
|
+
if (layer) layer.setAttribute('data-resizing', '1')
|
|
5441
|
+
try { node.setPointerCapture(event.pointerId) } catch (error) { /* 老浏览器没有就算了 */ }
|
|
5442
|
+
var changed = 0
|
|
5443
|
+
var move = function (moveEvent) {
|
|
5444
|
+
var next = { w: startSize.w, h: startSize.h }
|
|
5445
|
+
if (edge.indexOf('e') >= 0) next.w = startSize.w + (moveEvent.clientX - startX)
|
|
5446
|
+
if (edge.indexOf('s') >= 0) next.h = startSize.h + (moveEvent.clientY - startY)
|
|
5447
|
+
var clamped = clampPanelSize(next)
|
|
5448
|
+
changed = Math.abs(clamped.w - startSize.w) + Math.abs(clamped.h - startSize.h)
|
|
5449
|
+
panel.style.width = Math.round(clamped.w) + 'px'
|
|
5450
|
+
panel.style.height = Math.round(clamped.h) + 'px'
|
|
5451
|
+
panelSize = clamped
|
|
5452
|
+
panel.setAttribute('data-resized', '1')
|
|
5453
|
+
if (historyList.style.display === 'flex') placeHistoryList()
|
|
5454
|
+
}
|
|
5455
|
+
var up = function () {
|
|
5456
|
+
window.removeEventListener('pointermove', move, true)
|
|
5457
|
+
window.removeEventListener('pointerup', up, true)
|
|
5458
|
+
panelInteracting = false
|
|
5459
|
+
if (layer) layer.removeAttribute('data-resizing')
|
|
5460
|
+
// 尺寸定下来后,正文/输入框要按新高度重新分配(composer 自动撑高那套)
|
|
5461
|
+
autoGrowAskBox()
|
|
5462
|
+
// 尺寸变了 → 位置重夹,别让面板被顶出屏幕
|
|
5463
|
+
if (changed >= 2) {
|
|
5464
|
+
clampPanelPosition()
|
|
5465
|
+
savePanelSize()
|
|
5466
|
+
}
|
|
5467
|
+
}
|
|
5468
|
+
window.addEventListener('pointermove', move, true)
|
|
5469
|
+
window.addEventListener('pointerup', up, true)
|
|
5470
|
+
})
|
|
5471
|
+
// 双击角标 = 恢复默认大小
|
|
5472
|
+
if (edge === 'se') {
|
|
5473
|
+
listen(node, 'dblclick', function (event) {
|
|
5474
|
+
event.preventDefault()
|
|
5475
|
+
event.stopPropagation()
|
|
5476
|
+
resetPanelSize()
|
|
5477
|
+
})
|
|
5478
|
+
}
|
|
5479
|
+
}
|
|
5480
|
+
|
|
4127
5481
|
function showPanel(anchor) {
|
|
4128
5482
|
panelOpen = true
|
|
4129
5483
|
// 键盘用户开窗即可打字:这一轮的输入框一旦可见就聚焦一次(见 focusComposerOnce)
|
|
@@ -4135,6 +5489,11 @@ window.__ModuleLoader__.load({
|
|
|
4135
5489
|
panel.style.display = 'flex'
|
|
4136
5490
|
panel.style.left = '0px'
|
|
4137
5491
|
panel.style.top = '0px'
|
|
5492
|
+
// 每次都按选区重新锚定 —— **不要**在这里插"用上次的位置"的分支:
|
|
5493
|
+
// 位置一旦被记住,开窗就不再跟着划词的地方走,这套锚定逻辑等于被架空。
|
|
5494
|
+
// 尺寸是另一回事:宽度沿用用户调过的,高度**每次开窗都回到自适应**(只保留天花板),
|
|
5495
|
+
// 否则新会话内容短、面板却硬撑成上次那么高,下面留一大片空白。
|
|
5496
|
+
applyPanelSizeOnOpen()
|
|
4138
5497
|
// 面板刚显示出来才量得到 scrollHeight:草稿里原来的几行要立刻撑开
|
|
4139
5498
|
autoGrowAskBox()
|
|
4140
5499
|
var width = panel.offsetWidth
|
|
@@ -4148,10 +5507,20 @@ window.__ModuleLoader__.load({
|
|
|
4148
5507
|
panel.style.left = Math.round(Math.max(10, left)) + 'px'
|
|
4149
5508
|
panel.style.top = Math.round(Math.max(10, top)) + 'px'
|
|
4150
5509
|
paintPill()
|
|
5510
|
+
// 跟随态下输入框那枚胶囊报的是"本会话在用的模型":开窗先对齐一次,
|
|
5511
|
+
// 之后交给 2 秒的轮询(主会话里换了模型,这里自己就跟着变,不用等开一次设置页)。
|
|
5512
|
+
refreshFollowRoute()
|
|
5513
|
+
syncRouteWatch()
|
|
5514
|
+
// 胶囊上的模型名要按 provider + model 从清单里换(否则显示裸 id),顺手拉一次清单
|
|
5515
|
+
warmModelCatalog()
|
|
4151
5516
|
}
|
|
4152
5517
|
|
|
4153
5518
|
function closePanel() {
|
|
4154
5519
|
closeModelMenu()
|
|
5520
|
+
// 小窗关掉时设置抽屉也一起收:否则下次开窗会直接停在设置页上
|
|
5521
|
+
// (用户上次是在关窗前改的设置,回来想看的是解读,不是设置)。
|
|
5522
|
+
// closeSettings 顺手把还没落盘的改动补发掉,不会"关掉就走"丢改动。
|
|
5523
|
+
closeSettings()
|
|
4155
5524
|
panelOpen = false
|
|
4156
5525
|
panel.style.display = 'none'
|
|
4157
5526
|
hideHistoryList()
|
|
@@ -4166,6 +5535,8 @@ window.__ModuleLoader__.load({
|
|
|
4166
5535
|
if (state.request) state.request.abort()
|
|
4167
5536
|
state.request = null
|
|
4168
5537
|
paintPill()
|
|
5538
|
+
// 小窗收了:抽屉若也关着就把轮询停掉(两处都不需要这个值了)
|
|
5539
|
+
syncRouteWatch()
|
|
4169
5540
|
}
|
|
4170
5541
|
|
|
4171
5542
|
/**
|
|
@@ -4286,7 +5657,8 @@ window.__ModuleLoader__.load({
|
|
|
4286
5657
|
}
|
|
4287
5658
|
|
|
4288
5659
|
// —— 按阶段重排 ——
|
|
4289
|
-
//
|
|
5660
|
+
// 只有翻译时:不显示小节序号(轻量卡片形态)。宽度**不随阶段变**(恒 540)——
|
|
5661
|
+
// 以前这里还顺手收窄到 440,追问一下又跳回 540,用户看着像窗口自己抖了一下。
|
|
4290
5662
|
var hasDetail = !!(state.parts.detail || stage === 'detail')
|
|
4291
5663
|
// 追问输入框:**翻译一出来就能问,不必先展开详解**
|
|
4292
5664
|
var translationReady = state.phase === 'done' && !!state.parts.translation
|
|
@@ -5163,6 +6535,8 @@ window.__ModuleLoader__.load({
|
|
|
5163
6535
|
applyHistoryEntry(data.entry, 'history')
|
|
5164
6536
|
// 先画内容再摆位置:showPanel 要量面板高度才能决定放上面还是下面
|
|
5165
6537
|
if (!fromList) showPanel(anchor || rectOfPill())
|
|
6538
|
+
// 从列表进入时**不走 showPanel**(面板不挪位置)—— 轮询与跟随值这里自己补上
|
|
6539
|
+
else { refreshFollowRoute(); syncRouteWatch(); warmModelCatalog() }
|
|
5166
6540
|
// 内容换了、面板高度可能变 → 列表跟着重摆(列表此时已被 applyHistoryEntry 收起,按需再开)
|
|
5167
6541
|
if (historyList.style.display === 'flex') placeHistoryList()
|
|
5168
6542
|
})
|
|
@@ -5704,7 +7078,7 @@ window.__ModuleLoader__.load({
|
|
|
5704
7078
|
text: payload.text,
|
|
5705
7079
|
context: payload.context,
|
|
5706
7080
|
label: payload.label,
|
|
5707
|
-
sessionId:
|
|
7081
|
+
sessionId: panelOrCurrentSessionId(),
|
|
5708
7082
|
question: sent,
|
|
5709
7083
|
history: history,
|
|
5710
7084
|
...(state.webAnswer ? { webAnswer: true } : {}),
|
|
@@ -6191,7 +7565,7 @@ window.__ModuleLoader__.load({
|
|
|
6191
7565
|
method: 'POST',
|
|
6192
7566
|
headers: { 'content-type': 'application/json' },
|
|
6193
7567
|
body: JSON.stringify({
|
|
6194
|
-
sessionId:
|
|
7568
|
+
sessionId: panelOrCurrentSessionId(),
|
|
6195
7569
|
text: payload.text,
|
|
6196
7570
|
context: payload.context,
|
|
6197
7571
|
label: payload.label,
|
|
@@ -6363,7 +7737,16 @@ window.__ModuleLoader__.load({
|
|
|
6363
7737
|
var EFFORT_KEY = 'dsh-selection-explain:effort'
|
|
6364
7738
|
var TIER_LABEL = { off: '关', low: '低', medium: '中', high: '高', max: '最大' }
|
|
6365
7739
|
var FALLBACK_TIERS = ['off', 'low', 'high', 'max']
|
|
6366
|
-
|
|
7740
|
+
/**
|
|
7741
|
+
* 模型清单缓存。
|
|
7742
|
+
*
|
|
7743
|
+
* `pending` 是**正在飞的那次请求**:并发调用必须共享它。
|
|
7744
|
+
* 以前 loading=true 时直接 `Promise.resolve(modelCatalog)` 把**当前还空着的**缓存返回,
|
|
7745
|
+
* 于是"菜单刚打开、设置页也来要清单"这种并发场景下,后到的那个拿到 0 个模型
|
|
7746
|
+
* (实测:设置页的模型下拉空着,只有一行「跟随主会话」)。
|
|
7747
|
+
*/
|
|
7748
|
+
var modelCatalog = { at: 0, items: [], current: null, stages: null, loading: false, pending: null, error: '', leakyOff: [], sessionId: null }
|
|
7749
|
+
var modelCatalogRequestSeq = 0
|
|
6367
7750
|
var modelPillClicks = 0
|
|
6368
7751
|
|
|
6369
7752
|
var BAD_TIER_KEY = 'dsh-selection-explain:badTiers'
|
|
@@ -6441,38 +7824,70 @@ window.__ModuleLoader__.load({
|
|
|
6441
7824
|
/** 档位显示名:优先用目录里声明的名字(Off/Low/High/Max),否则退回中文映射。 */
|
|
6442
7825
|
function tierLabel(id) {
|
|
6443
7826
|
if (!id) return ''
|
|
7827
|
+
// 按**当前在用**那个模型声明的档位取名(跟随态也算,见 effectiveItem)
|
|
7828
|
+
var item = effectiveItem()
|
|
7829
|
+
var efforts = (item && item.efforts) || []
|
|
7830
|
+
for (var j = 0; j < efforts.length; j += 1) {
|
|
7831
|
+
if (efforts[j].id === id) return TIER_LABEL[id] || efforts[j].name || id
|
|
7832
|
+
}
|
|
7833
|
+
return TIER_LABEL[id] || id
|
|
7834
|
+
}
|
|
7835
|
+
|
|
7836
|
+
/**
|
|
7837
|
+
* 清单里按 **provider + model 两个键**找那一项。
|
|
7838
|
+
*
|
|
7839
|
+
* 两个键缺一不可:同一个 model id 在多个 provider 下是**不同模型、不同名字** ——
|
|
7840
|
+
* 实测 `deepseek-flash` 在 opencode-go 叫「DeepSeek Flash」、在 deepseek-official /
|
|
7841
|
+
* deepseek-account 叫「DeepSeek-V41-Flash」。只按 model 查会张冠李戴。
|
|
7842
|
+
*/
|
|
7843
|
+
function catalogItemOf(provider, model) {
|
|
6444
7844
|
var items = modelCatalog.items || []
|
|
6445
7845
|
for (var i = 0; i < items.length; i += 1) {
|
|
6446
|
-
|
|
6447
|
-
if (!choice) continue
|
|
6448
|
-
var efforts = items[i].efforts || []
|
|
6449
|
-
for (var j = 0; j < efforts.length; j += 1) {
|
|
6450
|
-
if (efforts[j].id === id) return TIER_LABEL[id] || efforts[j].name || id
|
|
6451
|
-
}
|
|
7846
|
+
if (items[i].provider === provider && items[i].model === model) return items[i]
|
|
6452
7847
|
}
|
|
6453
|
-
return
|
|
7848
|
+
return null
|
|
6454
7849
|
}
|
|
6455
7850
|
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
7851
|
+
/**
|
|
7852
|
+
* 现在**实际在用**的那个模型:显式选过就是那个;没选过(跟随主会话)就是会话解析出来的
|
|
7853
|
+
* `modelCatalog.current`。
|
|
7854
|
+
*
|
|
7855
|
+
* 菜单里"哪一行打勾""档位按钮列哪几档""哪个模型关档会泄漏"都该按它算 ——
|
|
7856
|
+
* 这几处以前只认 `state.modelChoice`,跟随态于是**一行都不打勾**(用户报的
|
|
7857
|
+
* "模型列表上没有标示当前正在使用的模型")。
|
|
7858
|
+
*/
|
|
7859
|
+
function effectiveChoice() {
|
|
7860
|
+
if (state.modelChoice) return state.modelChoice
|
|
7861
|
+
if (modelCatalog.current && modelCatalog.current.provider && modelCatalog.current.model) {
|
|
7862
|
+
return { provider: String(modelCatalog.current.provider), model: String(modelCatalog.current.model) }
|
|
7863
|
+
}
|
|
7864
|
+
return null
|
|
7865
|
+
}
|
|
7866
|
+
|
|
7867
|
+
/** 清单里当前在用的那一项(显式选的 / 跟随会话的);清单里没有就返回 null。 */
|
|
7868
|
+
function effectiveItem() {
|
|
7869
|
+
var choice = effectiveChoice()
|
|
7870
|
+
return choice ? catalogItemOf(choice.provider, choice.model) : null
|
|
6459
7871
|
}
|
|
6460
7872
|
|
|
6461
7873
|
/** 胶囊文案:模型短名 + 当前档位。 */
|
|
6462
7874
|
function paintModelPill() {
|
|
6463
7875
|
var name = ''
|
|
6464
7876
|
var provider = ''
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
}
|
|
7877
|
+
// 用户选定的那个模型:按 provider + model 换成清单里的显示名
|
|
7878
|
+
var chosen = state.modelChoice ? catalogItemOf(state.modelChoice.provider, state.modelChoice.model) : null
|
|
7879
|
+
if (chosen) {
|
|
7880
|
+
name = chosen.name || chosen.model
|
|
7881
|
+
provider = chosen.providerName || chosen.provider
|
|
6471
7882
|
}
|
|
6472
7883
|
if (!name && state.modelChoice) name = state.modelChoice.model
|
|
7884
|
+
// 跟随主会话:`current` 报的是 { provider, model } 两个 id,同样要换成显示名 ——
|
|
7885
|
+
// 直接显示裸 id 就会是 "deepseek-flash",而主会话里那个模型叫「DeepSeek-V41-Flash」
|
|
7886
|
+
// (用户报的)。清单没加载出来时只能先显示 id,加载完会重画(见 warmModelCatalog)。
|
|
6473
7887
|
if (!name && modelCatalog.current) {
|
|
6474
|
-
|
|
6475
|
-
|
|
7888
|
+
var followed = catalogItemOf(modelCatalog.current.provider, modelCatalog.current.model)
|
|
7889
|
+
name = (followed && (followed.name || followed.model)) || String(modelCatalog.current.model || '')
|
|
7890
|
+
provider = (followed && (followed.providerName || followed.provider)) || String(modelCatalog.current.provider || '')
|
|
6476
7891
|
}
|
|
6477
7892
|
if (!name) name = '模型'
|
|
6478
7893
|
// 短名:去掉 provider 前缀那种很长的写法("DeepSeek V4.1 Flash" → 保留原样,只截断)
|
|
@@ -6480,35 +7895,73 @@ window.__ModuleLoader__.load({
|
|
|
6480
7895
|
var tier = state.effort || (modelCatalog.stages && modelCatalog.stages.chat) || ''
|
|
6481
7896
|
modelPillTier.textContent = tier ? '· ' + tierLabel(tier) : ''
|
|
6482
7897
|
modelPill.title = (provider ? provider + ' · ' : '') + name + (tier ? ' · 推理等级 ' + tierLabel(tier) : '') +
|
|
6483
|
-
'
|
|
7898
|
+
'(模型与追问思考强度)'
|
|
7899
|
+
}
|
|
7900
|
+
|
|
7901
|
+
/**
|
|
7902
|
+
* 顺手把模型清单拉起来(60 秒缓存)。
|
|
7903
|
+
*
|
|
7904
|
+
* 胶囊上的模型名要按 provider + model 从清单里换(见 paintModelPill)—— 清单没加载时
|
|
7905
|
+
* 只能显示裸 id("deepseek-flash"),跟主会话里那个名字对不上。
|
|
7906
|
+
* 以前只有"打开模型菜单 / 开设置抽屉"才会拉清单,所以正常用下来胶囊一直是裸 id。
|
|
7907
|
+
*/
|
|
7908
|
+
function warmModelCatalog() {
|
|
7909
|
+
loadModelCatalog(false, panelSessionId || currentSessionId()).then(function () { paintModelPill() })
|
|
6484
7910
|
}
|
|
6485
7911
|
|
|
6486
|
-
/**
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
7912
|
+
/**
|
|
7913
|
+
* 拉模型清单(60 秒缓存)。
|
|
7914
|
+
*
|
|
7915
|
+
* 调用点:打开模型菜单 / 开设置抽屉(强制)/ **开窗(warmModelCatalog,为了胶囊上的显示名)**。
|
|
7916
|
+
* 不进 ping / analyze 那种每轮都走的热路径。
|
|
7917
|
+
*/
|
|
7918
|
+
function loadModelCatalog(force, sessionId) {
|
|
7919
|
+
// 会话换了就得重取:`current` 是**按会话**解析的,沿用上一个会话的缓存
|
|
7920
|
+
// 会让「跟随主会话(当前:…)」报出别的会话在用的模型。
|
|
7921
|
+
// sessionId 由调用方给(小窗用它自己所属的会话,不是"当前正在看"的那个)。
|
|
7922
|
+
var sessionNow = sessionId === undefined ? panelOrCurrentSessionId() : sessionId
|
|
7923
|
+
var sameSession = modelCatalog.sessionId === sessionNow
|
|
7924
|
+
// 只复用同一会话的请求;A 的响应不能冒充刚切到的 B。
|
|
7925
|
+
if (sameSession && modelCatalog.loading && modelCatalog.pending) return modelCatalog.pending
|
|
7926
|
+
if (sameSession && !force && modelCatalog.items.length > 0 && Date.now() - modelCatalog.at < 60000) return Promise.resolve(modelCatalog)
|
|
7927
|
+
var requestSeq = ++modelCatalogRequestSeq
|
|
7928
|
+
if (!sameSession) {
|
|
7929
|
+
force = true
|
|
7930
|
+
modelCatalog.current = null
|
|
7931
|
+
modelCatalog.items = []
|
|
7932
|
+
modelCatalog.at = 0
|
|
7933
|
+
}
|
|
7934
|
+
modelCatalog.sessionId = sessionNow
|
|
6490
7935
|
modelCatalog.loading = true
|
|
6491
|
-
|
|
7936
|
+
// 带 sessionId:`current` 要按**当前会话**解析(否则括号里报的是全局默认,跟会话对不上)
|
|
7937
|
+
var modelQuery = []
|
|
7938
|
+
if (force) modelQuery.push('refresh=1')
|
|
7939
|
+
if (sessionNow) modelQuery.push('sessionId=' + encodeURIComponent(sessionNow))
|
|
7940
|
+
modelCatalog.pending = fetch(MODELS + (modelQuery.length ? '?' + modelQuery.join('&') : ''), { headers: { accept: 'application/json' } })
|
|
6492
7941
|
.then(function (response) {
|
|
6493
7942
|
return response.json()
|
|
6494
7943
|
})
|
|
6495
7944
|
.then(function (data) {
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
7945
|
+
return {
|
|
7946
|
+
sessionId: sessionNow,
|
|
7947
|
+
items: Array.isArray(data && data.models) ? data.models : [],
|
|
7948
|
+
current: (data && data.current) || null,
|
|
7949
|
+
stages: (data && data.stages) || null,
|
|
7950
|
+
error: String((data && data.error) || ''),
|
|
7951
|
+
leakyOff: Array.isArray(data && data.leakyOff) ? data.leakyOff : [],
|
|
7952
|
+
at: Date.now(), loading: false, pending: null,
|
|
7953
|
+
}
|
|
6503
7954
|
})
|
|
6504
7955
|
.catch(function (error) {
|
|
6505
|
-
|
|
6506
|
-
|
|
7956
|
+
return { sessionId: sessionNow, at: 0, items: [], current: null, stages: null,
|
|
7957
|
+
error: String((error && error.message) || error), leakyOff: [], loading: false, pending: null }
|
|
6507
7958
|
})
|
|
6508
7959
|
.then(function (result) {
|
|
6509
|
-
|
|
7960
|
+
// 调用者得到自己的快照;较早的请求不得覆盖较新的会话缓存。
|
|
7961
|
+
if (requestSeq === modelCatalogRequestSeq) modelCatalog = result
|
|
6510
7962
|
return result
|
|
6511
7963
|
})
|
|
7964
|
+
return modelCatalog.pending
|
|
6512
7965
|
}
|
|
6513
7966
|
|
|
6514
7967
|
function renderModelMenu() {
|
|
@@ -6526,20 +7979,31 @@ window.__ModuleLoader__.load({
|
|
|
6526
7979
|
)
|
|
6527
7980
|
} else {
|
|
6528
7981
|
var list = el('div', 'dsh-sel-pickerlist')
|
|
7982
|
+
// 打勾的是**当前在用**那个:显式选过就是它,跟随主会话时就是会话在用的那个
|
|
7983
|
+
var inUse = effectiveChoice()
|
|
6529
7984
|
for (var i = 0; i < items.length; i += 1) {
|
|
6530
7985
|
;(function (item) {
|
|
7986
|
+
var on = !!inUse && item.provider === inUse.provider && item.model === inUse.model
|
|
6531
7987
|
var row = el('button', 'dsh-sel-pickerrow')
|
|
6532
7988
|
row.type = 'button'
|
|
6533
7989
|
row.setAttribute('role', 'menuitemradio')
|
|
6534
|
-
row.setAttribute('data-on',
|
|
6535
|
-
row.
|
|
6536
|
-
row.appendChild(el('span',
|
|
7990
|
+
row.setAttribute('data-on', on ? '1' : '0')
|
|
7991
|
+
row.title = (item.name || item.model) + ' · ' + (item.providerName || item.provider)
|
|
7992
|
+
row.appendChild(el('span', 'dsh-sel-pickercheck', on ? '✓' : ''))
|
|
7993
|
+
// 名字自己带省略号:一行里要塞下 勾 + 名字 + 「跟随」小标 + provider,
|
|
7994
|
+
// 长名字("DeepSeek V4.1 Flash")挤不下时**收窄名字**,别让整行折成两行。
|
|
7995
|
+
row.appendChild(el('span', 'dsh-sel-pickername', item.name || item.model))
|
|
7996
|
+
// 跟随态补一枚「跟随」小标:这个勾不是你选过,而是主会话正在用它 ——
|
|
7997
|
+
// 否则一个没点过的模型打着勾,会让人以为是自己选的。
|
|
7998
|
+
if (on && !state.modelChoice) {
|
|
7999
|
+
row.appendChild(el('span', 'dsh-sel-pickerfollow', '跟随'))
|
|
8000
|
+
row.title = '跟随主会话:主会话正在用这个模型 · ' + (item.providerName || item.provider)
|
|
8001
|
+
}
|
|
6537
8002
|
row.appendChild(el('span', 'dsh-sel-pickerprov', item.providerName || item.provider))
|
|
6538
8003
|
listen(row, 'click', function (event) {
|
|
6539
8004
|
event.preventDefault()
|
|
6540
8005
|
event.stopPropagation()
|
|
6541
|
-
|
|
6542
|
-
writeStore(MODEL_KEY, item.provider + '\t' + item.model)
|
|
8006
|
+
chooseSharedModel({ provider: item.provider, model: item.model })
|
|
6543
8007
|
paintModelPill()
|
|
6544
8008
|
renderModelMenu()
|
|
6545
8009
|
closeModelMenu()
|
|
@@ -6554,15 +8018,17 @@ window.__ModuleLoader__.load({
|
|
|
6554
8018
|
modelMenu.appendChild(el('div', 'dsh-sel-pickergroup', '推理等级(追问档)'))
|
|
6555
8019
|
var tiers = el('div', 'dsh-sel-tiers')
|
|
6556
8020
|
var effortIds = FALLBACK_TIERS
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
8021
|
+
// 档位按钮列的是**当前在用那个模型**声明的档位(跟随态也照它列,否则列出来的档位
|
|
8022
|
+
// 和实际发出去的模型对不上)
|
|
8023
|
+
var inUseItem = effectiveItem()
|
|
8024
|
+
if (inUseItem && (inUseItem.efforts || []).length > 0) {
|
|
8025
|
+
effortIds = []
|
|
8026
|
+
for (var e = 0; e < inUseItem.efforts.length; e += 1) effortIds.push(inUseItem.efforts[e].id)
|
|
6562
8027
|
}
|
|
6563
8028
|
var badTiers = badTiersOf()
|
|
6564
8029
|
// host 在进程内学到的:这个模型"关"档会把思考写进正文
|
|
6565
|
-
|
|
8030
|
+
var leaky = effectiveChoice()
|
|
8031
|
+
if (leaky && modelCatalog.leakyOff.indexOf(leaky.provider + '/' + leaky.model) >= 0) {
|
|
6566
8032
|
badTiers = { ...badTiers, off: 'leak' }
|
|
6567
8033
|
}
|
|
6568
8034
|
var active = state.effort || (modelCatalog.stages && modelCatalog.stages.chat) || ''
|
|
@@ -6592,7 +8058,7 @@ window.__ModuleLoader__.load({
|
|
|
6592
8058
|
}
|
|
6593
8059
|
modelMenu.appendChild(tiers)
|
|
6594
8060
|
modelMenu.appendChild(
|
|
6595
|
-
el('div', 'dsh-sel-pickerhint', '
|
|
8061
|
+
el('div', 'dsh-sel-pickerhint', '思考强度仅影响追问。'),
|
|
6596
8062
|
)
|
|
6597
8063
|
}
|
|
6598
8064
|
|
|
@@ -6663,11 +8129,8 @@ window.__ModuleLoader__.load({
|
|
|
6663
8129
|
paintWebMode()
|
|
6664
8130
|
|
|
6665
8131
|
// 模型/档位:从 localStorage 恢复;没存过就跟随"当前路由 + 配置档位"(ping 里就有)
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
var parts = storedModel.split('\t')
|
|
6669
|
-
state.modelChoice = { provider: parts[0], model: parts[1] }
|
|
6670
|
-
}
|
|
8132
|
+
// 从 host 的统一设置恢复模型,不读取旧浏览器模型覆盖。
|
|
8133
|
+
loadSettings(false)
|
|
6671
8134
|
var storedEffort = readStore(EFFORT_KEY, '')
|
|
6672
8135
|
if (storedEffort) state.effort = storedEffort
|
|
6673
8136
|
paintModelPill()
|
|
@@ -6680,8 +8143,12 @@ window.__ModuleLoader__.load({
|
|
|
6680
8143
|
.then(function (data) {
|
|
6681
8144
|
modelCatalog.current = (data && data.route) || null
|
|
6682
8145
|
modelCatalog.stages = { chat: (data && data.reasoningEffortByStage && data.reasoningEffortByStage.chat) || '' }
|
|
8146
|
+
if (!settingsSpec && data && data.limits) applySelectionLimit(data.limits)
|
|
6683
8147
|
// 侧边栏网页划词桥的总开关(host 配置 bridgeSidebarPreview,默认开)
|
|
6684
8148
|
if (data && data.bridgeSidebarPreview === false) bridgeOn = false
|
|
8149
|
+
// 悬浮胶囊的两个偏好跟着 ping 一起下发(省得为它单开一次请求)。
|
|
8150
|
+
// 拿不到就保持默认(显示 + 10 秒收球)—— ping 失败不该让胶囊消失。
|
|
8151
|
+
if (data && data.pill) applyPillPrefs(data.pill)
|
|
6685
8152
|
paintModelPill()
|
|
6686
8153
|
return null
|
|
6687
8154
|
})
|
|
@@ -6771,6 +8238,7 @@ window.__ModuleLoader__.load({
|
|
|
6771
8238
|
|
|
6772
8239
|
// 费用胶囊的位置/尺寸会变(展开、缩放、数字变长、插件迟到挂载)→ 自适应频率地贴上去
|
|
6773
8240
|
loadPillPos() // 先问一句上次拖到哪儿(异步,回来再摆)
|
|
8241
|
+
loadPanelSize() // 小窗的尺寸也问一句(拖角改过之后才有的值;位置不记)
|
|
6774
8242
|
placePill()
|
|
6775
8243
|
ensureResizeWatch()
|
|
6776
8244
|
schedulePillPlacer()
|
|
@@ -6800,10 +8268,23 @@ window.__ModuleLoader__.load({
|
|
|
6800
8268
|
|
|
6801
8269
|
var offKeyDown = listen(document, 'keydown', function (event) {
|
|
6802
8270
|
if (event.key !== 'Escape' || !panelOpen) return
|
|
6803
|
-
// Esc
|
|
6804
|
-
// ①
|
|
6805
|
-
// ②
|
|
8271
|
+
// Esc 分三级:**从最内层往外退**,一次只退一层(用户按一次不该连关两层)。
|
|
8272
|
+
// ① 设置抽屉开着 → 只收抽屉,小窗留着(用户多半只是改完想接着看解读);
|
|
8273
|
+
// ② 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着);
|
|
8274
|
+
// ③ 其余情况 → 关掉小窗(一次到位,不受"有没有追问过"限制)。
|
|
6806
8275
|
// 以前是一律关窗:正在说一句话时按 Esc 想放弃这句话,结果整个小窗没了。
|
|
8276
|
+
if (settingsOpen) {
|
|
8277
|
+
event.preventDefault()
|
|
8278
|
+
event.stopPropagation()
|
|
8279
|
+
// 抽屉里还开着模型下拉时,Esc 先收下拉 —— 一次只退最内层
|
|
8280
|
+
var anyPickOpen = Object.keys(settingsByKey).some(function (key) {
|
|
8281
|
+
var entry = settingsByKey[key]
|
|
8282
|
+
return !!(entry && entry.menu && entry.menu.getAttribute('data-open') === '1')
|
|
8283
|
+
})
|
|
8284
|
+
if (anyPickOpen) closeModelPicks()
|
|
8285
|
+
else closeSettings()
|
|
8286
|
+
return
|
|
8287
|
+
}
|
|
6807
8288
|
if (captureRow.getAttribute('data-show') === '1') {
|
|
6808
8289
|
event.preventDefault()
|
|
6809
8290
|
event.stopPropagation()
|
|
@@ -6815,6 +8296,36 @@ window.__ModuleLoader__.load({
|
|
|
6815
8296
|
closePanel()
|
|
6816
8297
|
}, true)
|
|
6817
8298
|
|
|
8299
|
+
var offSettingsOpen = listen(settingsButton, 'click', function (event) {
|
|
8300
|
+
event.stopPropagation()
|
|
8301
|
+
if (settingsOpen) closeSettings()
|
|
8302
|
+
else openSettings()
|
|
8303
|
+
})
|
|
8304
|
+
var offSettingsBack = listen(settingsBack, 'click', function (event) {
|
|
8305
|
+
event.stopPropagation()
|
|
8306
|
+
closeSettings()
|
|
8307
|
+
})
|
|
8308
|
+
var offSettingsClose = listen(settingsClose, 'click', function (event) {
|
|
8309
|
+
event.stopPropagation()
|
|
8310
|
+
closeSettings()
|
|
8311
|
+
})
|
|
8312
|
+
var offSettingsReset = listen(settingsReset, 'click', function (event) {
|
|
8313
|
+
event.stopPropagation()
|
|
8314
|
+
resetSettings()
|
|
8315
|
+
})
|
|
8316
|
+
// 抽屉里不该触发起拖面板 / 划词:整个抽屉吞掉 mousedown 的冒泡
|
|
8317
|
+
var offSettingsStop = listen(settingsSheet, 'mousedown', function (event) {
|
|
8318
|
+
event.stopPropagation()
|
|
8319
|
+
})
|
|
8320
|
+
// 点抽屉里的空白处(正文 / 分类栏)把模型下拉收掉 —— 点菜单内部不会走到这里(行自己 stopPropagation)
|
|
8321
|
+
var offSettingsPickAway = listen(settingsSheet, 'click', function () {
|
|
8322
|
+
closeModelPicks()
|
|
8323
|
+
})
|
|
8324
|
+
var offSettingsScrim = listen(settingsScrim, 'click', function (event) {
|
|
8325
|
+
event.stopPropagation()
|
|
8326
|
+
closeSettings()
|
|
8327
|
+
})
|
|
8328
|
+
|
|
6818
8329
|
var offHistory = listen(historyButton, 'click', function (event) {
|
|
6819
8330
|
event.stopPropagation()
|
|
6820
8331
|
toggleHistoryList()
|
|
@@ -8120,10 +9631,12 @@ window.__ModuleLoader__.load({
|
|
|
8120
9631
|
|
|
8121
9632
|
var offDrag = listen(head, 'mousedown', function (event) {
|
|
8122
9633
|
if (promoteButton.contains(event.target) || closeButton.contains(event.target)) return
|
|
9634
|
+
if (settingsButton.contains(event.target) || historyButton.contains(event.target)) return
|
|
8123
9635
|
event.preventDefault()
|
|
8124
9636
|
var rect = panel.getBoundingClientRect()
|
|
8125
9637
|
var offsetX = event.clientX - rect.left
|
|
8126
9638
|
var offsetY = event.clientY - rect.top
|
|
9639
|
+
panelInteracting = true
|
|
8127
9640
|
var move = function (moveEvent) {
|
|
8128
9641
|
panel.style.left = clamp(moveEvent.clientX - offsetX, 4, window.innerWidth - rect.width - 4) + 'px'
|
|
8129
9642
|
panel.style.top = clamp(moveEvent.clientY - offsetY, 4, window.innerHeight - 40) + 'px'
|
|
@@ -8132,13 +9645,41 @@ window.__ModuleLoader__.load({
|
|
|
8132
9645
|
var up = function () {
|
|
8133
9646
|
window.removeEventListener('mousemove', move, true)
|
|
8134
9647
|
window.removeEventListener('mouseup', up, true)
|
|
9648
|
+
panelInteracting = false
|
|
9649
|
+
// **不存位置**:拖动只对这一次有效;下次开窗仍按选区重新锚定。
|
|
8135
9650
|
}
|
|
8136
9651
|
window.addEventListener('mousemove', move, true)
|
|
8137
9652
|
window.addEventListener('mouseup', up, true)
|
|
8138
9653
|
})
|
|
8139
9654
|
|
|
9655
|
+
bindResizeGrip(gripCorner, 'se')
|
|
9656
|
+
bindResizeGrip(gripRight, 'e')
|
|
9657
|
+
bindResizeGrip(gripBottom, 's')
|
|
9658
|
+
|
|
9659
|
+
// 窗口变小:把**尺寸**夹回可视区(位置不归这里管 —— 它由 showPanel 的锚定决定)
|
|
9660
|
+
var offPanelResize = listen(window, 'resize', function () {
|
|
9661
|
+
if (!panelSize || panelInteracting) return
|
|
9662
|
+
var next = clampPanelSize({ w: panelSize.w, h: panelSize.h })
|
|
9663
|
+
if (next.w === panelSize.w && next.h === panelSize.h) return
|
|
9664
|
+
panelSize = next
|
|
9665
|
+
applyPanelSizeOnOpen()
|
|
9666
|
+
clampPanelPosition()
|
|
9667
|
+
savePanelSize()
|
|
9668
|
+
})
|
|
9669
|
+
|
|
8140
9670
|
// —— 调试钩子(自动化验证用) ——
|
|
8141
9671
|
window.__dshSelectionExplain = {
|
|
9672
|
+
/** 重新读一次 host 上的小窗尺寸(测试用;正常路径只在挂载时读一次)。 */
|
|
9673
|
+
loadPanelSize: function () {
|
|
9674
|
+
panelSizeAsked = false
|
|
9675
|
+
loadPanelSize()
|
|
9676
|
+
return true
|
|
9677
|
+
},
|
|
9678
|
+
/** 当前小窗几何(测试用:直接读渲染值,不经过变量)。 */
|
|
9679
|
+
panelGeom: function () {
|
|
9680
|
+
var rect = panel.getBoundingClientRect()
|
|
9681
|
+
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height, resized: panel.getAttribute('data-resized') === '1' }
|
|
9682
|
+
},
|
|
8142
9683
|
/** 重新读一次 host 上的胶囊位置(测试用;正常路径只在挂载时读一次)。 */
|
|
8143
9684
|
loadPillPos: function () {
|
|
8144
9685
|
pillPosAsked = false
|
|
@@ -8615,6 +10156,14 @@ window.__ModuleLoader__.load({
|
|
|
8615
10156
|
offResize()
|
|
8616
10157
|
offKeyDown()
|
|
8617
10158
|
offPromote()
|
|
10159
|
+
// 设置抽屉:摘掉它自己那几条监听(抽屉 DOM 随面板一起销毁)
|
|
10160
|
+
offSettingsOpen()
|
|
10161
|
+
offSettingsBack()
|
|
10162
|
+
offSettingsClose()
|
|
10163
|
+
offSettingsReset()
|
|
10164
|
+
offSettingsStop()
|
|
10165
|
+
offSettingsPickAway()
|
|
10166
|
+
offSettingsScrim()
|
|
8618
10167
|
offRetry()
|
|
8619
10168
|
offExpand()
|
|
8620
10169
|
offAskKey()
|
|
@@ -8628,6 +10177,7 @@ window.__ModuleLoader__.load({
|
|
|
8628
10177
|
cancelVoice()
|
|
8629
10178
|
offClose()
|
|
8630
10179
|
offDrag()
|
|
10180
|
+
offPanelResize()
|
|
8631
10181
|
offPillDown()
|
|
8632
10182
|
offPillClick()
|
|
8633
10183
|
offPillEnter()
|
|
@@ -8714,11 +10264,118 @@ window.__ModuleLoader__.load({
|
|
|
8714
10264
|
return { root: root, content: content, hint: hint, title: titleNode }
|
|
8715
10265
|
}
|
|
8716
10266
|
|
|
8717
|
-
/**
|
|
8718
|
-
|
|
8719
|
-
|
|
10267
|
+
/**
|
|
10268
|
+
* 统一的小图标画布:头部 / 工具行所有图标都从这里出。
|
|
10269
|
+
*
|
|
10270
|
+
* 规范(与既有 drawXxxIcon 一族逐条对齐,别在调用点另起一套):
|
|
10271
|
+
* viewBox 0 0 16 16 · 渲染 14px · fill:none · stroke:currentColor
|
|
10272
|
+
* · stroke-width 1.5 · linecap/linejoin round
|
|
10273
|
+
* 这样一排图标才有同一套笔画粗细与圆角;混用 Unicode 字形(🕘 ↗ ✕ 🎤)
|
|
10274
|
+
* 会因字体不同而粗细、基线、视觉重量全不一样 —— 摆一排就是"割裂"。
|
|
10275
|
+
*/
|
|
10276
|
+
function iconCanvas(size) {
|
|
10277
|
+
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
10278
|
+
svg.setAttribute('class', 'dsh-sel-ic')
|
|
10279
|
+
svg.setAttribute('viewBox', '0 0 16 16')
|
|
10280
|
+
svg.setAttribute('width', String(size || 14))
|
|
10281
|
+
svg.setAttribute('height', String(size || 14))
|
|
10282
|
+
svg.setAttribute('fill', 'none')
|
|
10283
|
+
svg.setAttribute('stroke', 'currentColor')
|
|
10284
|
+
svg.setAttribute('stroke-width', '1.5')
|
|
10285
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
10286
|
+
svg.setAttribute('stroke-linejoin', 'round')
|
|
10287
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
10288
|
+
svg.setAttribute('focusable', 'false')
|
|
10289
|
+
return svg
|
|
10290
|
+
}
|
|
10291
|
+
|
|
10292
|
+
function iconPath(svg, d) {
|
|
10293
|
+
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
10294
|
+
path.setAttribute('d', d)
|
|
10295
|
+
svg.appendChild(path)
|
|
10296
|
+
return path
|
|
10297
|
+
}
|
|
10298
|
+
|
|
10299
|
+
/** 关闭(✕):两条斜线,照既有的 closeIcon 路径。 */
|
|
10300
|
+
function headerCloseIcon() {
|
|
10301
|
+
var svg = iconCanvas(14)
|
|
10302
|
+
iconPath(svg, 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6')
|
|
10303
|
+
return svg
|
|
10304
|
+
}
|
|
10305
|
+
|
|
10306
|
+
/** 设置:两轨两把手滑杆。
|
|
10307
|
+
*
|
|
10308
|
+
* 为什么不是齿轮:多齿版在 14px 下齿牙糊成一团,少齿版**直接渲染成空白**(实测对照过)。
|
|
10309
|
+
* 为什么只有两条轨:三条轨三个把手在 14px 下把手会连成一片。
|
|
10310
|
+
* 把手画成实心:缩小后对比度更高,仍然认得出"这是滑杆"。 */
|
|
10311
|
+
function headerSettingsIcon() {
|
|
10312
|
+
var svg = iconCanvas(14)
|
|
10313
|
+
iconPath(svg, 'M2.6 5.2h10.8M2.6 10.8h10.8')
|
|
10314
|
+
var dots = [[6.2, 5.2], [10.4, 10.8]]
|
|
10315
|
+
for (var i = 0; i < dots.length; i += 1) {
|
|
10316
|
+
var dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
10317
|
+
dot.setAttribute('cx', String(dots[i][0]))
|
|
10318
|
+
dot.setAttribute('cy', String(dots[i][1]))
|
|
10319
|
+
dot.setAttribute('r', '1.7')
|
|
10320
|
+
// 实心把手:fill 跟 currentColor,同时取消描边(否则 1.5 描边会把它胀大一圈)
|
|
10321
|
+
dot.setAttribute('fill', 'currentColor')
|
|
10322
|
+
dot.setAttribute('stroke', 'none')
|
|
10323
|
+
svg.appendChild(dot)
|
|
10324
|
+
}
|
|
10325
|
+
return svg
|
|
10326
|
+
}
|
|
10327
|
+
|
|
10328
|
+
/** 最近(时钟):表盘 + 时针分针。外圈 6.1 让墨迹在 16 画布里有 1.9 的呼吸。 */
|
|
10329
|
+
function headerHistoryIcon() {
|
|
10330
|
+
var svg = iconCanvas(14)
|
|
10331
|
+
var circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
10332
|
+
circle.setAttribute('cx', '8')
|
|
10333
|
+
circle.setAttribute('cy', '8')
|
|
10334
|
+
circle.setAttribute('r', '6.1')
|
|
10335
|
+
svg.appendChild(circle)
|
|
10336
|
+
iconPath(svg, 'M8 4.6V8l2.4 1.5')
|
|
10337
|
+
return svg
|
|
10338
|
+
}
|
|
10339
|
+
|
|
10340
|
+
/** 升格:右下角缺口的方框 + 甩向右上的箭头。 */
|
|
10341
|
+
function headerPromoteIcon() {
|
|
10342
|
+
var svg = iconCanvas(14)
|
|
10343
|
+
iconPath(svg, 'M13.2 9.6v2.4a1.8 1.8 0 0 1-1.8 1.8H4a1.8 1.8 0 0 1-1.8-1.8V4.6A1.8 1.8 0 0 1 4 2.8h2.4')
|
|
10344
|
+
iconPath(svg, 'M9.4 2.8h3.8v3.8')
|
|
10345
|
+
iconPath(svg, 'M13.2 2.8 8.2 7.8')
|
|
10346
|
+
return svg
|
|
10347
|
+
}
|
|
10348
|
+
|
|
10349
|
+
/**
|
|
10350
|
+
* 带文字的头部动作键(最近 / 升格 / 设置)。
|
|
10351
|
+
*
|
|
10352
|
+
* 三个键长得一样很重要:以前混着"文字 key(🕘 最近 / ↗ 升格)"和"纯图标 key(✕)",
|
|
10353
|
+
* 图标又都是 Unicode 字形,粗细与基线各不相同,一排看着像拼起来的。
|
|
10354
|
+
* 现在统一成"同一套 SVG 图标 + 同一套边框",只是有字没字的区别。
|
|
10355
|
+
*/
|
|
10356
|
+
function actionButton(title, label, buildIcon) {
|
|
10357
|
+
var node = el('button', 'dsh-sel-action')
|
|
10358
|
+
node.type = 'button'
|
|
10359
|
+
node.title = title
|
|
10360
|
+
if (typeof buildIcon === 'function') node.appendChild(buildIcon())
|
|
10361
|
+
node.appendChild(el('span', null, label))
|
|
10362
|
+
return node
|
|
10363
|
+
}
|
|
10364
|
+
|
|
10365
|
+
/** 返回(‹):设置页头部左上的折角。 */
|
|
10366
|
+
function backIcon() {
|
|
10367
|
+
var svg = iconCanvas(14)
|
|
10368
|
+
iconPath(svg, 'M9.8 3.4 5.2 8l4.6 4.6')
|
|
10369
|
+
return svg
|
|
10370
|
+
}
|
|
10371
|
+
|
|
10372
|
+
/** 面板头部的小图标按钮。glyph 传图标构造函数(不再是 Unicode 字形)。 */
|
|
10373
|
+
function iconButton(title, buildIcon) {
|
|
10374
|
+
var node = el('button', 'dsh-sel-icon')
|
|
8720
10375
|
node.type = 'button'
|
|
8721
10376
|
node.title = title
|
|
10377
|
+
if (typeof buildIcon === 'function') node.appendChild(buildIcon())
|
|
10378
|
+
else if (buildIcon) node.textContent = buildIcon
|
|
8722
10379
|
return node
|
|
8723
10380
|
}
|
|
8724
10381
|
|