@dsh-plugins/dsh-llm-hub 1.5.4 → 1.5.5
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 +16 -0
- package/lib/client.js +407 -42
- package/lib/index.js +257 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.5.5] - 2026-09-27
|
|
6
|
+
|
|
7
|
+
### 🪧 路由卡:每条候选的能力 chip(思考 + 多模态)
|
|
8
|
+
|
|
9
|
+
之前 routing 卡里只把「多模态」用一个 `👁` emoji 标在 model id 后面,**思考档位完全没标** —— 用户必须查文档才能知道这个模型能思考、能用哪些 effort。这一版把这条缝上:
|
|
10
|
+
|
|
11
|
+
- **`routingCatalogOf()`**(`lib/index.js:2789`)每个 model 多带一条 `reasoning: { levels: [{ id, name }, …] } | null`,从 settings 的 `reasoningEfforts` 抽出;键名是人话(`off` / `low` / `high` / `max`),值是适配器认的 effort id,`null` 值的档位(强制思考、不能关的 `off`)不进 chip 文本。
|
|
12
|
+
- **`routing card`**(`lib/client.js` RoutingCard):
|
|
13
|
+
- 每个候选后面追加两枚 chip(仅在能力存在时出现):`💭 思考 low/high/max`、`👁 图像`;思考档位 >3 个时折成 `low/medium/high+2`。
|
|
14
|
+
- 多模态 chip 之前是裸 emoji,现在与思考 chip 共用一套样式(`.dsh-llm-hub-routing__reasoning / __multimodal`),按 `state-info` / `state-success` 主题色上边框 —— 一眼分得清「能干什么」。
|
|
15
|
+
- 添加候选的下拉 `<option>` 同步带 `💭 … 👁` 后缀(同一 catalog 数据源)。
|
|
16
|
+
- **i18n**:`routeMultimodalShort` / `routeReasoningShort` / `routeReasoningTip` 加进 zh + en;思考档位的 tooltip 列出 `name → id` 的映射,方便排查 `off: null` 之类的「明明配置了 off 却不能用」的情况。
|
|
17
|
+
- **测试**:`test/client-cards.test.mjs` 的多模态 chip 用例改成查新类名(`dsh-llm-hub-routing__multimodal`),`node --test` 131 项全过。
|
|
18
|
+
|
|
19
|
+
数据真源仍是 settings —— 不打网关、不改路由逻辑,只是把已经在那儿的事实亮出来。
|
|
20
|
+
|
|
5
21
|
## [1.5.4] - 2026-09-27
|
|
6
22
|
|
|
7
23
|
### 🛡️ 智能路由接管的两处加固(1.5.0 的补丁)
|
package/lib/client.js
CHANGED
|
@@ -77,6 +77,10 @@ window.__ModuleLoader__.load({
|
|
|
77
77
|
const ROUTING_STATE_URL = '/api/dsh-llm-hub/routing/state'
|
|
78
78
|
/** 路由组切换:POST { mode?, activeGroup? },持久化进 settings。 */
|
|
79
79
|
const ROUTING_SELECT_URL = '/api/dsh-llm-hub/routing/select'
|
|
80
|
+
/** 可路由目录:settings 里已配置的 provider 与模型(离线读,不打网关)。 */
|
|
81
|
+
const ROUTING_CATALOG_URL = '/api/dsh-llm-hub/routing/catalog'
|
|
82
|
+
/** 路由组编辑保存:POST { groups, mode?, activeGroup? }。 */
|
|
83
|
+
const ROUTING_CONFIG_URL = '/api/dsh-llm-hub/routing/config'
|
|
80
84
|
/** 模型别名:把长 id 显示成短名(人话)。 */
|
|
81
85
|
const ALIASES_URL = '/api/dsh-llm-hub/aliases'
|
|
82
86
|
const BILLING_MODE_URL = '/api/dsh-llm-hub/billing-mode'
|
|
@@ -172,6 +176,34 @@ window.__ModuleLoader__.load({
|
|
|
172
176
|
routeChipLastRoute: '最近实际',
|
|
173
177
|
routeChipReasonNone: '候选全部不可用',
|
|
174
178
|
routeChipFailOpen: '路由失败时按原配置发出',
|
|
179
|
+
routeRuleTitle: '规则',
|
|
180
|
+
routeRuleText: '按组内顺序取第一个可用;探测失败或额度耗尽的自动跳过;全不可用则保持原样(fail-open)。顺序即优先级 —— 固定成本(包月/订阅)放前面,按量付费放后面兜底。',
|
|
181
|
+
routeSmartNotice: '已接管:输入框的模型下拉只作参考,实际请求按本卡规则走(实时结果见聊天框左侧 chip)。',
|
|
182
|
+
routeManualNotice: '未接管:请求还是用你在模型下拉里选的;点「智能路由」才按本卡规则自动切。',
|
|
183
|
+
routeEditHint: '编辑后点保存写入 settings.dsh-llm-hub.routing',
|
|
184
|
+
routeSave: '保存',
|
|
185
|
+
routeSaved: '已保存',
|
|
186
|
+
routeSaveFailed: '保存失败',
|
|
187
|
+
routeAddCandidate: '+ 添加候选',
|
|
188
|
+
routeAddProvider: '提供方',
|
|
189
|
+
routeAddModel: '模型',
|
|
190
|
+
routeAdd: '添加',
|
|
191
|
+
routeNewGroup: '+ 新建组',
|
|
192
|
+
routeDeleteGroup: '删除该组',
|
|
193
|
+
routeUp: '上移',
|
|
194
|
+
routeDown: '下移',
|
|
195
|
+
routeRemove: '移除',
|
|
196
|
+
routeGroupLabelPlaceholder: '组名(公司 / 个人)',
|
|
197
|
+
routeActiveTag: '当前组',
|
|
198
|
+
routeSetActive: '设为当前组',
|
|
199
|
+
routePrimaryTag: '主力',
|
|
200
|
+
routeFallbackTag: '备',
|
|
201
|
+
routeNoCatalog: '读不到 provider 目录 —— 先在设置里配好 provider 再来加候选',
|
|
202
|
+
routeDropped: '有配置被忽略',
|
|
203
|
+
routeMultimodalTip: '多模态:输入含图像等非文本模态(来自 settings 的模态声明)',
|
|
204
|
+
routeMultimodalShort: '图像',
|
|
205
|
+
routeReasoningTip: '思考链:该模型声明的推理档位(来自 settings 的 reasoningEfforts)',
|
|
206
|
+
routeReasoningShort: '思考',
|
|
175
207
|
aliasesMissing: '尚未配置别名',
|
|
176
208
|
heroTitle: 'LLM 矩阵概览',
|
|
177
209
|
heroProviders: '活跃提供方',
|
|
@@ -296,6 +328,34 @@ window.__ModuleLoader__.load({
|
|
|
296
328
|
routeChipLastRoute: 'Last used',
|
|
297
329
|
routeChipReasonNone: 'All candidates unavailable',
|
|
298
330
|
routeChipFailOpen: 'Falls back to your selection when routing fails',
|
|
331
|
+
routeRuleTitle: 'Rule',
|
|
332
|
+
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.',
|
|
333
|
+
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).',
|
|
334
|
+
routeManualNotice: 'Not taking over: requests still use the model you picked in the dropdown. Switch to Smart routing to auto-select.',
|
|
335
|
+
routeEditHint: 'Save writes settings.dsh-llm-hub.routing',
|
|
336
|
+
routeSave: 'Save',
|
|
337
|
+
routeSaved: 'Saved',
|
|
338
|
+
routeSaveFailed: 'Save failed',
|
|
339
|
+
routeAddCandidate: '+ Add candidate',
|
|
340
|
+
routeAddProvider: 'Provider',
|
|
341
|
+
routeAddModel: 'Model',
|
|
342
|
+
routeAdd: 'Add',
|
|
343
|
+
routeNewGroup: '+ New group',
|
|
344
|
+
routeDeleteGroup: 'Delete group',
|
|
345
|
+
routeUp: 'Move up',
|
|
346
|
+
routeDown: 'Move down',
|
|
347
|
+
routeRemove: 'Remove',
|
|
348
|
+
routeGroupLabelPlaceholder: 'Group name (work / personal)',
|
|
349
|
+
routeActiveTag: 'active',
|
|
350
|
+
routeSetActive: 'Set active',
|
|
351
|
+
routePrimaryTag: 'primary',
|
|
352
|
+
routeFallbackTag: 'alt',
|
|
353
|
+
routeNoCatalog: 'No provider directory — configure a provider first',
|
|
354
|
+
routeDropped: 'Some entries ignored',
|
|
355
|
+
routeMultimodalTip: 'Multimodal: accepts non-text input such as images (from the modality declared in settings)',
|
|
356
|
+
routeMultimodalShort: 'vision',
|
|
357
|
+
routeReasoningTip: 'Reasoning chain: effort levels declared by this model (from settings.reasoningEfforts)',
|
|
358
|
+
routeReasoningShort: 'think',
|
|
299
359
|
aliasesMissing: 'no aliases configured',
|
|
300
360
|
heroTitle: 'LLM Matrix Overview',
|
|
301
361
|
heroProviders: 'Active providers',
|
|
@@ -537,6 +597,21 @@ window.__ModuleLoader__.load({
|
|
|
537
597
|
'border-radius:3px;border:.5px solid var(--dsw-alias-border-l2);',
|
|
538
598
|
'background:transparent;font-size:10px;color:var(--dsw-alias-label-secondary)}',
|
|
539
599
|
'.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
600
|
+
// capability 标识:思考 / 多模态。两枚 chip 同形,左思考、右图像;
|
|
601
|
+
// 边色走 state 提示色,跟健康状态 chip 区分开 —— 一个说「能干什么」,
|
|
602
|
+
// 一个说「能不能用」。
|
|
603
|
+
'.dsh-llm-hub-routing__reasoning,.dsh-llm-hub-routing__multimodal{',
|
|
604
|
+
'display:inline-flex;align-items:center;height:16px;padding:0 6px;',
|
|
605
|
+
'border-radius:3px;font-size:10px;line-height:14px;font-weight:500;',
|
|
606
|
+
'background:transparent;cursor:help}',
|
|
607
|
+
// 思考:走 warn 主题色(消耗更多 token、需要警觉)
|
|
608
|
+
'.dsh-llm-hub-routing__reasoning{',
|
|
609
|
+
'border:.5px solid var(--dsw-alias-state-warn-primary);',
|
|
610
|
+
'color:var(--dsw-alias-state-warn-primary)}',
|
|
611
|
+
// 多模态:走 success 主题色(输入更丰富 = 好)
|
|
612
|
+
'.dsh-llm-hub-routing__multimodal{',
|
|
613
|
+
'border:.5px solid var(--dsw-alias-state-success-primary);',
|
|
614
|
+
'color:var(--dsw-alias-state-success-primary)}',
|
|
540
615
|
'.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);padding-top:2px}',
|
|
541
616
|
'.dsh-llm-hub-routing__reason{color:var(--dsw-alias-state-error-primary);padding-top:2px}',
|
|
542
617
|
// composer 快切 chip(conversation.input.left)
|
|
@@ -567,6 +642,30 @@ window.__ModuleLoader__.load({
|
|
|
567
642
|
'.dsh-llm-hub-route__group-name{font-weight:600;font-size:11px}',
|
|
568
643
|
'.dsh-llm-hub-route__state{font-size:10px;color:var(--dsw-alias-label-secondary);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
569
644
|
'.dsh-llm-hub-route__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
|
|
645
|
+
'.dsh-llm-hub-route__order{font-size:10px;color:var(--dsw-alias-label-secondary);',
|
|
646
|
+
'font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;line-height:1.4}',
|
|
647
|
+
// 智能路由编辑器(设置页路由卡)
|
|
648
|
+
'.dsh-llm-hub-routing__modes{display:flex;gap:4px;margin-left:auto}',
|
|
649
|
+
'.dsh-llm-hub-routing__rule{display:flex;gap:6px;align-items:flex-start;font-size:10.5px;line-height:1.5;',
|
|
650
|
+
'color:var(--dsw-alias-label-secondary);padding:6px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2)}',
|
|
651
|
+
'.dsh-llm-hub-routing__rule-tag{flex:none;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
652
|
+
'.dsh-llm-hub-routing__now{font-size:11px;color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
653
|
+
'.dsh-llm-hub-routing__groups{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
|
|
654
|
+
'.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}',
|
|
655
|
+
'.dsh-llm-hub-routing__group.is-active{border-color:var(--dsw-alias-state-warn-primary)}',
|
|
656
|
+
'.dsh-llm-hub-routing__group-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
657
|
+
'.dsh-llm-hub-routing__group-toggle{border:0;background:0 0;padding:0;cursor:pointer;font:inherit;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
658
|
+
'.dsh-llm-hub-routing__group-label{flex:1;min-width:110px;box-sizing:border-box;height:22px;padding:0 6px;border-radius:4px;',
|
|
659
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px}',
|
|
660
|
+
'.dsh-llm-hub-routing__group-state{font-size:10.5px;color:var(--dsw-alias-label-secondary);max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
661
|
+
'.dsh-llm-hub-routing__candidates{display:flex;flex-direction:column;gap:3px;padding-top:2px}',
|
|
662
|
+
'.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}',
|
|
663
|
+
'.dsh-llm-hub-routing__select{box-sizing:border-box;height:22px;padding:0 4px;border-radius:4px;border:.5px solid var(--dsw-alias-border-l1);',
|
|
664
|
+
'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;max-width:170px}',
|
|
665
|
+
'.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}',
|
|
666
|
+
'.dsh-llm-hub-routing__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
|
|
667
|
+
'.dsh-llm-hub-routing__mm{display:inline-flex;align-items:center;margin-left:4px;font-size:10px;cursor:help;',
|
|
668
|
+
'color:var(--dsw-alias-state-success-primary,#10b981)}',
|
|
570
669
|
// Hero 全景统计大横条
|
|
571
670
|
'.dsh-llm-hub-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin:12px 0 6px 0}',
|
|
572
671
|
'.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)}',
|
|
@@ -1649,7 +1748,15 @@ window.__ModuleLoader__.load({
|
|
|
1649
1748
|
},
|
|
1650
1749
|
h('span', { className: 'dsh-llm-hub-route__group-top' },
|
|
1651
1750
|
h('span', { className: 'dsh-llm-hub-route__group-name' }, group.label ?? group.id),
|
|
1652
|
-
h('span', { className: 'dsh-llm-hub-route__state' }, stateText(group)))
|
|
1751
|
+
h('span', { className: 'dsh-llm-hub-route__state' }, stateText(group))),
|
|
1752
|
+
// 顺序即优先级:把候选按组内顺序摊出来(① = 主力),让人一眼看到
|
|
1753
|
+
// 「智能」到底怎么个智能法,而不是只有一个 wouldRoute 结果。
|
|
1754
|
+
h('span', { className: 'dsh-llm-hub-route__order' },
|
|
1755
|
+
(group.candidates ?? []).map((candidate, index) => `${index === 0 ? '①' : index === 1 ? '②' : index === 2 ? '③' : `(${index + 1})`}${candidate.provider}/${candidate.model}${candidate.state === 'unavailable' ? '✗' : ''}`).join(' → ')),
|
|
1756
|
+
group.reason !== null && group.reason !== undefined && group.wouldRoute === null
|
|
1757
|
+
? h('span', { className: 'dsh-llm-hub-route__hint' }, group.reason)
|
|
1758
|
+
: null)),
|
|
1759
|
+
h('div', { className: 'dsh-llm-hub-route__hint' }, t('routeRuleText'))) : null
|
|
1653
1760
|
return h('div', { className: 'dsh-llm-hub-route' },
|
|
1654
1761
|
h('button', {
|
|
1655
1762
|
type: 'button',
|
|
@@ -1685,56 +1792,314 @@ window.__ModuleLoader__.load({
|
|
|
1685
1792
|
*/
|
|
1686
1793
|
function RoutingCard(props) {
|
|
1687
1794
|
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1688
|
-
const [
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1795
|
+
const [state, setState] = React.useState(null)
|
|
1796
|
+
const [catalog, setCatalog] = React.useState([])
|
|
1797
|
+
// draft 是编辑器的工作副本:[{ id, label, candidates: [{provider, model}] }],
|
|
1798
|
+
// candidates[0] = 主力(primary),其余是 fallback —— 与 settings 形态一一对应。
|
|
1799
|
+
const [draft, setDraft] = React.useState(null)
|
|
1800
|
+
const [activeGroup, setActiveGroup] = React.useState('')
|
|
1801
|
+
const [expanded, setExpanded] = React.useState('')
|
|
1802
|
+
const [picker, setPicker] = React.useState({ provider: '', model: '' })
|
|
1803
|
+
const [busy, setBusy] = React.useState(false)
|
|
1804
|
+
const [notice, setNotice] = React.useState(null)
|
|
1805
|
+
// 保存过/刚拉回的 draft 指纹:用它判断「脏」,避免后台刷新把用户正在编辑的内容冲掉。
|
|
1806
|
+
const [savedKey, setSavedKey] = React.useState('')
|
|
1807
|
+
// label 归一成「空 = null」:草稿里是 '',服务端回的是 null,不归一会永远显示「未保存」。
|
|
1808
|
+
const draftKeyOf = (report) => JSON.stringify((report?.groups ?? []).map((group) => [
|
|
1809
|
+
group.id,
|
|
1810
|
+
group.label ? group.label : null,
|
|
1811
|
+
(group.candidates ?? []).map((candidate) => `${candidate.provider}/${candidate.model}`)
|
|
1812
|
+
]))
|
|
1813
|
+
const draftFrom = (report) => (report.groups ?? []).map((group) => ({
|
|
1814
|
+
id: group.id,
|
|
1815
|
+
label: group.label ?? '',
|
|
1816
|
+
candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model }))
|
|
1817
|
+
}))
|
|
1818
|
+
// force=true 只用在一处:保存成功后的回读 —— 那时服务端就是权威。
|
|
1819
|
+
// 其余时候(挂载、切模式、轮询)保留用户正在编辑的草稿。
|
|
1820
|
+
const adopt = React.useCallback((report, force) => {
|
|
1821
|
+
setState(report)
|
|
1822
|
+
setActiveGroup(report.activeGroup)
|
|
1823
|
+
setSavedKey(draftKeyOf(report))
|
|
1824
|
+
setDraft((prev) => (force === true || prev === null ? draftFrom(report) : prev))
|
|
1698
1825
|
}, [])
|
|
1699
|
-
React.
|
|
1700
|
-
|
|
1826
|
+
const loadState = React.useCallback(() => {
|
|
1827
|
+
fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
|
|
1828
|
+
.then((res) => res.json())
|
|
1829
|
+
.then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) adopt(body) })
|
|
1830
|
+
.catch(() => {})
|
|
1831
|
+
}, [adopt])
|
|
1832
|
+
React.useEffect(() => {
|
|
1833
|
+
loadState()
|
|
1834
|
+
fetch(ROUTING_CATALOG_URL, { headers: { accept: 'application/json' } })
|
|
1835
|
+
.then((res) => res.json())
|
|
1836
|
+
.then((body) => { if (body !== null && typeof body === 'object' && body.ok === true && Array.isArray(body.providers)) setCatalog(body.providers) })
|
|
1837
|
+
.catch(() => {})
|
|
1838
|
+
}, [loadState])
|
|
1839
|
+
if (state === null) {
|
|
1701
1840
|
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
1702
1841
|
h('div', { className: 'dsh-llm-hub-routing__head' },
|
|
1703
1842
|
h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
|
|
1704
1843
|
h('span', null, t('routingLoading'))))
|
|
1705
1844
|
}
|
|
1706
|
-
if (report.ok !== true) return null
|
|
1707
|
-
const candidates = Array.isArray(report.candidates) ? report.candidates : []
|
|
1708
|
-
if (candidates.length === 0) {
|
|
1709
|
-
return null
|
|
1710
|
-
}
|
|
1711
1845
|
ensureStyle()
|
|
1712
|
-
const
|
|
1713
|
-
const
|
|
1714
|
-
const
|
|
1715
|
-
const
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1846
|
+
const isSmart = state.mode === 'smart'
|
|
1847
|
+
const groups = draft ?? []
|
|
1848
|
+
const isDirty = draft !== null && draftKeyOf({ groups: groups.map((group) => ({ id: group.id, label: group.label, candidates: group.candidates })) }) !== savedKey
|
|
1849
|
+
const post = (url, payload) => fetch(url, {
|
|
1850
|
+
method: 'POST',
|
|
1851
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1852
|
+
body: JSON.stringify(payload)
|
|
1853
|
+
}).then((res) => res.json())
|
|
1854
|
+
const setMode = (mode) => {
|
|
1855
|
+
setNotice(null)
|
|
1856
|
+
post(ROUTING_SELECT_URL, { mode })
|
|
1857
|
+
.then((body) => { if (body?.ok === true) adopt(body, true) })
|
|
1858
|
+
.catch(() => setNotice('fail'))
|
|
1859
|
+
}
|
|
1860
|
+
const markActive = (id) => {
|
|
1861
|
+
setActiveGroup(id)
|
|
1862
|
+
setNotice(null)
|
|
1863
|
+
post(ROUTING_SELECT_URL, { activeGroup: id })
|
|
1864
|
+
.then((body) => { if (body?.ok === true) adopt(body) })
|
|
1865
|
+
.catch(() => setNotice('fail'))
|
|
1866
|
+
}
|
|
1867
|
+
const save = () => {
|
|
1868
|
+
if (groups.length === 0) return
|
|
1869
|
+
setBusy(true)
|
|
1870
|
+
setNotice(null)
|
|
1871
|
+
post(ROUTING_CONFIG_URL, { groups, activeGroup })
|
|
1872
|
+
.then((body) => {
|
|
1873
|
+
if (body?.ok === true) { adopt(body, true); setNotice('saved') }
|
|
1874
|
+
else setNotice('fail')
|
|
1875
|
+
})
|
|
1876
|
+
.catch(() => setNotice('fail'))
|
|
1877
|
+
.finally(() => setBusy(false))
|
|
1878
|
+
}
|
|
1879
|
+
// ── 草稿编辑(全部是纯函数式更新,不碰服务端;保存才落盘)──
|
|
1880
|
+
const editGroup = (id, mutate) => setDraft(groups.map((group) => (group.id === id ? mutate(group) : group)))
|
|
1881
|
+
const moveCandidate = (id, index, delta) => editGroup(id, (group) => {
|
|
1882
|
+
const next = [...group.candidates]
|
|
1883
|
+
const target = index + delta
|
|
1884
|
+
if (target < 0 || target >= next.length) return group
|
|
1885
|
+
const [item] = next.splice(index, 1)
|
|
1886
|
+
next.splice(target, 0, item)
|
|
1887
|
+
return { ...group, candidates: next }
|
|
1888
|
+
})
|
|
1889
|
+
const removeCandidate = (id, index) => editGroup(id, (group) => ({ ...group, candidates: group.candidates.filter((_, i) => i !== index) }))
|
|
1890
|
+
const addCandidate = (id) => {
|
|
1891
|
+
const provider = picker.provider
|
|
1892
|
+
const model = picker.model
|
|
1893
|
+
if (provider === '' || model === '') return
|
|
1894
|
+
editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model }] }))
|
|
1895
|
+
setPicker({ provider: '', model: '' })
|
|
1896
|
+
}
|
|
1897
|
+
const addGroup = () => {
|
|
1898
|
+
let index = groups.length + 1
|
|
1899
|
+
let id = `group-${index}`
|
|
1900
|
+
while (groups.some((group) => group.id === id)) { index += 1; id = `group-${index}` }
|
|
1901
|
+
setDraft([...groups, { id, label: '', candidates: [] }])
|
|
1902
|
+
setExpanded(id)
|
|
1903
|
+
}
|
|
1904
|
+
const removeGroup = (id) => {
|
|
1905
|
+
setDraft(groups.filter((group) => group.id !== id))
|
|
1906
|
+
if (expanded === id) setExpanded('')
|
|
1907
|
+
}
|
|
1908
|
+
const stateChipClass = (value) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${value ?? 'unknown'}`
|
|
1909
|
+
/**
|
|
1910
|
+
* 一个候选是否多模态(输入含 text 之外的模态,典型是 image)。
|
|
1911
|
+
*
|
|
1912
|
+
* 数据来自目录接口的 `input` 字段(settings 里本来就有);目录读不到时
|
|
1913
|
+
* 返回 undefined —— UI 不标而不是瞎猜。
|
|
1914
|
+
*/
|
|
1915
|
+
const isMultimodal = (provider, modelId) => {
|
|
1916
|
+
const entry = catalog.find((item) => item.id === provider)
|
|
1917
|
+
const model = Array.isArray(entry?.models) ? entry.models.find((item) => item.id === modelId) : undefined
|
|
1918
|
+
const input = Array.isArray(model?.input) ? model.input : []
|
|
1919
|
+
return input.length > 0 && input.some((kind) => kind !== 'text')
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* 一个候选的推理档位列表(来自 settings 的 reasoningEfforts 字段);
|
|
1923
|
+
* 没声明推理能力时返回 undefined —— UI 不标。
|
|
1924
|
+
* 返回的是档位对象的浅拷贝(UI 用),外面别 mutate。
|
|
1925
|
+
*/
|
|
1926
|
+
const reasoningLevels = (provider, modelId) => {
|
|
1927
|
+
const entry = catalog.find((item) => item.id === provider)
|
|
1928
|
+
const model = Array.isArray(entry?.models) ? entry.models.find((item) => item.id === modelId) : undefined
|
|
1929
|
+
const levels = Array.isArray(model?.reasoning?.levels) ? model.reasoning.levels : []
|
|
1930
|
+
return levels.length > 0 ? levels : undefined
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* 推理档位的人话短文本:超 3 个档位时压成 `a/b/c+2`,避免长列表把行挤爆。
|
|
1934
|
+
*/
|
|
1935
|
+
const reasoningShort = (levels) => {
|
|
1936
|
+
if (levels === undefined) return ''
|
|
1937
|
+
const names = levels.map((level) => level.name)
|
|
1938
|
+
if (names.length <= 3) return names.join('/')
|
|
1939
|
+
return `${names.slice(0, 3).join('/')}+${names.length - 3}`
|
|
1940
|
+
}
|
|
1941
|
+
// 「现在会路由到哪」:smart 模式看 lastDecision(真实请求),否则按组顺序的推演。
|
|
1942
|
+
const resolvedText = () => {
|
|
1943
|
+
if (isSmart) {
|
|
1944
|
+
const decision = state.lastDecision
|
|
1945
|
+
if (decision?.to) return `${t('routeChipLastRoute')}: ${decision.to.provider}/${decision.to.model}`
|
|
1946
|
+
return decision?.reason ?? t('routeChipFailOpen')
|
|
1947
|
+
}
|
|
1948
|
+
const group = state.groups.find((item) => item.id === activeGroup) ?? state.groups[0]
|
|
1949
|
+
if (group?.wouldRoute) return `${t('routeChipWouldRoute')}: ${group.wouldRoute.provider}/${group.wouldRoute.model}`
|
|
1950
|
+
return group?.reason ?? t('routeChipReasonNone')
|
|
1951
|
+
}
|
|
1952
|
+
const header = h('div', { className: 'dsh-llm-hub-routing__head' },
|
|
1719
1953
|
h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1954
|
+
h('div', { className: 'dsh-llm-hub-routing__modes' },
|
|
1955
|
+
h('button', {
|
|
1956
|
+
type: 'button',
|
|
1957
|
+
className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
|
|
1958
|
+
onClick: () => { if (!isSmart) setMode('smart') }
|
|
1959
|
+
}, `⚡ ${t('routeChipModeSmart')}`),
|
|
1960
|
+
h('button', {
|
|
1961
|
+
type: 'button',
|
|
1962
|
+
className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
|
|
1963
|
+
onClick: () => { if (isSmart) setMode('manual') }
|
|
1964
|
+
}, t('routeChipModeManual'))))
|
|
1965
|
+
const rule = h('div', { className: 'dsh-llm-hub-routing__rule' },
|
|
1966
|
+
h('span', { className: 'dsh-llm-hub-routing__rule-tag' }, t('routeRuleTitle')),
|
|
1967
|
+
h('span', null, t('routeRuleText')))
|
|
1968
|
+
const noticeLine = h('div', { className: 'dsh-llm-hub-routing__reason' },
|
|
1969
|
+
isSmart ? t('routeSmartNotice') : t('routeManualNotice'))
|
|
1970
|
+
const nowLine = h('div', { className: 'dsh-llm-hub-routing__now' }, resolvedText())
|
|
1971
|
+
const groupRows = groups.map((group, groupIndex) => {
|
|
1972
|
+
const live = state.groups.find((item) => item.id === group.id)
|
|
1973
|
+
const isOpen = expanded === group.id
|
|
1974
|
+
const candidates = group.candidates
|
|
1975
|
+
return h('div', { key: group.id, className: `dsh-llm-hub-routing__group${activeGroup === group.id ? ' is-active' : ''}` },
|
|
1976
|
+
h('div', { className: 'dsh-llm-hub-routing__group-top' },
|
|
1977
|
+
h('button', {
|
|
1978
|
+
type: 'button',
|
|
1979
|
+
className: 'dsh-llm-hub-routing__group-toggle',
|
|
1980
|
+
onClick: () => setExpanded(isOpen ? '' : group.id)
|
|
1981
|
+
}, `${isOpen ? '▾' : '▸'} ${groupIndex + 1}`),
|
|
1982
|
+
h('input', {
|
|
1983
|
+
className: 'dsh-llm-hub-routing__group-label',
|
|
1984
|
+
value: group.label ?? '',
|
|
1985
|
+
placeholder: t('routeGroupLabelPlaceholder'),
|
|
1986
|
+
onChange: (event) => editGroup(group.id, (current) => ({ ...current, label: event.target.value }))
|
|
1987
|
+
}),
|
|
1988
|
+
live?.wouldRoute
|
|
1989
|
+
? h('span', { className: 'dsh-llm-hub-routing__group-state' }, `${live.wouldRoute.provider}/${live.wouldRoute.model}`)
|
|
1990
|
+
: h('span', { className: 'dsh-llm-hub-routing__group-state' }, live?.reason ?? t('routeChipReasonNone')),
|
|
1991
|
+
activeGroup === group.id
|
|
1992
|
+
? h('span', { className: 'dsh-llm-hub-routing__role' }, t('routeActiveTag'))
|
|
1993
|
+
: h('button', {
|
|
1994
|
+
type: 'button',
|
|
1995
|
+
className: 'dsh-llm-hub-foot__link',
|
|
1996
|
+
onClick: () => markActive(group.id)
|
|
1997
|
+
}, t('routeSetActive'))),
|
|
1998
|
+
isOpen
|
|
1999
|
+
? h('div', { className: 'dsh-llm-hub-routing__candidates' },
|
|
2000
|
+
...candidates.map((candidate, index) => {
|
|
2001
|
+
const liveCandidate = live?.candidates?.find((item) => item.provider === candidate.provider && item.model === candidate.model)
|
|
2002
|
+
const multimodal = isMultimodal(candidate.provider, candidate.model)
|
|
2003
|
+
const reasoning = reasoningLevels(candidate.provider, candidate.model)
|
|
2004
|
+
const reasoningTip = reasoning === undefined
|
|
2005
|
+
? undefined
|
|
2006
|
+
: `${t('routeReasoningTip')}\n${reasoning.map((level) => ` · ${level.name} → ${level.id}`).join('\n')}`
|
|
2007
|
+
return h('div', { key: `${candidate.provider}/${candidate.model}/${index}`, className: 'dsh-llm-hub-routing__row' },
|
|
2008
|
+
h('span', { className: 'dsh-llm-hub-routing__role' }, index === 0 ? t('routePrimaryTag') : `${t('routeFallbackTag')}${index}`),
|
|
2009
|
+
h('span', { className: 'dsh-llm-hub-routing__name' }, `${candidate.provider}/${candidate.model}`),
|
|
2010
|
+
reasoning !== undefined
|
|
2011
|
+
? h('span', {
|
|
2012
|
+
key: 'reasoning',
|
|
2013
|
+
className: 'dsh-llm-hub-routing__reasoning',
|
|
2014
|
+
title: reasoningTip
|
|
2015
|
+
}, `💭 ${t('routeReasoningShort')} ${reasoningShort(reasoning)}`)
|
|
2016
|
+
: null,
|
|
2017
|
+
multimodal === true
|
|
2018
|
+
? h('span', {
|
|
2019
|
+
key: 'multimodal',
|
|
2020
|
+
className: 'dsh-llm-hub-routing__multimodal',
|
|
2021
|
+
title: t('routeMultimodalTip')
|
|
2022
|
+
}, '👁 ' + t('routeMultimodalShort'))
|
|
2023
|
+
: null,
|
|
2024
|
+
h('span', { className: stateChipClass(liveCandidate?.state) }, liveCandidate?.state === 'available' ? '可用' : (liveCandidate?.state === 'unknown' ? '未探明' : '不可用')),
|
|
2025
|
+
h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeUp'), onClick: () => moveCandidate(group.id, index, -1) }, '↑'),
|
|
2026
|
+
h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeDown'), onClick: () => moveCandidate(group.id, index, 1) }, '↓'),
|
|
2027
|
+
h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeRemove'), onClick: () => removeCandidate(group.id, index) }, '✕'))
|
|
2028
|
+
}),
|
|
2029
|
+
h('div', { className: 'dsh-llm-hub-routing__add' },
|
|
2030
|
+
h('span', null, t('routeAddProvider')),
|
|
2031
|
+
catalog.length > 0
|
|
2032
|
+
? h('select', {
|
|
2033
|
+
className: 'dsh-llm-hub-routing__select',
|
|
2034
|
+
value: picker.provider,
|
|
2035
|
+
onChange: (event) => {
|
|
2036
|
+
const provider = event.target.value
|
|
2037
|
+
const entry = catalog.find((item) => item.id === provider)
|
|
2038
|
+
setPicker({ provider, model: entry?.models?.[0]?.id ?? '' })
|
|
2039
|
+
}
|
|
2040
|
+
},
|
|
2041
|
+
h('option', { value: '' }, '—'),
|
|
2042
|
+
...catalog.map((entry) => h('option', { key: entry.id, value: entry.id }, `${entry.displayName ?? entry.id}`)))
|
|
2043
|
+
// 目录读不到(host 还没重启 / settings 里没有 provider)时退回手输 ——
|
|
2044
|
+
// 不让「拉不到目录」变成「编辑不了」。
|
|
2045
|
+
: h('input', {
|
|
2046
|
+
className: 'dsh-llm-hub-routing__select',
|
|
2047
|
+
placeholder: 'provider',
|
|
2048
|
+
value: picker.provider,
|
|
2049
|
+
onChange: (event) => setPicker({ provider: event.target.value, model: picker.model })
|
|
2050
|
+
}),
|
|
2051
|
+
h('span', null, t('routeAddModel')),
|
|
2052
|
+
catalog.find((item) => item.id === picker.provider) !== undefined
|
|
2053
|
+
? h('select', {
|
|
2054
|
+
className: 'dsh-llm-hub-routing__select',
|
|
2055
|
+
value: picker.model,
|
|
2056
|
+
onChange: (event) => setPicker({ provider: picker.provider, model: event.target.value })
|
|
2057
|
+
},
|
|
2058
|
+
h('option', { value: '' }, '—'),
|
|
2059
|
+
...((catalog.find((item) => item.id === picker.provider)?.models) ?? []).map((model) => {
|
|
2060
|
+
const multimodal = Array.isArray(model?.input) && model.input.some((kind) => kind !== 'text')
|
|
2061
|
+
const reasoningLevels = Array.isArray(model?.reasoning?.levels) ? model.reasoning.levels : []
|
|
2062
|
+
const flags = []
|
|
2063
|
+
if (reasoningLevels.length > 0) flags.push(`💭 ${reasoningShort(reasoningLevels)}`)
|
|
2064
|
+
if (multimodal === true) flags.push('👁')
|
|
2065
|
+
return h('option', { key: model.id, value: model.id }, flags.length === 0 ? model.id : `${model.id} ${flags.join(' ')}`)
|
|
2066
|
+
}))
|
|
2067
|
+
: h('input', {
|
|
2068
|
+
className: 'dsh-llm-hub-routing__select',
|
|
2069
|
+
placeholder: 'model',
|
|
2070
|
+
value: picker.model,
|
|
2071
|
+
onChange: (event) => setPicker({ provider: picker.provider, model: event.target.value })
|
|
2072
|
+
}),
|
|
2073
|
+
h('button', {
|
|
2074
|
+
type: 'button',
|
|
2075
|
+
className: 'dsh-llm-hub-btn',
|
|
2076
|
+
onClick: () => addCandidate(group.id)
|
|
2077
|
+
}, t('routeAdd'))),
|
|
2078
|
+
catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null,
|
|
2079
|
+
h('button', {
|
|
2080
|
+
type: 'button',
|
|
2081
|
+
className: 'dsh-llm-hub-foot__link',
|
|
2082
|
+
onClick: () => removeGroup(group.id)
|
|
2083
|
+
}, t('routeDeleteGroup')))
|
|
2084
|
+
: null)
|
|
2085
|
+
})
|
|
2086
|
+
const footer = h('div', { className: 'dsh-llm-hub-routing__actions' },
|
|
2087
|
+
h('button', { type: 'button', className: 'dsh-llm-hub-btn', onClick: addGroup }, t('routeNewGroup')),
|
|
2088
|
+
h('button', {
|
|
2089
|
+
type: 'button',
|
|
2090
|
+
className: 'dsh-llm-hub-btn',
|
|
2091
|
+
disabled: busy || !isDirty,
|
|
2092
|
+
onClick: save
|
|
2093
|
+
}, isDirty ? t('routeSave') : t('routeSaved')),
|
|
2094
|
+
notice === 'fail' ? h('span', { className: 'dsh-llm-hub-routing__reason' }, t('routeSaveFailed')) : null,
|
|
2095
|
+
h('span', { className: 'dsh-llm-hub-routing__hint' }, t('routeEditHint')))
|
|
1729
2096
|
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
? h('div', { className: 'dsh-llm-hub-routing__reason' }, t('routingNoRecommendation'))
|
|
1737
|
-
: null)
|
|
2097
|
+
header,
|
|
2098
|
+
rule,
|
|
2099
|
+
noticeLine,
|
|
2100
|
+
nowLine,
|
|
2101
|
+
h('div', { className: 'dsh-llm-hub-routing__groups' }, groupRows),
|
|
2102
|
+
footer)
|
|
1738
2103
|
}
|
|
1739
2104
|
|
|
1740
2105
|
/**
|
package/lib/index.js
CHANGED
|
@@ -202,6 +202,16 @@ const ROUTING_STATE_PATH = '/api/dsh-llm-hub/routing/state'
|
|
|
202
202
|
* `dsh-llm-hub.routing` 段 —— 状态持久化,重启不丢。
|
|
203
203
|
*/
|
|
204
204
|
const ROUTING_SELECT_PATH = '/api/dsh-llm-hub/routing/select'
|
|
205
|
+
/**
|
|
206
|
+
* 路由组编辑:`GET /api/dsh-llm-hub/routing/catalog` 列可路由的 provider 与模型,
|
|
207
|
+
* `POST /api/dsh-llm-hub/routing/config` 整体写回 `routing.groups`。
|
|
208
|
+
*
|
|
209
|
+
* 为什么要这两条:1.5.0 只给了 composer 的切组 chip,配置本身仍只能手改
|
|
210
|
+
* settings.yaml —— 「有入口、没闭环」。设置页的路由卡用 catalog 渲染候选选择器,
|
|
211
|
+
* 用 config 落盘编辑结果。
|
|
212
|
+
*/
|
|
213
|
+
const ROUTING_CATALOG_PATH = '/api/dsh-llm-hub/routing/catalog'
|
|
214
|
+
const ROUTING_CONFIG_PATH = '/api/dsh-llm-hub/routing/config'
|
|
205
215
|
/** 插件自有的 settings 命名空间(1.5.0 起注册)—— routing / warning / aliases 都住这里。 */
|
|
206
216
|
const HUB_NS = 'dsh-llm-hub'
|
|
207
217
|
/** 模型别名:客户端渲染 model id 时显示短名(典型场景:deepseek-reasoner → 推理)。 */
|
|
@@ -348,6 +358,80 @@ export function pickSmartRoute(entries, stateFor, active) {
|
|
|
348
358
|
return { to: { provider: winner.provider, model: winner.model }, reason: null, switched: true }
|
|
349
359
|
}
|
|
350
360
|
|
|
361
|
+
/**
|
|
362
|
+
* 校验并规范化「路由组编辑」提交的 groups(真源,供 config 路由与测试共用)。
|
|
363
|
+
*
|
|
364
|
+
* 两种输入形态都收,因为两种调用方都在:
|
|
365
|
+
* - `candidates: [a, b, c]` —— 顺序数组,**首个即主力**。设置页编辑器提交的就是
|
|
366
|
+
* 这个形态(UI 里对用户呈现的是「有序候选列表」,不区分 primary/fallback 概念);
|
|
367
|
+
* - `primary: 'a/b', fallbacks: [...]` —— settings 落盘形态,手工调用方友好。
|
|
368
|
+
* 两者同时出现时以 `candidates` 为准(它是更精确的顺序表达)。
|
|
369
|
+
*
|
|
370
|
+
* 判据:
|
|
371
|
+
* - 组 id:非空、去重(后出现的重名组丢弃)、最长 64;
|
|
372
|
+
* - label 可选,纯空白视为没有;
|
|
373
|
+
* - primary / fallbacks[] 必须是合法 `provider/model`;非法项**过滤**而不是
|
|
374
|
+
* 整单拒绝 —— 用户手敲错一行不应该让整次保存失败(UI 会把合法项显示出来);
|
|
375
|
+
* - 没有 primary 也没有 fallback 的组丢弃(空组无法路由);
|
|
376
|
+
* - 上限:≤ 20 组、每组 ≤ 20 个候选(防呆,手抖粘贴不会把 settings 撑爆)。
|
|
377
|
+
*
|
|
378
|
+
* @param {unknown} raw - 客户端提交的 groups 数组。
|
|
379
|
+
* @returns {{ groups: Array, dropped: string[] }} 规范化结果与被丢弃项的原因说明。
|
|
380
|
+
*/
|
|
381
|
+
export function normalizeRoutingGroupsInput(raw) {
|
|
382
|
+
const dropped = []
|
|
383
|
+
if (!Array.isArray(raw)) return { groups: [], dropped: ['groups 不是数组'] }
|
|
384
|
+
/**
|
|
385
|
+
* 候选的两种写法都收:字符串 `'provider/model'`(settings 落盘形态)与对象
|
|
386
|
+
* `{ provider, model }`(编辑器 / 状态接口形态)。对象经此折回字符串再走同一套
|
|
387
|
+
* 正则校验 —— 只有一份合法性判据。
|
|
388
|
+
* @param value - 一个候选。
|
|
389
|
+
* @returns {{ provider: string, model: string } | undefined}
|
|
390
|
+
*/
|
|
391
|
+
const asEntry = (value) => {
|
|
392
|
+
if (typeof value === 'string') return parseRouteEntryPure(value)
|
|
393
|
+
if (value !== null && typeof value === 'object') {
|
|
394
|
+
const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
|
|
395
|
+
const model = typeof value.model === 'string' ? value.model.trim() : ''
|
|
396
|
+
if (provider === '' || model === '') return undefined
|
|
397
|
+
return parseRouteEntryPure(`${provider}/${model}`)
|
|
398
|
+
}
|
|
399
|
+
return undefined
|
|
400
|
+
}
|
|
401
|
+
/** 非法项的说明文本:对象要展开,否则日志里只有 [object Object]。 */
|
|
402
|
+
const describe = (value) => (typeof value === 'string' ? value : JSON.stringify(value))
|
|
403
|
+
const groups = []
|
|
404
|
+
const seen = new Set()
|
|
405
|
+
for (const item of raw) {
|
|
406
|
+
if (groups.length >= 20) { dropped.push('超过 20 组,其余忽略'); break }
|
|
407
|
+
if (item === null || typeof item !== 'object') { dropped.push('组不是对象'); continue }
|
|
408
|
+
const id = typeof item.id === 'string' ? item.id.trim().slice(0, 64) : ''
|
|
409
|
+
if (id === '') { dropped.push('组缺少 id'); continue }
|
|
410
|
+
if (seen.has(id)) { dropped.push(`组 id 重复:${id}`); continue }
|
|
411
|
+
// candidates[](编辑器形态)优先;否则读 primary + fallbacks[](落盘形态)。
|
|
412
|
+
const ordered = Array.isArray(item.candidates) ? item.candidates : null
|
|
413
|
+
const rawPrimary = ordered === null ? item.primary : ordered[0]
|
|
414
|
+
const rawFallbacks = ordered === null ? item.fallbacks : ordered.slice(1)
|
|
415
|
+
const primary = asEntry(rawPrimary) ?? null
|
|
416
|
+
const fallbacks = []
|
|
417
|
+
for (const entry of Array.isArray(rawFallbacks) ? rawFallbacks : []) {
|
|
418
|
+
if (fallbacks.length >= 20) { dropped.push(`组 ${id} 的 fallback 超过 20 个`); break }
|
|
419
|
+
const parsed = asEntry(entry)
|
|
420
|
+
if (parsed !== undefined) fallbacks.push(parsed)
|
|
421
|
+
else dropped.push(`组 ${id} 的非法候选被忽略:${describe(entry)}`)
|
|
422
|
+
}
|
|
423
|
+
if (primary === null && fallbacks.length === 0) { dropped.push(`组 ${id} 没有任何合法候选`); continue }
|
|
424
|
+
seen.add(id)
|
|
425
|
+
groups.push({
|
|
426
|
+
id,
|
|
427
|
+
label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim().slice(0, 32) : null,
|
|
428
|
+
primary,
|
|
429
|
+
fallbacks
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
return { groups, dropped }
|
|
433
|
+
}
|
|
434
|
+
|
|
351
435
|
/** 路由配置从 settings 哪一段读 —— 顶层 dsh-llm-hub 段下,与 warning 同级。 */
|
|
352
436
|
const ROUTING_KEY = 'routing'
|
|
353
437
|
/** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
|
|
@@ -2651,6 +2735,179 @@ export function apply(ctx) {
|
|
|
2651
2735
|
}
|
|
2652
2736
|
}
|
|
2653
2737
|
|
|
2738
|
+
/**
|
|
2739
|
+
* 可路由目录:settings 里已配置的 provider 与各自的模型 id(离线,不打网关)。
|
|
2740
|
+
*
|
|
2741
|
+
* 数据源是**配置段**而不是 `ctx.llm.listProviders()`:后者会被本插件的隐藏
|
|
2742
|
+
* 过滤器影响,拿它当候选来源会「隐藏了就再也加不回来」。
|
|
2743
|
+
* @returns {{ providers: Array<{ id, displayName, ns, models: string[] }> }}
|
|
2744
|
+
*/
|
|
2745
|
+
/**
|
|
2746
|
+
* 从 settings 里的一个 model 条目读输入模态。
|
|
2747
|
+
*
|
|
2748
|
+
* 两种字段名都收:llm-deepseek 段写 `inputModalities: [text, image]`,
|
|
2749
|
+
* llm-pi-ai 段写 `input: [text, image]`。读不到时为空数组 —— 空数组表示
|
|
2750
|
+
* 「不知道」,UI 不标(而不是猜一个)。
|
|
2751
|
+
* @param model - settings 里的 model 条目对象。
|
|
2752
|
+
* @returns {string[]}
|
|
2753
|
+
*/
|
|
2754
|
+
const modalitiesOf = (model) => {
|
|
2755
|
+
const raw = Array.isArray(model?.inputModalities) ? model.inputModalities : (Array.isArray(model?.input) ? model.input : [])
|
|
2756
|
+
return raw.filter((item) => typeof item === 'string')
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
/**
|
|
2760
|
+
* 从 settings 里的一个 model 条目读推理(reasoning)能力。
|
|
2761
|
+
*
|
|
2762
|
+
* 形态是 `{ off: null, low: 'low', high: 'high', max: 'max' }`:键名 = 人类可读
|
|
2763
|
+
* 的档位名,值 = 适配器真正认的 effort id(`null` 表示「这个档位不开放」,
|
|
2764
|
+
* 比如 `off: null` 就是该模型强制思考、不能关)。模型没有这条字段时返回 null
|
|
2765
|
+
* —— UI 不标,而不是猜一个(用户没声明「能思考」就当不能)。
|
|
2766
|
+
*
|
|
2767
|
+
* 只暴露非空档位;off/null 这类「不可用」档位不进 chip 文本,免得显示「思考 · off」
|
|
2768
|
+
* 自相矛盾。
|
|
2769
|
+
* @param model - settings 里的 model 条目对象。
|
|
2770
|
+
* @returns {{ levels: Array<{ id: string, name: string }> } | null}
|
|
2771
|
+
*/
|
|
2772
|
+
const reasoningOf = (model) => {
|
|
2773
|
+
const efforts = model?.reasoningEfforts
|
|
2774
|
+
if (efforts === null || typeof efforts !== 'object') return null
|
|
2775
|
+
const levels = []
|
|
2776
|
+
for (const [name, id] of Object.entries(efforts)) {
|
|
2777
|
+
if (id === null || id === undefined || id === '') continue
|
|
2778
|
+
levels.push({ id: String(id), name: String(name) })
|
|
2779
|
+
}
|
|
2780
|
+
if (levels.length === 0) return null
|
|
2781
|
+
return { levels }
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/**
|
|
2785
|
+
* 可路由目录:settings 里已配置的 provider 与各自的模型 id(离线,不打网关)。
|
|
2786
|
+
*
|
|
2787
|
+
* 数据源是**配置段**而不是 `ctx.llm.listProviders()`:后者会被本插件的隐藏
|
|
2788
|
+
* 过滤器影响,拿它当候选来源会「隐藏了就再也加不回来」。
|
|
2789
|
+
*
|
|
2790
|
+
* 每个模型带两条 capability 事实:
|
|
2791
|
+
* · `input: string[]` —— 输入模态(text / image …);空数组表示「不知道」。
|
|
2792
|
+
* · `reasoning: { levels: [{ id, name }, …] } | null` —— 推理档位;
|
|
2793
|
+
* 非空 = 模型能思考,UI 标「思考 · low/high/max」;null = 不标。
|
|
2794
|
+
* 这两条都是 settings 里原本就有的事实(2026-09-27 用户反馈),不亮出来
|
|
2795
|
+
* 用户就只能靠背 —— 「这个模型支持多模态吗」「它能思考吗」每次都得查文档。
|
|
2796
|
+
* @returns {{ providers: Array<{ id, displayName, ns, models: Array<{ id, input: string[], reasoning: object | null }> }> }}
|
|
2797
|
+
*/
|
|
2798
|
+
const routingCatalogOf = () => {
|
|
2799
|
+
const out = []
|
|
2800
|
+
for (const [id, profile] of piaiProviders()) {
|
|
2801
|
+
const models = (Array.isArray(profile.models) ? profile.models : [])
|
|
2802
|
+
.map((model) => (model !== null && typeof model === 'object' && typeof model.id === 'string' ? { id: model.id, input: modalitiesOf(model), reasoning: reasoningOf(model) } : undefined))
|
|
2803
|
+
.filter((model) => model !== undefined)
|
|
2804
|
+
out.push({
|
|
2805
|
+
id,
|
|
2806
|
+
displayName: typeof profile.displayName === 'string' && profile.displayName.trim() !== '' ? profile.displayName : id,
|
|
2807
|
+
ns: PIAI_NS,
|
|
2808
|
+
models
|
|
2809
|
+
})
|
|
2810
|
+
}
|
|
2811
|
+
const deepseek = sectionOf()
|
|
2812
|
+
if (deepseek !== undefined) {
|
|
2813
|
+
const models = (Array.isArray(deepseek.models) ? deepseek.models : [])
|
|
2814
|
+
.map((model) => (model !== null && typeof model === 'object' && typeof model.id === 'string' ? { id: model.id, input: modalitiesOf(model), reasoning: reasoningOf(model) } : undefined))
|
|
2815
|
+
.filter((model) => model !== undefined)
|
|
2816
|
+
out.push({
|
|
2817
|
+
id: DEEPSEEK_PROVIDER,
|
|
2818
|
+
displayName: typeof deepseek.displayName === 'string' && deepseek.displayName.trim() !== '' ? deepseek.displayName : 'DeepSeek 官方直连',
|
|
2819
|
+
ns: NS,
|
|
2820
|
+
models
|
|
2821
|
+
})
|
|
2822
|
+
}
|
|
2823
|
+
out.sort((a, b) => a.id.localeCompare(b.id))
|
|
2824
|
+
return { providers: out }
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
/** 写回整份路由组(设置页编辑器保存路径),并返回最新状态。 */
|
|
2828
|
+
const applyRoutingConfig = async (payload) => {
|
|
2829
|
+
const settings = ctx.get('settings')
|
|
2830
|
+
if (settings === undefined || hubRegistered !== true) {
|
|
2831
|
+
return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
|
|
2832
|
+
}
|
|
2833
|
+
const normalized = normalizeRoutingGroupsInput(payload?.groups)
|
|
2834
|
+
if (normalized.groups.length === 0) {
|
|
2835
|
+
return { ok: false, error: '至少需要一组有效候选(provider/model)', dropped: normalized.dropped }
|
|
2836
|
+
}
|
|
2837
|
+
const current = routingObjOf()
|
|
2838
|
+
// 生效组若在新配置里没了,落到第一组 —— 不让 settings 里留一个悬空的 activeGroup。
|
|
2839
|
+
const requested = typeof payload?.activeGroup === 'string' ? payload.activeGroup.trim() : ''
|
|
2840
|
+
const fallbackActive = normalized.groups.some((group) => group.id === requested)
|
|
2841
|
+
? requested
|
|
2842
|
+
: (normalized.groups.some((group) => group.id === current.activeGroup) ? current.activeGroup : normalized.groups[0].id)
|
|
2843
|
+
const mode = payload?.mode === 'smart' || payload?.mode === 'manual'
|
|
2844
|
+
? payload.mode
|
|
2845
|
+
: (current.mode === 'smart' ? 'smart' : 'manual')
|
|
2846
|
+
try {
|
|
2847
|
+
await settings.update(HUB_NS, {
|
|
2848
|
+
routing: {
|
|
2849
|
+
...current,
|
|
2850
|
+
mode,
|
|
2851
|
+
activeGroup: fallbackActive,
|
|
2852
|
+
groups: normalized.groups.map((group) => ({
|
|
2853
|
+
id: group.id,
|
|
2854
|
+
...(group.label !== null ? { label: group.label } : {}),
|
|
2855
|
+
...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
|
|
2856
|
+
fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
|
|
2857
|
+
}))
|
|
2858
|
+
}
|
|
2859
|
+
})
|
|
2860
|
+
return { ok: true, dropped: normalized.dropped, ...smartStateOf() }
|
|
2861
|
+
} catch (error) {
|
|
2862
|
+
return { ok: false, error: `写不进 settings(${error?.message ?? error})` }
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2867
|
+
kind: 'exact',
|
|
2868
|
+
path: ROUTING_CATALOG_PATH,
|
|
2869
|
+
handler: (request, response) => {
|
|
2870
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
2871
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
2872
|
+
response.end()
|
|
2873
|
+
return
|
|
2874
|
+
}
|
|
2875
|
+
if (!sameOrigin(request)) {
|
|
2876
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
2877
|
+
return
|
|
2878
|
+
}
|
|
2879
|
+
if (request.method === 'HEAD') {
|
|
2880
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
2881
|
+
response.end()
|
|
2882
|
+
return
|
|
2883
|
+
}
|
|
2884
|
+
sendJson(response, 200, { ok: true, ...routingCatalogOf() })
|
|
2885
|
+
}
|
|
2886
|
+
}), 'dsh-llm-hub: routing catalog route')
|
|
2887
|
+
|
|
2888
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2889
|
+
kind: 'exact',
|
|
2890
|
+
path: ROUTING_CONFIG_PATH,
|
|
2891
|
+
handler: async (request, response) => {
|
|
2892
|
+
if (request.method !== 'POST') {
|
|
2893
|
+
response.writeHead(405, { allow: 'POST' })
|
|
2894
|
+
response.end()
|
|
2895
|
+
return
|
|
2896
|
+
}
|
|
2897
|
+
if (!sameOrigin(request)) {
|
|
2898
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
|
|
2899
|
+
return
|
|
2900
|
+
}
|
|
2901
|
+
const payload = await readPostBody(request)
|
|
2902
|
+
if (payload === null) {
|
|
2903
|
+
sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
|
|
2904
|
+
return
|
|
2905
|
+
}
|
|
2906
|
+
const result = await applyRoutingConfig(payload)
|
|
2907
|
+
sendJson(response, result.ok === true ? 200 : 400, result)
|
|
2908
|
+
}
|
|
2909
|
+
}), 'dsh-llm-hub: routing config route')
|
|
2910
|
+
|
|
2654
2911
|
ctx.effect(() => ctx.webServer.register({
|
|
2655
2912
|
kind: 'exact',
|
|
2656
2913
|
path: ROUTING_STATE_PATH,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plugins/dsh-llm-hub",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.5",
|
|
4
4
|
"description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|