@dsh-plugins/dsh-llm-hub 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -168,6 +168,10 @@ window.__ModuleLoader__.load({
168
168
  routingPrimary: '主力',
169
169
  routingNoRecommendation: '全部不可用',
170
170
  routingLoading: '路由解析中…',
171
+ routingNav: '路由',
172
+ // 只讲「这页怎么改」。原先这里还复述了一遍「按顺序取第一个可用的」,
173
+ // 而下面的规则块和模式提示已经各讲了一次 —— 同一件事在三屏之内说了三遍。
174
+ routingPageDesc: '每个组是一串按优先级排的模型:第一个是主力,后面依次兜底。点「设为主力」或直接在下拉框里换模型来调整顺序。',
171
175
  routeChipSmart: '智能',
172
176
  routeChipManual: '手动',
173
177
  routeChipModeSmart: '智能路由',
@@ -180,10 +184,18 @@ window.__ModuleLoader__.load({
180
184
  routeRuleText: '按组内顺序取第一个可用;探测失败或额度耗尽的自动跳过;全不可用则保持原样(fail-open)。顺序即优先级 —— 固定成本(包月/订阅)放前面,按量付费放后面兜底。',
181
185
  routeSmartNotice: '已接管:输入框的模型下拉只作参考,实际请求按本卡规则走(实时结果见聊天框左侧 chip)。',
182
186
  routeManualNotice: '未接管:请求还是用你在模型下拉里选的;点「智能路由」才按本卡规则自动切。',
183
- routeEditHint: '编辑后点保存写入 settings.dsh-llm-hub.routing',
187
+ routeEditHint: '改动会自动保存到 settings.dsh-llm-hub.routing',
184
188
  routeSave: '保存',
185
189
  routeSaved: '已保存',
186
190
  routeSaveFailed: '保存失败',
191
+ routeSaving: '保存中…',
192
+ routeAutoSaved: '已自动保存',
193
+ routeRetry: '重试',
194
+ routeEdit: '编辑',
195
+ routeCollapse: '收起',
196
+ routeMakePrimary: '设为主力',
197
+ routeNotePlaceholder: '备注',
198
+ routeGroupNotePlaceholder: '这组的备注(比如:公司报销额度,先用它)',
187
199
  routeAddCandidate: '+ 添加候选',
188
200
  routeAddProvider: '提供方',
189
201
  routeAddModel: '模型',
@@ -213,10 +225,6 @@ window.__ModuleLoader__.load({
213
225
  heroFastest: '最优延迟',
214
226
  heroSecurity: '凭据防护',
215
227
  heroSecSafe: '零明文脱敏中',
216
- presetsTitle: '⚡ 热门大模型一键装配',
217
- presetsSub: '点击主流服务商快速复制 YAML 配置模板与直达控制台',
218
- presetsCopied: '已复制配置片段',
219
- presetsCopyBtn: '复制 YAML',
220
228
  presetsDocsBtn: '获取 Key ↗',
221
229
  usageTitle: '本月用量',
222
230
  usageCalls: '次调用',
@@ -322,6 +330,8 @@ window.__ModuleLoader__.load({
322
330
  routingPrimary: 'primary',
323
331
  routingNoRecommendation: 'none available',
324
332
  routingLoading: 'resolving routing…',
333
+ routingNav: 'Routing',
334
+ routingPageDesc: 'Each group is a priority-ordered list: the first is primary, the rest follow as fallbacks. Use “Make primary” or swap the model in a dropdown to change the order.',
325
335
  routeChipSmart: 'Smart',
326
336
  routeChipManual: 'Manual',
327
337
  routeChipModeSmart: 'Smart routing',
@@ -334,7 +344,15 @@ window.__ModuleLoader__.load({
334
344
  routeRuleText: 'First usable candidate in group order wins; failed probes and exhausted quotas are skipped; if none is usable the original selection is kept (fail-open). Order is priority — fixed-cost plans first, metered pay-as-you-go last.',
335
345
  routeSmartNotice: 'Takeover is on: the composer model dropdown is only a reference; requests follow this card (live result shows in the chip beside the composer).',
336
346
  routeManualNotice: 'Not taking over: requests still use the model you picked in the dropdown. Switch to Smart routing to auto-select.',
337
- routeEditHint: 'Save writes settings.dsh-llm-hub.routing',
347
+ routeEditHint: 'Changes auto-save to settings.dsh-llm-hub.routing',
348
+ routeSaving: 'Saving…',
349
+ routeAutoSaved: 'Auto-saved',
350
+ routeRetry: 'Retry',
351
+ routeEdit: 'Edit',
352
+ routeCollapse: 'Collapse',
353
+ routeMakePrimary: 'Make primary',
354
+ routeNotePlaceholder: 'Note',
355
+ routeGroupNotePlaceholder: 'Group note (e.g. company quota, use first)',
338
356
  routeSave: 'Save',
339
357
  routeSaved: 'Saved',
340
358
  routeSaveFailed: 'Save failed',
@@ -367,10 +385,6 @@ window.__ModuleLoader__.load({
367
385
  heroFastest: 'Fastest ping',
368
386
  heroSecurity: 'Key security',
369
387
  heroSecSafe: 'Masked in memory',
370
- presetsTitle: '⚡ Popular LLM Quick Presets',
371
- presetsSub: 'One-click YAML config snippets and official key links',
372
- presetsCopied: 'YAML snippet copied',
373
- presetsCopyBtn: 'Copy YAML',
374
388
  presetsDocsBtn: 'Get Key ↗',
375
389
  usageTitle: 'This month',
376
390
  usageCalls: 'calls',
@@ -597,15 +611,20 @@ window.__ModuleLoader__.load({
597
611
  '.dsh-llm-hub-routing__active{color:var(--dsw-alias-label-primary)}',
598
612
  '.dsh-llm-hub-routing__rows{display:flex;flex-direction:column;gap:2px}',
599
613
  '.dsh-llm-hub-routing__row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px}',
600
- '.dsh-llm-hub-routing__role{display:inline-flex;align-items:center;height:16px;padding:0 5px;',
614
+ '.dsh-llm-hub-routing__role{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:18px;padding:0 6px;white-space:nowrap;',
601
615
  'border-radius:3px;border:.5px solid var(--dsw-alias-border-l2);',
602
- 'background:transparent;font-size:10px;color:var(--dsw-alias-label-secondary)}',
616
+ 'background:transparent;font-size:10.5px;color:var(--dsw-alias-label-secondary);transition:all .15s ease}',
617
+ // 主力:品牌色徽标,与兜底行的中性色拉开差距
618
+ '.dsh-llm-hub-routing__role.is-primary{',
619
+ 'color:var(--dsw-alias-brand-primary,#3b82f6);',
620
+ 'border-color:var(--dsw-alias-brand-primary,#3b82f6);',
621
+ 'background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.06));font-weight:600}',
603
622
  '.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
604
623
  // capability 标识:思考 / 多模态。两枚 chip 同形,左思考、右图像;
605
624
  // 边色走 state 提示色,跟健康状态 chip 区分开 —— 一个说「能干什么」,
606
625
  // 一个说「能不能用」。
607
626
  '.dsh-llm-hub-routing__reasoning,.dsh-llm-hub-routing__multimodal{',
608
- 'display:inline-flex;align-items:center;height:16px;padding:0 6px;',
627
+ 'display:inline-flex;align-items:center;justify-content:center;height:18px;min-width:20px;padding:0 3px;',
609
628
  'border-radius:3px;font-size:10px;line-height:14px;font-weight:500;',
610
629
  'background:transparent;cursor:help}',
611
630
  // 思考:走 warn 主题色(消耗更多 token、需要警觉)
@@ -651,31 +670,99 @@ window.__ModuleLoader__.load({
651
670
  'font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;line-height:1.4}',
652
671
  // 智能路由编辑器(设置页路由卡)
653
672
  '.dsh-llm-hub-routing__modes{display:flex;gap:4px;margin-left:auto}',
654
- '.dsh-llm-hub-routing__rule{display:flex;gap:6px;align-items:flex-start;font-size:10.5px;line-height:1.5;',
655
- 'color:var(--dsw-alias-label-secondary);padding:6px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2)}',
656
- '.dsh-llm-hub-routing__rule-tag{flex:none;font-weight:600;color:var(--dsw-alias-label-primary)}',
673
+ // 规则说明:去掉盒子背景,只留淡灰单行注脚,视觉权重要低于第 2 行「将路由到…」
674
+ '.dsh-llm-hub-routing__rule{display:flex;gap:6px;align-items:center;flex-wrap:wrap;font-size:10.5px;line-height:1.5;',
675
+ 'color:var(--dsw-alias-label-tertiary);padding:0}',
676
+ '.dsh-llm-hub-routing__rule-tag{flex:none;font-weight:500;color:var(--dsw-alias-label-secondary)}',
657
677
  '.dsh-llm-hub-routing__now{font-size:11px;color:var(--dsw-alias-label-primary);font-weight:600}',
658
678
  '.dsh-llm-hub-routing__groups{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
659
- '.dsh-llm-hub-routing__group{border:.5px solid var(--dsw-alias-border-l1);border-radius:6px;padding:6px 8px;display:flex;flex-direction:column;gap:5px}',
660
- '.dsh-llm-hub-routing__group.is-active{border-color:var(--dsw-alias-state-warn-primary)}',
679
+ '.dsh-llm-hub-routing__group{border:.5px solid var(--dsw-alias-border-l1);border-radius:6px;padding:6px 8px;display:flex;flex-direction:column;gap:5px;transition:border-color .15s ease,box-shadow .15s ease}',
680
+ // 当前组:边框降一档 + 顶部 brand 细线高亮,替代原本的 warn 四边描边
681
+ '.dsh-llm-hub-routing__group.is-active{border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));box-shadow:inset 0 1px 0 var(--dsw-alias-brand-primary,#3b82f6)}',
661
682
  '.dsh-llm-hub-routing__group-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
662
- '.dsh-llm-hub-routing__group-toggle{border:0;background:0 0;padding:0;cursor:pointer;font:inherit;font-size:11px;color:var(--dsw-alias-label-secondary)}',
683
+ '.dsh-llm-hub-routing__group-toggle{display:inline-flex;align-items:center;justify-content:center;height:22px;padding:0 8px;margin-left:4px;',
684
+ 'border:.5px solid var(--dsw-alias-border-l1);border-radius:4px;background:transparent;cursor:pointer;',
685
+ 'font:inherit;font-size:11px;line-height:1;color:var(--dsw-alias-label-secondary);white-space:nowrap;transition:all .15s ease}',
686
+ '.dsh-llm-hub-routing__group-toggle:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));background:var(--dsw-alias-bg-layer-2)}',
663
687
  '.dsh-llm-hub-routing__group-order{display:flex;align-items:center;gap:2px;flex:none}',
664
- '.dsh-llm-hub-routing__group-move{border:0;background:0 0;padding:0 2px;cursor:pointer;font:inherit;font-size:10px;',
665
- 'color:var(--dsw-alias-label-tertiary);line-height:1}',
666
- '.dsh-llm-hub-routing__group-move:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
688
+ '.dsh-llm-hub-routing__group-move{display:inline-flex;align-items:center;justify-content:center;width:18px;height:22px;',
689
+ 'border:0;background:transparent;padding:0;cursor:pointer;font:inherit;font-size:12px;line-height:1;',
690
+ 'color:var(--dsw-alias-label-tertiary);border-radius:3px;transition:all .15s ease}',
691
+ '.dsh-llm-hub-routing__group-move:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}',
667
692
  '.dsh-llm-hub-routing__group-move:disabled{opacity:.25;cursor:default}',
668
- '.dsh-llm-hub-routing__group-state b{font-weight:600;color:var(--dsw-alias-label-primary)}',
669
- '.dsh-llm-hub-routing__group-more{color:var(--dsw-alias-label-tertiary)}',
670
- '.dsh-llm-hub-routing__group-would{color:var(--dsw-alias-state-warn-primary);margin-left:5px}',
671
693
  '.dsh-llm-hub-routing__group-empty{font-style:normal;color:var(--dsw-alias-label-tertiary)}',
672
- '.dsh-llm-hub-routing__group-label{flex:0 0 128px;width:128px;box-sizing:border-box;height:22px;padding:0 6px;border-radius:4px;',
673
- 'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px}',
674
- '.dsh-llm-hub-routing__group-state{flex:1;min-width:0;font-size:10.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
694
+ // 折叠行(1.6.2):组名 + 「将路由到 X」+ 主力 + 兜底条数,一行读完。
695
+ // 组名给固定宽度,与下面展开态的 label 输入框同宽 —— 收起/展开时这一列不跳。
696
+ '.dsh-llm-hub-routing__group-name{flex:0 0 112px;min-width:0;overflow:hidden;text-overflow:ellipsis;',
697
+ 'white-space:nowrap;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-primary)}',
698
+ '.dsh-llm-hub-routing__group-label{flex:0 0 112px;width:112px;box-sizing:border-box;height:22px;padding:0 8px;border-radius:5px;',
699
+ 'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);',
700
+ 'font:inherit;font-size:11px;outline:none;transition:border-color .15s ease}',
701
+ '.dsh-llm-hub-routing__group-label:focus{border-color:var(--dsw-alias-brand-primary,#3b82f6)}',
702
+ // 允许换行,不允许截断(1.6.2 浏览器实测后改的)。设置内容区只有 564px,
703
+ // 扣掉序号/组名/两个按钮,摘要只剩 ~210px;而真实数据的 provider/model
704
+ // 要 200-215px —— 差的这几十像素,省不掉,只能换行。之前用 nowrap+ellipsis
705
+ // 把「→ modelgo-gateway/claude-sonnet-5」砍成「→ modelgo-gatewa…」,
706
+ // 砍掉的正好是判断路由结果最需要的那段:看不出是 sonnet-5 还是别的。
707
+ // 组头因此高一行,是「读得全」该付的代价。
708
+ '.dsh-llm-hub-routing__group-state{flex:1;min-width:0;font-size:10.5px;line-height:1.5;',
709
+ 'color:var(--dsw-alias-label-secondary);display:flex;flex-wrap:wrap;align-items:center;gap:2px 8px}',
710
+ // 断行点放在 provider 与 model 之间的斜杠上:读起来是两条完整信息,
711
+ // 而不是把一个词从中间劈开。break-all 是兜底 —— provider id 里也有长词。
712
+ '.dsh-llm-hub-routing__group-would{font-weight:600;color:var(--dsw-alias-label-primary);',
713
+ 'min-width:0;word-break:break-word;overflow-wrap:anywhere}',
714
+ // 可用/总:紧跟在「将路由到」后面,且**不参与压缩** —— 压它等于把
715
+ // 「还剩几个能兜」这个答案藏掉,而它只要 30px。
716
+ '.dsh-llm-hub-routing__group-count{flex:none;display:inline-flex;align-items:center;gap:3px;',
717
+ 'color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
718
+ '.dsh-llm-hub-routing__group-count .dsh-llm-hub-routing__dot{margin:0}',
675
719
  '.dsh-llm-hub-routing__candidates{display:flex;flex-direction:column;gap:3px;padding-top:2px}',
720
+ // 1.7.0 路由编辑器:序号徽标、编辑按钮、原地下拉、备注、放大的操作按钮、自动保存状态
721
+ '.dsh-llm-hub-routing__group-index{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:22px;border-radius:4px;',
722
+ 'padding:0 6px;font-size:10.5px;font-weight:600;background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.08));color:var(--dsw-alias-label-secondary)}',
723
+ // 全组共用一张网格:行与 pick 都是 display:contents,列宽由整组一起决定 ——
724
+ // 各行自成网格时,右侧标签列按本行内容伸缩,模型下拉的右边缘每行都不一样(浏览器实测 908 vs 845px)。
725
+ '.dsh-llm-hub-routing-page{display:flex;flex-direction:column;gap:6px}',
726
+ '.dsh-llm-hub-routing-page__head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
727
+ '.dsh-llm-hub-routing__group-delete{margin-left:4px;white-space:nowrap}',
728
+ '.dsh-llm-hub-routing-page__title{margin:0;font-size:18px;font-weight:600;color:var(--dsw-alias-label-primary)}',
729
+ '.dsh-llm-hub-routing-page__desc{margin:0 0 8px;font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary)}',
730
+ '.dsh-llm-hub-routing__candidates{display:grid;grid-template-columns:40px minmax(0,1fr) minmax(0,1.3fr);gap:4px 6px;align-items:center}',
731
+ '.dsh-llm-hub-routing__candidates>:not(.dsh-llm-hub-routing__row){grid-column:1/-1}',
732
+ '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__row,.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__pick{display:contents}',
733
+ '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__role{grid-column:1;justify-self:start;margin-top:6px}',
734
+ '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__pick .dsh-llm-hub-routing__select{width:100%;min-width:0;max-width:none;margin-top:6px}',
735
+ '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__name{grid-column:2/4;min-width:0;margin-top:6px}',
736
+ '.dsh-llm-hub-routing__line2{grid-column:2/-1;display:flex;align-items:center;gap:6px;min-width:0}',
737
+ // 标签区给固定宽度并靠右:有无「思考/图像」标签的行,备注框都一样宽,右边缘对齐。
738
+ '.dsh-llm-hub-routing__meta{display:inline-flex;align-items:center;justify-content:flex-end;gap:4px;white-space:nowrap;flex:none;width:78px}',
739
+ '.dsh-llm-hub-routing__dot{display:inline-block;flex:none;width:7px;height:7px;border-radius:50%;margin:0 2px;cursor:help;background:var(--dsw-alias-label-tertiary)}',
740
+ '.dsh-llm-hub-routing__dot--available{background:var(--dsw-alias-state-success-primary)}',
741
+ '.dsh-llm-hub-routing__dot--unavailable{background:var(--dsw-alias-state-error-primary)}',
742
+ // 半可用单独一色:全绿和全红之间还有「还剩几个」这个状态,
743
+ // 混成全绿会让人以为不用管,混成全红会误报故障。
744
+ '.dsh-llm-hub-routing__dot--degraded{background:var(--dsw-alias-state-warn-primary)}',
745
+ '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__row .dsh-llm-hub-routing__note{flex:1 1 auto;width:auto;max-width:none;min-width:60px}',
746
+ // 同理固定宽度:主力行没有「设为主力」,按钮区只有 77px(其余 141px),备注右边缘会错开 64px。
747
+ '.dsh-llm-hub-routing__ops{display:inline-flex;justify-content:flex-end;gap:4px;white-space:nowrap;flex:none;min-width:141px}',
748
+ '.dsh-llm-hub-routing__note{box-sizing:border-box;height:22px;padding:0 8px;border-radius:4px;',
749
+ 'border:.5px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);',
750
+ 'font:inherit;font-size:10.5px;flex:1 1 120px;min-width:90px;max-width:260px;outline:none;transition:border-color .15s ease}',
751
+ '.dsh-llm-hub-routing__note:focus{border-color:var(--dsw-alias-brand-primary,#3b82f6)}',
752
+ '.dsh-llm-hub-routing__note::placeholder{color:var(--dsw-alias-label-tertiary)}',
753
+ // 候选区是纵向 flex:note 的 flex-basis 120px 在这里会变成「高 120px」,组备注必须脱离 flex 伸缩。
754
+ '.dsh-llm-hub-routing__note--group{display:block;flex:none;width:100%;max-width:none;margin:2px 0 6px}',
755
+ '.dsh-llm-hub-routing__op{border:.5px solid var(--dsw-alias-border-l1);background:transparent;border-radius:4px;min-width:22px;height:22px;padding:0 6px;cursor:pointer;font:inherit;font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
756
+ '.dsh-llm-hub-routing__op:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))}',
757
+ '.dsh-llm-hub-routing__op:disabled{opacity:.3;cursor:default}',
758
+ '.dsh-llm-hub-routing__op--danger:hover:not(:disabled){color:var(--dsw-alias-state-error-primary)}',
759
+ '.dsh-llm-hub-routing__status{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}',
760
+ '.dsh-llm-hub-routing__status.is-fail{color:var(--dsw-alias-state-error-primary)}',
676
761
  '.dsh-llm-hub-routing__add{display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:10.5px;color:var(--dsw-alias-label-secondary);padding-top:4px}',
677
- '.dsh-llm-hub-routing__select{box-sizing:border-box;height:22px;padding:0 4px;border-radius:4px;border:.5px solid var(--dsw-alias-border-l1);',
678
- 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;max-width:170px}',
762
+ '.dsh-llm-hub-routing__select{box-sizing:border-box;height:22px;padding:0 6px;border-radius:4px;border:.5px solid var(--dsw-alias-border-l1);',
763
+ 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;max-width:170px;outline:none;',
764
+ 'transition:border-color .15s ease;cursor:pointer}',
765
+ '.dsh-llm-hub-routing__select:focus{border-color:var(--dsw-alias-brand-primary,#3b82f6)}',
679
766
  '.dsh-llm-hub-routing__actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-l1);margin-top:8px}',
680
767
  '.dsh-llm-hub-routing__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
681
768
  '.dsh-llm-hub-routing__mm{display:inline-flex;align-items:center;margin-left:4px;font-size:10px;cursor:help;',
@@ -685,17 +772,12 @@ window.__ModuleLoader__.load({
685
772
  '.dsh-llm-hub-hero__card{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.02));backdrop-filter:blur(6px)}',
686
773
  '.dsh-llm-hub-hero__num{font-size:15px;font-weight:700;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:-0.3px;line-height:1.2}',
687
774
  '.dsh-llm-hub-hero__label{font-size:10.5px;color:var(--dsw-alias-label-secondary);opacity:.85}',
688
- // 热门大模型一键装配 Hub
689
- '.dsh-llm-hub-presets{display:flex;flex-direction:column;gap:6px;margin-top:10px;padding:8px 10px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:transparent}',
690
- '.dsh-llm-hub-presets__head{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;user-select:none}',
691
- '.dsh-llm-hub-presets__title{color:var(--dsw-alias-label-primary);font-weight:600;font-size:12px;display:inline-flex;align-items:center;gap:5px}',
692
- '.dsh-llm-hub-presets__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px;margin-top:6px}',
693
- '.dsh-llm-hub-presets__item{display:flex;flex-direction:column;gap:3px;padding:6px 8px;border-radius:5px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.02));cursor:pointer;transition:all .15s ease}',
694
- '.dsh-llm-hub-presets__item:hover{border-color:var(--dsw-alias-brand-primary,#3b82f6);transform:translateY(-1px);background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.05))}',
695
- '.dsh-llm-hub-presets__name{font-weight:600;font-size:11.5px;color:var(--dsw-alias-label-primary)}',
696
- '.dsh-llm-hub-presets__desc{font-size:10px;color:var(--dsw-alias-label-secondary);opacity:.75;line-height:1.3}',
697
- '.dsh-llm-hub-presets__detail{margin-top:6px;padding:8px;border-radius:4px;background:var(--dsw-alias-bg-layer-3,rgba(0,0,0,.2));border:.5px solid var(--dsw-alias-border-l1);font-size:11px}',
698
- '.dsh-llm-hub-presets__code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10px;white-space:pre-wrap;color:var(--dsw-alias-label-secondary);margin:4px 0}',
775
+ // 添加提供方草稿卡上的匹配提示(1.6.0)
776
+ '.dsh-llm-hub-preset-hint{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;margin:0 0 8px;padding:6px 10px;border-radius:6px;',
777
+ 'border:.5px solid var(--dsw-alias-border-l1);font-size:11px;line-height:1.5}',
778
+ '.dsh-llm-hub-preset-hint__name{font-weight:600;color:var(--dsw-alias-label-primary)}',
779
+ '.dsh-llm-hub-preset-hint__desc{color:var(--dsw-alias-label-secondary);flex:1 1 auto;min-width:0}',
780
+ '.dsh-llm-hub-preset-hint__link{color:var(--dsw-alias-brand-primary,#3b82f6);text-decoration:none;white-space:nowrap}',
699
781
  // 延迟高亮胶囊
700
782
  '.dsh-llm-hub-latency-badge{display:inline-flex;align-items:center;gap:3.5px;padding:1px 5px;border-radius:3px;font-size:10.5px;font-variant-numeric:tabular-nums;line-height:1}',
701
783
  '.dsh-llm-hub-latency-badge.is-fast{background:rgba(16,185,129,.08);color:var(--dsw-alias-state-success-primary,#10b981);border:.5px solid rgba(16,185,129,.2)}',
@@ -1543,45 +1625,45 @@ window.__ModuleLoader__.load({
1543
1625
  const PRESETS = [
1544
1626
  {
1545
1627
  id: 'deepseek',
1628
+ match: ['deepseek', 'llm-deepseek'],
1546
1629
  name: '🇨🇳 DeepSeek 官方',
1547
1630
  desc: 'V4.1 Flash / V4 Pro 极速官方直连',
1548
- docUrl: 'https://platform.deepseek.com/api_keys',
1549
- yaml: 'llm-deepseek:\n keyPool:\n llm-deepseek:\n - name: "主账号"\n env: DEEPSEEK_API_KEY\n models:\n - id: deepseek-flash\n name: DeepSeek-V4.1-Flash\n - id: deepseek-v4-pro\n name: DeepSeek-V4-Pro'
1631
+ docUrl: 'https://platform.deepseek.com/api_keys'
1550
1632
  },
1551
1633
  {
1552
1634
  id: 'minimax',
1635
+ match: ['minimax', 'minimax-cn'],
1553
1636
  name: '⚡ MiniMax',
1554
1637
  desc: 'MiniMax-M3 百万上下文超强大模型',
1555
- docUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
1556
- yaml: 'minimax:\n displayName: MiniMax\n api: openai-completions\n baseURL: https://api.minimaxi.com/v1\n apiKeyEnv: MINIMAX_API_KEY\n models:\n - id: MiniMax-M3\n name: MiniMax-M3\n - id: MiniMax-M2.7\n name: MiniMax-M2.7'
1638
+ docUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key'
1557
1639
  },
1558
1640
  {
1559
1641
  id: 'zhipu',
1642
+ match: ['zai-coding-cn', 'zai', 'zhipu'],
1560
1643
  name: '🧠 智谱清言 GLM',
1561
1644
  desc: 'GLM-5 / GLM-4.7 国内 Coding 利器',
1562
- docUrl: 'https://open.bigmodel.cn/usercenter/apikeys',
1563
- yaml: 'zai-coding-cn:\n displayName: 智谱 GLM\n api: openai-completions\n baseURL: https://open.bigmodel.cn/api/paas/v4\n apiKeyEnv: ZHIPU_API_KEY\n models:\n - id: glm-5-turbo\n - id: glm-4.7'
1645
+ docUrl: 'https://open.bigmodel.cn/usercenter/apikeys'
1564
1646
  },
1565
1647
  {
1566
1648
  id: 'volc',
1649
+ match: ['volcengine', 'volc', 'ark'],
1567
1650
  name: '🌋 火山方舟 Ark',
1568
1651
  desc: '字节豆包 Doubao-1.5 多模态矩阵',
1569
- docUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey',
1570
- yaml: 'volcengine:\n displayName: 火山方舟\n api: openai-completions\n baseURL: https://ark.cn-beijing.volces.com/api/v3\n apiKeyEnv: ARK_API_KEY\n models:\n - id: doubao-1.5-pro-32k\n - id: doubao-1.5-lite-32k'
1652
+ docUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey'
1571
1653
  },
1572
1654
  {
1573
1655
  id: 'siliconflow',
1656
+ match: ['siliconflow'],
1574
1657
  name: '🚀 硅基流动',
1575
1658
  desc: 'SiliconFlow 聚合开源高性价比模型',
1576
- docUrl: 'https://cloud.siliconflow.cn/account/ak',
1577
- yaml: 'siliconflow:\n displayName: 硅基流动\n api: openai-completions\n baseURL: https://api.siliconflow.cn/v1\n apiKeyEnv: SILICONFLOW_API_KEY\n models:\n - id: deepseek-ai/DeepSeek-V3\n - id: Qwen/Qwen2.5-72B-Instruct'
1659
+ docUrl: 'https://cloud.siliconflow.cn/account/ak'
1578
1660
  },
1579
1661
  {
1580
1662
  id: 'openrouter',
1663
+ match: ['openrouter'],
1581
1664
  name: '🌐 OpenRouter',
1582
1665
  desc: '聚合全球 Claude / GPT / Gemini 全模型',
1583
- docUrl: 'https://openrouter.ai/keys',
1584
- yaml: 'openrouter:\n displayName: OpenRouter\n api: openai-completions\n baseURL: https://openrouter.ai/api/v1\n apiKeyEnv: OPENROUTER_API_KEY\n models:\n - id: anthropic/claude-3.7-sonnet\n - id: deepseek/deepseek-r1'
1666
+ docUrl: 'https://openrouter.ai/keys'
1585
1667
  }
1586
1668
  ]
1587
1669
 
@@ -1621,58 +1703,52 @@ window.__ModuleLoader__.load({
1621
1703
  }
1622
1704
 
1623
1705
  /**
1624
- * 热门大模型一键装配抽屉
1706
+ * 添加提供方时的匹配提示(1.6.0 起取代页脚的「热门大模型一键装配」抽屉)。
1707
+ *
1708
+ * 为什么改:抽屉常驻在模型页底部,和用户正在配哪一家毫无关系,读起来像一块广告。
1709
+ * 该出现的时机是「用户在添加提供方里选中了某一家」—— 宿主此时渲染草稿卡,并以
1710
+ * `configured: false` 派发 settings.models.provider-card(见宿主 slot-contract.d.ts)。
1711
+ * 于是只在这张卡上、只给匹配到的那一家一行:名称 · 推荐模型 · 获取 Key。
1712
+ * 不再给 YAML:草稿卡本身就是配置表单,而模板根键(minimax)与目录 id(minimax-cn)
1713
+ * 对不上,复制过去反而误导。没匹配到的厂商不渲染任何东西。
1714
+ *
1715
+ * @param {unknown} provider - 宿主传入的 entry 对象或 provider id 字符串。
1716
+ * @returns {object | undefined} 命中的预设。
1625
1717
  */
1626
- function QuickPresetsCard(props) {
1627
- const t = typeof props.t === 'function' ? props.t : fallbackT
1628
- const [expanded, setExpanded] = React.useState(false)
1629
- const [selected, setSelected] = React.useState(null)
1630
- const [copied, setCopied] = React.useState(false)
1718
+ const presetFor = (provider) => {
1719
+ const id = typeof provider === 'string' ? provider : (provider && typeof provider.provider === 'string' ? provider.provider : '')
1720
+ return id === '' ? undefined : PRESETS.find((preset) => preset.match.includes(id))
1721
+ }
1631
1722
 
1723
+ function PresetHint(props) {
1724
+ const t = typeof props.t === 'function' ? props.t : fallbackT
1725
+ const preset = presetFor(props.provider)
1726
+ if (preset === undefined) return null
1632
1727
  ensureStyle()
1728
+ return h('div', { className: 'dsh-llm-hub-preset-hint' },
1729
+ h('span', { className: 'dsh-llm-hub-preset-hint__name' }, preset.name),
1730
+ h('span', { className: 'dsh-llm-hub-preset-hint__desc' }, preset.desc),
1731
+ h('a', {
1732
+ href: preset.docUrl,
1733
+ target: '_blank',
1734
+ rel: 'noreferrer',
1735
+ className: 'dsh-llm-hub-preset-hint__link'
1736
+ }, t('presetsDocsBtn')))
1737
+ }
1633
1738
 
1634
- const copyYaml = (e, yaml) => {
1635
- e.stopPropagation()
1636
- navigator.clipboard?.writeText(yaml).then(() => {
1637
- setCopied(true)
1638
- setTimeout(() => setCopied(false), 2000)
1639
- }).catch(() => {})
1640
- }
1641
-
1642
- return h('div', { className: 'dsh-llm-hub-presets' },
1643
- h('div', {
1644
- className: 'dsh-llm-hub-presets__head',
1645
- onClick: () => setExpanded((prev) => !prev)
1646
- },
1647
- h('span', { className: 'dsh-llm-hub-presets__title' },
1648
- t('presetsTitle'),
1649
- h('span', { style: { fontSize: '10px', opacity: 0.6 } }, expanded ? ' ▲ 收起' : ' ▼ 展开模版')),
1650
- h('span', { className: 'dsh-llm-hub-subtext' }, t('presetsSub'))),
1651
- expanded ? h('div', { className: 'dsh-llm-hub-presets__grid' },
1652
- PRESETS.map((p) => h('div', {
1653
- key: p.id,
1654
- className: 'dsh-llm-hub-presets__item',
1655
- onClick: () => setSelected(selected?.id === p.id ? null : p)
1656
- },
1657
- h('span', { className: 'dsh-llm-hub-presets__name' }, p.name),
1658
- h('span', { className: 'dsh-llm-hub-presets__desc' }, p.desc)))) : null,
1659
- expanded && selected !== null ? h('div', { className: 'dsh-llm-hub-presets__detail' },
1660
- h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1661
- h('span', { style: { fontWeight: '600' } }, `${selected.name} 配置模版`),
1662
- h('div', { style: { display: 'flex', gap: '6px' } },
1663
- h('button', {
1664
- type: 'button',
1665
- className: 'dsh-llm-hub-btn',
1666
- onClick: (e) => copyYaml(e, selected.yaml)
1667
- }, copied ? t('presetsCopied') : t('presetsCopyBtn')),
1668
- h('a', {
1669
- href: selected.docUrl,
1670
- target: '_blank',
1671
- rel: 'noreferrer',
1672
- className: 'dsh-llm-hub-btn',
1673
- style: { textDecoration: 'none' }
1674
- }, t('presetsDocsBtn')))),
1675
- h('pre', { className: 'dsh-llm-hub-presets__code' }, selected.yaml)) : null)
1739
+ /**
1740
+ * 给 provider-card 组件套上草稿态行为。直接调用原组件(而不是 h(Card)),
1741
+ * 保证原组件的 hooks 挂在同一个组件上、调用顺序不变 —— 草稿保存后 configured
1742
+ * 翻成 true,同一个组件实例继续渲染,hook 数量不能变。
1743
+ *
1744
+ * 草稿态(configured === false)只给匹配提示、丢掉原卡输出:原卡此时去查一个
1745
+ * 还没写进配置的 provider,必然渲染红字「llm-pi-ai 段里没有 "xxx" 这个 provider」,
1746
+ * 用户只是选了一下下拉框,看到的却像报错(2026-09-28 浏览器实测)。
1747
+ */
1748
+ const withPresetHint = (Card) => (props) => {
1749
+ const card = Card(props)
1750
+ if (props.configured !== false) return card
1751
+ return PresetHint(props)
1676
1752
  }
1677
1753
 
1678
1754
  /**
@@ -1694,6 +1770,8 @@ window.__ModuleLoader__.load({
1694
1770
  const [state, setState] = React.useState(null)
1695
1771
  const [open, setOpen] = React.useState(false)
1696
1772
  const [busy, setBusy] = React.useState(false)
1773
+ // 浮层根节点:判断「这次点击算不算点在浮层外面」要用它。
1774
+ const rootRef = React.useRef(null)
1697
1775
  const query = React.useCallback(() => {
1698
1776
  fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
1699
1777
  .then((res) => res.json())
@@ -1705,6 +1783,25 @@ window.__ModuleLoader__.load({
1705
1783
  const interval = setInterval(query, 30000)
1706
1784
  return () => clearInterval(interval)
1707
1785
  }, [query])
1786
+ // 点浮层外面 / 按 Esc 收起来。少了这条,用户在浮层里做完选择后
1787
+ // 手会先伸向输入框 —— 浮层却还盖在上面,不点第二次 chip 就挡着视线。
1788
+ // 只在 open 时挂监听:关着的时候不留全局 handler。
1789
+ // 必须写在下面那个 `state === null` 早返回之前 —— hooks 不能条件调用。
1790
+ React.useEffect(() => {
1791
+ if (!open) return undefined
1792
+ const onPointerDown = (event) => {
1793
+ const root = rootRef.current
1794
+ if (root !== null && root !== undefined && typeof root.contains === 'function' && root.contains(event.target)) return
1795
+ setOpen(false)
1796
+ }
1797
+ const onKeyDown = (event) => { if (event.key === 'Escape') setOpen(false) }
1798
+ document.addEventListener('mousedown', onPointerDown)
1799
+ document.addEventListener('keydown', onKeyDown)
1800
+ return () => {
1801
+ document.removeEventListener('mousedown', onPointerDown)
1802
+ document.removeEventListener('keydown', onKeyDown)
1803
+ }
1804
+ }, [open])
1708
1805
  // 一个组都没有:chip 无事可做,不渲染(配置在 settings.yaml 里加)。
1709
1806
  if (state === null || !Array.isArray(state.groups) || state.groups.length === 0) return null
1710
1807
  ensureStyle()
@@ -1727,7 +1824,10 @@ window.__ModuleLoader__.load({
1727
1824
  body: JSON.stringify(patch)
1728
1825
  })
1729
1826
  .then((res) => res.json())
1730
- .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) setState(body) })
1827
+ // 选完即收:浮层是「选一个值」的一次性动作,不是常驻面板。
1828
+ // 留着不关,用户会以为没点上,只能再点一次 chip 才收得回去。
1829
+ // 写失败时**不关** —— 状态没变,收起来等于把失败藏了。
1830
+ .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) { setState(body); setOpen(false) } })
1731
1831
  .catch(() => {})
1732
1832
  .finally(() => { setBusy(false); query() })
1733
1833
  }
@@ -1743,12 +1843,14 @@ window.__ModuleLoader__.load({
1743
1843
  h('button', {
1744
1844
  type: 'button',
1745
1845
  className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
1746
- onClick: () => { if (!isSmart) select({ mode: 'smart' }) }
1846
+ // 点当前已生效的那个模式:没有请求要发,但**照样收起浮层**。
1847
+ // 否则「点了完全没反应」,用户会当成界面卡住反复点。
1848
+ onClick: () => { if (isSmart) setOpen(false); else select({ mode: 'smart' }) }
1747
1849
  }, `⚡ ${t('routeChipModeSmart')}`),
1748
1850
  h('button', {
1749
1851
  type: 'button',
1750
1852
  className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
1751
- onClick: () => { if (isSmart) select({ mode: 'manual' }) }
1853
+ onClick: () => { if (!isSmart) setOpen(false); else select({ mode: 'manual' }) }
1752
1854
  }, t('routeChipModeManual'))),
1753
1855
  ...state.groups.map((group) => h('button', {
1754
1856
  key: group.id,
@@ -1771,7 +1873,7 @@ window.__ModuleLoader__.load({
1771
1873
  ? h('span', { className: 'dsh-llm-hub-route__hint' }, group.reason)
1772
1874
  : null)),
1773
1875
  h('div', { className: 'dsh-llm-hub-route__hint' }, t('routeRuleText'))) : null
1774
- return h('div', { className: 'dsh-llm-hub-route' },
1876
+ return h('div', { className: 'dsh-llm-hub-route', ref: rootRef },
1775
1877
  h('button', {
1776
1878
  type: 'button',
1777
1879
  className: chipClass,
@@ -1812,22 +1914,29 @@ window.__ModuleLoader__.load({
1812
1914
  // candidates[0] = 主力(primary),其余是 fallback —— 与 settings 形态一一对应。
1813
1915
  const [draft, setDraft] = React.useState(null)
1814
1916
  const [activeGroup, setActiveGroup] = React.useState('')
1815
- const [expanded, setExpanded] = React.useState('')
1917
+ // null = 还没手动开合过,此时默认展开当前组 —— 一进来就能看到、能改。
1918
+ const [expanded, setExpanded] = React.useState(null)
1816
1919
  const [picker, setPicker] = React.useState({ provider: '', model: '' })
1817
1920
  const [busy, setBusy] = React.useState(false)
1818
1921
  const [notice, setNotice] = React.useState(null)
1819
1922
  // 保存过/刚拉回的 draft 指纹:用它判断「脏」,避免后台刷新把用户正在编辑的内容冲掉。
1820
1923
  const [savedKey, setSavedKey] = React.useState('')
1821
1924
  // label 归一成「空 = null」:草稿里是 '',服务端回的是 null,不归一会永远显示「未保存」。
1822
- const draftKeyOf = (report) => JSON.stringify((report?.groups ?? []).map((group) => [
1823
- group.id,
1824
- group.label ? group.label : null,
1825
- (group.candidates ?? []).map((candidate) => `${candidate.provider}/${candidate.model}`)
1826
- ]))
1925
+ // 空组(刚点「新建组」、还没加候选)不参与比较:服务端会丢弃空组,
1926
+ // 若把它算成「脏」,自动保存会一直重试、而且保存回读后这个组就消失了。
1927
+ const draftKeyOf = (report) => JSON.stringify((report?.groups ?? [])
1928
+ .filter((group) => (group.candidates ?? []).length > 0)
1929
+ .map((group) => [
1930
+ group.id,
1931
+ group.label ? group.label : null,
1932
+ group.note ? group.note : null,
1933
+ (group.candidates ?? []).map((candidate) => [`${candidate.provider}/${candidate.model}`, candidate.note ? candidate.note : null])
1934
+ ]))
1827
1935
  const draftFrom = (report) => (report.groups ?? []).map((group) => ({
1828
1936
  id: group.id,
1829
1937
  label: group.label ?? '',
1830
- candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model }))
1938
+ note: group.note ?? '',
1939
+ candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model, note: candidate.note ?? '' }))
1831
1940
  }))
1832
1941
  // force=true 只用在一处:保存成功后的回读 —— 那时服务端就是权威。
1833
1942
  // 其余时候(挂载、切模式、轮询)保留用户正在编辑的草稿。
@@ -1850,16 +1959,25 @@ window.__ModuleLoader__.load({
1850
1959
  .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true && Array.isArray(body.providers)) setCatalog(body.providers) })
1851
1960
  .catch(() => {})
1852
1961
  }, [loadState])
1962
+ // 自动保存的防抖定时器。必须写在下面那个 `state === null` 提前 return 之前 ——
1963
+ // hooks 不能出现在条件分支后面;save 定义在后面,所以经 ref 调用。
1964
+ const saveRef = React.useRef(null)
1965
+ const draftKey = draft === null ? '' : draftKeyOf({ groups: draft })
1966
+ React.useEffect(() => {
1967
+ if (draft === null || draftKey === savedKey || notice === 'fail' || notice === 'saving') return undefined
1968
+ const timer = setTimeout(() => { if (typeof saveRef.current === 'function') saveRef.current() }, 800)
1969
+ return () => clearTimeout(timer)
1970
+ }, [draftKey, savedKey, notice])
1853
1971
  if (state === null) {
1854
- return h('div', { className: 'dsh-llm-hub-routing' },
1855
- h('div', { className: 'dsh-llm-hub-routing__head' },
1856
- h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
1972
+ return h('div', { className: 'dsh-llm-hub-routing dsh-llm-hub-routing-page' },
1973
+ h('div', { className: 'dsh-llm-hub-routing-page__head' },
1974
+ h('h2', { className: 'dsh-llm-hub-routing-page__title' }, t('routingNav')),
1857
1975
  h('span', null, t('routingLoading'))))
1858
1976
  }
1859
1977
  ensureStyle()
1860
1978
  const isSmart = state.mode === 'smart'
1861
1979
  const groups = draft ?? []
1862
- const isDirty = draft !== null && draftKeyOf({ groups: groups.map((group) => ({ id: group.id, label: group.label, candidates: group.candidates })) }) !== savedKey
1980
+ const isDirty = draft !== null && draftKeyOf({ groups }) !== savedKey
1863
1981
  const post = (url, payload) => fetch(url, {
1864
1982
  method: 'POST',
1865
1983
  headers: { 'content-type': 'application/json', accept: 'application/json' },
@@ -1878,18 +1996,23 @@ window.__ModuleLoader__.load({
1878
1996
  .then((body) => { if (body?.ok === true) adopt(body) })
1879
1997
  .catch(() => setNotice('fail'))
1880
1998
  }
1999
+ // 自动保存(1.7.0):原来要去卡片最底下点「保存」,改完就走的人会丢改动。
2000
+ // 草稿变「脏」后 800ms 防抖落盘;回读**不强制**覆盖草稿 —— 用户可能还在打字。
2001
+ // 只提交有候选的组(空组服务端本来就丢弃),草稿里的空组留在页面上。
1881
2002
  const save = () => {
1882
- if (groups.length === 0) return
2003
+ const payload = groups.filter((group) => group.candidates.length > 0)
2004
+ if (payload.length === 0) return
1883
2005
  setBusy(true)
1884
- setNotice(null)
1885
- post(ROUTING_CONFIG_URL, { groups, activeGroup })
2006
+ setNotice('saving')
2007
+ post(ROUTING_CONFIG_URL, { groups: payload, activeGroup })
1886
2008
  .then((body) => {
1887
- if (body?.ok === true) { adopt(body, true); setNotice('saved') }
2009
+ if (body?.ok === true) { adopt(body); setNotice('saved') }
1888
2010
  else setNotice('fail')
1889
2011
  })
1890
2012
  .catch(() => setNotice('fail'))
1891
2013
  .finally(() => setBusy(false))
1892
2014
  }
2015
+ saveRef.current = save
1893
2016
  // ── 草稿编辑(全部是纯函数式更新,不碰服务端;保存才落盘)──
1894
2017
  const editGroup = (id, mutate) => setDraft(groups.map((group) => (group.id === id ? mutate(group) : group)))
1895
2018
  const moveCandidate = (id, index, delta) => editGroup(id, (group) => {
@@ -1919,26 +2042,64 @@ window.__ModuleLoader__.load({
1919
2042
  next.splice(target, 0, item)
1920
2043
  setDraft(next)
1921
2044
  }
2045
+ const updateCandidate = (id, index, patch) => editGroup(id, (group) => ({
2046
+ ...group,
2047
+ candidates: group.candidates.map((candidate, i) => (i === index ? { ...candidate, ...patch } : candidate))
2048
+ }))
2049
+ // 换提供方时模型跟着换成该家第一个模型 —— 留着旧 model 名几乎一定是错的组合。
2050
+ const changeProvider = (id, index, provider) => {
2051
+ const entry = catalog.find((item) => item.id === provider)
2052
+ updateCandidate(id, index, { provider, model: entry?.models?.[0]?.id ?? '' })
2053
+ }
2054
+ const makePrimary = (id, index) => editGroup(id, (group) => {
2055
+ const next = [...group.candidates]
2056
+ const [item] = next.splice(index, 1)
2057
+ return { ...group, candidates: [item, ...next] }
2058
+ })
1922
2059
  const removeCandidate = (id, index) => editGroup(id, (group) => ({ ...group, candidates: group.candidates.filter((_, i) => i !== index) }))
1923
2060
  const addCandidate = (id) => {
1924
2061
  const provider = picker.provider
1925
2062
  const model = picker.model
1926
2063
  if (provider === '' || model === '') return
1927
- editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model }] }))
2064
+ editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model, note: '' }] }))
1928
2065
  setPicker({ provider: '', model: '' })
1929
2066
  }
1930
2067
  const addGroup = () => {
1931
2068
  let index = groups.length + 1
1932
2069
  let id = `group-${index}`
1933
2070
  while (groups.some((group) => group.id === id)) { index += 1; id = `group-${index}` }
1934
- setDraft([...groups, { id, label: '', candidates: [] }])
2071
+ setDraft([...groups, { id, label: '', note: '', candidates: [] }])
1935
2072
  setExpanded(id)
1936
2073
  }
1937
2074
  const removeGroup = (id) => {
1938
2075
  setDraft(groups.filter((group) => group.id !== id))
1939
- if (expanded === id) setExpanded('')
2076
+ if (expanded === id) setExpanded(null)
2077
+ }
2078
+ const catalogEntryFor = (provider) => catalog.find((item) => item.id === provider)
2079
+ const modelOptionsFor = (provider, current) => {
2080
+ const ids = (catalogEntryFor(provider)?.models ?? []).map((model) => model.id)
2081
+ return current && !ids.includes(current) ? [current, ...ids] : ids
1940
2082
  }
1941
2083
  const stateChipClass = (value) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${value ?? 'unknown'}`
2084
+ /**
2085
+ * 一组里探测为「可用」的候选数。探不到(没记录)时返回 0 —— 折叠行要报
2086
+ * 「0/N」而不是假装健康:未探明和不可用在用户眼里同样是「先别指望它」。
2087
+ * @param {object | undefined} live - /routing/state 里该组的快照。
2088
+ * @returns {number} 可用候选数。
2089
+ */
2090
+ const availableOf = (live) => (live?.candidates ?? []).filter((item) => item.state === 'available').length
2091
+ /**
2092
+ * 折叠行那个健康点的颜色:全可用 / 还剩几个 / 一个都不剩。
2093
+ * 不用单一绿点,绿点只说「还行」,说不出「还剩几个能兜」。
2094
+ * @param {object | undefined} live - 该组快照。
2095
+ * @returns {'available' | 'degraded' | 'unavailable'}
2096
+ */
2097
+ const groupDotState = (live) => {
2098
+ const total = live?.candidates?.length ?? 0
2099
+ const available = availableOf(live)
2100
+ if (total === 0 || available === 0) return 'unavailable'
2101
+ return available < total ? 'degraded' : 'available'
2102
+ }
1942
2103
  /**
1943
2104
  * 一个候选是否多模态(输入含 text 之外的模态,典型是 image)。
1944
2105
  *
@@ -1982,8 +2143,9 @@ window.__ModuleLoader__.load({
1982
2143
  if (group?.wouldRoute) return `${t('routeChipWouldRoute')}: ${group.wouldRoute.provider}/${group.wouldRoute.model}`
1983
2144
  return group?.reason ?? t('routeChipReasonNone')
1984
2145
  }
1985
- const header = h('div', { className: 'dsh-llm-hub-routing__head' },
1986
- h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
2146
+ // 页头(1.7.0):「路由」标题与模式开关同一行。原来页头一个「路由」、卡内又一个「当前路由」,重复。
2147
+ const header = h('div', { className: 'dsh-llm-hub-routing-page__head' },
2148
+ h('h2', { className: 'dsh-llm-hub-routing-page__title' }, t('routingNav')),
1987
2149
  h('div', { className: 'dsh-llm-hub-routing__modes' },
1988
2150
  h('button', {
1989
2151
  type: 'button',
@@ -1995,25 +2157,29 @@ window.__ModuleLoader__.load({
1995
2157
  className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
1996
2158
  onClick: () => { if (isSmart) setMode('manual') }
1997
2159
  }, t('routeChipModeManual'))))
1998
- const rule = h('div', { className: 'dsh-llm-hub-routing__rule' },
2160
+ const pageDesc = h('p', { className: 'dsh-llm-hub-routing-page__desc' }, t('routingPageDesc'))
2161
+ const rule = h('div', { className: 'dsh-llm-hub-routing__rule' },
1999
2162
  h('span', { className: 'dsh-llm-hub-routing__rule-tag' }, t('routeRuleTitle')),
2000
2163
  h('span', null, t('routeRuleText')))
2001
- const noticeLine = h('div', { className: 'dsh-llm-hub-routing__reason' },
2002
- isSmart ? t('routeSmartNotice') : t('routeManualNotice'))
2164
+ // 只在智能路由真的接管时提示。手动模式下那行「请求还是用你在模型下拉里
2165
+ // 选的」讲的是当前高亮的按钮本身,没有新信息,却常驻占一整行;
2166
+ // 而接管后「下拉只作参考」是按钮推不出来的,必须留着。
2167
+ const noticeLine = isSmart
2168
+ ? h('div', { className: 'dsh-llm-hub-routing__reason' }, t('routeSmartNotice'))
2169
+ : null
2003
2170
  const nowLine = h('div', { className: 'dsh-llm-hub-routing__now' }, resolvedText())
2004
2171
  const groupRows = groups.map((group, groupIndex) => {
2005
2172
  const live = state.groups.find((item) => item.id === group.id)
2173
+ // 始终折叠,展开才编辑(1.6.2)。原先是「当前组默认展开」,于是这页一进来
2174
+ // 就是一屏逐条候选的表单 —— 而进来看的是「这几组各自会路由到哪」,
2175
+ // 那是折叠行就能答完的问题。编辑是低频动作,折叠是默认态。
2006
2176
  const isOpen = expanded === group.id
2007
2177
  const candidates = group.candidates
2008
2178
  return h('div', { key: group.id, className: `dsh-llm-hub-routing__group${activeGroup === group.id ? ' is-active' : ''}` },
2009
2179
  h('div', { className: 'dsh-llm-hub-routing__group-top' },
2010
2180
  // 顺序即优先级,所以序号 + 上下移都得露在折叠态 —— 折叠着也能改顺序。
2011
2181
  h('div', { className: 'dsh-llm-hub-routing__group-order' },
2012
- h('button', {
2013
- type: 'button',
2014
- className: 'dsh-llm-hub-routing__group-toggle',
2015
- onClick: () => setExpanded(isOpen ? '' : group.id)
2016
- }, `${isOpen ? '▾' : '▸'} ${groupIndex + 1}`),
2182
+ h('span', { className: 'dsh-llm-hub-routing__group-index' }, `${groupIndex + 1}`),
2017
2183
  h('button', {
2018
2184
  type: 'button',
2019
2185
  className: 'dsh-llm-hub-routing__group-move',
@@ -2028,42 +2194,78 @@ window.__ModuleLoader__.load({
2028
2194
  disabled: groupIndex === groups.length - 1,
2029
2195
  onClick: () => moveGroup(groupIndex, 1)
2030
2196
  }, '↓')),
2031
- h('input', {
2032
- className: 'dsh-llm-hub-routing__group-label',
2033
- value: group.label ?? '',
2034
- placeholder: t('routeGroupLabelPlaceholder'),
2035
- onChange: (event) => editGroup(group.id, (current) => ({ ...current, label: event.target.value }))
2036
- }),
2037
- // 折叠态给出「这一组会按什么顺序试」的摘要:主力 + 兜底条数。
2038
- // 之前顺序完全藏在折叠区里,用户看不到自己在排什么。
2039
- h('span', { className: 'dsh-llm-hub-routing__group-state' },
2040
- candidates.length === 0
2041
- ? h('em', { className: 'dsh-llm-hub-routing__group-empty' }, t('routeNoCandidate'))
2042
- : [
2043
- h('b', { key: 'p' }, `${candidates[0].provider}/${candidates[0].model}`),
2044
- candidates.length > 1
2045
- ? h('span', { key: 'n', className: 'dsh-llm-hub-routing__group-more' },
2046
- ` + ${candidates.length - 1} ${t('routeFallbackCount')}`)
2047
- : null,
2048
- // 只在「实际会切到非主模型」时才提示 —— 跟主模型相同的箭头是纯冗余,
2049
- // 还会把摘要挤到省略号里。真正有信息量的只有「兜底被选上了」。
2050
- live?.wouldRoute && `${live.wouldRoute.provider}/${live.wouldRoute.model}` !== `${candidates[0].provider}/${candidates[0].model}`
2051
- ? h('span', { key: 'w', className: 'dsh-llm-hub-routing__group-would' },
2052
- `→ ${live.wouldRoute.provider}/${live.wouldRoute.model}`)
2053
- : null,
2054
- live?.reason
2055
- ? h('span', { key: 'r', className: 'dsh-llm-hub-routing__group-would' }, live.reason)
2056
- : null,
2057
- ]),
2197
+ // 组名折叠时是**纯文本**,展开才给输入框。折叠行还摆着一个输入框,
2198
+ // 会让「这一行正在编辑」和「这一行只是显示」看起来一模一样 ——
2199
+ // 而折叠行的职责恰恰是「看,不是改」。
2200
+ isOpen
2201
+ ? h('input', {
2202
+ className: 'dsh-llm-hub-routing__group-label',
2203
+ value: group.label ?? '',
2204
+ placeholder: t('routeGroupLabelPlaceholder'),
2205
+ onChange: (event) => editGroup(group.id, (current) => ({ ...current, label: event.target.value }))
2206
+ })
2207
+ : h('span', { className: 'dsh-llm-hub-routing__group-name' }, group.label || group.id),
2208
+ // 折叠态就三件事:会路由到哪 · 组里几个候选、几个可用 · 组名。
2209
+ // 为什么是这三件:设置内容区只有 564px,组头扣掉序号/组名/两个按钮后,
2210
+ // 摘要只剩 ~235px;而「将路由到 modelgo-gateway/claude-sonnet-5」按真实数据
2211
+ // 就要 231px。再塞第四件(主力 provider/model)必然把最该被读到的那段
2212
+ // 压成「将路由到 m…」(浏览器实测)。所以砍内容,不是调 CSS:
2213
+ // · 主力去掉 —— 它答「第一个是谁」,这行该答「实际走哪个」,展开态第一行就有;
2214
+ // · 「+N 条兜底」去掉 —— 旁边「4/5」已把总数和可用数都说了,是同义重复;
2215
+ // · 「将路由到」四字换成箭头 —— 省 48px,全称进 title 悬停可见。
2216
+ !isOpen
2217
+ ? h('span', { className: 'dsh-llm-hub-routing__group-state' },
2218
+ candidates.length === 0
2219
+ ? h('em', { className: 'dsh-llm-hub-routing__group-empty' }, t('routeNoCandidate'))
2220
+ : [
2221
+ live?.wouldRoute
2222
+ ? h('b', {
2223
+ key: 'w',
2224
+ className: 'dsh-llm-hub-routing__group-would',
2225
+ title: `${t('routeChipWouldRoute')} ${live.wouldRoute.provider}/${live.wouldRoute.model}`
2226
+ }, `\u2192 ${live.wouldRoute.provider}/${live.wouldRoute.model}`)
2227
+ : h('em', {
2228
+ key: 'w',
2229
+ className: 'dsh-llm-hub-routing__group-would',
2230
+ title: live?.reason ?? t('routeChipReasonNone')
2231
+ }, live?.reason ?? t('routeChipReasonNone')),
2232
+ // 「可用/总」比一个孤零零的绿点有信息:点只说「还行」,数字说清还剩几个能兜。
2233
+ h('span', { key: 'c', className: 'dsh-llm-hub-routing__group-count' },
2234
+ h('span', { className: `dsh-llm-hub-routing__dot dsh-llm-hub-routing__dot--${groupDotState(live)}` }),
2235
+ `${availableOf(live)}/${live?.candidates?.length ?? candidates.length}`),
2236
+ ])
2237
+ : null,
2058
2238
  activeGroup === group.id
2059
2239
  ? h('span', { className: 'dsh-llm-hub-routing__role' }, t('routeActiveTag'))
2060
2240
  : h('button', {
2061
2241
  type: 'button',
2062
2242
  className: 'dsh-llm-hub-foot__link',
2063
2243
  onClick: () => markActive(group.id)
2064
- }, t('routeSetActive'))),
2244
+ }, t('routeSetActive')),
2245
+ // 「删除该组」原来孤零零挂在组底正中;挪到组头、只在展开编辑时出现,挨着「收起」。
2246
+ isOpen
2247
+ ? h('button', {
2248
+ type: 'button',
2249
+ className: 'dsh-llm-hub-routing__op dsh-llm-hub-routing__op--danger dsh-llm-hub-routing__group-delete',
2250
+ onClick: () => removeGroup(group.id)
2251
+ }, t('routeDeleteGroup'))
2252
+ : null,
2253
+ // 原来开合藏在序号「▸ 1」上,看不出能点;现在是一个写明「编辑 / 收起」的按钮。
2254
+ // 关起用 null 而不是 '':空串只是「曾经用过的哨兵值」,读代码的人分不清它
2255
+ // 跟「还没开合过」是不是一回事(1.6.2 之前确实当成两码事)。
2256
+ h('button', {
2257
+ type: 'button',
2258
+ className: 'dsh-llm-hub-btn dsh-llm-hub-routing__group-toggle',
2259
+ onClick: () => setExpanded(isOpen ? null : group.id)
2260
+ }, isOpen ? `▾ ${t('routeCollapse')}` : `✎ ${t('routeEdit')}`)),
2065
2261
  isOpen
2066
2262
  ? h('div', { className: 'dsh-llm-hub-routing__candidates' },
2263
+ h('input', {
2264
+ className: 'dsh-llm-hub-routing__note dsh-llm-hub-routing__note--group',
2265
+ value: group.note ?? '',
2266
+ placeholder: t('routeGroupNotePlaceholder'),
2267
+ onChange: (event) => editGroup(group.id, (current) => ({ ...current, note: event.target.value }))
2268
+ }),
2067
2269
  ...candidates.map((candidate, index) => {
2068
2270
  const liveCandidate = live?.candidates?.find((item) => item.provider === candidate.provider && item.model === candidate.model)
2069
2271
  const multimodal = isMultimodal(candidate.provider, candidate.model)
@@ -2071,28 +2273,67 @@ window.__ModuleLoader__.load({
2071
2273
  const reasoningTip = reasoning === undefined
2072
2274
  ? undefined
2073
2275
  : `${t('routeReasoningTip')}\n${reasoning.map((level) => ` · ${level.name} → ${level.id}`).join('\n')}`
2074
- return h('div', { key: `${candidate.provider}/${candidate.model}/${index}`, className: 'dsh-llm-hub-routing__row' },
2075
- h('span', { className: 'dsh-llm-hub-routing__role' }, index === 0 ? t('routePrimaryTag') : `${t('routeFallbackTag')}${index}`),
2076
- h('span', { className: 'dsh-llm-hub-routing__name' }, `${candidate.provider}/${candidate.model}`),
2077
- reasoning !== undefined
2078
- ? h('span', {
2079
- key: 'reasoning',
2080
- className: 'dsh-llm-hub-routing__reasoning',
2081
- title: reasoningTip
2082
- }, `💭 ${t('routeReasoningShort')} ${reasoningShort(reasoning)}`)
2083
- : null,
2084
- multimodal === true
2085
- ? h('span', {
2086
- key: 'multimodal',
2087
- className: 'dsh-llm-hub-routing__multimodal',
2088
- title: t('routeMultimodalTip')
2089
- }, '👁 ' + t('routeMultimodalShort'))
2276
+ return h('div', { key: `${candidate.provider}/${candidate.model}/${index}`, className: `dsh-llm-hub-routing__row${index === 0 ? ' is-primary' : ''}` },
2277
+ h('span', { className: `dsh-llm-hub-routing__role${index === 0 ? ' is-primary' : ''}` }, index === 0 ? t('routePrimaryTag') : `${t('routeFallbackTag')}${index}`),
2278
+ // 原地改(1.7.0):提供方 / 模型直接是下拉框,不用删了重加再连点 ↑。
2279
+ // 目录里没有这家(没配置 / 目录没拉到)时退回纯文本,不让一行坏数据变成下拉框里的空值。
2280
+ catalogEntryFor(candidate.provider) !== undefined
2281
+ ? h('span', { className: 'dsh-llm-hub-routing__pick' },
2282
+ h('select', {
2283
+ className: 'dsh-llm-hub-routing__select',
2284
+ value: candidate.provider,
2285
+ title: t('routeAddProvider'),
2286
+ onChange: (event) => changeProvider(group.id, index, event.target.value)
2287
+ }, ...catalog.map((entry) => h('option', { key: entry.id, value: entry.id }, `${entry.displayName ?? entry.id}`))),
2288
+ h('select', {
2289
+ className: 'dsh-llm-hub-routing__select',
2290
+ value: candidate.model,
2291
+ title: t('routeAddModel'),
2292
+ onChange: (event) => updateCandidate(group.id, index, { model: event.target.value })
2293
+ }, ...modelOptionsFor(candidate.provider, candidate.model).map((id) => h('option', { key: id, value: id }, id))))
2294
+ : h('span', { className: 'dsh-llm-hub-routing__name' }, `${candidate.provider}/${candidate.model}`),
2295
+ // 行排成固定两行网格(1.7.0):第一行 角色 · 提供方 · 模型 · 能力/状态,
2296
+ // 第二行 备注(占满) · 操作按钮(右对齐)。原来全部 flex-wrap,每行按内容长短
2297
+ // 在不同位置折行,五行排不成列(浏览器实测截图)。
2298
+ // 第二行:备注(撑满)· 能力/状态标签 · 操作按钮。标签原来在第一行最右,固定吃掉约 180px,
2299
+ // 两个下拉被挤到截断「Antigravity 本地反…」「MiniMax-M3.1-Flash-Prev…」。
2300
+ h('span', { className: 'dsh-llm-hub-routing__line2' },
2301
+ h('input', {
2302
+ className: 'dsh-llm-hub-routing__note',
2303
+ value: candidate.note ?? '',
2304
+ placeholder: t('routeNotePlaceholder'),
2305
+ title: candidate.note ?? '',
2306
+ onChange: (event) => updateCandidate(group.id, index, { note: event.target.value })
2307
+ }),
2308
+ h('span', { className: 'dsh-llm-hub-routing__meta' },
2309
+ // 窄容器只放图标,文字进 title(1.7.0):「思考 high/max / 图像 / 可用」三枚文字标签
2310
+ // 吃掉约 176px,备注框被挤到只剩 150px、备注被截断(浏览器实测)。
2311
+ reasoning !== undefined
2312
+ ? h('span', {
2313
+ key: 'reasoning',
2314
+ className: 'dsh-llm-hub-routing__reasoning',
2315
+ title: `${t('routeReasoningShort')} ${reasoningShort(reasoning)}\n${reasoningTip}`
2316
+ }, '💭')
2317
+ : null,
2318
+ multimodal === true
2319
+ ? h('span', {
2320
+ key: 'multimodal',
2321
+ className: 'dsh-llm-hub-routing__multimodal',
2322
+ title: `${t('routeMultimodalShort')} · ${t('routeMultimodalTip')}`
2323
+ }, '👁')
2324
+ : null,
2325
+ h('span', {
2326
+ className: `dsh-llm-hub-routing__dot dsh-llm-hub-routing__dot--${liveCandidate?.state ?? 'unknown'}`,
2327
+ title: liveCandidate?.state === 'available' ? '可用' : (liveCandidate?.state === 'unknown' ? '未探明' : '不可用')
2328
+ })),
2329
+ h('span', { className: 'dsh-llm-hub-routing__ops' },
2330
+ index > 0
2331
+ ? h('button', { type: 'button', className: 'dsh-llm-hub-routing__op', onClick: () => makePrimary(group.id, index) }, t('routeMakePrimary'))
2090
2332
  : null,
2091
- h('span', { className: stateChipClass(liveCandidate?.state) }, liveCandidate?.state === 'available' ? '可用' : (liveCandidate?.state === 'unknown' ? '未探明' : '不可用')),
2092
- h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeUp'), onClick: () => moveCandidate(group.id, index, -1) }, '↑'),
2093
- h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeDown'), onClick: () => moveCandidate(group.id, index, 1) }, '↓'),
2094
- h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeRemove'), onClick: () => removeCandidate(group.id, index) }, '✕'))
2095
- }),
2333
+ h('button', { type: 'button', className: 'dsh-llm-hub-routing__op', title: t('routeUp'), disabled: index === 0, onClick: () => moveCandidate(group.id, index, -1) }, '↑'),
2334
+ h('button', { type: 'button', className: 'dsh-llm-hub-routing__op', title: t('routeDown'), disabled: index === candidates.length - 1, onClick: () => moveCandidate(group.id, index, 1) }, '↓'),
2335
+ h('button', { type: 'button', className: 'dsh-llm-hub-routing__op dsh-llm-hub-routing__op--danger', title: t('routeRemove'), onClick: () => removeCandidate(group.id, index) }, '✕'))))
2336
+ }),
2096
2337
  h('div', { className: 'dsh-llm-hub-routing__add' },
2097
2338
  h('span', null, t('routeAddProvider')),
2098
2339
  catalog.length > 0
@@ -2142,26 +2383,31 @@ window.__ModuleLoader__.load({
2142
2383
  className: 'dsh-llm-hub-btn',
2143
2384
  onClick: () => addCandidate(group.id)
2144
2385
  }, t('routeAdd'))),
2145
- catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null,
2146
- h('button', {
2147
- type: 'button',
2148
- className: 'dsh-llm-hub-foot__link',
2149
- onClick: () => removeGroup(group.id)
2150
- }, t('routeDeleteGroup')))
2386
+ catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null)
2151
2387
  : null)
2152
2388
  })
2389
+ // 空闲态不再写死「已保存」—— 那三个字跟「刚刚存成功」长得一模一样,
2390
+ // 读起来像一条一直挂着的成功提示,而它其实什么都没说。空闲时干脆不占位,
2391
+ // 真存过 / 存到一半 / 存失败才给反馈。
2392
+ const noticing = notice === 'fail' || notice === 'saving' || notice === 'saved'
2153
2393
  const footer = h('div', { className: 'dsh-llm-hub-routing__actions' },
2154
2394
  h('button', { type: 'button', className: 'dsh-llm-hub-btn', onClick: addGroup }, t('routeNewGroup')),
2155
- h('button', {
2156
- type: 'button',
2157
- className: 'dsh-llm-hub-btn',
2158
- disabled: busy || !isDirty,
2159
- onClick: save
2160
- }, isDirty ? t('routeSave') : t('routeSaved')),
2161
- notice === 'fail' ? h('span', { className: 'dsh-llm-hub-routing__reason' }, t('routeSaveFailed')) : null,
2395
+ // 注意这里不能写成 h(cond ? <span/> : null) —— 多包的那层 h 没有 tag,
2396
+ // flatten 不递归进去,状态文案在测试里就永远取不到(真实浏览器里没事,
2397
+ // 因为 DOM 照样渲染 —— 正是「测试挂了但界面看着正常」的那类坑)。
2398
+ noticing || busy || isDirty
2399
+ ? h('span', { className: `dsh-llm-hub-routing__status${notice === 'fail' ? ' is-fail' : ''}` },
2400
+ notice === 'fail' ? t('routeSaveFailed')
2401
+ : (busy || notice === 'saving' || isDirty) ? t('routeSaving')
2402
+ : t('routeAutoSaved'))
2403
+ : null,
2404
+ notice === 'fail'
2405
+ ? h('button', { type: 'button', className: 'dsh-llm-hub-btn', disabled: busy, onClick: () => { setNotice(null); save() } }, t('routeRetry'))
2406
+ : null,
2162
2407
  h('span', { className: 'dsh-llm-hub-routing__hint' }, t('routeEditHint')))
2163
- return h('div', { className: 'dsh-llm-hub-routing' },
2408
+ return h('div', { className: 'dsh-llm-hub-routing dsh-llm-hub-routing-page' },
2164
2409
  header,
2410
+ pageDesc,
2165
2411
  rule,
2166
2412
  noticeLine,
2167
2413
  nowLine,
@@ -2169,6 +2415,14 @@ window.__ModuleLoader__.load({
2169
2415
  footer)
2170
2416
  }
2171
2417
 
2418
+ /**
2419
+ * 「路由」设置页:页头(标题 + 一句怎么用)+ 路由编辑器。
2420
+ * 直接调用 RoutingCard(props) 而不是 h(RoutingCard):少一层组件,hooks 顺序不变。
2421
+ */
2422
+ function RoutingSection(props) {
2423
+ return RoutingCard(props)
2424
+ }
2425
+
2172
2426
  /**
2173
2427
  * 格式化 token 数字 —— 大数带千分位,>9999 折成 1.2万。
2174
2428
  * @param value - token 数。
@@ -2631,13 +2885,13 @@ window.__ModuleLoader__.load({
2631
2885
  key: CARD_KEY,
2632
2886
  locale: NS,
2633
2887
  inject: () => ({ t, availability })
2634
- }, BalanceCard))
2888
+ }, withPresetHint(BalanceCard)))
2635
2889
  ctx.slots.inject('settings.models.provider-card', () => ctx.slots.register({
2636
2890
  name: 'settings.models.provider-card',
2637
2891
  key: PIAI_CARD_KEY,
2638
2892
  locale: NS,
2639
2893
  inject: () => ({ t, availability })
2640
- }, PiAiCard))
2894
+ }, withPresetHint(PiAiCard)))
2641
2895
  // settings.models.footer 的契约是 kind:'list'(见宿主 dsh-client-ui-settings-models
2642
2896
  // 的 children 声明),list 类 slot 注册**必须带 id** —— 参照宿主自己的
2643
2897
  // settings.onboarding 注册(id:'welcome-notice', order:-100)。
@@ -2651,13 +2905,6 @@ window.__ModuleLoader__.load({
2651
2905
  locale: NS,
2652
2906
  inject: () => ({ t, availability })
2653
2907
  }, HeroOverviewCard))
2654
- ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2655
- name: 'settings.models.footer',
2656
- id: 'dsh-llm-hub-presets',
2657
- order: 65,
2658
- locale: NS,
2659
- inject: () => ({ t })
2660
- }, QuickPresetsCard))
2661
2908
  ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2662
2909
  name: 'settings.models.footer',
2663
2910
  id: 'dsh-llm-hub-health',
@@ -2665,13 +2912,17 @@ window.__ModuleLoader__.load({
2665
2912
  locale: NS,
2666
2913
  inject: () => ({ t, availability })
2667
2914
  }, HealthCard))
2668
- ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2669
- name: 'settings.models.footer',
2915
+ // 路由配置是独立的一页(1.7.0):原来挂在「模型」页脚第 75 位,要往下翻好几屏才找得到,
2916
+ // 用户的原话是「我自己怎么选主力、在哪里改」。settings.section 的 order:
2917
+ // 通用 0 / 模型 10 / 插件 15 —— 取 12,正好排在「模型」下面。
2918
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
2919
+ name: 'settings.section',
2670
2920
  id: 'dsh-llm-hub-routing',
2671
- order: 75,
2921
+ order: 12,
2922
+ label: () => t('routingNav'),
2672
2923
  locale: NS,
2673
2924
  inject: () => ({ t })
2674
- }, RoutingCard))
2925
+ }, RoutingSection))
2675
2926
  ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2676
2927
  name: 'settings.models.footer',
2677
2928
  id: 'dsh-llm-hub-usage',