@dsh-plugins/dsh-llm-hub 1.6.1 → 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 +30 -0
- package/lib/client.js +398 -121
- package/lib/index.js +91 -11
- package/package.json +1 -1
- package/scripts/prepublish-gate.mjs +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.7.0] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### 🧭 路由设置卡:始终折叠,展开才编辑
|
|
8
|
+
|
|
9
|
+
- **默认全折叠**,当前组也不例外(反了 1.7.0 之前的「当前组默认展开」)。进这页要回答的是「这几组各自会路由到哪」,折叠行就答完了;逐条候选是低频编辑动作,默认展开等于一进来就糊一屏表单
|
|
10
|
+
- 折叠行折叠时组名是**纯文本**,展开才给输入框 —— 折叠行还摆着输入框,会让「在编辑」和「在显示」长得一样
|
|
11
|
+
- 折叠行只留三件事:**会路由到哪** · **可用/总** · 组名。为此砍掉两处:
|
|
12
|
+
- 「主力 provider/model」—— 答的是「第一个是谁」,展开态第一行就有
|
|
13
|
+
- 「+N 条兜底」—— 与「4/5」同义重复
|
|
14
|
+
- 新增组健康点三色:全绿(都可用)/ 琥珀(还剩几个)/ 红(一个不剩)。半可用混成全绿会让人以为不用管,混成全红会误报故障
|
|
15
|
+
- 全不可用时,不可用原因顶替「会路由到」那个位置 —— 两者是同一个槽位,不是两件事
|
|
16
|
+
|
|
17
|
+
**踩坑(都靠浏览器实测发现,单测一条都拦不住)**
|
|
18
|
+
|
|
19
|
+
- 设置内容区只有 564px,组头扣掉序号/组名/两个按钮后摘要只剩 ~210px,真实数据的 provider/model 要 200-215px。差的这几十像素省不掉:先砍内容,仍不够才改成**换行**而不是截断 —— 砍掉的正好是判断路由最需要的模型名
|
|
20
|
+
- `.dot--degraded` 一开始写在基础 `.dot` 规则**之前**:同特异度下后者生效,灰底把琥珀色盖掉,`4/5` 显示成灰点。已移到基础规则之后
|
|
21
|
+
- DSH 的 client bundle 缓存很硬:`Page.reload --ignoreCache` 不足以刷新样式表,必须 `Network.clearBrowserCache`。中途因此把「源码已改、页面没变」误判成 CSS 没写对
|
|
22
|
+
|
|
23
|
+
## [1.6.2] - 2026-09-29
|
|
24
|
+
|
|
25
|
+
### 🐛 修复:路由快切 chip 的浮层点完不消失
|
|
26
|
+
|
|
27
|
+
- 症状:在浮层里点「智能路由 / 手动模型」或点某个组,按钮高亮变了、状态也切了,浮层却纹丝不动地盖在输入框上,只能**再点一次 chip** 才收得回去(2026-09-29 浏览器实测)
|
|
28
|
+
- 根因:`open` 只有 chip 按钮那一个 toggle 在写 —— 菜单内所有 `onClick` 都只调 `select()`,从不 `setOpen(false)`,也没有「点外部 / Esc」的兜底
|
|
29
|
+
- 修复:
|
|
30
|
+
- 点组 / 切模式**写成功后立刻收起**浮层(写失败**不收** —— 状态没变,收起来等于把失败藏了)
|
|
31
|
+
- 点当前**已生效**的那个模式(本来就没请求可发)也收起,否则「点了完全没反应」会被当成界面卡住
|
|
32
|
+
- 补上「点浮层外面」与「Esc」两条关闭路径;只在 `open` 时挂 document 监听,卸载时卸干净
|
|
33
|
+
- 测试:新增 7 条回归。React 桩补上 setter / ref / effect 记录 —— 「点完浮层有没有收起」看渲染树验不出来,发生在事件回调里,只能观察状态被写成了什么
|
|
34
|
+
|
|
5
35
|
## [1.6.1] - 2026-09-28
|
|
6
36
|
|
|
7
37
|
### 🧹 去掉模型页底部的「热门大模型一键装配」,改为添加时按厂商匹配
|
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: '模型',
|
|
@@ -318,6 +330,8 @@ window.__ModuleLoader__.load({
|
|
|
318
330
|
routingPrimary: 'primary',
|
|
319
331
|
routingNoRecommendation: 'none available',
|
|
320
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.',
|
|
321
335
|
routeChipSmart: 'Smart',
|
|
322
336
|
routeChipManual: 'Manual',
|
|
323
337
|
routeChipModeSmart: 'Smart routing',
|
|
@@ -330,7 +344,15 @@ window.__ModuleLoader__.load({
|
|
|
330
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.',
|
|
331
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).',
|
|
332
346
|
routeManualNotice: 'Not taking over: requests still use the model you picked in the dropdown. Switch to Smart routing to auto-select.',
|
|
333
|
-
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)',
|
|
334
356
|
routeSave: 'Save',
|
|
335
357
|
routeSaved: 'Saved',
|
|
336
358
|
routeSaveFailed: 'Save failed',
|
|
@@ -589,15 +611,20 @@ window.__ModuleLoader__.load({
|
|
|
589
611
|
'.dsh-llm-hub-routing__active{color:var(--dsw-alias-label-primary)}',
|
|
590
612
|
'.dsh-llm-hub-routing__rows{display:flex;flex-direction:column;gap:2px}',
|
|
591
613
|
'.dsh-llm-hub-routing__row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px}',
|
|
592
|
-
'.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;',
|
|
593
615
|
'border-radius:3px;border:.5px solid var(--dsw-alias-border-l2);',
|
|
594
|
-
'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}',
|
|
595
622
|
'.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
596
623
|
// capability 标识:思考 / 多模态。两枚 chip 同形,左思考、右图像;
|
|
597
624
|
// 边色走 state 提示色,跟健康状态 chip 区分开 —— 一个说「能干什么」,
|
|
598
625
|
// 一个说「能不能用」。
|
|
599
626
|
'.dsh-llm-hub-routing__reasoning,.dsh-llm-hub-routing__multimodal{',
|
|
600
|
-
'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;',
|
|
601
628
|
'border-radius:3px;font-size:10px;line-height:14px;font-weight:500;',
|
|
602
629
|
'background:transparent;cursor:help}',
|
|
603
630
|
// 思考:走 warn 主题色(消耗更多 token、需要警觉)
|
|
@@ -643,31 +670,99 @@ window.__ModuleLoader__.load({
|
|
|
643
670
|
'font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;line-height:1.4}',
|
|
644
671
|
// 智能路由编辑器(设置页路由卡)
|
|
645
672
|
'.dsh-llm-hub-routing__modes{display:flex;gap:4px;margin-left:auto}',
|
|
646
|
-
|
|
647
|
-
'
|
|
648
|
-
'
|
|
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)}',
|
|
649
677
|
'.dsh-llm-hub-routing__now{font-size:11px;color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
650
678
|
'.dsh-llm-hub-routing__groups{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
|
|
651
|
-
'.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}',
|
|
652
|
-
|
|
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)}',
|
|
653
682
|
'.dsh-llm-hub-routing__group-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
654
|
-
'.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)}',
|
|
655
687
|
'.dsh-llm-hub-routing__group-order{display:flex;align-items:center;gap:2px;flex:none}',
|
|
656
|
-
'.dsh-llm-hub-routing__group-move{
|
|
657
|
-
'
|
|
658
|
-
'
|
|
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)}',
|
|
659
692
|
'.dsh-llm-hub-routing__group-move:disabled{opacity:.25;cursor:default}',
|
|
660
|
-
'.dsh-llm-hub-routing__group-state b{font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
661
|
-
'.dsh-llm-hub-routing__group-more{color:var(--dsw-alias-label-tertiary)}',
|
|
662
|
-
'.dsh-llm-hub-routing__group-would{color:var(--dsw-alias-state-warn-primary);margin-left:5px}',
|
|
663
693
|
'.dsh-llm-hub-routing__group-empty{font-style:normal;color:var(--dsw-alias-label-tertiary)}',
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
'.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}',
|
|
667
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)}',
|
|
668
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}',
|
|
669
|
-
'.dsh-llm-hub-routing__select{box-sizing:border-box;height:22px;padding:0
|
|
670
|
-
'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)}',
|
|
671
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}',
|
|
672
767
|
'.dsh-llm-hub-routing__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
|
|
673
768
|
'.dsh-llm-hub-routing__mm{display:inline-flex;align-items:center;margin-left:4px;font-size:10px;cursor:help;',
|
|
@@ -1675,6 +1770,8 @@ window.__ModuleLoader__.load({
|
|
|
1675
1770
|
const [state, setState] = React.useState(null)
|
|
1676
1771
|
const [open, setOpen] = React.useState(false)
|
|
1677
1772
|
const [busy, setBusy] = React.useState(false)
|
|
1773
|
+
// 浮层根节点:判断「这次点击算不算点在浮层外面」要用它。
|
|
1774
|
+
const rootRef = React.useRef(null)
|
|
1678
1775
|
const query = React.useCallback(() => {
|
|
1679
1776
|
fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
|
|
1680
1777
|
.then((res) => res.json())
|
|
@@ -1686,6 +1783,25 @@ window.__ModuleLoader__.load({
|
|
|
1686
1783
|
const interval = setInterval(query, 30000)
|
|
1687
1784
|
return () => clearInterval(interval)
|
|
1688
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])
|
|
1689
1805
|
// 一个组都没有:chip 无事可做,不渲染(配置在 settings.yaml 里加)。
|
|
1690
1806
|
if (state === null || !Array.isArray(state.groups) || state.groups.length === 0) return null
|
|
1691
1807
|
ensureStyle()
|
|
@@ -1708,7 +1824,10 @@ window.__ModuleLoader__.load({
|
|
|
1708
1824
|
body: JSON.stringify(patch)
|
|
1709
1825
|
})
|
|
1710
1826
|
.then((res) => res.json())
|
|
1711
|
-
|
|
1827
|
+
// 选完即收:浮层是「选一个值」的一次性动作,不是常驻面板。
|
|
1828
|
+
// 留着不关,用户会以为没点上,只能再点一次 chip 才收得回去。
|
|
1829
|
+
// 写失败时**不关** —— 状态没变,收起来等于把失败藏了。
|
|
1830
|
+
.then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) { setState(body); setOpen(false) } })
|
|
1712
1831
|
.catch(() => {})
|
|
1713
1832
|
.finally(() => { setBusy(false); query() })
|
|
1714
1833
|
}
|
|
@@ -1724,12 +1843,14 @@ window.__ModuleLoader__.load({
|
|
|
1724
1843
|
h('button', {
|
|
1725
1844
|
type: 'button',
|
|
1726
1845
|
className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
|
|
1727
|
-
|
|
1846
|
+
// 点当前已生效的那个模式:没有请求要发,但**照样收起浮层**。
|
|
1847
|
+
// 否则「点了完全没反应」,用户会当成界面卡住反复点。
|
|
1848
|
+
onClick: () => { if (isSmart) setOpen(false); else select({ mode: 'smart' }) }
|
|
1728
1849
|
}, `⚡ ${t('routeChipModeSmart')}`),
|
|
1729
1850
|
h('button', {
|
|
1730
1851
|
type: 'button',
|
|
1731
1852
|
className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
|
|
1732
|
-
onClick: () => { if (isSmart) select({ mode: 'manual' }) }
|
|
1853
|
+
onClick: () => { if (!isSmart) setOpen(false); else select({ mode: 'manual' }) }
|
|
1733
1854
|
}, t('routeChipModeManual'))),
|
|
1734
1855
|
...state.groups.map((group) => h('button', {
|
|
1735
1856
|
key: group.id,
|
|
@@ -1752,7 +1873,7 @@ window.__ModuleLoader__.load({
|
|
|
1752
1873
|
? h('span', { className: 'dsh-llm-hub-route__hint' }, group.reason)
|
|
1753
1874
|
: null)),
|
|
1754
1875
|
h('div', { className: 'dsh-llm-hub-route__hint' }, t('routeRuleText'))) : null
|
|
1755
|
-
return h('div', { className: 'dsh-llm-hub-route' },
|
|
1876
|
+
return h('div', { className: 'dsh-llm-hub-route', ref: rootRef },
|
|
1756
1877
|
h('button', {
|
|
1757
1878
|
type: 'button',
|
|
1758
1879
|
className: chipClass,
|
|
@@ -1793,22 +1914,29 @@ window.__ModuleLoader__.load({
|
|
|
1793
1914
|
// candidates[0] = 主力(primary),其余是 fallback —— 与 settings 形态一一对应。
|
|
1794
1915
|
const [draft, setDraft] = React.useState(null)
|
|
1795
1916
|
const [activeGroup, setActiveGroup] = React.useState('')
|
|
1796
|
-
|
|
1917
|
+
// null = 还没手动开合过,此时默认展开当前组 —— 一进来就能看到、能改。
|
|
1918
|
+
const [expanded, setExpanded] = React.useState(null)
|
|
1797
1919
|
const [picker, setPicker] = React.useState({ provider: '', model: '' })
|
|
1798
1920
|
const [busy, setBusy] = React.useState(false)
|
|
1799
1921
|
const [notice, setNotice] = React.useState(null)
|
|
1800
1922
|
// 保存过/刚拉回的 draft 指纹:用它判断「脏」,避免后台刷新把用户正在编辑的内容冲掉。
|
|
1801
1923
|
const [savedKey, setSavedKey] = React.useState('')
|
|
1802
1924
|
// label 归一成「空 = null」:草稿里是 '',服务端回的是 null,不归一会永远显示「未保存」。
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
(group.candidates ?? []).
|
|
1807
|
-
|
|
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
|
+
]))
|
|
1808
1935
|
const draftFrom = (report) => (report.groups ?? []).map((group) => ({
|
|
1809
1936
|
id: group.id,
|
|
1810
1937
|
label: group.label ?? '',
|
|
1811
|
-
|
|
1938
|
+
note: group.note ?? '',
|
|
1939
|
+
candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model, note: candidate.note ?? '' }))
|
|
1812
1940
|
}))
|
|
1813
1941
|
// force=true 只用在一处:保存成功后的回读 —— 那时服务端就是权威。
|
|
1814
1942
|
// 其余时候(挂载、切模式、轮询)保留用户正在编辑的草稿。
|
|
@@ -1831,16 +1959,25 @@ window.__ModuleLoader__.load({
|
|
|
1831
1959
|
.then((body) => { if (body !== null && typeof body === 'object' && body.ok === true && Array.isArray(body.providers)) setCatalog(body.providers) })
|
|
1832
1960
|
.catch(() => {})
|
|
1833
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])
|
|
1834
1971
|
if (state === null) {
|
|
1835
|
-
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
1836
|
-
h('div', { className: 'dsh-llm-hub-
|
|
1837
|
-
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')),
|
|
1838
1975
|
h('span', null, t('routingLoading'))))
|
|
1839
1976
|
}
|
|
1840
1977
|
ensureStyle()
|
|
1841
1978
|
const isSmart = state.mode === 'smart'
|
|
1842
1979
|
const groups = draft ?? []
|
|
1843
|
-
const isDirty = draft !== null && draftKeyOf({ groups
|
|
1980
|
+
const isDirty = draft !== null && draftKeyOf({ groups }) !== savedKey
|
|
1844
1981
|
const post = (url, payload) => fetch(url, {
|
|
1845
1982
|
method: 'POST',
|
|
1846
1983
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
@@ -1859,18 +1996,23 @@ window.__ModuleLoader__.load({
|
|
|
1859
1996
|
.then((body) => { if (body?.ok === true) adopt(body) })
|
|
1860
1997
|
.catch(() => setNotice('fail'))
|
|
1861
1998
|
}
|
|
1999
|
+
// 自动保存(1.7.0):原来要去卡片最底下点「保存」,改完就走的人会丢改动。
|
|
2000
|
+
// 草稿变「脏」后 800ms 防抖落盘;回读**不强制**覆盖草稿 —— 用户可能还在打字。
|
|
2001
|
+
// 只提交有候选的组(空组服务端本来就丢弃),草稿里的空组留在页面上。
|
|
1862
2002
|
const save = () => {
|
|
1863
|
-
|
|
2003
|
+
const payload = groups.filter((group) => group.candidates.length > 0)
|
|
2004
|
+
if (payload.length === 0) return
|
|
1864
2005
|
setBusy(true)
|
|
1865
|
-
setNotice(
|
|
1866
|
-
post(ROUTING_CONFIG_URL, { groups, activeGroup })
|
|
2006
|
+
setNotice('saving')
|
|
2007
|
+
post(ROUTING_CONFIG_URL, { groups: payload, activeGroup })
|
|
1867
2008
|
.then((body) => {
|
|
1868
|
-
if (body?.ok === true) { adopt(body
|
|
2009
|
+
if (body?.ok === true) { adopt(body); setNotice('saved') }
|
|
1869
2010
|
else setNotice('fail')
|
|
1870
2011
|
})
|
|
1871
2012
|
.catch(() => setNotice('fail'))
|
|
1872
2013
|
.finally(() => setBusy(false))
|
|
1873
2014
|
}
|
|
2015
|
+
saveRef.current = save
|
|
1874
2016
|
// ── 草稿编辑(全部是纯函数式更新,不碰服务端;保存才落盘)──
|
|
1875
2017
|
const editGroup = (id, mutate) => setDraft(groups.map((group) => (group.id === id ? mutate(group) : group)))
|
|
1876
2018
|
const moveCandidate = (id, index, delta) => editGroup(id, (group) => {
|
|
@@ -1900,26 +2042,64 @@ window.__ModuleLoader__.load({
|
|
|
1900
2042
|
next.splice(target, 0, item)
|
|
1901
2043
|
setDraft(next)
|
|
1902
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
|
+
})
|
|
1903
2059
|
const removeCandidate = (id, index) => editGroup(id, (group) => ({ ...group, candidates: group.candidates.filter((_, i) => i !== index) }))
|
|
1904
2060
|
const addCandidate = (id) => {
|
|
1905
2061
|
const provider = picker.provider
|
|
1906
2062
|
const model = picker.model
|
|
1907
2063
|
if (provider === '' || model === '') return
|
|
1908
|
-
editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model }] }))
|
|
2064
|
+
editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model, note: '' }] }))
|
|
1909
2065
|
setPicker({ provider: '', model: '' })
|
|
1910
2066
|
}
|
|
1911
2067
|
const addGroup = () => {
|
|
1912
2068
|
let index = groups.length + 1
|
|
1913
2069
|
let id = `group-${index}`
|
|
1914
2070
|
while (groups.some((group) => group.id === id)) { index += 1; id = `group-${index}` }
|
|
1915
|
-
setDraft([...groups, { id, label: '', candidates: [] }])
|
|
2071
|
+
setDraft([...groups, { id, label: '', note: '', candidates: [] }])
|
|
1916
2072
|
setExpanded(id)
|
|
1917
2073
|
}
|
|
1918
2074
|
const removeGroup = (id) => {
|
|
1919
2075
|
setDraft(groups.filter((group) => group.id !== id))
|
|
1920
|
-
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
|
|
1921
2082
|
}
|
|
1922
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
|
+
}
|
|
1923
2103
|
/**
|
|
1924
2104
|
* 一个候选是否多模态(输入含 text 之外的模态,典型是 image)。
|
|
1925
2105
|
*
|
|
@@ -1963,8 +2143,9 @@ window.__ModuleLoader__.load({
|
|
|
1963
2143
|
if (group?.wouldRoute) return `${t('routeChipWouldRoute')}: ${group.wouldRoute.provider}/${group.wouldRoute.model}`
|
|
1964
2144
|
return group?.reason ?? t('routeChipReasonNone')
|
|
1965
2145
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
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')),
|
|
1968
2149
|
h('div', { className: 'dsh-llm-hub-routing__modes' },
|
|
1969
2150
|
h('button', {
|
|
1970
2151
|
type: 'button',
|
|
@@ -1976,25 +2157,29 @@ window.__ModuleLoader__.load({
|
|
|
1976
2157
|
className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
|
|
1977
2158
|
onClick: () => { if (isSmart) setMode('manual') }
|
|
1978
2159
|
}, t('routeChipModeManual'))))
|
|
1979
|
-
|
|
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' },
|
|
1980
2162
|
h('span', { className: 'dsh-llm-hub-routing__rule-tag' }, t('routeRuleTitle')),
|
|
1981
2163
|
h('span', null, t('routeRuleText')))
|
|
1982
|
-
|
|
1983
|
-
|
|
2164
|
+
// 只在智能路由真的接管时提示。手动模式下那行「请求还是用你在模型下拉里
|
|
2165
|
+
// 选的」讲的是当前高亮的按钮本身,没有新信息,却常驻占一整行;
|
|
2166
|
+
// 而接管后「下拉只作参考」是按钮推不出来的,必须留着。
|
|
2167
|
+
const noticeLine = isSmart
|
|
2168
|
+
? h('div', { className: 'dsh-llm-hub-routing__reason' }, t('routeSmartNotice'))
|
|
2169
|
+
: null
|
|
1984
2170
|
const nowLine = h('div', { className: 'dsh-llm-hub-routing__now' }, resolvedText())
|
|
1985
2171
|
const groupRows = groups.map((group, groupIndex) => {
|
|
1986
2172
|
const live = state.groups.find((item) => item.id === group.id)
|
|
2173
|
+
// 始终折叠,展开才编辑(1.6.2)。原先是「当前组默认展开」,于是这页一进来
|
|
2174
|
+
// 就是一屏逐条候选的表单 —— 而进来看的是「这几组各自会路由到哪」,
|
|
2175
|
+
// 那是折叠行就能答完的问题。编辑是低频动作,折叠是默认态。
|
|
1987
2176
|
const isOpen = expanded === group.id
|
|
1988
2177
|
const candidates = group.candidates
|
|
1989
2178
|
return h('div', { key: group.id, className: `dsh-llm-hub-routing__group${activeGroup === group.id ? ' is-active' : ''}` },
|
|
1990
2179
|
h('div', { className: 'dsh-llm-hub-routing__group-top' },
|
|
1991
2180
|
// 顺序即优先级,所以序号 + 上下移都得露在折叠态 —— 折叠着也能改顺序。
|
|
1992
2181
|
h('div', { className: 'dsh-llm-hub-routing__group-order' },
|
|
1993
|
-
h('
|
|
1994
|
-
type: 'button',
|
|
1995
|
-
className: 'dsh-llm-hub-routing__group-toggle',
|
|
1996
|
-
onClick: () => setExpanded(isOpen ? '' : group.id)
|
|
1997
|
-
}, `${isOpen ? '▾' : '▸'} ${groupIndex + 1}`),
|
|
2182
|
+
h('span', { className: 'dsh-llm-hub-routing__group-index' }, `${groupIndex + 1}`),
|
|
1998
2183
|
h('button', {
|
|
1999
2184
|
type: 'button',
|
|
2000
2185
|
className: 'dsh-llm-hub-routing__group-move',
|
|
@@ -2009,42 +2194,78 @@ window.__ModuleLoader__.load({
|
|
|
2009
2194
|
disabled: groupIndex === groups.length - 1,
|
|
2010
2195
|
onClick: () => moveGroup(groupIndex, 1)
|
|
2011
2196
|
}, '↓')),
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
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,
|
|
2039
2238
|
activeGroup === group.id
|
|
2040
2239
|
? h('span', { className: 'dsh-llm-hub-routing__role' }, t('routeActiveTag'))
|
|
2041
2240
|
: h('button', {
|
|
2042
2241
|
type: 'button',
|
|
2043
2242
|
className: 'dsh-llm-hub-foot__link',
|
|
2044
2243
|
onClick: () => markActive(group.id)
|
|
2045
|
-
}, 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')}`)),
|
|
2046
2261
|
isOpen
|
|
2047
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
|
+
}),
|
|
2048
2269
|
...candidates.map((candidate, index) => {
|
|
2049
2270
|
const liveCandidate = live?.candidates?.find((item) => item.provider === candidate.provider && item.model === candidate.model)
|
|
2050
2271
|
const multimodal = isMultimodal(candidate.provider, candidate.model)
|
|
@@ -2052,28 +2273,67 @@ window.__ModuleLoader__.load({
|
|
|
2052
2273
|
const reasoningTip = reasoning === undefined
|
|
2053
2274
|
? undefined
|
|
2054
2275
|
: `${t('routeReasoningTip')}\n${reasoning.map((level) => ` · ${level.name} → ${level.id}`).join('\n')}`
|
|
2055
|
-
return h('div', { key: `${candidate.provider}/${candidate.model}/${index}`, className:
|
|
2056
|
-
h('span', { className:
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
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'))
|
|
2071
2332
|
: null,
|
|
2072
|
-
h('
|
|
2073
|
-
h('button', { type: 'button', className: 'dsh-llm-hub-
|
|
2074
|
-
h('button', { type: 'button', className: 'dsh-llm-hub-
|
|
2075
|
-
|
|
2076
|
-
}),
|
|
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
|
+
}),
|
|
2077
2337
|
h('div', { className: 'dsh-llm-hub-routing__add' },
|
|
2078
2338
|
h('span', null, t('routeAddProvider')),
|
|
2079
2339
|
catalog.length > 0
|
|
@@ -2123,26 +2383,31 @@ window.__ModuleLoader__.load({
|
|
|
2123
2383
|
className: 'dsh-llm-hub-btn',
|
|
2124
2384
|
onClick: () => addCandidate(group.id)
|
|
2125
2385
|
}, t('routeAdd'))),
|
|
2126
|
-
catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null
|
|
2127
|
-
h('button', {
|
|
2128
|
-
type: 'button',
|
|
2129
|
-
className: 'dsh-llm-hub-foot__link',
|
|
2130
|
-
onClick: () => removeGroup(group.id)
|
|
2131
|
-
}, t('routeDeleteGroup')))
|
|
2386
|
+
catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null)
|
|
2132
2387
|
: null)
|
|
2133
2388
|
})
|
|
2389
|
+
// 空闲态不再写死「已保存」—— 那三个字跟「刚刚存成功」长得一模一样,
|
|
2390
|
+
// 读起来像一条一直挂着的成功提示,而它其实什么都没说。空闲时干脆不占位,
|
|
2391
|
+
// 真存过 / 存到一半 / 存失败才给反馈。
|
|
2392
|
+
const noticing = notice === 'fail' || notice === 'saving' || notice === 'saved'
|
|
2134
2393
|
const footer = h('div', { className: 'dsh-llm-hub-routing__actions' },
|
|
2135
2394
|
h('button', { type: 'button', className: 'dsh-llm-hub-btn', onClick: addGroup }, t('routeNewGroup')),
|
|
2136
|
-
h(
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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,
|
|
2143
2407
|
h('span', { className: 'dsh-llm-hub-routing__hint' }, t('routeEditHint')))
|
|
2144
|
-
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
2408
|
+
return h('div', { className: 'dsh-llm-hub-routing dsh-llm-hub-routing-page' },
|
|
2145
2409
|
header,
|
|
2410
|
+
pageDesc,
|
|
2146
2411
|
rule,
|
|
2147
2412
|
noticeLine,
|
|
2148
2413
|
nowLine,
|
|
@@ -2150,6 +2415,14 @@ window.__ModuleLoader__.load({
|
|
|
2150
2415
|
footer)
|
|
2151
2416
|
}
|
|
2152
2417
|
|
|
2418
|
+
/**
|
|
2419
|
+
* 「路由」设置页:页头(标题 + 一句怎么用)+ 路由编辑器。
|
|
2420
|
+
* 直接调用 RoutingCard(props) 而不是 h(RoutingCard):少一层组件,hooks 顺序不变。
|
|
2421
|
+
*/
|
|
2422
|
+
function RoutingSection(props) {
|
|
2423
|
+
return RoutingCard(props)
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2153
2426
|
/**
|
|
2154
2427
|
* 格式化 token 数字 —— 大数带千分位,>9999 折成 1.2万。
|
|
2155
2428
|
* @param value - token 数。
|
|
@@ -2639,13 +2912,17 @@ window.__ModuleLoader__.load({
|
|
|
2639
2912
|
locale: NS,
|
|
2640
2913
|
inject: () => ({ t, availability })
|
|
2641
2914
|
}, HealthCard))
|
|
2642
|
-
|
|
2643
|
-
|
|
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',
|
|
2644
2920
|
id: 'dsh-llm-hub-routing',
|
|
2645
|
-
order:
|
|
2921
|
+
order: 12,
|
|
2922
|
+
label: () => t('routingNav'),
|
|
2646
2923
|
locale: NS,
|
|
2647
2924
|
inject: () => ({ t })
|
|
2648
|
-
},
|
|
2925
|
+
}, RoutingSection))
|
|
2649
2926
|
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
2650
2927
|
name: 'settings.models.footer',
|
|
2651
2928
|
id: 'dsh-llm-hub-usage',
|
package/lib/index.js
CHANGED
|
@@ -291,6 +291,35 @@ export function parseRouteEntryPure(entry) {
|
|
|
291
291
|
* @param {object|undefined|null} routing - settings.routing 段。
|
|
292
292
|
* @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
|
|
293
293
|
*/
|
|
294
|
+
/**
|
|
295
|
+
* 备注清洗:只收字符串,去首尾空白,空串视为没有,最长 120 字。
|
|
296
|
+
* @param {unknown} value - 原始备注。
|
|
297
|
+
* @returns {string | null}
|
|
298
|
+
*/
|
|
299
|
+
export function cleanRouteNote(value) {
|
|
300
|
+
if (typeof value !== 'string') return null
|
|
301
|
+
const note = value.trim().slice(0, 120)
|
|
302
|
+
return note === '' ? null : note
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 候选备注表:只保留键是合法 `provider/model`、且确实在本组候选里的条目 ——
|
|
307
|
+
* 删掉候选时它的备注一并消失,不在 settings 里留孤儿。
|
|
308
|
+
* @param {unknown} raw - settings 里的 notes 字典。
|
|
309
|
+
* @param {Array<{provider: string, model: string}>} entries - 本组全部候选。
|
|
310
|
+
* @returns {Record<string, string> | null}
|
|
311
|
+
*/
|
|
312
|
+
function cleanRouteNotes(raw, entries) {
|
|
313
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
314
|
+
const keys = new Set(entries.map((entry) => `${entry.provider}/${entry.model}`))
|
|
315
|
+
const out = {}
|
|
316
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
317
|
+
const note = cleanRouteNote(value)
|
|
318
|
+
if (note !== null && keys.has(key)) out[key] = note
|
|
319
|
+
}
|
|
320
|
+
return Object.keys(out).length > 0 ? out : null
|
|
321
|
+
}
|
|
322
|
+
|
|
294
323
|
export function parseRoutingGroups(routing) {
|
|
295
324
|
const r = routing !== null && typeof routing === 'object' ? routing : {}
|
|
296
325
|
const mode = r.mode === 'smart' ? 'smart' : 'manual'
|
|
@@ -306,11 +335,15 @@ export function parseRoutingGroups(routing) {
|
|
|
306
335
|
.filter((entry) => entry !== undefined)
|
|
307
336
|
if (primary === null && fallbacks.length === 0) continue
|
|
308
337
|
seen.add(id)
|
|
338
|
+
const note = cleanRouteNote(raw.note)
|
|
339
|
+
const notes = cleanRouteNotes(raw.notes, [...(primary !== null ? [primary] : []), ...fallbacks])
|
|
309
340
|
groups.push({
|
|
310
341
|
id,
|
|
311
342
|
label: typeof raw.label === 'string' && raw.label.trim() !== '' ? raw.label.trim() : null,
|
|
312
343
|
primary,
|
|
313
|
-
fallbacks
|
|
344
|
+
fallbacks,
|
|
345
|
+
...(note !== null ? { note } : {}),
|
|
346
|
+
...(notes !== null ? { notes } : {})
|
|
314
347
|
})
|
|
315
348
|
}
|
|
316
349
|
if (groups.length === 0) {
|
|
@@ -422,11 +455,22 @@ export function normalizeRoutingGroupsInput(raw) {
|
|
|
422
455
|
}
|
|
423
456
|
if (primary === null && fallbacks.length === 0) { dropped.push(`组 ${id} 没有任何合法候选`); continue }
|
|
424
457
|
seen.add(id)
|
|
458
|
+
// 候选备注两种来源:编辑器形态里每个候选对象自带 note;落盘形态是组上的 notes 字典。
|
|
459
|
+
const noteSource = { ...(item.notes !== null && typeof item.notes === 'object' ? item.notes : {}) }
|
|
460
|
+
for (const candidate of ordered ?? []) {
|
|
461
|
+
const entry = asEntry(candidate)
|
|
462
|
+
const note = candidate !== null && typeof candidate === 'object' ? cleanRouteNote(candidate.note) : null
|
|
463
|
+
if (entry !== undefined && note !== null) noteSource[`${entry.provider}/${entry.model}`] = note
|
|
464
|
+
}
|
|
465
|
+
const note = cleanRouteNote(item.note)
|
|
466
|
+
const notes = cleanRouteNotes(noteSource, [...(primary !== null ? [primary] : []), ...fallbacks])
|
|
425
467
|
groups.push({
|
|
426
468
|
id,
|
|
427
469
|
label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim().slice(0, 32) : null,
|
|
428
470
|
primary,
|
|
429
|
-
fallbacks
|
|
471
|
+
fallbacks,
|
|
472
|
+
...(note !== null ? { note } : {}),
|
|
473
|
+
...(notes !== null ? { notes } : {})
|
|
430
474
|
})
|
|
431
475
|
}
|
|
432
476
|
return { groups, dropped }
|
|
@@ -638,7 +682,9 @@ export function apply(ctx) {
|
|
|
638
682
|
try {
|
|
639
683
|
const mod = await import('@deepseek-ai/schemastery')
|
|
640
684
|
const z = (mod && typeof mod === 'object' ? mod.default ?? mod : mod)
|
|
641
|
-
|
|
685
|
+
// note / notes(1.7.0):页面上写的备注。YAML 注释在页面保存时会被整段替换冲掉,
|
|
686
|
+
// 所以说明要落成字段才留得住。notes 以 'provider/model' 为键。
|
|
687
|
+
const groupItem = z.object({ id: z.string().required(), label: z.string(), note: z.string(), primary: z.string(), fallbacks: z.array(z.string()), notes: z.dict(z.string()) })
|
|
642
688
|
schema = z.object({
|
|
643
689
|
routing: z.object({
|
|
644
690
|
mode: z.union(['manual', 'smart']).default('manual'),
|
|
@@ -1294,7 +1340,12 @@ export function apply(ctx) {
|
|
|
1294
1340
|
auth: 'raw',
|
|
1295
1341
|
shape: (body) => {
|
|
1296
1342
|
if (body?.success === false || (typeof body?.code === 'number' && body.code !== 200)) {
|
|
1297
|
-
|
|
1343
|
+
// 服务端用一份完整的 JSON 明确回答「你这个号没有套餐」
|
|
1344
|
+
// (2026-09-29 实测:`当前用户不存在coding plan`)。
|
|
1345
|
+
// 这跟 `fail()` 那几条「连不上 / 看不懂 / 没这个接口」不是一回事:
|
|
1346
|
+
// 前者是确知不可用,值得判死并隐藏;后者是拿不准,必须 fail-open 保留。
|
|
1347
|
+
// 不标 definitive 的话,欠费会永远停在「显示着但一用就报错」。
|
|
1348
|
+
return { unavailable: label(body?.msg) ?? 'quota unavailable', definitive: true }
|
|
1298
1349
|
}
|
|
1299
1350
|
const limits = Array.isArray(body?.data?.limits) ? body.data.limits : []
|
|
1300
1351
|
const items = []
|
|
@@ -1361,6 +1412,13 @@ export function apply(ctx) {
|
|
|
1361
1412
|
return undefined
|
|
1362
1413
|
}
|
|
1363
1414
|
|
|
1415
|
+
/**
|
|
1416
|
+
* provider -> 最近一次余额信封。排序要用它判计费模式,而排序是同步的
|
|
1417
|
+
* (`llm.listProviders` 不能 await),所以只能读探测顺手存下来的快照。
|
|
1418
|
+
* @type {Map<string, object>}
|
|
1419
|
+
*/
|
|
1420
|
+
const balanceSnapshot = new Map()
|
|
1421
|
+
|
|
1364
1422
|
/**
|
|
1365
1423
|
* 查询某 provider 的余额/配额。统一信封:`{ ok, kind, available, items?, reason? }`;
|
|
1366
1424
|
* 匹配不到适配器给 `{ ok, supported:false }`,无 key / 上游失败都折叠成 `available:false`
|
|
@@ -1419,9 +1477,12 @@ export function apply(ctx) {
|
|
|
1419
1477
|
}
|
|
1420
1478
|
const shaped = adapter.shape(body)
|
|
1421
1479
|
if (shaped.unavailable !== undefined) {
|
|
1422
|
-
|
|
1480
|
+
// definitive 只在「上游结构化回答了不可用」时为真;网络/解析失败走 fail(),没有这个字段。
|
|
1481
|
+
return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable, definitive: shaped.definitive === true }
|
|
1423
1482
|
}
|
|
1424
|
-
|
|
1483
|
+
const envelope = { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
|
|
1484
|
+
balanceSnapshot.set(provider, envelope)
|
|
1485
|
+
return envelope
|
|
1425
1486
|
}
|
|
1426
1487
|
|
|
1427
1488
|
/**
|
|
@@ -1665,9 +1726,18 @@ export function apply(ctx) {
|
|
|
1665
1726
|
return undefined
|
|
1666
1727
|
}
|
|
1667
1728
|
if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true || envelope.supported === false) return undefined
|
|
1668
|
-
// available=false
|
|
1669
|
-
//
|
|
1670
|
-
|
|
1729
|
+
// available=false 有两种,混在一起处理会让欠费永远漏网:
|
|
1730
|
+
// · definitive —— 上游用完整 JSON 确知「这个号没有套餐」,判死并隐藏;
|
|
1731
|
+
// · 其余(没 key / 连不上 / 看不懂 / 没这个接口)—— 只是这次没查成,
|
|
1732
|
+
// 按 fail-open 保留,否则一次网络抖动就把能用的模型藏了。
|
|
1733
|
+
// 2026-09-29 实测智谱 coding 端点就栽在这:余额接口白纸黑字回了
|
|
1734
|
+
// 「当前用户不存在coding plan」,却因为和「查不通」共用 available=false 而被放过。
|
|
1735
|
+
if (envelope.available === false) {
|
|
1736
|
+
if (envelope.definitive === true) {
|
|
1737
|
+
return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: label(envelope.reason) ?? '账户没有可用套餐' }
|
|
1738
|
+
}
|
|
1739
|
+
return undefined
|
|
1740
|
+
}
|
|
1671
1741
|
const items = Array.isArray(envelope.items) ? envelope.items : []
|
|
1672
1742
|
if (envelope.kind === 'cash') {
|
|
1673
1743
|
const amount = items[0]?.amount
|
|
@@ -1993,7 +2063,13 @@ export function apply(ctx) {
|
|
|
1993
2063
|
if (profile?.billingMode === 'usage' || profile?.billingMode === 'subscription') {
|
|
1994
2064
|
return profile.billingMode
|
|
1995
2065
|
}
|
|
1996
|
-
|
|
2066
|
+
// ⚠️ balance 必须传:少了它,`detectBillingMode` 里认 `kind: 'plan' | 'quota'`、
|
|
2067
|
+
// `resetsAt`、`weeklyPercent` 的那几条分支全是死代码,最后一律落到兜底的
|
|
2068
|
+
// `return 'usage'`。2026-09-29 实测:MiniMax 余额信封明明是
|
|
2069
|
+
// `{kind:'plan', items:[{resetsAt:'15:00', weeklyPercent:97}]}`,下拉里却被当成
|
|
2070
|
+
// 按量付费排在套餐后面 —— 配置卡片显示对了(client 传了 balance),
|
|
2071
|
+
// 下拉排序却是错的(host 没传),两边对不上就是这个漏传造成的。
|
|
2072
|
+
return detectBillingMode(profile || { provider: cleanId }, profile, balanceSnapshot.get(cleanId) ?? balanceSnapshot.get(id))
|
|
1997
2073
|
}
|
|
1998
2074
|
|
|
1999
2075
|
const filtered = function (...args) {
|
|
@@ -2707,9 +2783,11 @@ export function apply(ctx) {
|
|
|
2707
2783
|
return {
|
|
2708
2784
|
id: group.id,
|
|
2709
2785
|
label: group.label,
|
|
2786
|
+
note: group.note ?? null,
|
|
2710
2787
|
candidates: candidates.map((candidate) => ({
|
|
2711
2788
|
provider: candidate.provider,
|
|
2712
2789
|
model: candidate.model,
|
|
2790
|
+
note: group.notes?.[`${candidate.provider}/${candidate.model}`] ?? null,
|
|
2713
2791
|
state: stateFor(candidate.provider, candidate.model)
|
|
2714
2792
|
})),
|
|
2715
2793
|
wouldRoute: winner.to,
|
|
@@ -2877,8 +2955,10 @@ export function apply(ctx) {
|
|
|
2877
2955
|
groups: normalized.groups.map((group) => ({
|
|
2878
2956
|
id: group.id,
|
|
2879
2957
|
...(group.label !== null ? { label: group.label } : {}),
|
|
2958
|
+
...(group.note ? { note: group.note } : {}),
|
|
2880
2959
|
...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
|
|
2881
|
-
fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
|
|
2960
|
+
fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`),
|
|
2961
|
+
...(group.notes ? { notes: group.notes } : {})
|
|
2882
2962
|
}))
|
|
2883
2963
|
}
|
|
2884
2964
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plugins/dsh-llm-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -85,8 +85,17 @@ const walkStrings = (v) => {
|
|
|
85
85
|
for (const field of ['main', 'types', 'module', 'bin', 'exports']) walkStrings(pkg[field])
|
|
86
86
|
if (pkg.dsh && typeof pkg.dsh === 'object') walkStrings(pkg.dsh.bundle)
|
|
87
87
|
|
|
88
|
+
// exports 允许「子路径模式」:`"./styles/*": "./styles/*"`。
|
|
89
|
+
// 这是 npm 官方推荐的整目录可 import 写法,不是错字 —— 2026-09-22 的 retro 记过:
|
|
90
|
+
// 白名单式把每个子路径列一遍,漏一个下游构建就直接 `Missing "./styles/x.css"` 失败。
|
|
91
|
+
// 所以带 * 的条目必须按 glob 去匹配 tarball,而不是要求存在一个名叫 `styles/*` 的文件。
|
|
92
|
+
// 但「模式一个文件都匹配不到」仍要拦 —— 那说明目录被删了或没进 files[],模式是死的。
|
|
93
|
+
const entryGlobRe = (g) => new RegExp(
|
|
94
|
+
'^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
|
|
95
|
+
)
|
|
88
96
|
for (const p of entryPaths) {
|
|
89
|
-
|
|
97
|
+
const ok = /[*?]/.test(p) ? packFiles.some(f => entryGlobRe(p).test(f)) : inPack(p)
|
|
98
|
+
if (!ok) {
|
|
90
99
|
errors.push(`package.json 的入口字段引用了 '${p}',但 tarball 里没有\n → 装包的人会在 require/import 时直接失败`)
|
|
91
100
|
}
|
|
92
101
|
}
|