@yfwu2020/dsh-selection-explain 0.7.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -32,6 +32,15 @@ window.__ModuleLoader__.load({
32
32
  var HISTORY = '/selection-explain/api/history'
33
33
  /** 模型清单(带每个模型支持的推理等级)。 */
34
34
  var MODELS = '/selection-explain/api/models'
35
+ /**
36
+ * 小窗设置:GET 拿 spec + 当前值,POST 写回。
37
+ *
38
+ * spec(分组 / 标题 / 说明 / 边界 / 单位)由 host 下发 —— 客户端**不做业务判断**,
39
+ * 免得 UI 的边界和 host 的 clamp 各写一份、时间久了互相漂(见 src/settings.ts 顶部注释)。
40
+ */
41
+ var SETTINGS = '/selection-explain/api/settings'
42
+ /** 极轻的"这一轮用哪个模型"查询:设置页开着时按秒轮询它,让括号里的名字实时跟着变。 */
43
+ var ROUTE = '/selection-explain/api/route'
35
44
  /**
36
45
  * 语音输入(小窗麦克风):识别在 host 上做,浏览器只负责录一段规范 WAV 送过去。
37
46
  * · 目录接口说清"能不能录、要录多久、要不要先准备模型";
@@ -342,6 +351,11 @@ window.__ModuleLoader__.load({
342
351
  'background:transparent;color:inherit;font:inherit;font-size:11.5px;line-height:1;opacity:.85}',
343
352
  '.dsh-sel-action:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.16));opacity:1}',
344
353
  '.dsh-sel-action:disabled{opacity:.4;cursor:default}',
354
+ // 统一图标(头部 / 工具行):尺寸由 SVG 的 width/height 属性给,这里只钉住显示方式
355
+ '.dsh-sel-ic{display:block;flex:none}',
356
+ // 带文字的图标键里的图标:和文字之间留一点呼吸,别贴在一起
357
+ '.dsh-sel-action .dsh-sel-ic{margin-right:1px}',
358
+ '.dsh-sel-icon .dsh-sel-ic{margin:0}',
345
359
  // 选中文字quote:左侧强调条 + 略暗底
346
360
  // 选中文字条:在滚动区里(跟着消息一起滚),左右边距交给 body 的 padding 所以与内容对齐。
347
361
  // 可以自动换行、超过 84px 内部滚动,但**不显示滚动条**。
@@ -556,7 +570,11 @@ window.__ModuleLoader__.load({
556
570
  '.dsh-sel-pickerrow:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
557
571
  '.dsh-sel-pickerrow[data-on="1"]{font-weight:600}',
558
572
  '.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}',
573
+ // 名字占掉剩下的宽度、挤不下就省略号 —— 保证一行里塞得下 勾 + 名字 + 「跟随」小标 + provider
574
+ '.dsh-sel-pickerrow .dsh-sel-pickername{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
575
+ '.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;',
576
+ 'background:color-mix(in srgb,var(--sel-a1,#0d9488) 16%,transparent)}',
577
+ '.dsh-sel-pickerrow .dsh-sel-pickerprov{flex:0 0 auto;margin-left:auto;font-size:10.5px;opacity:.55}',
560
578
  '.dsh-sel-pickersplit{height:1px;margin:6px 4px;background:var(--dsw-alias-border-l4,rgba(140,140,140,.22))}',
561
579
  '.dsh-sel-tiers{display:flex;gap:6px;padding:4px 6px 6px}',
562
580
  '.dsh-sel-tierbtn{flex:1 1 0;height:26px;padding:0;border-radius:9px;cursor:pointer;font:inherit;font-size:11.5px;',
@@ -748,6 +766,136 @@ window.__ModuleLoader__.load({
748
766
  '.dsh-sel-err{color:var(--dsw-alias-state-error-primary,#e5484d);font-size:12.5px;white-space:pre-wrap}',
749
767
  '@media (prefers-reduced-motion:reduce){.dsh-sel-sk{animation:none}.dsh-sel-dots i{animation:none;opacity:.6}',
750
768
  '.dsh-sel-btn{transition:none}.dsh-sel-btn[data-pop="1"]{animation:none}}',
769
+ // ────────────────────── 设置页(抽屉) ──────────────────────
770
+ // 一个抽屉,不另开窗口:小窗本身就是窄容器,再弹一个浮窗只会互相打架。
771
+ // 从面板右侧滑入、盖住正文,面板尺寸一个像素都不变。
772
+ '.dsh-sel-sheet{position:absolute;top:0;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;',
773
+ 'background:var(--dsw-alias-bg-layer-1,Canvas);color:var(--dsw-alias-label-primary,CanvasText);',
774
+ // ⚠️ 值必须带引号:[data-open=1] 是**非法选择器**(CSS 标识符不能以数字开头),
775
+ // 浏览器会整条丢弃 —— 表现为"属性明明设了、抽屉却纹丝不动"(实测踩过)。
776
+ 'transform:translateX(100%);opacity:0;pointer-events:none;box-shadow:-14px 0 30px rgba(0,0,0,.16);',
777
+ 'transition:transform .34s cubic-bezier(.22,.8,.3,1),opacity .22s ease}',
778
+ '.dsh-sel-sheet[data-open="1"]{transform:translateX(0);opacity:1;pointer-events:auto}',
779
+ // 抽屉后面压一层薄纱:不遮死正文(用户还得看得出上下文),只把层次分开
780
+ '.dsh-sel-scrim{position:absolute;top:0;left:0;right:0;bottom:0;z-index:5;background:rgba(0,0,0,.06);',
781
+ 'opacity:0;pointer-events:none;transition:opacity .28s ease}',
782
+ '.dsh-sel-scrim[data-open="1"]{opacity:1}',
783
+ '.dsh-sel-layer[data-theme=dark] .dsh-sel-scrim{background:rgba(0,0,0,.32)}',
784
+ '@media (prefers-reduced-motion:reduce){.dsh-sel-sheet{transition:opacity .16s ease;transform:none}',
785
+ '.dsh-sel-scrim{transition:none}}',
786
+ '.dsh-sel-sheethead{display:flex;align-items:center;gap:8px;padding:10px 10px 10px 12px;flex:0 0 auto;',
787
+ 'border-bottom:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22))}',
788
+ '.dsh-sel-sheetback{flex:0 0 auto;width:24px;height:24px;display:grid;place-items:center;border:0;border-radius:8px;',
789
+ 'cursor:pointer;background:transparent;color:inherit;opacity:.75;padding:0}',
790
+ '.dsh-sel-sheetback:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.16));opacity:1}',
791
+ '.dsh-sel-sheettitle{font-weight:600;font-size:12.5px;letter-spacing:.2px}',
792
+ '.dsh-sel-sheetscope{margin-left:auto;font-size:11px;opacity:.5;padding-right:4px;white-space:nowrap}',
793
+ // 左栏分类 + 右栏内容
794
+ '.dsh-sel-sheetmain{flex:1 1 auto;display:flex;min-height:0}',
795
+ '.dsh-sel-rail{flex:0 0 96px;border-right:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22));',
796
+ 'padding:8px 6px;display:flex;flex-direction:column;gap:2px;overflow:auto}',
797
+ '.dsh-sel-railbtn{border:0;background:transparent;text-align:left;padding:6px 8px;border-radius:8px;cursor:pointer;',
798
+ 'font:inherit;font-size:12px;color:inherit;opacity:.72;white-space:nowrap}',
799
+ '.dsh-sel-railbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12));opacity:1}',
800
+ '.dsh-sel-railbtn[aria-current="true"]{background:var(--sel-a1-soft,rgba(13,148,136,.12));color:var(--sel-a1-text,#0f766e);',
801
+ 'font-weight:600;opacity:1}',
802
+ '.dsh-sel-sheetbody{flex:1 1 auto;overflow:auto;padding:2px 12px 14px;min-width:0;',
803
+ 'scrollbar-width:thin;scrollbar-color:rgba(140,140,140,.35) transparent}',
804
+ '.dsh-sel-sheetbody::-webkit-scrollbar{width:9px;height:9px}',
805
+ '.dsh-sel-sheetbody::-webkit-scrollbar-thumb{background:rgba(140,140,140,.3);border-radius:9px;border:2px solid transparent;background-clip:content-box}',
806
+ '.dsh-sel-grp{padding:12px 0 4px;border-bottom:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.16))}',
807
+ '.dsh-sel-grp:last-child{border-bottom:0;padding-bottom:0}',
808
+ '.dsh-sel-grph{font-size:11px;font-weight:600;opacity:.5;letter-spacing:.4px;margin-bottom:8px}',
809
+ '.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))}',
810
+ '.dsh-sel-ssectionh{font-size:14px;font-weight:600;line-height:1.5;margin:0 0 6px}',
811
+ '.dsh-sel-ssectionhint,.dsh-sel-ssectionnote{font-size:11.5px;opacity:.6;line-height:1.7;margin:3px 0 8px}',
812
+ '.dsh-sel-ssectionnote{margin:8px 0 0}',
813
+ '.dsh-sel-ssection>.dsh-sel-srow+.dsh-sel-srow{border-top:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.16))}',
814
+ '.dsh-sel-srow[data-disabled="1"]{opacity:.5}',
815
+ '.dsh-sel-srow input:disabled,.dsh-sel-srow button:disabled{cursor:default}',
816
+ '.dsh-sel-sheet textarea.dsh-sel-stext{resize:vertical;line-height:1.6}',
817
+
818
+ '.dsh-sel-srow{display:flex;align-items:flex-start;gap:14px;padding:8px 0}',
819
+ '.dsh-sel-srowlab{flex:1 1 auto;min-width:0}',
820
+ '.dsh-sel-srowt{font-size:13px;line-height:1.5}',
821
+ '.dsh-sel-srowd{font-size:11.5px;opacity:.55;line-height:1.6;margin-top:3px;max-width:46ch}',
822
+ '.dsh-sel-srowctl{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding-top:2px;min-width:0}',
823
+ // 宽输入框(provider / model / 工具清单)所在行:说明在上、输入框独占一行。
824
+ // 抽屉是嵌在小窗里的窄容器,视口再宽也帮不上忙,所以按"行里有没有宽输入框"来判。
825
+ '.dsh-sel-srow[data-wide="1"]{flex-wrap:wrap}',
826
+ '.dsh-sel-srow[data-wide="1"] .dsh-sel-srowlab{flex:1 1 100%}',
827
+ '.dsh-sel-srow[data-wide="1"] .dsh-sel-srowctl{flex:1 1 100%;padding-top:6px}',
828
+ '.dsh-sel-srow[data-wide="1"] .dsh-sel-stext{width:100%;max-width:none}',
829
+ '.dsh-sel-srow[data-wide="1"] .dsh-sel-spick{width:100%;max-width:none}',
830
+ // 控件:开关(38×22,触屏也点得住)
831
+ '.dsh-sel-sws{flex:none;width:38px;height:22px;border-radius:11px;position:relative;cursor:pointer;padding:0;',
832
+ '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));',
833
+ 'transition:background .18s ease}',
834
+ '.dsh-sel-sws:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
835
+ 'background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 3px rgba(0,0,0,.28);',
836
+ 'transition:transform .2s cubic-bezier(.34,1.4,.64,1)}',
837
+ '.dsh-sel-sws[aria-checked="true"]{background:var(--sel-fill,#0f766e);border-color:var(--sel-fill,#0f766e)}',
838
+ '.dsh-sel-sws[aria-checked="true"]:after{transform:translateX(16px);background:var(--sel-fill-fg,#fff)}',
839
+ // 控件:数字 / 文本
840
+ '.dsh-sel-snum{width:74px;border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));border-radius:8px;',
841
+ 'padding:5px 8px;background:transparent;color:inherit;font:inherit;font-size:12.5px;text-align:right;',
842
+ 'font-variant-numeric:tabular-nums;box-sizing:border-box}',
843
+ '.dsh-sel-stext{width:170px;border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));border-radius:8px;',
844
+ 'padding:5px 8px;background:transparent;color:inherit;font:inherit;font-size:12px;box-sizing:border-box}',
845
+ '.dsh-sel-snum:focus-visible,.dsh-sel-stext:focus-visible,.dsh-sel-sws:focus-visible,.dsh-sel-segcell:focus-visible,',
846
+ '.dsh-sel-sheetback:focus-visible,.dsh-sel-railbtn:focus-visible,.dsh-sel-sbtn:focus-visible,',
847
+ '.dsh-sel-action:focus-visible,.dsh-sel-icon:focus-visible{outline:2px solid var(--sel-a1,#0d9488);outline-offset:2px}',
848
+ '.dsh-sel-sunit{font-size:11.5px;opacity:.5;white-space:nowrap}',
849
+ // 模型选择:**自绘下拉**,不用原生 <select>。
850
+ // 原生 select 的弹出层由浏览器画,42 个模型时会顶出可视区且拿不到滚动条(用户报的)。
851
+ // 自绘的列表自己带 max-height + overflow:auto,滚动条是确定的;样式也和 composer 那枚模型菜单一致。
852
+ '.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));',
853
+ 'border-radius:8px;padding:5px 8px;background:transparent;color:inherit;font:inherit;font-size:12.5px;',
854
+ 'box-sizing:border-box;cursor:pointer;text-align:left}',
855
+ '.dsh-sel-spick:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
856
+ '.dsh-sel-spickname{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
857
+ '.dsh-sel-spickcaret{flex:none;font-size:9px;opacity:.6}',
858
+ // 菜单挂在**抽屉**上(不是行里):行的祖先链上有 overflow:auto 的滚动区,浮层会被它裁掉
859
+ '.dsh-sel-spickmenu{position:absolute;z-index:30;display:none;flex-direction:column;padding:6px;border-radius:12px;',
860
+ 'box-sizing:border-box;background:var(--dsw-alias-bg-layer-2,#232427);',
861
+ 'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));box-shadow:0 14px 34px rgba(0,0,0,.35)}',
862
+ '.dsh-sel-spickmenu[data-open="1"]{display:flex}',
863
+ '.dsh-sel-spickgroup{padding:2px 8px 5px;font-size:10.5px;opacity:.6;flex:none}',
864
+ // 列表自己滚:max-height 由 JS 按"抽屉底部还剩多少"给,这里只兜底
865
+ '.dsh-sel-spicklist{flex:1 1 auto;min-height:0;max-height:260px;overflow-y:auto;overflow-x:hidden;',
866
+ 'display:flex;flex-direction:column;gap:1px;scrollbar-width:thin;scrollbar-color:rgba(140,140,140,.45) transparent}',
867
+ '.dsh-sel-spicklist::-webkit-scrollbar{width:9px}',
868
+ '.dsh-sel-spicklist::-webkit-scrollbar-thumb{background:rgba(140,140,140,.4);border-radius:9px;border:2px solid transparent;background-clip:content-box}',
869
+ '.dsh-sel-spickrow{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer;',
870
+ 'font:inherit;font-size:12px;text-align:left;border:0;background:transparent;color:inherit;flex:none}',
871
+ '.dsh-sel-spickrow:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
872
+ '.dsh-sel-spickrow[data-on="1"]{font-weight:600}',
873
+ '.dsh-sel-spickcheck{width:12px;flex:0 0 auto;color:var(--sel-a1-text,#0f766e);font-weight:700}',
874
+ '.dsh-sel-spickrowname{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
875
+ '.dsh-sel-spickprov{flex:none;font-size:10.5px;opacity:.55;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
876
+ '.dsh-sel-spickempty{padding:8px;font-size:11.5px;opacity:.55}',
877
+ // 控件:分段(档位)
878
+ '.dsh-sel-sheet .dsh-sel-seg{display:inline-flex;background:var(--dsw-alias-bg-layer-2,rgba(140,140,140,.1));',
879
+ 'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.3));border-radius:9px;padding:2px;position:relative}',
880
+ '.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;',
881
+ 'border-radius:7px;color:inherit;position:relative;z-index:1;white-space:nowrap;opacity:.8}',
882
+ '.dsh-sel-sheet .dsh-sel-segcell[aria-checked="true"]{color:var(--sel-fill-fg,#fff);font-weight:600;opacity:1}',
883
+ '.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;',
884
+ 'transition:left .22s cubic-bezier(.34,1.3,.64,1),width .22s cubic-bezier(.34,1.3,.64,1)}',
885
+ // 底部动作栏:常驻;不脏时写"改动会自动保存",脏了才报数
886
+ '.dsh-sel-sheetfoot{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:9px 12px;',
887
+ '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))}',
888
+ '.dsh-sel-sfootnote{font-size:11.5px;opacity:.55;margin-right:auto;display:flex;align-items:center;gap:6px}',
889
+ '.dsh-sel-sfootnote i{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-error-primary,#e5484d);display:none}',
890
+ '.dsh-sel-sfootnote[data-dirty="1"]{opacity:.9}',
891
+ '.dsh-sel-sfootnote[data-dirty="1"] i{display:block}',
892
+ '.dsh-sel-sbtn{border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));border-radius:8px;padding:5px 11px;',
893
+ 'background:transparent;color:inherit;cursor:pointer;font:inherit;font-size:12px;white-space:nowrap}',
894
+ '.dsh-sel-sbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,140,.12))}',
895
+ '.dsh-sel-sbtn:disabled{opacity:.4;cursor:default}',
896
+ '.dsh-sel-sbtn[data-danger="1"]{color:var(--dsw-alias-state-error-primary,#e5484d);',
897
+ 'border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e5484d) 45%,transparent)}',
898
+ '.dsh-sel-sempty{font-size:12px;opacity:.55;padding:14px 2px}',
751
899
  ].join('')
752
900
 
753
901
  // ────────────────────── Markdown 轻渲染(全 DOM,无 innerHTML) ──────────────────────
@@ -1635,7 +1783,8 @@ window.__ModuleLoader__.load({
1635
1783
  window.__dshSelBridge = 1
1636
1784
  var WINDOW_CHARS = 1500
1637
1785
  var KEY_CHARS = 300
1638
- var MAX_SELECTION = 4000
1786
+ // 帧内只守设置允许的最高边界;父页面按当前设置决定是否显示入口。
1787
+ var MAX_SELECTION = 20000
1639
1788
  /** 鼠标按着超过这么久还没等到 mouseup,就当松手落在帧外了(别永久卡住上报)。 */
1640
1789
  var STALE_PRESS_MS = 6000
1641
1790
  /** 轮询连续这么多拍都读不到选区,才认为选区真没了(约 1.5s;瞬时读不到不清)。 */
@@ -2138,15 +2287,17 @@ window.__ModuleLoader__.load({
2138
2287
  // 撑开用:把右侧三个按钮(最近/升格/✕)顶到最右。
2139
2288
  // 以前这一步是"模型名标签"的 margin-left:auto 兼任的,标签一删按钮就贴到标题后面了。
2140
2289
  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)', '✕')
2290
+ var historyButton = actionButton('最近聊过的划词(点一条把那段对话调回来)', '最近', headerHistoryIcon)
2291
+ var promoteButton = actionButton('升格为正式会话(建在同一个项目下,带着这段文字与上面的讨论)', '升格', headerPromoteIcon)
2292
+ // 设置:与「最近 / 升格」**同款**(同边框、同高、同圆角、同 hover),图标 + 文字。
2293
+ // 用户要求"图标旁加上设置两字" —— 一排文字键,视觉上才是同一套东西。
2294
+ var settingsButton = actionButton('设置(模型 / 解读 / 背景 / 联网 / 界面 / 数据)', '设置', headerSettingsIcon)
2295
+ settingsButton.setAttribute('aria-haspopup', 'dialog')
2296
+ settingsButton.setAttribute('aria-pressed', 'false')
2297
+ var closeButton = iconButton('关闭(Esc)', headerCloseIcon)
2148
2298
  head.appendChild(historyButton)
2149
2299
  head.appendChild(promoteButton)
2300
+ head.appendChild(settingsButton)
2150
2301
  head.appendChild(closeButton)
2151
2302
  var quote = el('div', 'dsh-sel-quote')
2152
2303
  var body = el('div', 'dsh-sel-body')
@@ -2288,10 +2439,926 @@ window.__ModuleLoader__.load({
2288
2439
  var retryButton = el('button', 'dsh-sel-retry', '重新生成')
2289
2440
  retryButton.type = 'button'
2290
2441
  retryButton.style.display = 'none'
2442
+ // ══════════════════ 设置页(抽屉) ══════════════════
2443
+ // 挂在面板**内部**:面板是 position:fixed(也算定位祖先)且 overflow:hidden,
2444
+ // 于是"从右侧滑入"的那一帧会被圆角裁掉,不会溢出到面板外面去。
2445
+ var settingsScrim = el('div', 'dsh-sel-scrim')
2446
+ settingsScrim.setAttribute('data-open', '0')
2447
+ var settingsSheet = el('div', 'dsh-sel-sheet')
2448
+ settingsSheet.setAttribute('data-open', '0')
2449
+ settingsSheet.setAttribute('role', 'dialog')
2450
+ settingsSheet.setAttribute('aria-label', '划词解读设置')
2451
+ var settingsHead = el('div', 'dsh-sel-sheethead')
2452
+ var settingsBack = el('button', 'dsh-sel-sheetback')
2453
+ settingsBack.type = 'button'
2454
+ settingsBack.title = '返回(Esc)'
2455
+ settingsBack.setAttribute('aria-label', '返回')
2456
+ settingsBack.appendChild(backIcon())
2457
+ settingsHead.appendChild(settingsBack)
2458
+ settingsHead.appendChild(el('span', 'dsh-sel-sheettitle', '设置'))
2459
+ settingsHead.appendChild(el('span', 'dsh-sel-sheetscope', '仅本插件 · 即时生效'))
2460
+ var settingsMain = el('div', 'dsh-sel-sheetmain')
2461
+ var settingsRail = el('div', 'dsh-sel-rail')
2462
+ settingsRail.setAttribute('role', 'tablist')
2463
+ settingsRail.setAttribute('aria-orientation', 'vertical')
2464
+ var settingsBody = el('div', 'dsh-sel-sheetbody')
2465
+ settingsMain.appendChild(settingsRail)
2466
+ settingsMain.appendChild(settingsBody)
2467
+ var settingsFoot = el('div', 'dsh-sel-sheetfoot')
2468
+ var settingsNote = el('span', 'dsh-sel-sfootnote')
2469
+ settingsNote.setAttribute('data-dirty', '0')
2470
+ settingsNote.appendChild(el('i'))
2471
+ var settingsNoteText = el('span', null, '改动会自动保存')
2472
+ settingsNote.appendChild(settingsNoteText)
2473
+ var settingsReset = el('button', 'dsh-sel-sbtn', '恢复默认')
2474
+ settingsReset.type = 'button'
2475
+ settingsReset.title = '把所有设置写回 cordis.patch.yml / 出厂默认'
2476
+ var settingsClose = el('button', 'dsh-sel-sbtn', '关闭')
2477
+ settingsClose.type = 'button'
2478
+ settingsFoot.appendChild(settingsNote)
2479
+ settingsFoot.appendChild(settingsReset)
2480
+ settingsFoot.appendChild(settingsClose)
2481
+ settingsSheet.appendChild(settingsHead)
2482
+ settingsSheet.appendChild(settingsMain)
2483
+ settingsSheet.appendChild(settingsFoot)
2484
+ panel.appendChild(settingsScrim)
2485
+ panel.appendChild(settingsSheet)
2486
+
2487
+ /**
2488
+ * 设置页状态。
2489
+ *
2490
+ * 刻意**不查 DOM**(不用 querySelectorAll / innerHTML):插件通篇是"拿住节点引用"的写法,
2491
+ * 而且单测的 DOM 桩也没有这两个接口。分组、行、分段格的节点都存下来,重绘时直接按引用操作。
2492
+ *
2493
+ * spec —— host 下发的字段声明(客户端不硬编码任何字段与边界)
2494
+ * saved —— host 已落盘的值(算"改了几项"、也是「恢复默认」后的对照)
2495
+ * live —— 控件上的当前值
2496
+ * byKey —— key → 控件条目,保存时按 key 取值
2497
+ */
2498
+ var settingsOpen = false
2499
+ var settingsSpec = null
2500
+ var settingsSaved = {}
2501
+ var settingsLive = {}
2502
+ var settingsByKey = {}
2503
+ var settingsRailButtons = []
2504
+ var settingsPanes = []
2505
+ var settingsSegs = []
2506
+ /** 自绘下拉的菜单节点(挂在抽屉上,重渲染时要一起清掉,否则会叠出第二份浮层)。 */
2507
+ var settingsMenus = []
2508
+ var settingsFetching = false
2509
+ var settingsSaveTimer = 0
2510
+ var settingsSaveSeq = 0
2511
+ var settingsSaving = false
2512
+ var settingsSaveRequest = null
2513
+ var settingsResetting = false
2514
+
2515
+ /** 清空一个节点的子元素(替代 innerHTML='',两者在桩里都没有)。 */
2516
+ function clearNode(node) {
2517
+ while (node.firstChild) node.removeChild(node.firstChild)
2518
+ }
2519
+
2520
+ /**
2521
+ * 造一个 <option>(原生 select 用;设置页已改成自绘下拉,这里留给别处复用)。
2522
+ */
2523
+ function modelOption(value, label) {
2524
+ var option = el('option', null, label)
2525
+ option.value = value
2526
+ return option
2527
+ }
2528
+
2529
+ /**
2530
+ * 一个模型下拉行"选中了什么"。
2531
+ *
2532
+ * 每个模型行管**两个**配置字段(spec.keys,如 ['provider','model'] 或 ['chatProvider','chatModel'])。
2533
+ * 两个都有才算选定;否则这一行就是"跟随"状态。
2534
+ */
2535
+ function pairOf(entry) {
2536
+ var keys = entry.spec.keys || []
2537
+ var provider = settingsLive[keys[0]]
2538
+ var model = settingsLive[keys[1]]
2539
+ if (!provider || !model) return null
2540
+ return { provider: String(provider), model: String(model) }
2541
+ }
2542
+
2543
+ /** 一行"跟随"时该写什么字(两行语义不同:首轮/次轮跟主会话,追问跟首轮/次轮)。 */
2544
+ function followLabelOf(entry) {
2545
+ return '跟随主会话'
2546
+ }
2547
+
2548
+ /**
2549
+ * 下拉按钮上的文字。
2550
+ *
2551
+ * · 选定过 → 显示模型名(确定无疑)
2552
+ * · 没选定 → 「跟随…(当前:X)」—— X 是本行**实际会用**的那个模型:
2553
+ * 首轮/次轮行取 /route,追问行取 /route?chat=1。两个会话用不同模型时各报各的。
2554
+ */
2555
+ function paintModelPick(entry) {
2556
+ if (!entry.label) return
2557
+ var pair = pairOf(entry)
2558
+ if (pair) {
2559
+ entry.label.textContent = modelLabel(pair.provider, pair.model, entry.catalog)
2560
+ return
2561
+ }
2562
+ var base = followLabelOf(entry)
2563
+ var route = entry.route
2564
+ entry.label.textContent = route && route.provider && route.model
2565
+ ? base + '(当前:' + modelLabel(route.provider, route.model, entry.catalog) + ')'
2566
+ : base
2567
+ }
2568
+
2569
+ /** 关掉所有模型下拉(同一时刻只允许开一个)。 */
2570
+ function closeModelPicks() {
2571
+ Object.keys(settingsByKey).forEach(function (key) {
2572
+ var entry = settingsByKey[key]
2573
+ if (entry && entry.menu) {
2574
+ entry.menu.setAttribute('data-open', '0')
2575
+ if (entry.button) entry.button.setAttribute('aria-expanded', 'false')
2576
+ }
2577
+ })
2578
+ }
2579
+
2580
+ /**
2581
+ * 取"这一行现在实际会用哪个模型"(轻量端点,可高频轮询)。
2582
+ *
2583
+ * 用 /route 而不是 /models:后者要列 provider 与全部模型(42 个 + 端点发现),
2584
+ * 按秒轮询太重。这里只做一次会话模型解析。
2585
+ */
2586
+ function fetchRoute(sessionId, isChat) {
2587
+ var query = []
2588
+ if (sessionId) query.push('sessionId=' + encodeURIComponent(sessionId))
2589
+ if (isChat) query.push('chat=1')
2590
+ return fetch(ROUTE + (query.length ? '?' + query.join('&') : ''), { headers: { accept: 'application/json' } })
2591
+ .then(function (response) { return response.json() })
2592
+ .then(function (data) {
2593
+ if (!data || data.ok === false) return null
2594
+ return { provider: String(data.provider || ''), model: String(data.model || '') }
2595
+ })
2596
+ .catch(function () { return null })
2597
+ }
2598
+
2599
+ /**
2600
+ * 把"跟随主会话"解析出来的路由贴到界面上。
2601
+ *
2602
+ * 跟随态有两个地方显示它,**两处都要贴**:
2603
+ * · 抽屉里那一行的「跟随主会话(当前:…)」
2604
+ * · **输入框那枚模型胶囊**(跟随态它显示的就是这个值)
2605
+ * 分开贴是有原因的:以前胶囊的更新写在抽屉那行的 `if (!item.entry) return` 之后 ——
2606
+ * 设置没渲染出来(/settings 失败)时胶囊就永远不跟着变;这里各管各的。
2607
+ */
2608
+ function applyFollowRoute(route) {
2609
+ if (!route || !route.provider || !route.model) return
2610
+ // 选了具体模型时胶囊显示的是那个模型,不显示"跟随(当前:…)"
2611
+ if (!state.modelChoice) {
2612
+ var before = modelCatalog.current
2613
+ if (!before || before.provider !== route.provider || before.model !== route.model) {
2614
+ modelCatalog.current = route
2615
+ paintModelPill()
2616
+ }
2617
+ }
2618
+ var entry = settingsByKey.modelChoice
2619
+ if (!entry) return
2620
+ var old = entry.route
2621
+ if (old && old.provider === route.provider && old.model === route.model) return
2622
+ entry.route = route
2623
+ paintModelPick(entry)
2624
+ }
2625
+
2626
+ /**
2627
+ * 刷新一个模型行:清单(带缓存)+ 本行当前值(每次都新取)→ 画按钮、必要时重建列表。
2628
+ *
2629
+ * `forceRoute` 为真时连清单也强制重取(开抽屉那一次用)。
2630
+ */
2631
+ function refreshModelRow(entry, forceRoute) {
2632
+ if (!entry) return
2633
+ var isChat = false
2634
+ var sessionId = panelSessionId || currentSessionId()
2635
+ loadModelCatalog(!!forceRoute, sessionId).then(function (catalog) {
2636
+ // **快照**,不是直接拿 modelCatalog 那个单例:
2637
+ // 另一行稍后会带着自己的会话再拉一次,那会把单例上的字段改掉。
2638
+ entry.catalog = {
2639
+ current: catalog.current,
2640
+ items: catalog.items,
2641
+ sessionId: sessionId,
2642
+ error: catalog.error,
2643
+ }
2644
+ if (entry.filledSession !== sessionId) {
2645
+ entry.filledSession = sessionId
2646
+ entry.filled = false
2647
+ entry.rows = []
2648
+ }
2649
+ if (forceRoute) entry.filled = false
2650
+ fillModelMenu(entry, entry.catalog)
2651
+ paintModelPick(entry)
2652
+ })
2653
+ fetchRoute(sessionId, isChat).then(applyFollowRoute)
2654
+ }
2655
+
2656
+ /** 按清单把下拉列表填好(清单是异步来的,可能晚于渲染)。 */
2657
+ function fillModelMenu(entry, catalog) {
2658
+ if (!entry.menu || entry.filled) return
2659
+ entry.filled = true
2660
+ var list = entry.list
2661
+ clearNode(list)
2662
+ entry.rows = []
2663
+ var items = (catalog && catalog.items) || []
2664
+ if (items.length === 0) {
2665
+ list.appendChild(el('div', 'dsh-sel-spickempty',
2666
+ catalog && catalog.error ? '读取失败:' + catalog.error : '没有取到可用模型清单'))
2667
+ return
2668
+ }
2669
+ // 第一项永远是"跟随":它不是一个模型,而是"清空这一行的两个字段"
2670
+ list.appendChild(modelPickRow(entry, null))
2671
+ for (var i = 0; i < items.length; i += 1) list.appendChild(modelPickRow(entry, items[i]))
2672
+ }
2673
+
2674
+ /** 一行模型(item 为 null = 「跟随…」)。 */
2675
+ function modelPickRow(entry, item) {
2676
+ var row = el('button', 'dsh-sel-spickrow')
2677
+ row.type = 'button'
2678
+ row.setAttribute('role', 'option')
2679
+ var pair = pairOf(entry)
2680
+ var on = item
2681
+ ? !!pair && pair.provider === item.provider && pair.model === item.model
2682
+ : !pair
2683
+ row.setAttribute('data-on', on ? '1' : '0')
2684
+ row.setAttribute('aria-selected', on ? 'true' : 'false')
2685
+ row.appendChild(el('span', 'dsh-sel-spickcheck', on ? '✓' : ''))
2686
+ row.appendChild(el('span', 'dsh-sel-spickrowname', item ? String(item.name || item.model) : followLabelOf(entry)))
2687
+ if (item) row.appendChild(el('span', 'dsh-sel-spickprov', String(item.providerName || item.provider || '')))
2688
+ listen(row, 'click', function (event) {
2689
+ event.preventDefault()
2690
+ event.stopPropagation()
2691
+ var keys = entry.spec.keys || []
2692
+ if (item) {
2693
+ settingsLive[keys[0]] = String(item.provider || '')
2694
+ settingsLive[keys[1]] = String(item.model || '')
2695
+ } else {
2696
+ settingsLive[keys[0]] = ''
2697
+ settingsLive[keys[1]] = ''
2698
+ }
2699
+ repaintModelMenu(entry)
2700
+ paintModelPick(entry)
2701
+ closeModelPicks()
2702
+ syncComposerModel()
2703
+ renderSettingsNote('')
2704
+ scheduleSettingsSave()
2705
+ })
2706
+ if (item) entry.rows.push(item)
2707
+ return row
2708
+ }
2709
+
2710
+ /** 就地重画这一行列表的勾选(不重建节点,省得丢焦点/滚动位置)。 */
2711
+ function repaintModelMenu(entry) {
2712
+ if (!entry.list) return
2713
+ var pair = pairOf(entry)
2714
+ var rows = entry.list.children
2715
+ for (var i = 0; i < rows.length; i += 1) {
2716
+ var item = i === 0 ? null : (entry.rows[i - 1] || null)
2717
+ var on = item
2718
+ ? !!pair && pair.provider === item.provider && pair.model === item.model
2719
+ : !pair
2720
+ rows[i].setAttribute('data-on', on ? '1' : '0')
2721
+ rows[i].setAttribute('aria-selected', on ? 'true' : 'false')
2722
+ var check = rows[i].firstChild
2723
+ if (check) check.textContent = on ? '✓' : ''
2724
+ }
2725
+ }
2726
+
2727
+ /**
2728
+ * 开 / 关模型下拉。
2729
+ *
2730
+ * 菜单挂在**抽屉**上而不是行里:行的祖先链上有 `overflow:auto` 的滚动区,
2731
+ * 浮层放在行里会被那个滚动区裁掉(这正是"看不到滚动条"的一种成因)。
2732
+ * 位置按按钮的实测矩形算,高度按"抽屉底部还剩多少"给 —— 于是列表一定滚得动。
2733
+ */
2734
+ function toggleModelMenu(entry) {
2735
+ if (!entry.menu) return
2736
+ var wasOpen = entry.menu.getAttribute('data-open') === '1'
2737
+ closeModelPicks()
2738
+ if (wasOpen) return
2739
+ var sheetRect = settingsSheet.getBoundingClientRect()
2740
+ var btnRect = entry.button.getBoundingClientRect()
2741
+ var left = btnRect.left - sheetRect.left
2742
+ var width = btnRect.width
2743
+ entry.menu.style.left = String(Math.max(6, left)) + 'px'
2744
+ entry.menu.style.width = String(Math.max(140, width)) + 'px'
2745
+ // 优先向下弹;下方放不下(窗口矮 / 行贴着底)就翻到按钮**上方** ——
2746
+ // 否则浮层会被面板的 overflow:hidden 切掉一截,看起来又像"没有滚动条"。
2747
+ var roomBelow = sheetRect.bottom - btnRect.bottom - 18
2748
+ if (roomBelow >= 140) {
2749
+ entry.menu.style.top = String(btnRect.bottom - sheetRect.top + 6) + 'px'
2750
+ entry.menu.style.bottom = ''
2751
+ entry.menu.style.maxHeight = String(roomBelow) + 'px'
2752
+ } else {
2753
+ var roomAbove = btnRect.top - sheetRect.top - 18
2754
+ entry.menu.style.top = ''
2755
+ entry.menu.style.bottom = String(sheetRect.bottom - btnRect.top + 6) + 'px'
2756
+ entry.menu.style.maxHeight = String(Math.max(140, roomAbove)) + 'px'
2757
+ }
2758
+ entry.menu.setAttribute('data-open', '1')
2759
+ entry.button.setAttribute('aria-expanded', 'true')
2760
+ }
2761
+
2762
+ /**
2763
+ * 模型在下拉里的显示名。
2764
+ *
2765
+ * 优先用 host 给的人类可读名(`name`),没有就退回 `provider / model` ——
2766
+ * 清单里 42 个模型跨好几个 provider,只写模型名会分不清同名项。
2767
+ */
2768
+ function modelLabel(provider, model, catalog) {
2769
+ var items = (catalog && catalog.items) || modelCatalog.items || []
2770
+ for (var i = 0; i < items.length; i += 1) {
2771
+ if (items[i].provider === provider && items[i].model === model) {
2772
+ var name = String(items[i].name || items[i].model || '')
2773
+ var pname = String(items[i].providerName || provider || '')
2774
+ return items[i].providerName && items[i].providerName !== name ? pname + ' · ' + name : name
2775
+ }
2776
+ }
2777
+ return (provider ? provider + ' / ' : '') + model
2778
+ }
2779
+
2780
+ function settingsDirtyCount() {
2781
+ var n = 0
2782
+ Object.keys(settingsLive).forEach(function (key) {
2783
+ if (settingsSaved[key] !== settingsLive[key]) n += 1
2784
+ })
2785
+ return n
2786
+ }
2787
+
2788
+ function renderSettingsNote(state) {
2789
+ var n = settingsDirtyCount()
2790
+ settingsNote.setAttribute('data-dirty', state === 'error' || n > 0 ? '1' : '0')
2791
+ if (state === 'saving') settingsNoteText.textContent = '保存中…'
2792
+ else if (state === 'error') settingsNoteText.textContent = '保存失败,改动未生效'
2793
+ else if (n > 0) settingsNoteText.textContent = '未保存 · ' + String(n) + ' 项'
2794
+ else settingsNoteText.textContent = '改动会自动保存'
2795
+ }
2796
+
2797
+ /**
2798
+ * 把服务端返回的一组值铺到**所有**控件上(保存成功、恢复默认都走它)。
2799
+ *
2800
+ * 为什么不能只按返回的键逐个 setControlValue:模型行的条目 key 是虚拟的
2801
+ * (modelChoice),而服务端返回的是它底下的 provider/model ——
2802
+ * 按后者查条目**查不到**,于是"恢复默认"之后模型下拉纹丝不动(用户报的问题)。
2803
+ * 所以这里先整体更新 settingsLive/settingsSaved,再按**条目**重画一遍。
2804
+ */
2805
+ function applyServerValues(values, sent) {
2806
+ Object.keys(values || {}).forEach(function (key) {
2807
+ settingsSaved[key] = values[key]
2808
+ if (!sent || settingsLive[key] === sent[key]) settingsLive[key] = values[key]
2809
+ })
2810
+ Object.keys(settingsByKey).forEach(function (key) {
2811
+ var entry = settingsByKey[key]
2812
+ if (!entry) return
2813
+ if (entry.spec.kind === 'model') {
2814
+ paintModelPick(entry)
2815
+ repaintModelMenu(entry)
2816
+ } else {
2817
+ setControlValue(key, settingsLive[key])
2818
+ }
2819
+ })
2820
+ updateSettingDependencies()
2821
+ syncComposerModel()
2822
+ applySelectionLimit(settingsLive)
2823
+ if (!state.modelChoice) refreshModelRow(settingsByKey.modelChoice, true)
2824
+ bridgeOn = settingsLive.bridgeSidebarPreview !== false
2825
+ if (bridgeOn) scheduleBridgeScan()
2826
+ }
2827
+
2828
+ function applySelectionLimit(values) {
2829
+ var max = Number(values && values.maxSelectionChars)
2830
+ if (isFinite(max) && max > 0) MAX_SELECTION = max
2831
+ }
2832
+
2833
+ function syncComposerModel() {
2834
+ var provider = settingsLive.provider
2835
+ var model = settingsLive.model
2836
+ state.modelChoice = provider && model ? { provider: String(provider), model: String(model) } : null
2837
+ paintModelPill()
2838
+ }
2839
+
2840
+ function chooseSharedModel(choice) {
2841
+ settingsLive.provider = choice ? String(choice.provider || '') : ''
2842
+ settingsLive.model = choice ? String(choice.model || '') : ''
2843
+ syncComposerModel()
2844
+ var entry = settingsByKey.modelChoice
2845
+ if (entry) { paintModelPick(entry); repaintModelMenu(entry) }
2846
+ loadSettings(false)
2847
+ renderSettingsNote('')
2848
+ scheduleSettingsSave()
2849
+ }
2850
+
2851
+ function updateSettingDependencies() {
2852
+ Object.keys(settingsByKey).forEach(function (key) {
2853
+ var entry = settingsByKey[key]
2854
+ var disabled = !!entry.spec.enabledBy && settingsLive[entry.spec.enabledBy] === false
2855
+ if (entry.row) entry.row.setAttribute('data-disabled', disabled ? '1' : '0')
2856
+ if (entry.input) entry.input.disabled = disabled
2857
+ })
2858
+ }
2859
+
2860
+ /** 把一个值写进控件(首渲染与「恢复默认」共用)。 */
2861
+ function setControlValue(key, value) {
2862
+ var entry = settingsByKey[key]
2863
+ if (!entry) return
2864
+ if (entry.spec.kind === 'switch') {
2865
+ entry.input.setAttribute('aria-checked', value === true ? 'true' : 'false')
2866
+ return
2867
+ }
2868
+ if (entry.spec.kind === 'seg') {
2869
+ entry.seg.setAttribute('data-value', value === undefined || value === null ? '' : String(value))
2870
+ paintSegPill(entry)
2871
+ return
2872
+ }
2873
+ if (entry.spec.kind === 'model') {
2874
+ // 按钮文字与列表勾选都跟着 settingsLive 走
2875
+ paintModelPick(entry)
2876
+ repaintModelMenu(entry)
2877
+ return
2878
+ }
2879
+ // 配置单位 → 显示单位(时间统一按秒显示,见 SettingSpec.scale)
2880
+ var shown = value
2881
+ var scale = entry.spec.scale || 1
2882
+ if (scale !== 1 && typeof value === 'number' && isFinite(value)) shown = value / scale
2883
+ entry.input.value = shown === undefined || shown === null ? '' : String(shown)
2884
+ }
2885
+
2886
+
2887
+
2888
+ /**
2889
+ * 分段控件的滑块:量当前选中格的位置再摆。
2890
+ *
2891
+ * 格子是**隐藏时量不到宽度**的(display:none 的父节点里 offsetWidth 是 0),
2892
+ * 所以切页之后要重摆一次(见 showSettingsTab)。
2893
+ */
2894
+ function paintSegPill(entry) {
2895
+ if (!entry.seg || !entry.pill) return
2896
+ var value = entry.seg.getAttribute('data-value')
2897
+ var on = null
2898
+ for (var i = 0; i < entry.cells.length; i += 1) {
2899
+ var picked = entry.cells[i].getAttribute('data-value') === value
2900
+ entry.cells[i].setAttribute('aria-checked', picked ? 'true' : 'false')
2901
+ if (picked) on = entry.cells[i]
2902
+ }
2903
+ if (!on) return
2904
+ entry.pill.style.left = String(on.offsetLeft) + 'px'
2905
+ entry.pill.style.width = String(on.offsetWidth) + 'px'
2906
+ }
2907
+
2908
+ /** 一行设置(按 spec.kind 造对应控件)。 */
2909
+ function buildSettingRow(spec) {
2910
+ var row = el('div', 'dsh-sel-srow')
2911
+ var lab = el('div', 'dsh-sel-srowlab')
2912
+ lab.appendChild(el('div', 'dsh-sel-srowt', spec.label))
2913
+ if (spec.hint) lab.appendChild(el('div', 'dsh-sel-srowd', spec.hint))
2914
+ if (spec.kind === 'model') lab.style.display = 'none'
2915
+ var ctl = el('div', 'dsh-sel-srowctl')
2916
+ row.appendChild(lab)
2917
+ row.appendChild(ctl)
2918
+
2919
+ if (spec.kind === 'model') {
2920
+ // 模型选择:一个下拉同时定 provider + model(见 settings.ts 的 keys)。
2921
+ // 直接列 host 已有的模型清单 —— 用户不必记住 provider id 与 model id 怎么拼。
2922
+ row.setAttribute('data-wide', '1')
2923
+ var pick = el('button', 'dsh-sel-spick')
2924
+ pick.type = 'button'
2925
+ pick.setAttribute('aria-label', spec.label)
2926
+ pick.setAttribute('aria-haspopup', 'listbox')
2927
+ pick.setAttribute('aria-expanded', 'false')
2928
+ var pickName = el('span', 'dsh-sel-spickname', '跟随主会话')
2929
+ pick.appendChild(pickName)
2930
+ pick.appendChild(el('span', 'dsh-sel-spickcaret', '▾'))
2931
+ // 菜单挂到**抽屉**上(不是行里):行在 overflow:auto 的滚动区里,浮层会被裁掉
2932
+ var pickMenu = el('div', 'dsh-sel-spickmenu')
2933
+ pickMenu.setAttribute('data-open', '0')
2934
+ pickMenu.setAttribute('role', 'listbox')
2935
+ pickMenu.setAttribute('aria-label', spec.label)
2936
+ var pickList = el('div', 'dsh-sel-spicklist')
2937
+ pickMenu.appendChild(el('div', 'dsh-sel-spickgroup', '模型'))
2938
+ pickMenu.appendChild(pickList)
2939
+ settingsSheet.appendChild(pickMenu)
2940
+ settingsMenus.push(pickMenu)
2941
+ var entryModel = {
2942
+ spec: spec, row: row, button: pick, label: pickName,
2943
+ list: pickList, menu: pickMenu, filled: false, catalog: null, rows: [],
2944
+ }
2945
+ listen(pick, 'click', function (event) {
2946
+ event.preventDefault()
2947
+ event.stopPropagation()
2948
+ toggleModelMenu(entryModel)
2949
+ })
2950
+ ctl.appendChild(pick)
2951
+ settingsByKey[spec.key] = entryModel
2952
+ // 拉清单 + 取本行当前值,并按**本小窗所属会话**画按钮文字。
2953
+ // 开抽屉时还会再跑一遍(见 openSettings),轮询也会持续刷新。
2954
+ refreshModelRow(entryModel, false)
2955
+ return row
2956
+ }
2957
+
2958
+ if (spec.kind === 'switch') {
2959
+ var sw = el('button', 'dsh-sel-sws')
2960
+ sw.type = 'button'
2961
+ sw.setAttribute('role', 'switch')
2962
+ sw.setAttribute('aria-label', spec.label)
2963
+ sw.setAttribute('aria-checked', 'false')
2964
+ listen(sw, 'click', function () {
2965
+ var on = sw.getAttribute('aria-checked') === 'true'
2966
+ sw.setAttribute('aria-checked', on ? 'false' : 'true')
2967
+ settingsLive[spec.key] = !on
2968
+ updateSettingDependencies()
2969
+ renderSettingsNote('')
2970
+ scheduleSettingsSave()
2971
+ })
2972
+ ctl.appendChild(sw)
2973
+ settingsByKey[spec.key] = { spec: spec, row: row, input: sw }
2974
+ return row
2975
+ }
2976
+
2977
+ if (spec.kind === 'seg') {
2978
+ var seg = el('span', 'dsh-sel-seg')
2979
+ seg.setAttribute('role', 'radiogroup')
2980
+ seg.setAttribute('aria-label', spec.label)
2981
+ seg.setAttribute('data-value', '')
2982
+ var pill = el('span', 'dsh-sel-segpill')
2983
+ seg.appendChild(pill)
2984
+ var entry = { spec: spec, row: row, seg: seg, pill: pill, cells: [] }
2985
+ var options = spec.options || []
2986
+ options.forEach(function (option) {
2987
+ var cell = el('button', 'dsh-sel-segcell', { off: '关', low: '低', high: '高', max: '最高' }[option] || option)
2988
+ cell.type = 'button'
2989
+ cell.setAttribute('role', 'radio')
2990
+ cell.setAttribute('data-value', option)
2991
+ cell.setAttribute('aria-checked', 'false')
2992
+ listen(cell, 'click', function () {
2993
+ seg.setAttribute('data-value', option)
2994
+ paintSegPill(entry)
2995
+ settingsLive[spec.key] = option
2996
+ if (spec.key === 'chatReasoningEffort') { state.effort = option; writeStore(EFFORT_KEY, option); paintModelPill() }
2997
+ renderSettingsNote('')
2998
+ scheduleSettingsSave()
2999
+ })
3000
+ seg.appendChild(cell)
3001
+ entry.cells.push(cell)
3002
+ })
3003
+ ctl.appendChild(seg)
3004
+ settingsByKey[spec.key] = entry
3005
+ settingsSegs.push(entry)
3006
+ return row
3007
+ }
3008
+
3009
+ var input = el(spec.key === 'toolNames' ? 'textarea' : 'input', spec.kind === 'number' ? 'dsh-sel-snum' : 'dsh-sel-stext')
3010
+ if (spec.key === 'toolNames') input.setAttribute('rows', '2')
3011
+ var numScale = spec.scale || 1
3012
+ if (spec.kind === 'number') {
3013
+ input.type = 'number'
3014
+ // 边界按**显示单位**给(配置里是毫秒,界面按秒),换算只在这一处做
3015
+ if (spec.min !== undefined) input.setAttribute('min', String(spec.min / numScale))
3016
+ if (spec.max !== undefined) input.setAttribute('max', String(spec.max / numScale))
3017
+ if (spec.step !== undefined) input.setAttribute('step', String(spec.step / numScale))
3018
+ } else {
3019
+ input.type = 'text'
3020
+ // 宽文本行必须让输入框独占一行,否则值会被裁掉(抽屉是嵌在小窗里的窄容器)
3021
+ row.setAttribute('data-wide', '1')
3022
+ if (spec.placeholder) input.setAttribute('placeholder', spec.placeholder)
3023
+ }
3024
+ input.setAttribute('aria-label', spec.label)
3025
+ listen(input, 'input', function () {
3026
+ if (spec.kind === 'number') {
3027
+ // 空串(用户正在清空重打)先记成 undefined,别把 NaN 送去 host
3028
+ var text = String(input.value || '').trim()
3029
+ // 显示单位 → 配置单位(秒 → 毫秒);取整避免 5.000000000001 这种浮点尾巴
3030
+ settingsLive[spec.key] = text === '' ? undefined
3031
+ : (numScale === 1 ? Number(text) : Math.round(Number(text) * numScale))
3032
+ } else {
3033
+ settingsLive[spec.key] = input.value
3034
+ }
3035
+ renderSettingsNote('')
3036
+ scheduleSettingsSave()
3037
+ })
3038
+ // 数字框失焦时把值夹回边界再回显:否则界面上留着用户敲的越界数,
3039
+ // 而真正生效的是 host 夹过的值,两边对不上(用户会以为没保存成功)
3040
+ listen(input, 'blur', function () {
3041
+ if (spec.kind !== 'number') return
3042
+ var text = String(input.value || '').trim()
3043
+ if (text === '') return
3044
+ var num = Number(text)
3045
+ if (!isFinite(num)) return
3046
+ var min = spec.min === undefined ? -Infinity : spec.min / numScale
3047
+ var max = spec.max === undefined ? Infinity : spec.max / numScale
3048
+ var fixed = Math.min(max, Math.max(min, num))
3049
+ input.value = String(fixed)
3050
+ settingsLive[spec.key] = numScale === 1 ? fixed : Math.round(fixed * numScale)
3051
+ renderSettingsNote('')
3052
+ scheduleSettingsSave()
3053
+ })
3054
+ ctl.appendChild(input)
3055
+ if (spec.unit) ctl.appendChild(el('span', 'dsh-sel-sunit', spec.unit))
3056
+ settingsByKey[spec.key] = { spec: spec, row: row, input: input }
3057
+ return row
3058
+ }
3059
+
3060
+ /** 按 spec 画整页(拿到 spec 后调用一次)。 */
3061
+ function renderSettings(spec, values) {
3062
+ settingsSpec = spec
3063
+ settingsByKey = {}
3064
+ settingsRailButtons = []
3065
+ settingsPanes = []
3066
+ settingsSegs = []
3067
+ for (var m = 0; m < settingsMenus.length; m += 1) {
3068
+ if (settingsMenus[m].parentNode) settingsMenus[m].parentNode.removeChild(settingsMenus[m])
3069
+ }
3070
+ settingsMenus = []
3071
+ clearNode(settingsRail)
3072
+ clearNode(settingsBody)
3073
+ var groups = spec.groups || []
3074
+ groups.forEach(function (group, index) {
3075
+ var railBtn = el('button', 'dsh-sel-railbtn', group.label)
3076
+ railBtn.type = 'button'
3077
+ railBtn.setAttribute('role', 'tab')
3078
+ railBtn.setAttribute('data-tab', group.id)
3079
+ railBtn.setAttribute('aria-current', index === 0 ? 'true' : 'false')
3080
+ listen(railBtn, 'click', function () { showSettingsTab(group.id) })
3081
+ settingsRail.appendChild(railBtn)
3082
+ settingsRailButtons.push({ id: group.id, node: railBtn })
3083
+
3084
+ var section = el('section', 'dsh-sel-grp')
3085
+ section.setAttribute('data-pane', group.id)
3086
+ var parts = group.sections || [{ label: group.label, keys: group.items.map(function (item) { return item.key }) }]
3087
+ parts.forEach(function (part) {
3088
+ var block = el('section', 'dsh-sel-ssection')
3089
+ block.appendChild(el('h3', 'dsh-sel-ssectionh', part.label))
3090
+ if (part.hint) block.appendChild(el('p', 'dsh-sel-ssectionhint', part.hint))
3091
+ part.keys.forEach(function (key) {
3092
+ var item = group.items.find(function (candidate) { return candidate.key === key })
3093
+ if (item) block.appendChild(buildSettingRow(item))
3094
+ })
3095
+ if (part.note) block.appendChild(el('p', 'dsh-sel-ssectionnote', part.note))
3096
+ section.appendChild(block)
3097
+ })
3098
+ settingsBody.appendChild(section)
3099
+ settingsPanes.push({ id: group.id, node: section })
3100
+ })
3101
+ Object.keys(values || {}).forEach(function (key) {
3102
+ setControlValue(key, values[key])
3103
+ })
3104
+ updateSettingDependencies()
3105
+ syncComposerModel()
3106
+ applySelectionLimit(values)
3107
+ showSettingsTab(groups.length ? groups[0].id : '')
3108
+ }
3109
+
3110
+ function showSettingsTab(id) {
3111
+ var i
3112
+ for (i = 0; i < settingsRailButtons.length; i += 1) {
3113
+ settingsRailButtons[i].node.setAttribute('aria-current', settingsRailButtons[i].id === id ? 'true' : 'false')
3114
+ }
3115
+ for (i = 0; i < settingsPanes.length; i += 1) {
3116
+ settingsPanes[i].node.style.display = settingsPanes[i].id === id ? '' : 'none'
3117
+ }
3118
+ settingsBody.scrollTop = 0
3119
+ closeModelPicks() // 切分类时把模型下拉收掉(浮层挂在抽屉上,不跟着分类走)
3120
+ // 隐藏时量不到 offsetWidth,切页后重摆一次滑块(否则滑块停在 0 宽)
3121
+ for (i = 0; i < settingsSegs.length; i += 1) {
3122
+ if (settingsSegs[i].seg.offsetParent !== null) paintSegPill(settingsSegs[i])
3123
+ }
3124
+ }
3125
+
3126
+ /** 拉一次 spec + 当前值。失败就在正文里给出说明(重开抽屉即可重试)。 */
3127
+ function loadSettings(force) {
3128
+ if (settingsFetching) return
3129
+ if (settingsSpec && !force) return
3130
+ settingsFetching = true
3131
+ fetch(SETTINGS, { headers: { accept: 'application/json' } })
3132
+ .then(function (response) { return response.json() })
3133
+ .then(function (payload) {
3134
+ settingsFetching = false
3135
+ if (!payload || payload.ok === false || !payload.groups) {
3136
+ throw new Error((payload && payload.error) || '设置读取失败')
3137
+ }
3138
+ var pending = settingsSpec ? {} : Object.assign({}, settingsLive)
3139
+ settingsSaved = Object.assign({}, payload.values || {})
3140
+ settingsLive = Object.assign({}, payload.values || {}, pending)
3141
+ renderSettings({ groups: payload.groups }, settingsLive)
3142
+ renderSettingsNote('')
3143
+ if (settingsDirtyCount() > 0) scheduleSettingsSave()
3144
+ })
3145
+ .catch(function (error) {
3146
+ settingsFetching = false
3147
+ clearNode(settingsBody)
3148
+ settingsBody.appendChild(el('div', 'dsh-sel-sempty',
3149
+ '设置读取失败:' + String((error && error.message) || error) + '(点「关闭」后重开可重试)'))
3150
+ })
3151
+ }
3152
+
3153
+ /**
3154
+ * 保存(防抖 400ms)。
3155
+ *
3156
+ * 只发改过的键:补丁越小,越不容易和"另一个窗口同时改了别的项"撞车
3157
+ * (host 侧是**合并**而不是覆盖,两者配合才不会互相抹掉)。
3158
+ */
3159
+ function scheduleSettingsSave() {
3160
+ if (settingsSaveTimer) clearTimeout(settingsSaveTimer)
3161
+ settingsSaveTimer = setTimeout(function () {
3162
+ settingsSaveTimer = 0
3163
+ flushSettingsSave()
3164
+ }, 400)
3165
+ }
3166
+
3167
+ function flushSettingsSave() {
3168
+ if (settingsSaving || settingsResetting || !settingsSpec) return settingsSaveRequest
3169
+ var patch = {}
3170
+ Object.keys(settingsLive).forEach(function (key) {
3171
+ var value = settingsLive[key]
3172
+ if (value === undefined) return
3173
+ if (settingsSaved[key] === value) return
3174
+ patch[key] = value
3175
+ })
3176
+ if (Object.keys(patch).length === 0) {
3177
+ renderSettingsNote('')
3178
+ return
3179
+ }
3180
+ var seq = ++settingsSaveSeq
3181
+ var sent = Object.assign({}, settingsLive)
3182
+ var accepted = false
3183
+ settingsSaving = true
3184
+ renderSettingsNote('saving')
3185
+ settingsSaveRequest = fetch(SETTINGS, {
3186
+ method: 'POST',
3187
+ headers: { 'content-type': 'application/json' },
3188
+ body: JSON.stringify({ values: patch }),
3189
+ })
3190
+ .then(function (response) { return response.json() })
3191
+ .then(function (payload) {
3192
+ if (seq !== settingsSaveSeq) return // 有更新的保存在路上,别用旧结果覆盖
3193
+ if (!payload || payload.ok === false) throw new Error((payload && payload.error) || '保存失败')
3194
+ // 以 host 归一后的值为准(clamp 过的数字要和界面显示一致)
3195
+ applyServerValues(payload.values, sent)
3196
+ // 胶囊偏好立刻生效(不等下次刷新)—— 设置页承诺"即时生效"
3197
+ if (Object.prototype.hasOwnProperty.call(patch, 'pillEnabled') ||
3198
+ Object.prototype.hasOwnProperty.call(patch, 'pillIdleMs')) {
3199
+ applyPillPrefs({ enabled: settingsLive.pillEnabled, idleMs: settingsLive.pillIdleMs })
3200
+ }
3201
+ if (Array.isArray(payload.rejected) && payload.rejected.length > 0) {
3202
+ // 有字段被拒:只提示、不回滚 —— 同一批里其它字段已经生效了
3203
+ settingsNote.setAttribute('data-dirty', '1')
3204
+ settingsNoteText.textContent = '有 ' + String(payload.rejected.length) + ' 项未接受'
3205
+ return
3206
+ }
3207
+ accepted = true
3208
+ renderSettingsNote('')
3209
+ })
3210
+ .catch(function () {
3211
+ if (seq !== settingsSaveSeq) return
3212
+ renderSettingsNote('error')
3213
+ })
3214
+ .finally(function () {
3215
+ settingsSaving = false
3216
+ if (accepted && !settingsResetting && settingsDirtyCount() > 0 && settingsSaveSeq === seq) scheduleSettingsSave()
3217
+ })
3218
+ return settingsSaveRequest
3219
+ }
3220
+
3221
+ /**
3222
+ * 把胶囊的两个偏好落到界面上。
3223
+ *
3224
+ * ping(启动时)与设置保存后**都走这里** —— 设置页写着"即时生效",
3225
+ * 如果只在启动时读一次,用户关掉胶囊后要刷新页面才见效,那就是假承诺。
3226
+ */
3227
+ function applyPillPrefs(prefs) {
3228
+ if (!prefs) return
3229
+ var idle = Number(prefs.idleMs)
3230
+ if (isFinite(idle) && idle >= 2000) PILL_BALL_IDLE_MS = idle
3231
+ if (prefs.enabled === false) {
3232
+ pillHidden = true
3233
+ pill.style.display = 'none'
3234
+ } else if (prefs.enabled === true) {
3235
+ pillHidden = false
3236
+ pill.style.display = ''
3237
+ placePill()
3238
+ }
3239
+ if (pillHidden) {
3240
+ if (pillIdleId) clearTimeout(pillIdleId)
3241
+ pillIdleId = 0
3242
+ } else {
3243
+ schedulePillIdle()
3244
+ }
3245
+ }
3246
+
3247
+ /** 轮询句柄(抽屉和小窗都收了必须清掉,不然会一直打接口)。 */
3248
+ var routeWatchTimer = 0
3249
+ /** 轮询间隔:够快看得出"实时",又不至于把接口打爆(这条接口只做一次会话解析,很轻)。 */
3250
+ var ROUTE_WATCH_MS = 2000
3251
+
3252
+ /**
3253
+ * 盯着"当前会话在用的模型"。
3254
+ *
3255
+ * 主会话里随时可以换模型,而插件这边**没有**可订阅的变更事件(模型选择是会话侧的),
3256
+ * 所以只能轮询那条轻量 /route:值一变就把「跟随主会话(当前:…)」和输入框那枚胶囊改掉。
3257
+ *
3258
+ * **小窗开着就要盯,不只是抽屉开着**:跟随态下输入框那枚胶囊报的就是这个值 ——
3259
+ * 以前只在抽屉开着时轮询,于是"小窗开着、主会话换了模型"要等下一次开/关设置页才更新
3260
+ * (用户报的"没有及时刷新")。抽屉和小窗都收起来即停。
3261
+ */
3262
+ function startRouteWatch() {
3263
+ stopRouteWatch()
3264
+ routeWatchTimer = setInterval(function () {
3265
+ if (!settingsOpen && !panelOpen) { stopRouteWatch(); return }
3266
+ // 选了具体模型:胶囊与括号显示的都不是"跟随"的值,不必再问
3267
+ if (state.modelChoice) return
3268
+ fetchRoute(panelSessionId || currentSessionId(), false).then(applyFollowRoute)
3269
+ }, ROUTE_WATCH_MS)
3270
+ }
3271
+
3272
+ function stopRouteWatch() {
3273
+ if (routeWatchTimer) {
3274
+ clearInterval(routeWatchTimer)
3275
+ routeWatchTimer = 0
3276
+ }
3277
+ }
3278
+
3279
+ /** 抽屉或小窗还开着就继续轮询;两个都收了才停(开着时重复调用不重启计时)。 */
3280
+ function syncRouteWatch() {
3281
+ if (!settingsOpen && !panelOpen) { stopRouteWatch(); return }
3282
+ if (routeWatchTimer) return
3283
+ startRouteWatch()
3284
+ }
3285
+
3286
+ /** 先对齐一次"跟随"的值(开窗/开抽屉那一刻,不等第一拍轮询)。 */
3287
+ function refreshFollowRoute() {
3288
+ if (state.modelChoice) return
3289
+ fetchRoute(panelSessionId || currentSessionId(), false).then(applyFollowRoute)
3290
+ }
3291
+
3292
+ function openSettings() {
3293
+ if (settingsOpen) return
3294
+ settingsOpen = true
3295
+ settingsSheet.setAttribute('data-open', '1')
3296
+ settingsScrim.setAttribute('data-open', '1')
3297
+ settingsButton.setAttribute('aria-pressed', 'true')
3298
+ loadSettings(false)
3299
+ settingsSegs.forEach(function (entry) { paintSegPill(entry) })
3300
+ // 每次开抽屉都重取一次模型(强制,绕过缓存):
3301
+ // 会话换了、或主会话中途换了模型,括号里的名字都要跟着变。
3302
+ refreshModelRow(settingsByKey.modelChoice, true)
3303
+ syncRouteWatch()
3304
+ }
3305
+
3306
+ function closeSettings() {
3307
+ if (!settingsOpen) return
3308
+ settingsOpen = false
3309
+ closeModelPicks()
3310
+ settingsSheet.setAttribute('data-open', '0')
3311
+ settingsScrim.setAttribute('data-open', '0')
3312
+ settingsButton.setAttribute('aria-pressed', 'false')
3313
+ // 关抽屉前把还没落盘的改动补发一次,别让"关掉就走"丢掉刚改的值
3314
+ if (settingsSaveTimer) {
3315
+ clearTimeout(settingsSaveTimer)
3316
+ settingsSaveTimer = 0
3317
+ flushSettingsSave()
3318
+ }
3319
+ // 小窗还开着就继续盯(跟随态下输入框那枚胶囊还指着会话的模型)
3320
+ syncRouteWatch()
3321
+ }
3322
+
3323
+ function resetSettings() {
3324
+ if (settingsResetting) return
3325
+ settingsResetting = true
3326
+ if (settingsSaveTimer) { clearTimeout(settingsSaveTimer); settingsSaveTimer = 0 }
3327
+ var sent
3328
+ renderSettingsNote('saving')
3329
+ // 等正在保存的请求结束再恢复,避免晚到的保存把默认值覆盖掉。
3330
+ Promise.resolve(settingsSaveRequest).then(function () {
3331
+ sent = Object.assign({}, settingsLive)
3332
+ return fetch(SETTINGS, {
3333
+ method: 'POST',
3334
+ headers: { 'content-type': 'application/json' },
3335
+ body: JSON.stringify({ reset: true }),
3336
+ })
3337
+ }).then(function (response) { return response.json() })
3338
+ .then(function (payload) {
3339
+ if (!payload || payload.ok === false) throw new Error('恢复默认失败')
3340
+ applyServerValues(payload.values, sent)
3341
+ state.effort = settingsLive.chatReasoningEffort || ''
3342
+ writeStore(EFFORT_KEY, state.effort)
3343
+ applyPillPrefs({ enabled: settingsLive.pillEnabled, idleMs: settingsLive.pillIdleMs })
3344
+ bridgeOn = settingsLive.bridgeSidebarPreview !== false
3345
+ if (bridgeOn) scheduleBridgeScan()
3346
+ refreshModelRow(settingsByKey.modelChoice, true)
3347
+ paintModelPill()
3348
+ renderSettingsNote('')
3349
+ })
3350
+ .catch(function () { renderSettingsNote('error') })
3351
+ .finally(function () {
3352
+ settingsResetting = false
3353
+ if (settingsDirtyCount() > 0 && settingsNoteText.textContent !== '保存失败,改动未生效') scheduleSettingsSave()
3354
+ })
3355
+ }
3356
+
2291
3357
  panel.appendChild(head)
2292
3358
  panel.appendChild(body)
2293
3359
  panel.appendChild(askRow)
2294
3360
  layer.appendChild(panel)
3361
+
2295
3362
  // 「最近聊过的」是**独立的浮层**(贴面板侧边),不和消息共用一个滚动区
2296
3363
  var historyList = el('div', 'dsh-sel-history')
2297
3364
  historyList.style.display = 'none'
@@ -2334,6 +3401,14 @@ window.__ModuleLoader__.load({
2334
3401
  }
2335
3402
 
2336
3403
  var panelOpen = false
3404
+ /**
3405
+ * **本小窗所属的会话**(划词那一刻所在的那个)。
3406
+ *
3407
+ * 不能直接用 currentSessionId():那是"当前正在看的会话",用户切走之后它立刻变了。
3408
+ * 小窗的模型、背景、升格都该按**它自己那个会话**算 ——
3409
+ * 否则切到别的会话再打开设置,括号里会报成另一个会话的模型(用户报的问题)。
3410
+ */
3411
+ var panelSessionId = ''
2337
3412
  var rafPending = false
2338
3413
  /** 流式重绘限速(毫秒):见 scheduleTurnsRender / schedulePaint。 */
2339
3414
  var STREAM_RENDER_MS = 90
@@ -2355,6 +3430,8 @@ window.__ModuleLoader__.load({
2355
3430
  * · pillAnchorWidth:本次量到的费用胶囊宽度(0 = 没量到)—— 决定"跟随"还是"兜底",
2356
3431
  * 也是"能不能收球"的前提(用户定的:只有没有费用胶囊时才收成小球)。
2357
3432
  */
3433
+ /** 胶囊总开关:host 的 pillEnabled 说关就关(见下面 ping 分支)。 */
3434
+ var pillHidden = false
2358
3435
  var pillDock = 'anchor'
2359
3436
  var pillFreeRight = null
2360
3437
  var pillBall = false
@@ -3153,7 +4230,7 @@ window.__ModuleLoader__.load({
3153
4230
  */
3154
4231
  var offBridgeMessage = listen(window, 'message', function (event) {
3155
4232
  var data = event && event.data
3156
- if (!data || data.__dshSel !== 1) return
4233
+ if (!bridgeOn || !data || data.__dshSel !== 1) return
3157
4234
  var frame = frameBySource(event.source)
3158
4235
  if (!frame) return
3159
4236
  if (data.kind === 'press') {
@@ -3234,13 +4311,34 @@ window.__ModuleLoader__.load({
3234
4311
  // —— 面板 ——
3235
4312
 
3236
4313
  /** 当前会话 id(客户端 sessions 快照;取不到就留空,host 会跳过会话背景)。 */
4314
+ /**
4315
+ * 小窗相关请求该带的会话 id。
4316
+ *
4317
+ * 用 `panelSessionId`(划词那一刻的会话)而不是 `currentSessionId()`(当前正在看的会话):
4318
+ * 小窗开着时用户可能切走,若还用后者,解读/追问/升格都会跑到**另一个会话**上 ——
4319
+ * 背景取错、模型也跟着换成另一个会话的(设置页括号里显示的也会与实际用的对不上)。
4320
+ * 还没划过词(panelSessionId 为空,比如启动时的 ping)自然落回当前会话。
4321
+ */
4322
+ function panelOrCurrentSessionId() {
4323
+ return panelSessionId || currentSessionId()
4324
+ }
4325
+
3237
4326
  function currentSessionId() {
3238
4327
  try {
4328
+ // 新版 DSH 的列表只保存目录;导航身份在 uiSession 的主视图绑定中。
4329
+ var uiSession = ctx.get('uiSession')
4330
+ var bindingSource = uiSession && uiSession.adapter && uiSession.adapter.current
4331
+ if (bindingSource && typeof bindingSource.getSnapshot === 'function') {
4332
+ var binding = bindingSource.getSnapshot()
4333
+ var boundId = binding && binding.props && binding.props.sessionId
4334
+ return typeof boundId === 'string' ? boundId : ''
4335
+ }
4336
+ // 兼容尚未提供主视图绑定的旧版 DSH。
3239
4337
  var sessions = ctx.get('sessions')
3240
4338
  var store = sessions && sessions.list
3241
4339
  var snapshot = store && typeof store.getSnapshot === 'function' ? store.getSnapshot() : null
3242
4340
  var id = snapshot && snapshot.current
3243
- return id ? String(id) : ''
4341
+ return typeof id === 'string' ? id : ''
3244
4342
  } catch (error) {
3245
4343
  return ''
3246
4344
  }
@@ -3316,13 +4414,12 @@ window.__ModuleLoader__.load({
3316
4414
  label: label,
3317
4415
  title: document.title,
3318
4416
  url: location.href,
3319
- sessionId: currentSessionId(),
4417
+ sessionId: panelOrCurrentSessionId(),
3320
4418
  kind: selectionKind(text, label),
3321
4419
  stage: stage === 'detail' ? 'detail' : 'translation',
3322
4420
  ...(refresh ? { refresh: true } : {}),
3323
- // 模型选择对整窗生效(首轮/详解也用它);effort 只影响追问档,host 侧自己忽略
4421
+ // 模型对三个阶段一致;此处不携带追问思考档位。
3324
4422
  ...(state.modelChoice ? { provider: state.modelChoice.provider, model: state.modelChoice.model } : {}),
3325
- ...(state.effort ? { effort: state.effort } : {}),
3326
4423
  }
3327
4424
  }
3328
4425
 
@@ -3372,6 +4469,8 @@ window.__ModuleLoader__.load({
3372
4469
  * 否则钩子会绕过缓存(缓存行为只能靠人工点选才能验证)。
3373
4470
  */
3374
4471
  function openPanelWith(text, context, label, anchor, keyContext) {
4472
+ // 记下"这次划词发生在哪个会话"——小窗后续所有按会话解析的东西都用它
4473
+ panelSessionId = currentSessionId()
3375
4474
  state.payload = buildPayload(text, context, label)
3376
4475
  state.kind = state.payload.kind
3377
4476
  hideButton()
@@ -3636,6 +4735,8 @@ window.__ModuleLoader__.load({
3636
4735
  * 宽度和上限三种处境都一样算(拖动不该顺手改宽度),只有位置分家。
3637
4736
  */
3638
4737
  function placePill() {
4738
+ // 关掉胶囊时不做任何定位工作:它已经 display:none,量位置纯属白费(而且会一直轮询)
4739
+ if (pillHidden) return
3639
4740
  var anchor = measurePillAnchor()
3640
4741
  pillAnchorWidth = anchor.width
3641
4742
  // 费用胶囊**出现**了:小球没有存在的意义(收球的前提就是"没有可跟随的胶囊")→ 立刻展开
@@ -4148,10 +5249,20 @@ window.__ModuleLoader__.load({
4148
5249
  panel.style.left = Math.round(Math.max(10, left)) + 'px'
4149
5250
  panel.style.top = Math.round(Math.max(10, top)) + 'px'
4150
5251
  paintPill()
5252
+ // 跟随态下输入框那枚胶囊报的是"本会话在用的模型":开窗先对齐一次,
5253
+ // 之后交给 2 秒的轮询(主会话里换了模型,这里自己就跟着变,不用等开一次设置页)。
5254
+ refreshFollowRoute()
5255
+ syncRouteWatch()
5256
+ // 胶囊上的模型名要按 provider + model 从清单里换(否则显示裸 id),顺手拉一次清单
5257
+ warmModelCatalog()
4151
5258
  }
4152
5259
 
4153
5260
  function closePanel() {
4154
5261
  closeModelMenu()
5262
+ // 小窗关掉时设置抽屉也一起收:否则下次开窗会直接停在设置页上
5263
+ // (用户上次是在关窗前改的设置,回来想看的是解读,不是设置)。
5264
+ // closeSettings 顺手把还没落盘的改动补发掉,不会"关掉就走"丢改动。
5265
+ closeSettings()
4155
5266
  panelOpen = false
4156
5267
  panel.style.display = 'none'
4157
5268
  hideHistoryList()
@@ -4166,6 +5277,8 @@ window.__ModuleLoader__.load({
4166
5277
  if (state.request) state.request.abort()
4167
5278
  state.request = null
4168
5279
  paintPill()
5280
+ // 小窗收了:抽屉若也关着就把轮询停掉(两处都不需要这个值了)
5281
+ syncRouteWatch()
4169
5282
  }
4170
5283
 
4171
5284
  /**
@@ -5163,6 +6276,8 @@ window.__ModuleLoader__.load({
5163
6276
  applyHistoryEntry(data.entry, 'history')
5164
6277
  // 先画内容再摆位置:showPanel 要量面板高度才能决定放上面还是下面
5165
6278
  if (!fromList) showPanel(anchor || rectOfPill())
6279
+ // 从列表进入时**不走 showPanel**(面板不挪位置)—— 轮询与跟随值这里自己补上
6280
+ else { refreshFollowRoute(); syncRouteWatch(); warmModelCatalog() }
5166
6281
  // 内容换了、面板高度可能变 → 列表跟着重摆(列表此时已被 applyHistoryEntry 收起,按需再开)
5167
6282
  if (historyList.style.display === 'flex') placeHistoryList()
5168
6283
  })
@@ -5704,7 +6819,7 @@ window.__ModuleLoader__.load({
5704
6819
  text: payload.text,
5705
6820
  context: payload.context,
5706
6821
  label: payload.label,
5707
- sessionId: currentSessionId(),
6822
+ sessionId: panelOrCurrentSessionId(),
5708
6823
  question: sent,
5709
6824
  history: history,
5710
6825
  ...(state.webAnswer ? { webAnswer: true } : {}),
@@ -6191,7 +7306,7 @@ window.__ModuleLoader__.load({
6191
7306
  method: 'POST',
6192
7307
  headers: { 'content-type': 'application/json' },
6193
7308
  body: JSON.stringify({
6194
- sessionId: currentSessionId(),
7309
+ sessionId: panelOrCurrentSessionId(),
6195
7310
  text: payload.text,
6196
7311
  context: payload.context,
6197
7312
  label: payload.label,
@@ -6363,7 +7478,16 @@ window.__ModuleLoader__.load({
6363
7478
  var EFFORT_KEY = 'dsh-selection-explain:effort'
6364
7479
  var TIER_LABEL = { off: '关', low: '低', medium: '中', high: '高', max: '最大' }
6365
7480
  var FALLBACK_TIERS = ['off', 'low', 'high', 'max']
6366
- var modelCatalog = { at: 0, items: [], current: null, stages: null, loading: false, error: '', leakyOff: [] }
7481
+ /**
7482
+ * 模型清单缓存。
7483
+ *
7484
+ * `pending` 是**正在飞的那次请求**:并发调用必须共享它。
7485
+ * 以前 loading=true 时直接 `Promise.resolve(modelCatalog)` 把**当前还空着的**缓存返回,
7486
+ * 于是"菜单刚打开、设置页也来要清单"这种并发场景下,后到的那个拿到 0 个模型
7487
+ * (实测:设置页的模型下拉空着,只有一行「跟随主会话」)。
7488
+ */
7489
+ var modelCatalog = { at: 0, items: [], current: null, stages: null, loading: false, pending: null, error: '', leakyOff: [], sessionId: null }
7490
+ var modelCatalogRequestSeq = 0
6367
7491
  var modelPillClicks = 0
6368
7492
 
6369
7493
  var BAD_TIER_KEY = 'dsh-selection-explain:badTiers'
@@ -6441,38 +7565,70 @@ window.__ModuleLoader__.load({
6441
7565
  /** 档位显示名:优先用目录里声明的名字(Off/Low/High/Max),否则退回中文映射。 */
6442
7566
  function tierLabel(id) {
6443
7567
  if (!id) return ''
7568
+ // 按**当前在用**那个模型声明的档位取名(跟随态也算,见 effectiveItem)
7569
+ var item = effectiveItem()
7570
+ var efforts = (item && item.efforts) || []
7571
+ for (var j = 0; j < efforts.length; j += 1) {
7572
+ if (efforts[j].id === id) return TIER_LABEL[id] || efforts[j].name || id
7573
+ }
7574
+ return TIER_LABEL[id] || id
7575
+ }
7576
+
7577
+ /**
7578
+ * 清单里按 **provider + model 两个键**找那一项。
7579
+ *
7580
+ * 两个键缺一不可:同一个 model id 在多个 provider 下是**不同模型、不同名字** ——
7581
+ * 实测 `deepseek-flash` 在 opencode-go 叫「DeepSeek Flash」、在 deepseek-official /
7582
+ * deepseek-account 叫「DeepSeek-V41-Flash」。只按 model 查会张冠李戴。
7583
+ */
7584
+ function catalogItemOf(provider, model) {
6444
7585
  var items = modelCatalog.items || []
6445
7586
  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
- }
7587
+ if (items[i].provider === provider && items[i].model === model) return items[i]
6452
7588
  }
6453
- return TIER_LABEL[id] || id
7589
+ return null
6454
7590
  }
6455
7591
 
6456
- function currentChoiceOf(item) {
6457
- if (!state.modelChoice || !item) return false
6458
- return item.provider === state.modelChoice.provider && item.model === state.modelChoice.model
7592
+ /**
7593
+ * 现在**实际在用**的那个模型:显式选过就是那个;没选过(跟随主会话)就是会话解析出来的
7594
+ * `modelCatalog.current`。
7595
+ *
7596
+ * 菜单里"哪一行打勾""档位按钮列哪几档""哪个模型关档会泄漏"都该按它算 ——
7597
+ * 这几处以前只认 `state.modelChoice`,跟随态于是**一行都不打勾**(用户报的
7598
+ * "模型列表上没有标示当前正在使用的模型")。
7599
+ */
7600
+ function effectiveChoice() {
7601
+ if (state.modelChoice) return state.modelChoice
7602
+ if (modelCatalog.current && modelCatalog.current.provider && modelCatalog.current.model) {
7603
+ return { provider: String(modelCatalog.current.provider), model: String(modelCatalog.current.model) }
7604
+ }
7605
+ return null
7606
+ }
7607
+
7608
+ /** 清单里当前在用的那一项(显式选的 / 跟随会话的);清单里没有就返回 null。 */
7609
+ function effectiveItem() {
7610
+ var choice = effectiveChoice()
7611
+ return choice ? catalogItemOf(choice.provider, choice.model) : null
6459
7612
  }
6460
7613
 
6461
7614
  /** 胶囊文案:模型短名 + 当前档位。 */
6462
7615
  function paintModelPill() {
6463
7616
  var name = ''
6464
7617
  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
- }
7618
+ // 用户选定的那个模型:按 provider + model 换成清单里的显示名
7619
+ var chosen = state.modelChoice ? catalogItemOf(state.modelChoice.provider, state.modelChoice.model) : null
7620
+ if (chosen) {
7621
+ name = chosen.name || chosen.model
7622
+ provider = chosen.providerName || chosen.provider
6471
7623
  }
6472
7624
  if (!name && state.modelChoice) name = state.modelChoice.model
7625
+ // 跟随主会话:`current` 报的是 { provider, model } 两个 id,同样要换成显示名 ——
7626
+ // 直接显示裸 id 就会是 "deepseek-flash",而主会话里那个模型叫「DeepSeek-V41-Flash」
7627
+ // (用户报的)。清单没加载出来时只能先显示 id,加载完会重画(见 warmModelCatalog)。
6473
7628
  if (!name && modelCatalog.current) {
6474
- name = String(modelCatalog.current.model || '')
6475
- provider = String(modelCatalog.current.provider || '')
7629
+ var followed = catalogItemOf(modelCatalog.current.provider, modelCatalog.current.model)
7630
+ name = (followed && (followed.name || followed.model)) || String(modelCatalog.current.model || '')
7631
+ provider = (followed && (followed.providerName || followed.provider)) || String(modelCatalog.current.provider || '')
6476
7632
  }
6477
7633
  if (!name) name = '模型'
6478
7634
  // 短名:去掉 provider 前缀那种很长的写法("DeepSeek V4.1 Flash" → 保留原样,只截断)
@@ -6480,35 +7636,73 @@ window.__ModuleLoader__.load({
6480
7636
  var tier = state.effort || (modelCatalog.stages && modelCatalog.stages.chat) || ''
6481
7637
  modelPillTier.textContent = tier ? '· ' + tierLabel(tier) : ''
6482
7638
  modelPill.title = (provider ? provider + ' · ' : '') + name + (tier ? ' · 推理等级 ' + tierLabel(tier) : '') +
6483
- '(点一下切换模型或追问档位)'
7639
+ '(模型与追问思考强度)'
6484
7640
  }
6485
7641
 
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)
7642
+ /**
7643
+ * 顺手把模型清单拉起来(60 秒缓存)。
7644
+ *
7645
+ * 胶囊上的模型名要按 provider + model 从清单里换(见 paintModelPill)—— 清单没加载时
7646
+ * 只能显示裸 id("deepseek-flash"),跟主会话里那个名字对不上。
7647
+ * 以前只有"打开模型菜单 / 开设置抽屉"才会拉清单,所以正常用下来胶囊一直是裸 id。
7648
+ */
7649
+ function warmModelCatalog() {
7650
+ loadModelCatalog(false, panelSessionId || currentSessionId()).then(function () { paintModelPill() })
7651
+ }
7652
+
7653
+ /**
7654
+ * 拉模型清单(60 秒缓存)。
7655
+ *
7656
+ * 调用点:打开模型菜单 / 开设置抽屉(强制)/ **开窗(warmModelCatalog,为了胶囊上的显示名)**。
7657
+ * 不进 ping / analyze 那种每轮都走的热路径。
7658
+ */
7659
+ function loadModelCatalog(force, sessionId) {
7660
+ // 会话换了就得重取:`current` 是**按会话**解析的,沿用上一个会话的缓存
7661
+ // 会让「跟随主会话(当前:…)」报出别的会话在用的模型。
7662
+ // sessionId 由调用方给(小窗用它自己所属的会话,不是"当前正在看"的那个)。
7663
+ var sessionNow = sessionId === undefined ? panelOrCurrentSessionId() : sessionId
7664
+ var sameSession = modelCatalog.sessionId === sessionNow
7665
+ // 只复用同一会话的请求;A 的响应不能冒充刚切到的 B。
7666
+ if (sameSession && modelCatalog.loading && modelCatalog.pending) return modelCatalog.pending
7667
+ if (sameSession && !force && modelCatalog.items.length > 0 && Date.now() - modelCatalog.at < 60000) return Promise.resolve(modelCatalog)
7668
+ var requestSeq = ++modelCatalogRequestSeq
7669
+ if (!sameSession) {
7670
+ force = true
7671
+ modelCatalog.current = null
7672
+ modelCatalog.items = []
7673
+ modelCatalog.at = 0
7674
+ }
7675
+ modelCatalog.sessionId = sessionNow
6490
7676
  modelCatalog.loading = true
6491
- return fetch(MODELS + (force ? '?refresh=1' : ''), { headers: { accept: 'application/json' } })
7677
+ // 带 sessionId:`current` 要按**当前会话**解析(否则括号里报的是全局默认,跟会话对不上)
7678
+ var modelQuery = []
7679
+ if (force) modelQuery.push('refresh=1')
7680
+ if (sessionNow) modelQuery.push('sessionId=' + encodeURIComponent(sessionNow))
7681
+ modelCatalog.pending = fetch(MODELS + (modelQuery.length ? '?' + modelQuery.join('&') : ''), { headers: { accept: 'application/json' } })
6492
7682
  .then(function (response) {
6493
7683
  return response.json()
6494
7684
  })
6495
7685
  .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
7686
+ return {
7687
+ sessionId: sessionNow,
7688
+ items: Array.isArray(data && data.models) ? data.models : [],
7689
+ current: (data && data.current) || null,
7690
+ stages: (data && data.stages) || null,
7691
+ error: String((data && data.error) || ''),
7692
+ leakyOff: Array.isArray(data && data.leakyOff) ? data.leakyOff : [],
7693
+ at: Date.now(), loading: false, pending: null,
7694
+ }
6503
7695
  })
6504
7696
  .catch(function (error) {
6505
- modelCatalog.error = String((error && error.message) || error)
6506
- return modelCatalog
7697
+ return { sessionId: sessionNow, at: 0, items: [], current: null, stages: null,
7698
+ error: String((error && error.message) || error), leakyOff: [], loading: false, pending: null }
6507
7699
  })
6508
7700
  .then(function (result) {
6509
- modelCatalog.loading = false
7701
+ // 调用者得到自己的快照;较早的请求不得覆盖较新的会话缓存。
7702
+ if (requestSeq === modelCatalogRequestSeq) modelCatalog = result
6510
7703
  return result
6511
7704
  })
7705
+ return modelCatalog.pending
6512
7706
  }
6513
7707
 
6514
7708
  function renderModelMenu() {
@@ -6526,20 +7720,31 @@ window.__ModuleLoader__.load({
6526
7720
  )
6527
7721
  } else {
6528
7722
  var list = el('div', 'dsh-sel-pickerlist')
7723
+ // 打勾的是**当前在用**那个:显式选过就是它,跟随主会话时就是会话在用的那个
7724
+ var inUse = effectiveChoice()
6529
7725
  for (var i = 0; i < items.length; i += 1) {
6530
7726
  ;(function (item) {
7727
+ var on = !!inUse && item.provider === inUse.provider && item.model === inUse.model
6531
7728
  var row = el('button', 'dsh-sel-pickerrow')
6532
7729
  row.type = 'button'
6533
7730
  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))
7731
+ row.setAttribute('data-on', on ? '1' : '0')
7732
+ row.title = (item.name || item.model) + ' · ' + (item.providerName || item.provider)
7733
+ row.appendChild(el('span', 'dsh-sel-pickercheck', on ? '✓' : ''))
7734
+ // 名字自己带省略号:一行里要塞下 勾 + 名字 + 「跟随」小标 + provider,
7735
+ // 长名字("DeepSeek V4.1 Flash")挤不下时**收窄名字**,别让整行折成两行。
7736
+ row.appendChild(el('span', 'dsh-sel-pickername', item.name || item.model))
7737
+ // 跟随态补一枚「跟随」小标:这个勾不是你选过,而是主会话正在用它 ——
7738
+ // 否则一个没点过的模型打着勾,会让人以为是自己选的。
7739
+ if (on && !state.modelChoice) {
7740
+ row.appendChild(el('span', 'dsh-sel-pickerfollow', '跟随'))
7741
+ row.title = '跟随主会话:主会话正在用这个模型 · ' + (item.providerName || item.provider)
7742
+ }
6537
7743
  row.appendChild(el('span', 'dsh-sel-pickerprov', item.providerName || item.provider))
6538
7744
  listen(row, 'click', function (event) {
6539
7745
  event.preventDefault()
6540
7746
  event.stopPropagation()
6541
- state.modelChoice = { provider: item.provider, model: item.model }
6542
- writeStore(MODEL_KEY, item.provider + '\t' + item.model)
7747
+ chooseSharedModel({ provider: item.provider, model: item.model })
6543
7748
  paintModelPill()
6544
7749
  renderModelMenu()
6545
7750
  closeModelMenu()
@@ -6554,15 +7759,17 @@ window.__ModuleLoader__.load({
6554
7759
  modelMenu.appendChild(el('div', 'dsh-sel-pickergroup', '推理等级(追问档)'))
6555
7760
  var tiers = el('div', 'dsh-sel-tiers')
6556
7761
  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
- }
7762
+ // 档位按钮列的是**当前在用那个模型**声明的档位(跟随态也照它列,否则列出来的档位
7763
+ // 和实际发出去的模型对不上)
7764
+ var inUseItem = effectiveItem()
7765
+ if (inUseItem && (inUseItem.efforts || []).length > 0) {
7766
+ effortIds = []
7767
+ for (var e = 0; e < inUseItem.efforts.length; e += 1) effortIds.push(inUseItem.efforts[e].id)
6562
7768
  }
6563
7769
  var badTiers = badTiersOf()
6564
7770
  // host 在进程内学到的:这个模型"关"档会把思考写进正文
6565
- if (modelCatalog.leakyOff.indexOf((state.modelChoice ? state.modelChoice.provider : '') + '/' + (state.modelChoice ? state.modelChoice.model : '')) >= 0) {
7771
+ var leaky = effectiveChoice()
7772
+ if (leaky && modelCatalog.leakyOff.indexOf(leaky.provider + '/' + leaky.model) >= 0) {
6566
7773
  badTiers = { ...badTiers, off: 'leak' }
6567
7774
  }
6568
7775
  var active = state.effort || (modelCatalog.stages && modelCatalog.stages.chat) || ''
@@ -6592,7 +7799,7 @@ window.__ModuleLoader__.load({
6592
7799
  }
6593
7800
  modelMenu.appendChild(tiers)
6594
7801
  modelMenu.appendChild(
6595
- el('div', 'dsh-sel-pickerhint', '模型对整窗生效;推理等级只管追问(首轮翻译仍走低档换速度)。'),
7802
+ el('div', 'dsh-sel-pickerhint', '思考强度仅影响追问。'),
6596
7803
  )
6597
7804
  }
6598
7805
 
@@ -6663,11 +7870,8 @@ window.__ModuleLoader__.load({
6663
7870
  paintWebMode()
6664
7871
 
6665
7872
  // 模型/档位:从 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
- }
7873
+ // 从 host 的统一设置恢复模型,不读取旧浏览器模型覆盖。
7874
+ loadSettings(false)
6671
7875
  var storedEffort = readStore(EFFORT_KEY, '')
6672
7876
  if (storedEffort) state.effort = storedEffort
6673
7877
  paintModelPill()
@@ -6680,8 +7884,12 @@ window.__ModuleLoader__.load({
6680
7884
  .then(function (data) {
6681
7885
  modelCatalog.current = (data && data.route) || null
6682
7886
  modelCatalog.stages = { chat: (data && data.reasoningEffortByStage && data.reasoningEffortByStage.chat) || '' }
7887
+ if (!settingsSpec && data && data.limits) applySelectionLimit(data.limits)
6683
7888
  // 侧边栏网页划词桥的总开关(host 配置 bridgeSidebarPreview,默认开)
6684
7889
  if (data && data.bridgeSidebarPreview === false) bridgeOn = false
7890
+ // 悬浮胶囊的两个偏好跟着 ping 一起下发(省得为它单开一次请求)。
7891
+ // 拿不到就保持默认(显示 + 10 秒收球)—— ping 失败不该让胶囊消失。
7892
+ if (data && data.pill) applyPillPrefs(data.pill)
6685
7893
  paintModelPill()
6686
7894
  return null
6687
7895
  })
@@ -6800,10 +8008,23 @@ window.__ModuleLoader__.load({
6800
8008
 
6801
8009
  var offKeyDown = listen(document, 'keydown', function (event) {
6802
8010
  if (event.key !== 'Escape' || !panelOpen) return
6803
- // Esc 分两级,和主会话一致:**先退出正在进行的事,再关窗**。
6804
- // ① 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着),面板留着;
6805
- // ② 其余情况 → 关掉小窗(一次到位,不受"有没有追问过"限制)。
8011
+ // Esc 分三级:**从最内层往外退**,一次只退一层(用户按一次不该连关两层)。
8012
+ // ① 设置抽屉开着 → 只收抽屉,小窗留着(用户多半只是改完想接着看解读);
8013
+ // ② 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着);
8014
+ // ③ 其余情况 → 关掉小窗(一次到位,不受"有没有追问过"限制)。
6806
8015
  // 以前是一律关窗:正在说一句话时按 Esc 想放弃这句话,结果整个小窗没了。
8016
+ if (settingsOpen) {
8017
+ event.preventDefault()
8018
+ event.stopPropagation()
8019
+ // 抽屉里还开着模型下拉时,Esc 先收下拉 —— 一次只退最内层
8020
+ var anyPickOpen = Object.keys(settingsByKey).some(function (key) {
8021
+ var entry = settingsByKey[key]
8022
+ return !!(entry && entry.menu && entry.menu.getAttribute('data-open') === '1')
8023
+ })
8024
+ if (anyPickOpen) closeModelPicks()
8025
+ else closeSettings()
8026
+ return
8027
+ }
6807
8028
  if (captureRow.getAttribute('data-show') === '1') {
6808
8029
  event.preventDefault()
6809
8030
  event.stopPropagation()
@@ -6815,6 +8036,36 @@ window.__ModuleLoader__.load({
6815
8036
  closePanel()
6816
8037
  }, true)
6817
8038
 
8039
+ var offSettingsOpen = listen(settingsButton, 'click', function (event) {
8040
+ event.stopPropagation()
8041
+ if (settingsOpen) closeSettings()
8042
+ else openSettings()
8043
+ })
8044
+ var offSettingsBack = listen(settingsBack, 'click', function (event) {
8045
+ event.stopPropagation()
8046
+ closeSettings()
8047
+ })
8048
+ var offSettingsClose = listen(settingsClose, 'click', function (event) {
8049
+ event.stopPropagation()
8050
+ closeSettings()
8051
+ })
8052
+ var offSettingsReset = listen(settingsReset, 'click', function (event) {
8053
+ event.stopPropagation()
8054
+ resetSettings()
8055
+ })
8056
+ // 抽屉里不该触发起拖面板 / 划词:整个抽屉吞掉 mousedown 的冒泡
8057
+ var offSettingsStop = listen(settingsSheet, 'mousedown', function (event) {
8058
+ event.stopPropagation()
8059
+ })
8060
+ // 点抽屉里的空白处(正文 / 分类栏)把模型下拉收掉 —— 点菜单内部不会走到这里(行自己 stopPropagation)
8061
+ var offSettingsPickAway = listen(settingsSheet, 'click', function () {
8062
+ closeModelPicks()
8063
+ })
8064
+ var offSettingsScrim = listen(settingsScrim, 'click', function (event) {
8065
+ event.stopPropagation()
8066
+ closeSettings()
8067
+ })
8068
+
6818
8069
  var offHistory = listen(historyButton, 'click', function (event) {
6819
8070
  event.stopPropagation()
6820
8071
  toggleHistoryList()
@@ -8615,6 +9866,14 @@ window.__ModuleLoader__.load({
8615
9866
  offResize()
8616
9867
  offKeyDown()
8617
9868
  offPromote()
9869
+ // 设置抽屉:摘掉它自己那几条监听(抽屉 DOM 随面板一起销毁)
9870
+ offSettingsOpen()
9871
+ offSettingsBack()
9872
+ offSettingsClose()
9873
+ offSettingsReset()
9874
+ offSettingsStop()
9875
+ offSettingsPickAway()
9876
+ offSettingsScrim()
8618
9877
  offRetry()
8619
9878
  offExpand()
8620
9879
  offAskKey()
@@ -8714,11 +9973,118 @@ window.__ModuleLoader__.load({
8714
9973
  return { root: root, content: content, hint: hint, title: titleNode }
8715
9974
  }
8716
9975
 
8717
- /** 面板头部的小图标按钮。 */
8718
- function iconButton(title, glyph) {
8719
- var node = el('button', 'dsh-sel-icon', glyph)
9976
+ /**
9977
+ * 统一的小图标画布:头部 / 工具行所有图标都从这里出。
9978
+ *
9979
+ * 规范(与既有 drawXxxIcon 一族逐条对齐,别在调用点另起一套):
9980
+ * viewBox 0 0 16 16 · 渲染 14px · fill:none · stroke:currentColor
9981
+ * · stroke-width 1.5 · linecap/linejoin round
9982
+ * 这样一排图标才有同一套笔画粗细与圆角;混用 Unicode 字形(🕘 ↗ ✕ 🎤)
9983
+ * 会因字体不同而粗细、基线、视觉重量全不一样 —— 摆一排就是"割裂"。
9984
+ */
9985
+ function iconCanvas(size) {
9986
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
9987
+ svg.setAttribute('class', 'dsh-sel-ic')
9988
+ svg.setAttribute('viewBox', '0 0 16 16')
9989
+ svg.setAttribute('width', String(size || 14))
9990
+ svg.setAttribute('height', String(size || 14))
9991
+ svg.setAttribute('fill', 'none')
9992
+ svg.setAttribute('stroke', 'currentColor')
9993
+ svg.setAttribute('stroke-width', '1.5')
9994
+ svg.setAttribute('stroke-linecap', 'round')
9995
+ svg.setAttribute('stroke-linejoin', 'round')
9996
+ svg.setAttribute('aria-hidden', 'true')
9997
+ svg.setAttribute('focusable', 'false')
9998
+ return svg
9999
+ }
10000
+
10001
+ function iconPath(svg, d) {
10002
+ var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
10003
+ path.setAttribute('d', d)
10004
+ svg.appendChild(path)
10005
+ return path
10006
+ }
10007
+
10008
+ /** 关闭(✕):两条斜线,照既有的 closeIcon 路径。 */
10009
+ function headerCloseIcon() {
10010
+ var svg = iconCanvas(14)
10011
+ iconPath(svg, 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6')
10012
+ return svg
10013
+ }
10014
+
10015
+ /** 设置:两轨两把手滑杆。
10016
+ *
10017
+ * 为什么不是齿轮:多齿版在 14px 下齿牙糊成一团,少齿版**直接渲染成空白**(实测对照过)。
10018
+ * 为什么只有两条轨:三条轨三个把手在 14px 下把手会连成一片。
10019
+ * 把手画成实心:缩小后对比度更高,仍然认得出"这是滑杆"。 */
10020
+ function headerSettingsIcon() {
10021
+ var svg = iconCanvas(14)
10022
+ iconPath(svg, 'M2.6 5.2h10.8M2.6 10.8h10.8')
10023
+ var dots = [[6.2, 5.2], [10.4, 10.8]]
10024
+ for (var i = 0; i < dots.length; i += 1) {
10025
+ var dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
10026
+ dot.setAttribute('cx', String(dots[i][0]))
10027
+ dot.setAttribute('cy', String(dots[i][1]))
10028
+ dot.setAttribute('r', '1.7')
10029
+ // 实心把手:fill 跟 currentColor,同时取消描边(否则 1.5 描边会把它胀大一圈)
10030
+ dot.setAttribute('fill', 'currentColor')
10031
+ dot.setAttribute('stroke', 'none')
10032
+ svg.appendChild(dot)
10033
+ }
10034
+ return svg
10035
+ }
10036
+
10037
+ /** 最近(时钟):表盘 + 时针分针。外圈 6.1 让墨迹在 16 画布里有 1.9 的呼吸。 */
10038
+ function headerHistoryIcon() {
10039
+ var svg = iconCanvas(14)
10040
+ var circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
10041
+ circle.setAttribute('cx', '8')
10042
+ circle.setAttribute('cy', '8')
10043
+ circle.setAttribute('r', '6.1')
10044
+ svg.appendChild(circle)
10045
+ iconPath(svg, 'M8 4.6V8l2.4 1.5')
10046
+ return svg
10047
+ }
10048
+
10049
+ /** 升格:右下角缺口的方框 + 甩向右上的箭头。 */
10050
+ function headerPromoteIcon() {
10051
+ var svg = iconCanvas(14)
10052
+ 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')
10053
+ iconPath(svg, 'M9.4 2.8h3.8v3.8')
10054
+ iconPath(svg, 'M13.2 2.8 8.2 7.8')
10055
+ return svg
10056
+ }
10057
+
10058
+ /**
10059
+ * 带文字的头部动作键(最近 / 升格 / 设置)。
10060
+ *
10061
+ * 三个键长得一样很重要:以前混着"文字 key(🕘 最近 / ↗ 升格)"和"纯图标 key(✕)",
10062
+ * 图标又都是 Unicode 字形,粗细与基线各不相同,一排看着像拼起来的。
10063
+ * 现在统一成"同一套 SVG 图标 + 同一套边框",只是有字没字的区别。
10064
+ */
10065
+ function actionButton(title, label, buildIcon) {
10066
+ var node = el('button', 'dsh-sel-action')
10067
+ node.type = 'button'
10068
+ node.title = title
10069
+ if (typeof buildIcon === 'function') node.appendChild(buildIcon())
10070
+ node.appendChild(el('span', null, label))
10071
+ return node
10072
+ }
10073
+
10074
+ /** 返回(‹):设置页头部左上的折角。 */
10075
+ function backIcon() {
10076
+ var svg = iconCanvas(14)
10077
+ iconPath(svg, 'M9.8 3.4 5.2 8l4.6 4.6')
10078
+ return svg
10079
+ }
10080
+
10081
+ /** 面板头部的小图标按钮。glyph 传图标构造函数(不再是 Unicode 字形)。 */
10082
+ function iconButton(title, buildIcon) {
10083
+ var node = el('button', 'dsh-sel-icon')
8720
10084
  node.type = 'button'
8721
10085
  node.title = title
10086
+ if (typeof buildIcon === 'function') node.appendChild(buildIcon())
10087
+ else if (buildIcon) node.textContent = buildIcon
8722
10088
  return node
8723
10089
  }
8724
10090