dsh-router-core 0.3.21 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -63
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +28 -5
- package/docs/pool-sticky-block.md +158 -0
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +1300 -549
- package/lib/client.js +1300 -549
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +1169 -254
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -7
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm/adapter.d.ts +35 -4
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +65 -15
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +25 -21
- package/lib/types/supplier-config.d.ts +15 -3
- package/lib/types/suppliers/contract.d.ts +37 -4
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -2
- package/package.json +50 -16
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +32 -196
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +86 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +2592 -1635
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -7
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +165 -68
- package/src/llm/adapter.test.ts +175 -4
- package/src/llm/adapter.ts +109 -16
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +276 -62
- package/src/router/models-cache.test.ts +54 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +25 -24
- package/src/supplier-config.ts +33 -13
- package/src/suppliers/contract.ts +56 -4
- package/src/suppliers/loader.test.ts +38 -4
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +103 -23
- package/src/types.d.ts +2 -1
package/src/client/index.tsx
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { mountRouterWorkspace } from './workspace-mount.tsx'
|
|
12
12
|
import { RouterSettingsSection } from './settings-section.tsx'
|
|
13
|
+
import { RouterComponentsSection } from './RouterComponentsSection.tsx'
|
|
14
|
+
import { ExtTestPanel } from './ExtTestPanel.tsx'
|
|
15
|
+
import { registerExtPanel } from './ext-panels.ts'
|
|
16
|
+
import { EXT_TEST_ID } from '../ext-test/plugin.ts'
|
|
13
17
|
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
|
14
18
|
import { rewriteRouterModelHint } from './model-hint-copy.ts'
|
|
19
|
+
import { LastHitDock } from './LastHitDock.tsx'
|
|
20
|
+
import { ROUTER_API_BASE } from '../shared.ts'
|
|
15
21
|
import './router.css'
|
|
16
22
|
|
|
17
23
|
/**
|
|
@@ -37,6 +43,42 @@ interface Ctx {
|
|
|
37
43
|
effect: (fn: () => (() => void) | void, label?: string) => void
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 挂载输入框底部的「最近命中」徽章:注册到官方 `conversation.composer.dock`
|
|
48
|
+
* (kind: list, scope: session —— 即宿主 composer 卡片底部操作行
|
|
49
|
+
* `.uV2eYG_dock` 里、原生上下文环旁边的位置)。
|
|
50
|
+
* scope 为 session 时宿主给组件注入 `sessionId`,据此按**当前会话**取命中。
|
|
51
|
+
*
|
|
52
|
+
* **仅宿主 >= 0.1.7 才挂**:composer.dock 这个座位在 0.1.5 也在,但渲染在
|
|
53
|
+
* InputBar 之后的独立块(位置不对),只有 0.1.7 才放进底部操作行。座位名相同
|
|
54
|
+
* 没法靠契约区分,故先问核心 `/router/api/health` 的 `lastHitDock` 支持位。
|
|
55
|
+
* @returns 清理函数;不支持或宿主无 slots 时为空操作
|
|
56
|
+
*/
|
|
57
|
+
function mountLastHitDock(ctx: Ctx, supported: boolean): () => void {
|
|
58
|
+
const slots = ctx.slots
|
|
59
|
+
if (slots === undefined || !supported) return () => {}
|
|
60
|
+
return slots.inject('conversation.composer.dock', () => slots.register({
|
|
61
|
+
name: 'conversation.composer.dock',
|
|
62
|
+
id: 'dsh-router-last-hit',
|
|
63
|
+
order: 10,
|
|
64
|
+
}, LastHitDock))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 问核心是否支持「最近命中」徽章(宿主 >= 0.1.7)。
|
|
69
|
+
* 网络/解析失败一律当**不支持**——宁可不显示,也不在 0.1.5 上摆错位置。
|
|
70
|
+
*/
|
|
71
|
+
async function fetchLastHitDockSupport(): Promise<boolean> {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${ROUTER_API_BASE}/health`)
|
|
74
|
+
if (!res.ok) return false
|
|
75
|
+
const body = (await res.json()) as { lastHitDock?: unknown }
|
|
76
|
+
return body.lastHitDock === true
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
40
82
|
/** 挂载「设置 → 路由」页;拿不到 slots 时返回 undefined 表示跳过。 */
|
|
41
83
|
function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
|
|
42
84
|
const slots = ctx.slots
|
|
@@ -55,7 +97,46 @@ function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
|
|
|
55
97
|
}, RouterSettingsSection))
|
|
56
98
|
}
|
|
57
99
|
|
|
100
|
+
/**
|
|
101
|
+
* 官方插件页(设置 → 插件)里 dsh-router-core 详情页的「路由组件」一节。
|
|
102
|
+
*
|
|
103
|
+
* 为什么走 `plugins.detail.section` 而不是官方那个「包含的组件」:后者的行由
|
|
104
|
+
* 宿主从**本 bundle 自己的 cordis.patch.yml** 算出来,跨 bundle 没有注入通道,
|
|
105
|
+
* 而子插件(codebuddy / ext-rtk …)必须保持各自独立安装。官方给的位置就是这一
|
|
106
|
+
* 个槽位(渲染在原生行列表之后)。槽位不存在的老宿主上 `inject` 不会触发,
|
|
107
|
+
* 自动不挂——不引入硬 client inject。
|
|
108
|
+
*/
|
|
109
|
+
function mountComponentsSection(ctx: Ctx): (() => void) | undefined {
|
|
110
|
+
const slots = ctx.slots
|
|
111
|
+
if (slots === undefined) return undefined
|
|
112
|
+
return slots.inject('plugins.detail.section', () => slots.register({
|
|
113
|
+
name: 'plugins.detail.section',
|
|
114
|
+
id: 'dsh-router-components',
|
|
115
|
+
order: 10,
|
|
116
|
+
}, RouterComponentsSection))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 给「插件自检」扩展登记它的详情面板。
|
|
121
|
+
*
|
|
122
|
+
* 面板的**唯一入口**是设置 → 路由 → 扩展 → 点开那张卡片(`ExtDetail` 查注册表拿到
|
|
123
|
+
* 本组件)。**刻意不给插件页那一行再挂一个详情页**:那一行的宿主行开关已经能开/关
|
|
124
|
+
* 它了,再点进去看同一块面板就是同一个东西有两个入口 —— 与「内置扩展不在自绘节里
|
|
125
|
+
* 重复列」是同一条理由。
|
|
126
|
+
*/
|
|
127
|
+
function registerExtTestPanel(ctx: Ctx): void {
|
|
128
|
+
const dispose = registerExtPanel(EXT_TEST_ID, ExtTestPanel)
|
|
129
|
+
ctx.effect(() => dispose, 'dsh-router: ext test panel registry')
|
|
130
|
+
}
|
|
131
|
+
|
|
58
132
|
export function apply(ctx: Ctx): void {
|
|
133
|
+
// 「最近命中」徽章:走官方 conversation.composer.dock(scope: session → 组件
|
|
134
|
+
// props 注入 sessionId,按当前会话取命中)。**仅宿主 >= 0.1.7 才挂**——
|
|
135
|
+
// composer.dock 在 0.1.5 渲染位置不对,先问核心拿支持位,避免摆错位置。
|
|
136
|
+
// 查询是异步的,拿到结果(无论成败)再决定注册与否。
|
|
137
|
+
void fetchLastHitDockSupport().then((supported) => {
|
|
138
|
+
ctx.effect(() => mountLastHitDock(ctx, supported), 'dsh-router: last-hit dock')
|
|
139
|
+
})
|
|
59
140
|
const disposeSettings = mountSettingsSection(ctx)
|
|
60
141
|
if (disposeSettings === undefined) {
|
|
61
142
|
// 老宿主(无 slots):回退到侧边栏入口 + 中心栏面板
|
|
@@ -64,6 +145,11 @@ export function apply(ctx: Ctx): void {
|
|
|
64
145
|
return
|
|
65
146
|
}
|
|
66
147
|
ctx.effect(() => disposeSettings, 'dsh-router: settings section')
|
|
148
|
+
const disposeComponents = mountComponentsSection(ctx)
|
|
149
|
+
if (disposeComponents !== undefined) {
|
|
150
|
+
ctx.effect(() => disposeComponents, 'dsh-router: plugin page components')
|
|
151
|
+
}
|
|
152
|
+
registerExtTestPanel(ctx)
|
|
67
153
|
// 换掉宿主的默认齿轮(契约没有 icon 字段,只能注册后认领自己的行)
|
|
68
154
|
ctx.effect(() => registerSettingsNavIcon(SECTION_LABEL), 'dsh-router: settings nav icon')
|
|
69
155
|
// 设置-模型 里 Router 卡片的那句死路提示 → 正确说法(同样只能认领后就地改写)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「最近命中」的会话级数据源。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这层:徽章组件可能因宿主重渲染而卸载/重建,但**轮询不该跟着重建**
|
|
5
|
+
* (每个会话一个轮询器,多会话各自独立;组件只订阅结果)。这里做两件事:
|
|
6
|
+
* - 按 sessionId 维护「最近一次命中」的最新值 + 订阅者集合;
|
|
7
|
+
* - 每个会话一个定时器,只在有订阅者时开、无订阅者时停。
|
|
8
|
+
*
|
|
9
|
+
* 数据源:核心 `/router/api/last-hit?session=`(轻端点,不打 status)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DIGEST_API = '/router/api/last-hit'
|
|
13
|
+
/** 刷新间隔:命中信息跟着请求走,轮询取一个不打扰的频率。 */
|
|
14
|
+
const POLL_MS = 3000
|
|
15
|
+
|
|
16
|
+
/** 一次命中的快照(后端 `/last-hit` 的 `hit` 字段,只声明用到的)。 */
|
|
17
|
+
export interface LastHit {
|
|
18
|
+
supplier: string
|
|
19
|
+
model: string
|
|
20
|
+
requested: string
|
|
21
|
+
uid: string
|
|
22
|
+
account?: string
|
|
23
|
+
credits?: number
|
|
24
|
+
ok: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Entry {
|
|
28
|
+
value: LastHit | null
|
|
29
|
+
listeners: Set<(v: LastHit | null) => void>
|
|
30
|
+
timer: number | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const entries = new Map<string, Entry>()
|
|
34
|
+
|
|
35
|
+
function entryOf(key: string): Entry {
|
|
36
|
+
let e = entries.get(key)
|
|
37
|
+
if (e === undefined) {
|
|
38
|
+
e = { value: null, listeners: new Set(), timer: null }
|
|
39
|
+
entries.set(key, e)
|
|
40
|
+
}
|
|
41
|
+
return e
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emit(e: Entry): void {
|
|
45
|
+
for (const fn of e.listeners) fn(e.value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function refresh(key: string): Promise<void> {
|
|
49
|
+
const e = entryOf(key)
|
|
50
|
+
try {
|
|
51
|
+
// key 为空(宿主未注入 sessionId)时不带 session 参数 → 后端回退全局最近。
|
|
52
|
+
const qs = key === '' ? '' : `?session=${encodeURIComponent(key)}`
|
|
53
|
+
const res = await fetch(`${DIGEST_API}${qs}`, { cache: 'no-store' })
|
|
54
|
+
const data = await res.json() as { ok: boolean; hit?: LastHit | null }
|
|
55
|
+
const next = data.ok && data.hit ? data.hit : null
|
|
56
|
+
e.value = next
|
|
57
|
+
} catch {
|
|
58
|
+
// 网络抖动不该清空徽章(保留上次值)
|
|
59
|
+
}
|
|
60
|
+
emit(e)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 读该会话当前的最近命中(同步快照)。 */
|
|
64
|
+
export function getLastHit(sessionId?: string): LastHit | null {
|
|
65
|
+
return entryOf(sessionId ?? '').value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 订阅该会话的最近命中。首次订阅即启动轮询,最后一个退订时停。
|
|
70
|
+
* @param sessionId 宿主会话身份(可能为 undefined,此时键为空串 = 全局)
|
|
71
|
+
* @param listener 值变化回调
|
|
72
|
+
* @returns 退订函数
|
|
73
|
+
*/
|
|
74
|
+
export function subscribeLastHit(sessionId: string | undefined, listener: (v: LastHit | null) => void): () => void {
|
|
75
|
+
const key = sessionId ?? ''
|
|
76
|
+
const e = entryOf(key)
|
|
77
|
+
e.listeners.add(listener)
|
|
78
|
+
if (e.timer === null) {
|
|
79
|
+
void refresh(key)
|
|
80
|
+
e.timer = window.setInterval(() => void refresh(key), POLL_MS)
|
|
81
|
+
}
|
|
82
|
+
return () => {
|
|
83
|
+
e.listeners.delete(listener)
|
|
84
|
+
if (e.listeners.size === 0 && e.timer !== null) {
|
|
85
|
+
window.clearInterval(e.timer)
|
|
86
|
+
e.timer = null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「只列可用模型」这条判据的测试。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有:错了不会抛异常、不会白屏,只是下拉里多了几个用户明明关掉的模型 ——
|
|
5
|
+
* 挑中一个去测,测通了也不代表配置是对的,而用户看不出自己挑错了。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { modelChoice } from './model-choice.ts'
|
|
10
|
+
|
|
11
|
+
test('只列用户没停用的模型(端点给的是全部模型)', () => {
|
|
12
|
+
const c = modelChoice([
|
|
13
|
+
{ id: 'a', enabled: true },
|
|
14
|
+
{ id: 'b', enabled: false },
|
|
15
|
+
{ id: 'c', enabled: true },
|
|
16
|
+
{ id: 'd', enabled: false },
|
|
17
|
+
])
|
|
18
|
+
assert.deepEqual(c.ids, ['a', 'c'])
|
|
19
|
+
assert.equal(c.empty, undefined)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('保持端点给的顺序(端点已按优先级排好,重排会毁掉它)', () => {
|
|
23
|
+
const c = modelChoice([{ id: 'z', enabled: true }, { id: 'a', enabled: true }])
|
|
24
|
+
assert.deepEqual(c.ids, ['z', 'a'])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('缺 enabled 按「未停用」读 —— 不凭空把模型藏起来', () => {
|
|
28
|
+
const c = modelChoice([{ id: 'a' }, { id: 'b', enabled: false }])
|
|
29
|
+
assert.deepEqual(c.ids, ['a'])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('一个模型都没有 vs 全被停用:两种空得指去不同的地方', () => {
|
|
33
|
+
assert.equal(modelChoice([]).empty, 'none', '没模型 → 该去拉取')
|
|
34
|
+
assert.equal(modelChoice([{ id: 'a', enabled: false }]).empty, 'all-disabled', '有模型但全关 → 该去开启')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('全停用时 ids 为空,不会把停用的模型漏进下拉', () => {
|
|
38
|
+
const c = modelChoice([{ id: 'a', enabled: false }, { id: 'b', enabled: false }])
|
|
39
|
+
assert.deepEqual(c.ids, [])
|
|
40
|
+
assert.equal(c.empty, 'all-disabled')
|
|
41
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自检面板的「模型」下拉该列哪些 —— 纯函数,不碰 React。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不能把端点返回的模型全列出来:那个端点给的是**全部**模型(含用户在
|
|
5
|
+
* 供应商详情里停用的)。实测某供应商 30 个模型里只有 3 个是启用的 —— 把 30 个都
|
|
6
|
+
* 塞进下拉,用户会挑到一个自己明明关掉的模型去测,测通了也不代表配置是对的。
|
|
7
|
+
* 所以只列**可用**的(用户没停用的),与路由实际会用的那批保持一致。
|
|
8
|
+
*
|
|
9
|
+
* 顺带把「没有可选模型」拆成两种,因为**用户该做的事不一样**:
|
|
10
|
+
* - `none`(一个模型都没有)→ 该去供应商详情拉取模型;
|
|
11
|
+
* - `all-disabled`(有模型但全被停用)→ 该去供应商详情把它们打开,
|
|
12
|
+
* 去拉取是白跑一趟。
|
|
13
|
+
* 两种共用一句「没有模型」就是把用户往错的方向指。
|
|
14
|
+
*
|
|
15
|
+
* `enabled` 缺省按**未停用**读(`!== false`):这个字段是用户的配置,缺失时不该
|
|
16
|
+
* 凭空把模型藏起来。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** `/suppliers/:id/models` 里的一条模型。 */
|
|
20
|
+
export interface ModelRow {
|
|
21
|
+
id: string
|
|
22
|
+
enabled?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelChoice {
|
|
26
|
+
/** 可用模型的 id,按端点返回的顺序(不重排 —— 端点已经按优先级排过了)。 */
|
|
27
|
+
ids: string[]
|
|
28
|
+
/** 没有可选模型时的原因;`undefined` = 有得选。 */
|
|
29
|
+
empty?: 'none' | 'all-disabled'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 从端点答复里挑出可用的模型,并说明「空的」到底是哪一种空。 */
|
|
33
|
+
export function modelChoice(models: readonly ModelRow[]): ModelChoice {
|
|
34
|
+
const ids = models.filter((m) => m.enabled !== false).map((m) => m.id)
|
|
35
|
+
if (ids.length > 0) return { ids }
|
|
36
|
+
return { ids, empty: models.length === 0 ? 'none' : 'all-disabled' }
|
|
37
|
+
}
|
|
@@ -10,8 +10,8 @@ import assert from 'node:assert/strict'
|
|
|
10
10
|
import { routerHintRewrite, ROUTER_MODEL_HINT_COPY } from './model-hint-copy.ts'
|
|
11
11
|
|
|
12
12
|
test('认出宿主提示(中/英两种文案 + 命名空间指纹)', () => {
|
|
13
|
-
const zh = '其余字段在 settings.yaml 中,请直接编辑对应段。 (
|
|
14
|
-
const en = 'Other fields live in settings.yaml; edit that section directly. (
|
|
13
|
+
const zh = '其余字段在 settings.yaml 中,请直接编辑对应段。 (dsh-router)'
|
|
14
|
+
const en = 'Other fields live in settings.yaml; edit that section directly. (dsh-router)'
|
|
15
15
|
assert.equal(routerHintRewrite(zh), ROUTER_MODEL_HINT_COPY)
|
|
16
16
|
assert.equal(routerHintRewrite(en), ROUTER_MODEL_HINT_COPY)
|
|
17
17
|
})
|
|
@@ -21,6 +21,11 @@ test('别的 provider 的同类提示不动(指纹只属于 Router)', () =>
|
|
|
21
21
|
assert.equal(routerHintRewrite(deepseek), undefined)
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
+
test('旧命名空间指纹不再认领 —— 0.1.7 起宿主渲染的是行 id', () => {
|
|
25
|
+
const legacy = '其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)'
|
|
26
|
+
assert.equal(routerHintRewrite(legacy), undefined)
|
|
27
|
+
})
|
|
28
|
+
|
|
24
29
|
test('已是目标文案时早退 —— 观察器不自激', () => {
|
|
25
30
|
assert.equal(routerHintRewrite(ROUTER_MODEL_HINT_COPY), undefined)
|
|
26
31
|
})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 为什么不能直接改源头:Router 的卡片走宿主的 "unknown" 布局(宿主只认得
|
|
5
5
|
* llm-deepseek / llm-pi-ai 两套表单),那句
|
|
6
|
-
* 「其余字段在 settings.yaml 中,请直接编辑对应段。 (
|
|
6
|
+
* 「其余字段在 settings.yaml 中,请直接编辑对应段。 (dsh-router)」
|
|
7
7
|
* 属于宿主 `settings.models` 的 locale 字典,而字典注册对同一个 (ns, locale)
|
|
8
8
|
* 只允许一个占位者(重复注册直接抛),插件覆写不了,契约也没给可覆写字段。
|
|
9
9
|
* 所以沿用本插件既有的认领手法(同 settings-nav-icon.ts):按文案指纹认出宿主
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/** 指纹:语言无关(不受中英文切换影响),且只出现在 Router 卡片上。 */
|
|
19
|
-
const FINGERPRINT = '(
|
|
19
|
+
const FINGERPRINT = '(dsh-router)'
|
|
20
20
|
|
|
21
21
|
/** 正确说法:对齐 README「组合即模型」。 */
|
|
22
22
|
export const ROUTER_MODEL_HINT_COPY = '组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。'
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「路由组件」分组判据测试 —— 锁住三条会静默出错的规则。
|
|
3
|
+
*
|
|
4
|
+
* 1. `source` 判据取反:只有 external 是独立安装的 bundle,其余归本地组。
|
|
5
|
+
* 2. 缺 `source` 的供应商不许被静默丢掉,也不许被误报成独立插件。
|
|
6
|
+
* 3. 只有 ext 那组 `togglable` —— 供应商在这一页没有可写的开关。
|
|
7
|
+
*
|
|
8
|
+
* 这三条错了都不会抛异常,只会显示成「少了一组 / 多了个开关」,所以必须有闸门。
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { groupRouterComponents, isEmptyComponents, EMPTY_COMPONENTS } from './router-components.ts'
|
|
13
|
+
import type { RouterExtResponse, RouterHealthResponse } from '../shared.ts'
|
|
14
|
+
|
|
15
|
+
const health = (suppliers: NonNullable<RouterHealthResponse['suppliers']>): RouterHealthResponse => ({ ok: true, suppliers })
|
|
16
|
+
const noExt: RouterExtResponse = { ok: true, enhancers: [] }
|
|
17
|
+
|
|
18
|
+
test('内置供应商一个都不进这一节(它们是原生「包含的组件」里的行)', () => {
|
|
19
|
+
const out = groupRouterComponents(health([
|
|
20
|
+
{ id: 'opencode', name: 'OpenCode', source: 'builtin' },
|
|
21
|
+
{ id: 'openrouter', name: 'OpenRouter', source: 'builtin' },
|
|
22
|
+
{ id: 'nvidia', name: 'NVIDIA NIM', source: 'builtin' },
|
|
23
|
+
]), noExt)
|
|
24
|
+
// 重复显示两处 = 用户看到同一个东西两次,且有一处没有开关。
|
|
25
|
+
assert.deepEqual(out.external, [])
|
|
26
|
+
assert.deepEqual(out.ext, [])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('外部供应商插件进「供应商」组(各是独立 bundle,这一页有开关)', () => {
|
|
30
|
+
const out = groupRouterComponents(health([
|
|
31
|
+
{ id: 'codebuddy', name: 'CodeBuddy', source: 'external' },
|
|
32
|
+
{ id: 'traework', name: 'TRAE SOLO', source: 'external' },
|
|
33
|
+
]), noExt)
|
|
34
|
+
assert.deepEqual(out.external.map((r) => r.key), ['supplier:codebuddy', 'supplier:traework'])
|
|
35
|
+
assert.equal(out.external.every((r) => r.togglable), true, '供应商在这一页有开关(写 PATCH /suppliers/:id/enabled)')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('缺 source 的供应商不显示,也不被误报成独立插件', () => {
|
|
39
|
+
const out = groupRouterComponents(health([{ id: 'mystery', name: 'Mystery' }]), noExt)
|
|
40
|
+
assert.deepEqual(out.external, [])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('user 目录投放的供应商也不进这一节(它的对应物是行的覆盖或外部插件)', () => {
|
|
44
|
+
const out = groupRouterComponents(health([{ id: 'mine', name: 'My Supplier', source: 'user' }]), noExt)
|
|
45
|
+
assert.deepEqual(out.external, [])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('扩展插件进 ext 组且唯一可开关;状态按「宁可关着」读', () => {
|
|
49
|
+
const out = groupRouterComponents(health([]), {
|
|
50
|
+
ok: true,
|
|
51
|
+
enhancers: [
|
|
52
|
+
{ id: 'rtk', name: 'RTK', enabled: true, ready: true },
|
|
53
|
+
{ id: 'jev', name: 'JEV' },
|
|
54
|
+
],
|
|
55
|
+
})
|
|
56
|
+
assert.equal(out.ext.length, 2)
|
|
57
|
+
assert.equal(out.ext.every((r) => r.togglable), true)
|
|
58
|
+
assert.deepEqual(out.ext.map((r) => r.enabled), [true, false])
|
|
59
|
+
assert.deepEqual(out.ext.map((r) => r.ready), [true, false])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('图标与说明只在该有的时候带上(不塞 undefined 键)', () => {
|
|
63
|
+
const out = groupRouterComponents(health([{ id: 'loomy', name: 'Loomy', icon: 'data:image/png;base64,x', source: 'external' }]), {
|
|
64
|
+
ok: true,
|
|
65
|
+
enhancers: [{ id: 'rtk', name: 'RTK', detail: '本机未装 rtk' }],
|
|
66
|
+
})
|
|
67
|
+
const supplier = out.external.at(0)
|
|
68
|
+
const extRow = out.ext.at(0)
|
|
69
|
+
assert.ok(supplier !== undefined && extRow !== undefined)
|
|
70
|
+
assert.equal('icon' in supplier, true)
|
|
71
|
+
assert.equal('detail' in extRow, true)
|
|
72
|
+
assert.equal('icon' in extRow, false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('两个端点都没有内容时不渲染这一节(不留空标题)', () => {
|
|
76
|
+
assert.equal(isEmptyComponents(EMPTY_COMPONENTS), true)
|
|
77
|
+
assert.equal(isEmptyComponents(groupRouterComponents({ ok: true }, { ok: true })), true)
|
|
78
|
+
assert.equal(isEmptyComponents(groupRouterComponents({ ok: true }, { ok: true, enhancers: [{ id: 'rtk', name: 'RTK' }] })), false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('端点报错/缺字段时分组仍是空组,不抛(形状恒定)', () => {
|
|
82
|
+
const out = groupRouterComponents({ ok: false, error: 'boom' }, { ok: false, error: 'boom' })
|
|
83
|
+
assert.deepEqual(out, EMPTY_COMPONENTS)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('供应商的开关状态:缺 enabled 按开着读,关掉的仍列出来(关掉后要能再开)', () => {
|
|
87
|
+
const out = groupRouterComponents(health([
|
|
88
|
+
{ id: 'codebuddy', name: 'CodeBuddy', source: 'external' },
|
|
89
|
+
{ id: 'traework', name: 'TRAE SOLO', source: 'external', enabled: false },
|
|
90
|
+
]), noExt)
|
|
91
|
+
assert.deepEqual(out.external.map((r) => r.key), ['supplier:codebuddy', 'supplier:traework'],
|
|
92
|
+
'关掉的供应商不能从列表消失 —— 消失了就再没有可点的开关把它开回来')
|
|
93
|
+
assert.deepEqual(out.external.map((r) => r.enabled), [true, false])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('供应商行没有 ready(那是扩展的运行时事实)—— 缺失不得让它变成点不开', () => {
|
|
97
|
+
const out = groupRouterComponents(health([{ id: 'codebuddy', name: 'CodeBuddy', source: 'external' }]), noExt)
|
|
98
|
+
const row = out.external.at(0)
|
|
99
|
+
assert.ok(row !== undefined)
|
|
100
|
+
assert.equal('ready' in row, false, '供应商不报就绪,别塞一个 undefined 键进去')
|
|
101
|
+
assert.equal(row.togglable, true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('内置扩展不进这一节(它已经是原生「包含的组件」里的一行)', () => {
|
|
105
|
+
const out = groupRouterComponents({ ok: true }, {
|
|
106
|
+
ok: true,
|
|
107
|
+
enhancers: [
|
|
108
|
+
{ id: 'test', name: '插件自检', source: 'builtin' },
|
|
109
|
+
{ id: 'rtk', name: 'RTK' },
|
|
110
|
+
],
|
|
111
|
+
})
|
|
112
|
+
// 重复显示两处 = 用户看到同一个东西两次,且有一处没有开关。
|
|
113
|
+
assert.deepEqual(out.ext.map((r) => r.key), ['ext:rtk'])
|
|
114
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 官方插件页「路由组件」一节的分组 —— 纯函数,不碰 React。
|
|
3
|
+
*
|
|
4
|
+
* 为什么抽出来:这一节唯一有分支的地方就是「一条供应商/扩展归哪组、能不能
|
|
5
|
+
* 就地开关」(见下两条判据)。留在组件里就只能靠真页面点出来验,抽出来单测,
|
|
6
|
+
* 判据改错当场变红。
|
|
7
|
+
*
|
|
8
|
+
* 数据源是核心**已经在提供**的两个端点,本模块不新增任何 API:
|
|
9
|
+
* - `/health` 的 `suppliers[]` 带 `source`(builtin / user / external)
|
|
10
|
+
* - `/ext` 的 `enhancers[]` 是核心合并完开关与运行时事实的扩展表
|
|
11
|
+
*/
|
|
12
|
+
import type { RouterExtResponse, RouterHealthResponse } from '../shared.ts'
|
|
13
|
+
|
|
14
|
+
/** `/health` 里的一条供应商摘要(就地取型,不另抄一份形状)。 */
|
|
15
|
+
export type RouterSupplierSummary = NonNullable<RouterHealthResponse['suppliers']>[number]
|
|
16
|
+
|
|
17
|
+
/** 一行组件(两组共用同一种形状,渲染时才分叉)。 */
|
|
18
|
+
export interface RouterComponentRow {
|
|
19
|
+
/** 组内唯一键:`supplier:<id>` / `ext:<id>`。 */
|
|
20
|
+
key: string
|
|
21
|
+
name: string
|
|
22
|
+
icon?: string
|
|
23
|
+
/** ext 报的就绪状态说明(如「本机未装 rtk」)。 */
|
|
24
|
+
detail?: string
|
|
25
|
+
enabled?: boolean
|
|
26
|
+
ready?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* 能否就地开关。**两组都能**:扩展走 `PATCH /router/api/ext`(核心持久化到
|
|
29
|
+
* `<dataDir>/ext.json`),供应商走 `PATCH /router/api/suppliers/:id/enabled`
|
|
30
|
+
* (落到 `supplier-config.json`)。两组都是核心持久化的用户开关,核心都据此
|
|
31
|
+
* 真的停止路由 —— 不是一个只把卡片藏起来的假开关。
|
|
32
|
+
*/
|
|
33
|
+
togglable: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 这一节要列的两组。
|
|
38
|
+
*
|
|
39
|
+
* **为什么没有「内置与本地供应商」组**:内置供应商是 dsh-router-core 自己 patch
|
|
40
|
+
* 里的**行**(`cordis.patch.yml` insert 了三个子路径模块),已经在宿主的原生
|
|
41
|
+
* 「包含的组件」里,带着宿主管的开关。在这里再列一遍就是同一个东西显示两处。
|
|
42
|
+
*/
|
|
43
|
+
export interface RouterComponents {
|
|
44
|
+
/** 独立安装的供应商插件(各是独立 bundle,启停在它们自己的插件页)。 */
|
|
45
|
+
external: RouterComponentRow[]
|
|
46
|
+
/** 扩展插件(`router.ext`,开关归核心)。 */
|
|
47
|
+
ext: RouterComponentRow[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 空组:加载前与加载失败都用它,保证形状恒定。 */
|
|
51
|
+
export const EMPTY_COMPONENTS: RouterComponents = { external: [], ext: [] }
|
|
52
|
+
|
|
53
|
+
/** 一条外部供应商摘要 → 只读行。 */
|
|
54
|
+
function supplierRow(s: RouterSupplierSummary): RouterComponentRow {
|
|
55
|
+
return {
|
|
56
|
+
key: `supplier:${s.id}`,
|
|
57
|
+
name: s.name,
|
|
58
|
+
// 缺 `enabled` 按开着读(老核心没这个字段)。关掉的供应商**仍列出来** ——
|
|
59
|
+
// 面板「扩展」页的形状是「关掉就不出现」,但供应商关掉后如果也消失,这里就
|
|
60
|
+
// 少了一个可点的开关、再也开不回来。两组刻意不同。
|
|
61
|
+
enabled: s.enabled !== false,
|
|
62
|
+
togglable: true,
|
|
63
|
+
...(s.icon === undefined ? {} : { icon: s.icon }),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把两个端点的答复分成两组。
|
|
69
|
+
*
|
|
70
|
+
* 供应商这边只收 `source === 'external'`(独立安装的供应商插件)。`builtin`
|
|
71
|
+
* 的那三个已经作为**原生行**在宿主的「包含的组件」里显示了(core 自己 patch
|
|
72
|
+
* insert 的子路径模块,开关是宿主管的那个行开关),`user` 的是用户目录里投放的
|
|
73
|
+
* js —— 两者都不该在这里出现第二次。`source` 缺失按「不是 external」处理:
|
|
74
|
+
* 不显示总比把内置的错标成独立插件强。
|
|
75
|
+
*
|
|
76
|
+
* 两组都在这一页开关,指向核心的同两个端点(`PATCH /suppliers/:id/enabled` 与
|
|
77
|
+
* `PATCH /ext`),所以「在哪开关」是一致的:**设置 → 插件 → dsh-router-core**。
|
|
78
|
+
* 供应商的开关关掉是真的不参与路由(见 Router 的活跃集合),不是只隐藏卡片。
|
|
79
|
+
*/
|
|
80
|
+
export function groupRouterComponents(health: RouterHealthResponse, ext: RouterExtResponse): RouterComponents {
|
|
81
|
+
const external = (health.suppliers ?? [])
|
|
82
|
+
.filter((s) => s.source === 'external')
|
|
83
|
+
.map(supplierRow)
|
|
84
|
+
// 只列**独立安装**的扩展(`source !== 'builtin'`)。随核心分发的内置扩展已经在
|
|
85
|
+
// 宿主原生的「包含的组件」里占一行、自带宿主管的开关,在这里再列一遍就是同一个
|
|
86
|
+
// 东西显示两处 —— 与内置供应商的处理完全一样。
|
|
87
|
+
const extRows = (ext.enhancers ?? []).filter((e) => e.source !== 'builtin').map((e): RouterComponentRow => ({
|
|
88
|
+
key: `ext:${e.id}`,
|
|
89
|
+
name: e.name,
|
|
90
|
+
// 缺字段一律按「关 / 未就绪」读:没拿到的事实不当真,宁可显示成关着
|
|
91
|
+
// 让用户自己去开,也不要谎报在跑(`ready` 是运行时事实,猜不得)。
|
|
92
|
+
enabled: e.enabled === true,
|
|
93
|
+
ready: e.ready === true,
|
|
94
|
+
togglable: true,
|
|
95
|
+
...(e.icon === undefined ? {} : { icon: e.icon }),
|
|
96
|
+
...(e.detail === undefined ? {} : { detail: e.detail }),
|
|
97
|
+
}))
|
|
98
|
+
return { external, ext: extRows }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 两组加起来一个都没有 → 这一节不渲染(不留空标题)。 */
|
|
102
|
+
export function isEmptyComponents(components: RouterComponents): boolean {
|
|
103
|
+
return components.external.length === 0 && components.ext.length === 0
|
|
104
|
+
}
|