@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/CHANGELOG.md +39 -0
- package/lib/client.js +460 -209
- package/lib/index.js +91 -11
- package/package.json +1 -1
- package/scripts/prepublish-gate.mjs +10 -1
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: '
|
|
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: '
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
655
|
-
'
|
|
656
|
-
'
|
|
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
|
-
|
|
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{
|
|
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{
|
|
665
|
-
'
|
|
666
|
-
'
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
'.dsh-llm-hub-routing__group-
|
|
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
|
|
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
|
-
//
|
|
689
|
-
'.dsh-llm-hub-
|
|
690
|
-
'
|
|
691
|
-
'.dsh-llm-hub-
|
|
692
|
-
'.dsh-llm-hub-
|
|
693
|
-
'.dsh-llm-hub-
|
|
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
|
-
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
(group.candidates ?? []).
|
|
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
|
-
|
|
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-
|
|
1856
|
-
h('
|
|
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
|
|
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
|
-
|
|
2003
|
+
const payload = groups.filter((group) => group.candidates.length > 0)
|
|
2004
|
+
if (payload.length === 0) return
|
|
1883
2005
|
setBusy(true)
|
|
1884
|
-
setNotice(
|
|
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
|
|
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
|
-
|
|
1986
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2002
|
-
|
|
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('
|
|
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
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
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:
|
|
2075
|
-
h('span', { className:
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
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('
|
|
2092
|
-
h('button', { type: 'button', className: 'dsh-llm-hub-
|
|
2093
|
-
h('button', { type: 'button', className: 'dsh-llm-hub-
|
|
2094
|
-
|
|
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(
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
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
|
-
|
|
2669
|
-
|
|
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:
|
|
2921
|
+
order: 12,
|
|
2922
|
+
label: () => t('routingNav'),
|
|
2672
2923
|
locale: NS,
|
|
2673
2924
|
inject: () => ({ t })
|
|
2674
|
-
},
|
|
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',
|