@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.
@@ -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
- '.dsh-sel-panel{position:fixed;display:none;flex-direction:column;width:min(540px,calc(100vw - 20px));max-height:min(78vh,720px);',
325
- '.dsh-sel-panel[data-stage=translation]{width:min(440px,calc(100vw - 20px))}',
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
- '.dsh-sel-pickerrow .dsh-sel-pickerprov{margin-left:auto;font-size:10.5px;opacity:.55}',
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
- var MAX_SELECTION = 4000
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 = el('button', 'dsh-sel-action', '🕘 最近')
2142
- historyButton.type = 'button'
2143
- historyButton.title = '最近聊过的划词(点一条把那段对话调回来)'
2144
- var promoteButton = el('button', 'dsh-sel-action', '↗ 升格')
2145
- promoteButton.type = 'button'
2146
- promoteButton.title = '升格为正式会话(建在同一个项目下,带着这段文字与上面的讨论)'
2147
- var closeButton = iconButton('关闭(Esc)', '✕')
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 ? String(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: currentSessionId(),
4467
+ sessionId: panelOrCurrentSessionId(),
3320
4468
  kind: selectionKind(text, label),
3321
4469
  stage: stage === 'detail' ? 'detail' : 'translation',
3322
4470
  ...(refresh ? { refresh: true } : {}),
3323
- // 模型选择对整窗生效(首轮/详解也用它);effort 只影响追问档,host 侧自己忽略
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: currentSessionId(),
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: currentSessionId(),
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
- var modelCatalog = { at: 0, items: [], current: null, stages: null, loading: false, error: '', leakyOff: [] }
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
- var choice = currentChoiceOf(items[i])
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 TIER_LABEL[id] || id
7848
+ return null
6454
7849
  }
6455
7850
 
6456
- function currentChoiceOf(item) {
6457
- if (!state.modelChoice || !item) return false
6458
- return item.provider === state.modelChoice.provider && item.model === state.modelChoice.model
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
- var items = modelCatalog.items || []
6466
- for (var i = 0; i < items.length; i += 1) {
6467
- if (currentChoiceOf(items[i])) {
6468
- name = items[i].name || items[i].model
6469
- provider = items[i].providerName || items[i].provider
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
- name = String(modelCatalog.current.model || '')
6475
- provider = String(modelCatalog.current.provider || '')
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
- /** 拉模型清单(60 秒缓存;菜单打开时才拉,不进热路径)。 */
6487
- function loadModelCatalog(force) {
6488
- if (modelCatalog.loading) return Promise.resolve(modelCatalog)
6489
- if (!force && modelCatalog.items.length > 0 && Date.now() - modelCatalog.at < 60000) return Promise.resolve(modelCatalog)
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
- return fetch(MODELS + (force ? '?refresh=1' : ''), { headers: { accept: 'application/json' } })
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
- modelCatalog.items = Array.isArray(data && data.models) ? data.models : []
6497
- modelCatalog.current = (data && data.current) || null
6498
- modelCatalog.stages = (data && data.stages) || null
6499
- modelCatalog.error = String((data && data.error) || '')
6500
- modelCatalog.leakyOff = Array.isArray(data && data.leakyOff) ? data.leakyOff : []
6501
- modelCatalog.at = Date.now()
6502
- return modelCatalog
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
- modelCatalog.error = String((error && error.message) || error)
6506
- return modelCatalog
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
- modelCatalog.loading = false
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', currentChoiceOf(item) ? '1' : '0')
6535
- row.appendChild(el('span', 'dsh-sel-pickercheck', currentChoiceOf(item) ? '✓' : ''))
6536
- row.appendChild(el('span', null, item.name || item.model))
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
- state.modelChoice = { provider: item.provider, model: item.model }
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
- for (var k = 0; k < items.length; k += 1) {
6558
- if (currentChoiceOf(items[k]) && (items[k].efforts || []).length > 0) {
6559
- effortIds = []
6560
- for (var e = 0; e < items[k].efforts.length; e += 1) effortIds.push(items[k].efforts[e].id)
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
- if (modelCatalog.leakyOff.indexOf((state.modelChoice ? state.modelChoice.provider : '') + '/' + (state.modelChoice ? state.modelChoice.model : '')) >= 0) {
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
- var storedModel = readStore(MODEL_KEY, '')
6667
- if (storedModel && storedModel.indexOf('\t') > 0) {
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
- function iconButton(title, glyph) {
8719
- var node = el('button', 'dsh-sel-icon', glyph)
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