@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/README.md +76 -5
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1440 -74
- package/lib/index.js +366 -25
- package/lib/index.js.map +1 -1
- package/lib/settings.js +228 -0
- package/lib/settings.js.map +1 -0
- package/lib/types/index.d.ts +37 -6
- package/lib/types/settings.d.ts +125 -0
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +46 -1
- package/scripts/smoke-voice.mjs +3 -1
- package/scripts/test-bridge.mjs +4 -0
- package/scripts/test-client.mjs +643 -7
- package/scripts/test-model-follow.mjs +193 -0
- package/scripts/test-route-cache.mjs +138 -0
- package/scripts/test-settings-behavior.mjs +188 -0
- package/scripts/test-settings-host.mjs +48 -0
- package/scripts/test-settings.mjs +329 -0
- package/src/client/index.js +1440 -74
- package/src/index.ts +402 -20
- package/src/settings.ts +310 -0
package/src/client/index.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
|
-
|
|
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
|
-
|
|
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 =
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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 ?
|
|
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:
|
|
4417
|
+
sessionId: panelOrCurrentSessionId(),
|
|
3320
4418
|
kind: selectionKind(text, label),
|
|
3321
4419
|
stage: stage === 'detail' ? 'detail' : 'translation',
|
|
3322
4420
|
...(refresh ? { refresh: true } : {}),
|
|
3323
|
-
//
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7589
|
+
return null
|
|
6454
7590
|
}
|
|
6455
7591
|
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
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
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
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
|
-
|
|
6475
|
-
|
|
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
|
-
/**
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
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
|
-
|
|
6506
|
-
|
|
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
|
-
|
|
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',
|
|
6535
|
-
row.
|
|
6536
|
-
row.appendChild(el('span',
|
|
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
|
-
|
|
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
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6667
|
-
|
|
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
|
-
|
|
8719
|
-
|
|
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
|
|