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.
Files changed (120) hide show
  1. package/README.md +238 -63
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/pool-sticky-block.md +158 -0
  5. package/docs/suppliers.md +28 -5
  6. package/lib/client-registry.js +1300 -549
  7. package/lib/client.js +1300 -549
  8. package/lib/ext-test/index.js +47 -0
  9. package/lib/ext-test/locale/en.json +6 -0
  10. package/lib/ext-test/locale/zh.json +6 -0
  11. package/lib/ext-test/package.json +5 -0
  12. package/lib/http-state-C1vb3gW2.js +48 -0
  13. package/lib/index.js +1169 -254
  14. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  15. package/lib/suppliers/nvidia/locale/en.json +6 -0
  16. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  17. package/lib/suppliers/nvidia/package.json +5 -0
  18. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  19. package/lib/suppliers/opencode/locale/en.json +6 -0
  20. package/lib/suppliers/opencode/locale/zh.json +6 -0
  21. package/lib/suppliers/opencode/package.json +5 -0
  22. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  23. package/lib/suppliers/openrouter/locale/en.json +6 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  25. package/lib/suppliers/openrouter/package.json +5 -0
  26. package/lib/types/ext/contract.d.ts +9 -7
  27. package/lib/types/ext-test/index.d.ts +21 -0
  28. package/lib/types/ext-test/plugin.d.ts +23 -0
  29. package/lib/types/host-version.d.ts +15 -0
  30. package/lib/types/index.d.ts +6 -0
  31. package/lib/types/llm/adapter.d.ts +35 -4
  32. package/lib/types/router/account-pool.d.ts +53 -18
  33. package/lib/types/router/index.d.ts +65 -15
  34. package/lib/types/router/prefix-affinity.d.ts +18 -1
  35. package/lib/types/router/types.d.ts +7 -4
  36. package/lib/types/router/usage-store.d.ts +12 -0
  37. package/lib/types/shared.d.ts +25 -21
  38. package/lib/types/supplier-config.d.ts +15 -3
  39. package/lib/types/suppliers/contract.d.ts +37 -4
  40. package/lib/types/suppliers/http-state.d.ts +23 -0
  41. package/lib/types/suppliers/loader.d.ts +6 -4
  42. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  43. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  44. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  45. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  46. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  47. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  48. package/lib/types/suppliers/probe.d.ts +160 -0
  49. package/lib/types/suppliers/registry.d.ts +3 -2
  50. package/package.json +50 -16
  51. package/src/client/CombosTab.tsx +8 -53
  52. package/src/client/ExtDetail.tsx +32 -196
  53. package/src/client/ExtTab.tsx +25 -42
  54. package/src/client/ExtTestPanel.tsx +392 -0
  55. package/src/client/LastHitDock.tsx +141 -0
  56. package/src/client/RouterComponentsSection.tsx +241 -0
  57. package/src/client/RouterView.tsx +12 -5
  58. package/src/client/StatsTab.tsx +8 -6
  59. package/src/client/SupplierDetail.tsx +2 -34
  60. package/src/client/comp-button-scope.test.ts +58 -0
  61. package/src/client/comp-panel-layout.test.ts +58 -0
  62. package/src/client/env.d.ts +38 -1
  63. package/src/client/ext-panels.test.ts +44 -0
  64. package/src/client/ext-panels.ts +49 -0
  65. package/src/client/index.tsx +86 -0
  66. package/src/client/last-hit-store.ts +89 -0
  67. package/src/client/model-choice.test.ts +41 -0
  68. package/src/client/model-choice.ts +37 -0
  69. package/src/client/model-hint-copy.test.ts +7 -2
  70. package/src/client/model-hint-copy.ts +2 -2
  71. package/src/client/router-components.test.ts +114 -0
  72. package/src/client/router-components.ts +104 -0
  73. package/src/client/router.css +2592 -1635
  74. package/src/client/supplier-visibility.test.ts +57 -0
  75. package/src/client/supplier-visibility.ts +63 -0
  76. package/src/ext/contract.ts +9 -7
  77. package/src/ext-test/ext-test.test.ts +60 -0
  78. package/src/ext-test/index.ts +35 -0
  79. package/src/ext-test/locale/en.json +6 -0
  80. package/src/ext-test/locale/zh.json +6 -0
  81. package/src/ext-test/plugin.ts +34 -0
  82. package/src/ext-test/row.json +5 -0
  83. package/src/host-version.test.ts +61 -0
  84. package/src/host-version.ts +101 -0
  85. package/src/index.ts +165 -68
  86. package/src/llm/adapter.test.ts +175 -4
  87. package/src/llm/adapter.ts +109 -16
  88. package/src/router/account-pool.test.ts +189 -83
  89. package/src/router/account-pool.ts +120 -31
  90. package/src/router/disconnect-leak.test.ts +7 -12
  91. package/src/router/first-byte-budget.test.ts +160 -0
  92. package/src/router/index.ts +276 -62
  93. package/src/router/models-cache.test.ts +54 -9
  94. package/src/router/precise-dispatch.test.ts +6 -6
  95. package/src/router/prefix-affinity.ts +22 -1
  96. package/src/router/supplier-switch.test.ts +220 -0
  97. package/src/router/types.ts +7 -5
  98. package/src/router/usage-store.ts +16 -0
  99. package/src/router/usage.test.ts +1 -1
  100. package/src/shared.ts +25 -24
  101. package/src/supplier-config.ts +33 -13
  102. package/src/suppliers/contract.ts +56 -4
  103. package/src/suppliers/loader.test.ts +38 -4
  104. package/src/suppliers/loader.ts +33 -18
  105. package/src/suppliers/nvidia/index.ts +27 -0
  106. package/src/suppliers/nvidia/locale/en.json +6 -0
  107. package/src/suppliers/nvidia/locale/zh.json +6 -0
  108. package/src/suppliers/nvidia/row.json +5 -0
  109. package/src/suppliers/opencode/index.ts +27 -0
  110. package/src/suppliers/opencode/locale/en.json +6 -0
  111. package/src/suppliers/opencode/locale/zh.json +6 -0
  112. package/src/suppliers/opencode/row.json +5 -0
  113. package/src/suppliers/openrouter/index.ts +27 -0
  114. package/src/suppliers/openrouter/locale/en.json +6 -0
  115. package/src/suppliers/openrouter/locale/zh.json +6 -0
  116. package/src/suppliers/openrouter/row.json +5 -0
  117. package/src/suppliers/probe.test.ts +266 -0
  118. package/src/suppliers/probe.ts +475 -0
  119. package/src/suppliers/registry.ts +103 -23
  120. package/src/types.d.ts +2 -1
@@ -0,0 +1,241 @@
1
+ /**
2
+ * 官方插件页(设置 → 插件)里 dsh-router-core 详情页的「路由组件」一节。
3
+ *
4
+ * 为什么在这里而不是官方那个「包含的组件」:那一节的行由宿主从**本 bundle 自己
5
+ * 的 cordis.patch.yml** 算出来(plugin-manager `declaredRows`),跨 bundle 没有
6
+ * 注入通道;而子插件(dsh-router-codebuddy / -ext-rtk …)各自是独立 bundle,
7
+ * 必须保持独立安装。官方给的位置就是本节用的 `plugins.detail.section` 槽位
8
+ * (渲染在原生行列表**之后**,自带 chrome)。
9
+ *
10
+ * **内置的三个供应商不在这一节**:它们是 dsh-router-core 自己 patch 里的行
11
+ * (`cordis.patch.yml` insert 的子路径模块),已经在宿主的原生「包含的组件」里,
12
+ * 带着宿主管的开关。在这里再列一遍就是同一个东西显示两处。
13
+ *
14
+ * 数据全部来自核心**已经在提供**的两个端点,不新增任何 API:
15
+ * - `GET /router/api/health` → `suppliers[]`(只取 `source === 'external'`)
16
+ * - `GET /router/api/ext` → `enhancers[]`(核心已把开关与就绪状态合并好)
17
+ *
18
+ * 交互按各组真实能力给,不假装能开关:只有 ext 走 `PATCH /router/api/ext`
19
+ * (核心持久化到 `<dataDir>/ext.json`);供应商走 `PATCH /suppliers/:id/enabled`
20
+ * (落到 `supplier-config.json`),关掉后核心的活跃集合里就没有它,请求不会落到它。
21
+ */
22
+ import { useEffect, useRef, useState } from 'react'
23
+ import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
24
+ import { ROUTER_API_BASE, type RouterExtResponse, type RouterHealthResponse } from '../shared.ts'
25
+ import {
26
+ EMPTY_COMPONENTS,
27
+ groupRouterComponents,
28
+ isEmptyComponents,
29
+ type RouterComponentRow,
30
+ type RouterComponents,
31
+ } from './router-components.ts'
32
+
33
+ /**
34
+ * 本 bundle 的包名 —— 宿主用它标识「哪个插件的详情页」。
35
+ * 之前导出去是为了给行详情页的槽位 key 拼前缀;那个入口已撤,这一节自己用即可。
36
+ */
37
+ const BUNDLE_NAME = 'dsh-router-core'
38
+
39
+ /** 两组的标题与组级说明(顺序即渲染顺序)。 */
40
+ const GROUPS: ReadonlyArray<{ key: keyof RouterComponents; title: string; hint: string }> = [
41
+ { key: 'external', title: '供应商', hint: '关掉的供应商不参与路由,请求不会落到它' },
42
+ { key: 'ext', title: '扩展', hint: '独立安装的扩展插件;内置扩展在上面的「包含的组件」里' },
43
+ ]
44
+
45
+ /** 宿主 slot 注入的 owner props(本地声明,不 import 宿主的 slot-contract 类型)。 */
46
+ interface Subject {
47
+ kind?: string
48
+ pkg?: { name?: string }
49
+ }
50
+
51
+ /** 读一个 GET 端点;失败当空答复,形状恒定。 */
52
+ async function readJson<T>(path: string): Promise<T | undefined> {
53
+ try {
54
+ const response = await fetch(`${ROUTER_API_BASE}${path}`, { cache: 'no-store' })
55
+ const data = await response.json() as T & { ok?: boolean; error?: string }
56
+ if (data.ok === false) return undefined
57
+ return data
58
+ } catch {
59
+ return undefined
60
+ }
61
+ }
62
+
63
+ /** 写扩展开关;答复带回合并后的整张扩展表(核心 PATCH 后会重发一次列表)。 */
64
+ async function writeExt(id: string, enabled: boolean): Promise<RouterExtResponse | undefined> {
65
+ try {
66
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, {
67
+ method: 'PATCH',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ id, enabled }),
70
+ cache: 'no-store',
71
+ })
72
+ return await response.json() as RouterExtResponse
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 写供应商开关(核心的通用端点,`supplierRoutes` 生成)。
80
+ *
81
+ * 关掉是真的不参与路由 —— 核心的活跃集合里就没有它了,请求不会落到它,而它自己
82
+ * 仍留在列表里(关掉后还能再开)。答复只回 `{ok, id, enabled}`,所以成功后就重读
83
+ * `/health`,不在这儿猜状态。
84
+ */
85
+ async function writeSupplier(id: string, enabled: boolean): Promise<{ ok?: boolean; error?: string } | undefined> {
86
+ try {
87
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${encodeURIComponent(id)}/enabled`, {
88
+ method: 'PATCH',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({ enabled }),
91
+ cache: 'no-store',
92
+ })
93
+ return await response.json() as { ok?: boolean; error?: string }
94
+ } catch {
95
+ return undefined
96
+ }
97
+ }
98
+
99
+ function RowIcon({ row }: { row: RouterComponentRow }): JSX.Element {
100
+ if (row.icon === undefined) {
101
+ return <span className="dshr-compRowIcon" aria-hidden="true">◇</span>
102
+ }
103
+ return (
104
+ <span className="dshr-compRowIcon">
105
+ <img src={row.icon} alt="" onError={(e) => { e.currentTarget.style.display = 'none' }} />
106
+ </span>
107
+ )
108
+ }
109
+
110
+ /** 这一行是不是扩展(键前缀 `ext:`)—— 扩展有 `ready` 自检,供应商没有。 */
111
+ function isExt(row: RouterComponentRow): boolean {
112
+ return row.key.startsWith('ext:')
113
+ }
114
+
115
+ /**
116
+ * 一行:图标 + 名称 + 状态说明。
117
+ *
118
+ * 状态说明只给 ext 行(它的启停与就绪是运行时事实,值得逐条说);供应商行
119
+ * 在这一页没有可写的开关,含义由组标题承载,不编一句假状态给它。
120
+ */
121
+ function Row({ row, busy, onToggle }: {
122
+ row: RouterComponentRow
123
+ busy: boolean
124
+ onToggle: (row: RouterComponentRow, next: boolean) => void
125
+ }): JSX.Element {
126
+ return (
127
+ <li className="dshr-compRow">
128
+ <RowIcon row={row} />
129
+ <div className="dshr-compRowMain">
130
+ <span className="dshr-compRowName">{row.name}</span>
131
+ {row.togglable && (
132
+ <span className="dshr-compRowState">{row.detail ?? (row.enabled === true ? '已启用' : '已关闭')}</span>
133
+ )}
134
+ </div>
135
+ {row.togglable && (
136
+ <Switch
137
+ checked={row.enabled === true}
138
+ // 「自检未通过不给开」只对**扩展**成立(`ready` 是扩展器报的运行时事实,
139
+ // 约定写在 docs/ext.md)。供应商没有 `ready` 这个字段,若不按行类型区分,
140
+ // 它的开关会永远处于 disabled —— 一个点不开的开关比没有还糟。
141
+ disabled={busy || (!row.enabled && isExt(row) && row.ready !== true)}
142
+ label={`${row.name} 开关`}
143
+ title={!row.enabled && isExt(row) && row.ready !== true
144
+ ? (row.detail ?? '当前不可用,无法开启')
145
+ : row.enabled === true ? `关闭 ${row.name}` : `开启 ${row.name}`}
146
+ onChange={(next) => { onToggle(row, next) }}
147
+ />
148
+ )}
149
+ </li>
150
+ )
151
+ }
152
+
153
+ export function RouterComponentsSection({ subject }: { subject?: Subject }): JSX.Element | null {
154
+ const [components, setComponents] = useState<RouterComponents>(EMPTY_COMPONENTS)
155
+ const [error, setError] = useState('')
156
+ const [loaded, setLoaded] = useState(false)
157
+ const [busy, setBusy] = useState('')
158
+ const live = useRef(true)
159
+
160
+ const mine = subject?.kind === 'bundle' && subject?.pkg?.name === BUNDLE_NAME
161
+
162
+ const load = async (): Promise<void> => {
163
+ const [health, ext] = await Promise.all([
164
+ readJson<RouterHealthResponse>('/health'),
165
+ readJson<RouterExtResponse>('/ext'),
166
+ ])
167
+ if (!live.current) return
168
+ setError(health === undefined && ext === undefined ? '读取路由组件失败' : '')
169
+ setComponents(groupRouterComponents(health ?? { ok: false }, ext ?? { ok: false }))
170
+ setLoaded(true)
171
+ }
172
+
173
+ useEffect(() => {
174
+ if (!mine) return
175
+ live.current = true
176
+ void load()
177
+ return () => { live.current = false }
178
+ }, [mine])
179
+
180
+ if (!mine) return null
181
+
182
+ const toggle = async (row: RouterComponentRow, next: boolean): Promise<void> => {
183
+ // 行键形如 `ext:rtk` / `supplier:codebuddy`;两种组走**不同的端点**。
184
+ // (这里原先硬编码了 'ext:' 前缀 —— 供应商行的 id 会被切成 'supplier:codebuddy'
185
+ // 整串发给扩展端点,供应商的开关根本落不下去。)
186
+ const id = row.key.slice(row.key.indexOf(':') + 1)
187
+ setBusy(id)
188
+ setError('')
189
+ if (isExt(row)) {
190
+ const data = await writeExt(id, next)
191
+ if (!live.current) return
192
+ setBusy('')
193
+ if (data?.ok === true) {
194
+ // 扩展端点的 PATCH 会回发合并后的整张表,直接用它,省一次往返。
195
+ const health = await readJson<RouterHealthResponse>('/health')
196
+ setComponents(groupRouterComponents(health ?? { ok: true }, data))
197
+ return
198
+ }
199
+ // 核心拒了(未就绪 / 不存在)或网络失败:回读真值,不留乐观假象。
200
+ setError(data?.error ?? '切换扩展失败')
201
+ await load()
202
+ return
203
+ }
204
+ const data = await writeSupplier(id, next)
205
+ if (!live.current) return
206
+ setBusy('')
207
+ if (data?.ok === true) {
208
+ // 供应商端点只回 `{ok,id,enabled}`,状态以 `/health` 为准重读一次。
209
+ await load()
210
+ return
211
+ }
212
+ setError(data?.error ?? '切换供应商失败')
213
+ await load()
214
+ }
215
+
216
+ const groups = GROUPS
217
+ .map(({ key, title, hint }) => ({ title, hint, rows: components[key] }))
218
+ .filter((g) => g.rows.length > 0)
219
+ // 加载中或两组全空都不渲染:宿主那一节是「有内容才有一节标题」的形状。
220
+ if (!loaded || isEmptyComponents(components)) return null
221
+
222
+ return (
223
+ <section className="dshr-comp" data-router-components>
224
+ {error !== '' && <p className="dshr-compError" role="status">{error}</p>}
225
+ {groups.map((group) => (
226
+ <div key={group.title} className="dshr-compGroup">
227
+ <div className="dshr-compHead">
228
+ <h4 className="dshr-compTitle">{group.title}</h4>
229
+ <span className="dshr-compCount">共 {group.rows.length} 个</span>
230
+ </div>
231
+ {group.hint !== '' && <p className="dshr-compHint">{group.hint}</p>}
232
+ <ul className="dshr-compRows">
233
+ {group.rows.map((row) => (
234
+ <Row key={row.key} row={row} busy={busy === row.key.slice(row.key.indexOf(':') + 1)} onToggle={toggle} />
235
+ ))}
236
+ </ul>
237
+ </div>
238
+ ))}
239
+ </section>
240
+ )
241
+ }
@@ -27,6 +27,7 @@ import {
27
27
  import { SupplierDetail } from './SupplierDetail.tsx'
28
28
  import { EndpointTab } from './EndpointTab.tsx'
29
29
  import { CombosTab } from './CombosTab.tsx'
30
+ import { supplierVisibility, type SupplierSummary } from './supplier-visibility.ts'
30
31
  import { StatsTab } from './StatsTab.tsx'
31
32
  import { ExtTab } from './ExtTab.tsx'
32
33
 
@@ -86,13 +87,16 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
86
87
  }, [])
87
88
 
88
89
  const suppliers = snapshot.health?.suppliers ?? []
90
+ // 面板只列**开着**的供应商(关掉的卡片不出现,与「扩展」页同一形状);开关在
91
+ // 插件页那节。判据与空状态文案都在 supplier-visibility.ts,可单测。
92
+ const panel = supplierVisibility(suppliers)
89
93
  const accounts = snapshot.status?.accounts ?? []
90
94
  const combos = snapshot.combos?.combos ?? []
91
95
  const suppliersError = snapshot.status?.ok === false ? snapshot.status.error : undefined
92
96
  const combosError = snapshot.combos?.ok === false ? snapshot.combos.error : undefined
93
97
 
94
98
  /** 渲染一组供应商卡片(内置 / 插件分开)。 */
95
- const renderSupplierGroup = (title: string, group: Array<{ id: string; name: string; icon?: string; apiKeyHint?: string; source?: string }>): ReactNode => {
99
+ const renderSupplierGroup = (title: string, group: SupplierSummary[]): ReactNode => {
96
100
  if (group.length === 0) return null
97
101
  return (
98
102
  <div className="dshr-supplierGroup">
@@ -181,11 +185,14 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
181
185
  )
182
186
  : (
183
187
  <div className="dshr-tabBody">
184
- {suppliers.length === 0 && snapshot.health !== null && (
185
- <div className="dshr-empty">暂无供应商</div>
188
+ {snapshot.health !== null && panel.empty !== undefined && (
189
+ <div className="dshr-empty dshr-emptyStack">
190
+ <div>{panel.empty.title}</div>
191
+ {panel.empty.desc !== '' && <div className="dshr-muted">{panel.empty.desc}</div>}
192
+ </div>
186
193
  )}
187
- {renderSupplierGroup('内置', suppliers.filter(s => s.source !== 'external'))}
188
- {renderSupplierGroup('插件', suppliers.filter(s => s.source === 'external'))}
194
+ {renderSupplierGroup('内置', panel.builtin)}
195
+ {renderSupplierGroup('插件', panel.external)}
189
196
  {suppliersError !== undefined && (
190
197
  <div className="dshr-empty">{suppliersError}</div>
191
198
  )}
@@ -333,9 +333,9 @@ function RecentTable({ rows }: { rows: RouterUsageRecord[] }): JSX.Element {
333
333
  <div className="dshr-recentRow dshr-rankHead">
334
334
  <span className="dshr-recentTime">时间</span>
335
335
  <span className="dshr-recentModel">模型</span>
336
- <span className="dshr-recentSupplier">供应商</span>
337
- <span className="dshr-rankNum">In / Out</span>
338
- <span className="dshr-rankNum">耗时</span>
336
+ <span className="dshr-recentSupplier">连接</span>
337
+ <span className="dshr-recentTokens">In / Out</span>
338
+ <span className="dshr-recentDuration">耗时</span>
339
339
  </div>
340
340
  {rows.map((r, i) => (
341
341
  // 明细没有 id;同毫秒两条请求是可能的,索引用 ts+模型+序号兜底
@@ -347,13 +347,15 @@ function RecentTable({ rows }: { rows: RouterUsageRecord[] }): JSX.Element {
347
347
  <span className="dshr-recentModel dshr-mono" title={`请求 ${r.requested}${r.error === undefined ? '' : ` · ${r.error}`}`}>
348
348
  {r.model === '' ? r.requested : r.model}
349
349
  </span>
350
- <span className="dshr-recentSupplier">{r.supplier === '' ? '—' : r.supplier}</span>
351
- <span className="dshr-rankNum">
350
+ <span className="dshr-recentSupplier" title={r.uid ?? ''}>
351
+ {r.connection === undefined || r.connection === '' ? '—' : r.connection}
352
+ </span>
353
+ <span className="dshr-recentTokens">
352
354
  <span className="dshr-tokenIn">{fmtShort(r.promptTokens)}{r.inputEstimated ? '~' : ''}</span>
353
355
  {' / '}
354
356
  <span className="dshr-tokenOut">{fmtShort(r.completionTokens)}{r.outputEstimated ? '~' : ''}</span>
355
357
  </span>
356
- <span className="dshr-rankNum dshr-muted">{fmtDuration(r.durationMs)}</span>
358
+ <span className="dshr-recentDuration dshr-muted">{fmtDuration(r.durationMs)}</span>
357
359
  </div>
358
360
  ))}
359
361
  </div>
@@ -106,7 +106,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
106
106
  const [modelQuery, setModelQuery] = useState('')
107
107
  const [fetchingModels, setFetchingModels] = useState(false)
108
108
  const [removeModelTarget, setRemoveModelTarget] = useState<string | null>(null)
109
- const [poolStrategy, setPoolStrategy] = useState<'fallback' | 'round-robin'>('fallback')
110
109
  const [poolOrder, setPoolOrder] = useState<string[]>([])
111
110
  const [checkingIn, setCheckingIn] = useState(false)
112
111
  const [refreshing, setRefreshing] = useState(false)
@@ -146,16 +145,9 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
146
145
 
147
146
  const loadPoolConfig = useCallback(async (): Promise<void> => {
148
147
  try {
149
- const [strategyRes, orderRes] = await Promise.all([
150
- fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/strategy`, { cache: 'no-store' }),
151
- fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/order`, { cache: 'no-store' }),
152
- ])
153
- const [strategyData, orderData] = await Promise.all([strategyRes.json(), orderRes.json()]) as [
154
- { ok: boolean; strategy?: 'fallback' | 'round-robin' },
155
- { ok: boolean; order?: string[] },
156
- ]
148
+ const orderRes = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/order`, { cache: 'no-store' })
149
+ const orderData = await orderRes.json() as { ok: boolean; order?: string[] }
157
150
  if (mounted.current) {
158
- if (strategyData.ok && strategyData.strategy) setPoolStrategy(strategyData.strategy)
159
151
  if (orderData.ok && Array.isArray(orderData.order)) setPoolOrder(orderData.order)
160
152
  }
161
153
  } catch {
@@ -535,17 +527,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
535
527
  const data = await response.json() as { ok: boolean; error?: string }
536
528
  if (data.ok) {
537
529
  setAlias(aliasDraft.trim())
538
- // 同时保存连接池策略
539
- try {
540
- await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/strategy`, {
541
- method: 'POST',
542
- headers: { 'Content-Type': 'application/json' },
543
- body: JSON.stringify({ strategy: poolStrategy }),
544
- cache: 'no-store',
545
- })
546
- } catch {
547
- // 策略保存失败不阻断前缀保存
548
- }
549
530
  setShowAlias(false)
550
531
  } else {
551
532
  setAliasError(data.error ?? '保存失败')
@@ -603,7 +584,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
603
584
  <h1 className="dshr-providerName">{supplier.name}</h1>
604
585
  <p className="dshr-providerCount">
605
586
  {supplierAccounts.length} 链接
606
- {canPool && ` · ${poolStrategy === 'round-robin' ? '轮询' : '回退'}`}
607
587
  {canEdit && ` · 前缀 ${alias}`}
608
588
  </p>
609
589
  </div>
@@ -905,18 +885,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
905
885
  onChange={(e) => setAliasDraft(e.target.value)}
906
886
  />
907
887
  </>
908
- <>
909
- <label className="dshr-fieldLabel">连接池策略</label>
910
- <p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 = 按会话前缀粘住同一链接(前缀亲和,多会话不打架),拿不到会话信息时退回块轮转。</p>
911
- <select
912
- className="dshr-select dshr-input"
913
- value={poolStrategy}
914
- onChange={(e) => setPoolStrategy(e.target.value as 'fallback' | 'round-robin')}
915
- >
916
- <option value="fallback">回退</option>
917
- <option value="round-robin">轮询</option>
918
- </select>
919
- </>
920
888
  {aliasError !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{aliasError}</span></div>}
921
889
  <div className="dshr-modalActions">
922
890
  <button type="button" className="dshr-miniButton" onClick={() => setShowAlias(false)}>取消</button>
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 面板按钮的字色判据 —— 锁住「每个按钮都在那条抬特异性的规则覆盖范围内」。
3
+ *
4
+ * ## 为什么需要这条
5
+ *
6
+ * 宿主的 `Button` 原语在 `primary` 变体里要把字色设成
7
+ * `--dsw-alias-label-primary-foreground`,但 `router.css` 顶部那条兜底
8
+ * `.dshr-settings button { color: inherit; font: inherit }` 特异性 (1,1) **高于**
9
+ * 原语的 (1,0),赢的是它 —— 于是按钮变「#0f1115 底 + 继承来的 #0f1115 字」,
10
+ * **黑底黑字**。这个插件已经因此翻车两次:第一次是访问测试按钮,第二次是新增的
11
+ * 契约体检按钮(上一版修复只挂在 `.dshr-compActions` 上,新按钮在另一个容器里,
12
+ * 规则没盖到)。症状完全一样、且不报任何错。
13
+ *
14
+ * 修法是给每个动作行挂 `dshr-compBtnRow`,由一条 (2,1) 的规则统一还原字色。
15
+ * (这个类名特意**不是** `dshr-compActions` 的前缀:否则下面按子串计数会把它
16
+ * 一起算进去,判据自己就成了摆设。)
17
+ * 那么「新增按钮忘了挂这个类」就会退化成同样的黑底黑字 —— 这条判据就是拦它的。
18
+ */
19
+ import test from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+ import { readFileSync } from 'node:fs'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ const panel = readFileSync(fileURLToPath(new URL('./ExtTestPanel.tsx', import.meta.url)), 'utf8')
25
+ const css = readFileSync(fileURLToPath(new URL('./router.css', import.meta.url)), 'utf8')
26
+
27
+ test('面板里每个 Button 都在一个带 dshr-compBtnRow 的动作行里', () => {
28
+ // 一个动作行可以放多个按钮(「只读体检」+「深度体检」并排),所以判据是
29
+ // **每个 Button 在源码里都处于某个 dshr-compBtnRow 容器之内** —— 早期版本
30
+ // 用「按钮数 == 行数」来近似,被并排两个按钮的布局直接顶红了(这正是它的价值:
31
+ // 假设一旦不成立就会暴露,而不是继续绿)。
32
+ const opens = [...panel.matchAll(/<div className="([^"]*)">/g)].map((m) => m[1])
33
+ const rows = opens.filter((c) => (c ?? '').includes('dshr-compBtnRow')).length
34
+ assert.ok(rows > 0, '面板里应当有动作行(这条判据才有意义)')
35
+ const buttons = [...panel.matchAll(/<Button\b/g)]
36
+ assert.ok(buttons.length > 0, '面板里应当有 Button')
37
+ for (const b of buttons) {
38
+ const before = panel.slice(0, b.index)
39
+ // 取该 Button 之前最近一个开着的 <div>,看它是不是动作行
40
+ const nearest = [...before.matchAll(/<div className="([^"]*)">/g)].pop()
41
+ assert.ok((nearest?.[1] ?? '').includes('dshr-compBtnRow'),
42
+ `有一个 Button 不在任何 dshr-compBtnRow 动作行里(最近的容器:${nearest?.[1] ?? '无'})—— 会变回黑底黑字`)
43
+ }
44
+ })
45
+
46
+ test('那条字色规则挂在共用类上(不能再写死某个容器)', () => {
47
+ assert.match(css, /\.dshr-comp \.dshr-compBtnRow button\s*\{/,
48
+ '字色规则必须挂在 .dshr-compBtnRow 上:写死某个容器的话,下次新增按钮换个容器就漏')
49
+ assert.match(css, /\.dshr-comp \.dshr-compBtnRow button\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary-foreground\)/,
50
+ '字色仍由原语那个 token 决定,不自己另挑一个颜色')
51
+ })
52
+
53
+ test('规则特异性高于那条兜底(不依赖源码顺序)', () => {
54
+ // 兜底 = `.dshr-settings button` (1,1);本规则 = `.dshr-comp .dshr-compBtnRow button` (2,1)
55
+ const rule = css.match(/(\.[\w-]+)\s+(\.[\w-]+)\s+button\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary-foreground\)/)
56
+ assert.ok(rule !== null, '找不到那条字色规则')
57
+ assert.notEqual(rule[1], '.dshr-settings', '不能和兜底规则同容器 —— 同特异性就变成靠源码顺序了')
58
+ })
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 插件自检面板的**版面**判据 —— 不是像素级的样式测试,是「结构上别退回去」。
3
+ *
4
+ * 背景:核心侧那两项(模型启用状态 / 全部启用禁用)原先单独画了个带框的区块
5
+ * (`.dshr-compCore`,加粗、垫底色、单独一栏)。看着比真问题还显眼,而它们本来就是
6
+ * 体检结果的一部分,用户要的是「一眼扫完」。所以并进契约成员那一份列表,同一种行。
7
+ */
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const panel = readFileSync(fileURLToPath(new URL('./ExtTestPanel.tsx', import.meta.url)), 'utf8')
14
+ const css = readFileSync(fileURLToPath(new URL('./router.css', import.meta.url)), 'utf8')
15
+
16
+ test('核心侧那两项和契约成员同构:不另起区块', () => {
17
+ assert.equal(/className="dshr-compCore"/.test(panel), false,
18
+ '核心侧不该有自己的区块 —— 要和契约成员用同一份列表、同一种行')
19
+ assert.equal(/\.dshr-compCore\s*\{/.test(css), false, '核心侧区块的样式也该删掉')
20
+ })
21
+
22
+ test('「模型启用状态」和「全部启用/禁用」都渲染成 dshr-compMember 行', () => {
23
+ const rowCount = (panel.match(/className="dshr-compMember"/g) ?? []).length
24
+ assert.ok(rowCount >= 3, '至少要有:模型启用状态、全部启用禁用、契约成员各自的行')
25
+ assert.match(panel, /模型启用状态/, '模型启用状态要在面板上出现')
26
+ assert.match(panel, /report\.core\.operations\.map/, '全部启用/禁用由 core.operations 渲染')
27
+ })
28
+
29
+ test('面板顶部说明是一句话(不是写给维护者的长篇)', () => {
30
+ const intro = panel.match(/<p className="dshr-compIntro">([\s\S]*?)<\/p>/g) ?? []
31
+ for (const block of intro) {
32
+ const text = block.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
33
+ assert.ok(text.length <= 60, `说明要一句话(现在 ${text.length} 字):${text}`)
34
+ }
35
+ })
36
+
37
+ test('两节各有一个标题,两个按钮都叫「测试」(否则分不清点的是哪一个)', () => {
38
+ // 两个按钮同名是有意的(用户就是要一个统一的动词),代价是两节必须有标题来区分
39
+ assert.match(panel, /<h4 className="dshr-compSectionTitle">连接测试<\/h4>/, '第一节要有标题')
40
+ assert.match(panel, /<h4 className="dshr-compSectionTitle">插件测试<\/h4>/, '第二节叫「插件测试」')
41
+ assert.equal((panel.match(/'测试'|'测试中…'/g) ?? []).length, 4,
42
+ '两节各一对(常态 + 进行中)')
43
+ assert.equal(panel.includes('跑一次访问测试'), false, '旧的按钮长称别回来')
44
+ assert.equal(panel.includes('跑一次出厂体检'), false, '旧的按钮长称别回来')
45
+ assert.equal(panel.includes('插件契约体检'), false, '旧的节名别回来')
46
+ })
47
+
48
+ test('报告头只给数字,不给判词(合格/不合格该由看报告的人自己下)', () => {
49
+ for (const word of ['可以出厂', '不合格', '有未验项', '结论']) {
50
+ assert.equal(panel.includes(word), false, `判词「${word}」不该出现在面板上`)
51
+ }
52
+ assert.equal(panel.includes('dshr-compVerdict'), false, '徽标整块删掉')
53
+ assert.equal(/\.dshr-compVerdict/.test(css), false, '徽标样式也删掉')
54
+ // 数字要够:实跑数与「未验」都不能少 —— 没条件验的项混进「通过」就是撒谎
55
+ for (const field of ['total', 'implemented', 'ran', 'ok', 'fail', 'unverified', 'absent', 'bulk']) {
56
+ assert.match(panel, new RegExp(`summary\\.${field}`), `汇总里要能看到 ${field}`)
57
+ }
58
+ })
@@ -4,4 +4,41 @@ declare namespace JSX {
4
4
  }
5
5
 
6
6
  // CSS 副作用导入的模块声明(vite 产物内联,仅静态类型需要)。
7
- declare module '*.css'
7
+ declare module '*.css'
8
+
9
+ // 宿主 client 座位包不装进本插件的 node_modules(靠 package.json 的
10
+ // dsh.client.inject 声明,运行时由宿主的模块表解析),所以这里只补**本插件
11
+ // 真正用到的那一个**导出,签名照宿主的 Switch.d.ts 抄。
12
+ //
13
+ // 代价要说清:这是契约的本地副本,宿主改了签名这里不会自动变红;运行期用的是
14
+ // 宿主的真组件,漂移会表现为行为差异而不是构建失败。新增用到别的导出时同理。
15
+ declare module '@deepseek-ai/dsh-client-ui-primitives' {
16
+ /** 开关。checked 完全受控;onChange 收到点击所请求的状态。 */
17
+ export function Switch(props: {
18
+ checked: boolean
19
+ onChange: (next: boolean) => void
20
+ /** 无障碍名,由渲染点自己给。 */
21
+ label: string
22
+ disabled?: boolean
23
+ title?: string
24
+ className?: string
25
+ }): import('react').JSX.Element
26
+
27
+ /**
28
+ * 按钮。variant 各自对应宿主的 `--dsw-alias-button-*` token 家族,size `md`
29
+ * 是 36px 控件 / 12px 圆角(`sm` 是 28px / 8px)。原生 button 属性透传。
30
+ */
31
+ export function Button(props: {
32
+ variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
33
+ size?: 'md' | 'sm'
34
+ /** 前置 16px 图标。 */
35
+ icon?: import('react').ReactNode
36
+ className?: string
37
+ children?: import('react').ReactNode
38
+ type?: 'button' | 'submit' | 'reset'
39
+ disabled?: boolean
40
+ /** 原生 button 属性透传,所以 title 等标准属性可用。 */
41
+ title?: string
42
+ onClick?: () => void
43
+ }): import('react').JSX.Element
44
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 扩展开放详情面板的注册表测试。
3
+ *
4
+ * 为什么要测:注册表错了**不报任何错** —— 详情页只是安静地退回通用只读页,
5
+ * 用户看到的是「面板没出来」,没有任何线索指向「面板没被登记」。所以「登记 →
6
+ * 取用 → 清理」这条链要钉住。
7
+ */
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { registerExtPanel, hasExtPanel, extPanel } from './ext-panels.ts'
11
+
12
+ const A = (): null => null
13
+ const B = (): null => null
14
+
15
+ test('登记后能按扩展 id 取到同一个组件', () => {
16
+ const dispose = registerExtPanel('t-a', A)
17
+ assert.equal(hasExtPanel('t-a'), true)
18
+ assert.equal(extPanel('t-a'), A, '取到的必须就是登记进去的那个(两处详情页共用同一份)')
19
+ dispose()
20
+ })
21
+
22
+ test('没登记的扩展取不到(调用方据此走通用只读页)', () => {
23
+ assert.equal(hasExtPanel('t-missing'), false)
24
+ assert.equal(extPanel('t-missing'), undefined)
25
+ })
26
+
27
+ test('重复登记同 id:后者顶掉前者(apply 可能跑多次)', () => {
28
+ const first = registerExtPanel('t-b', A)
29
+ const second = registerExtPanel('t-b', B)
30
+ assert.equal(extPanel('t-b'), B)
31
+ first()
32
+ assert.equal(extPanel('t-b'), B, '清理旧的那次不该把新登记的也删掉')
33
+ second()
34
+ assert.equal(extPanel('t-b'), undefined)
35
+ })
36
+
37
+ test('清理只删自己那次登记', () => {
38
+ const first = registerExtPanel('t-c', A)
39
+ const disposeB = registerExtPanel('t-c', B)
40
+ first()
41
+ assert.equal(extPanel('t-c'), B, '旧登记被顶掉后,它的清理不该误删新登记')
42
+ disposeB()
43
+ assert.equal(hasExtPanel('t-c'), false)
44
+ })
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 扩展自带详情面板的注册表 —— 客户端,按扩展 id 索引。
3
+ *
4
+ * 为什么要有这个:`router.ext` 契约里只有 `id / name / description / icon /
5
+ * getState`,**没有「自带详情内容」的口子**。于是扩展详情页(设置 → 路由 → 扩展 →
6
+ * 点开卡片)只能是一张写死的只读页:名字、id、状态、一段说明,扩展自己想放的
7
+ * 东西(要选的模型、要跑的测试、要看的诊断)**没地方放**。
8
+ *
9
+ * 本表就是那个口子:扩展(按扩展 id)登记一个组件,详情页优先渲染它。
10
+ *
11
+ * 两处详情页共用它,两处都以扩展 id 为键,所以**同一个面板注册一次**:
12
+ * - 设置 → 路由 → 扩展:卡片点开的详情页(`ExtDetail`)
13
+ * - 设置 → 插件 → dsh-router-core:「包含的组件」里那一行的详情页
14
+ * (宿主的 `plugins.row.config` 座位,key 是 `<包名>#<行 id>`,与扩展 id 不同,
15
+ * 所以那一处由注册处用一个薄适配器转接,不在这里登记第二份)
16
+ *
17
+ * 这是**客户端**注册表(不是 cordis service):扩展的服务端对象活在宿主进程里,
18
+ * 面板是浏览器里的组件,两边不在一个地址空间。模块级 Map 够用 —— 面板在单个
19
+ * client bundle 里,模块只有一份实例。
20
+ */
21
+ import type { FC } from 'react'
22
+
23
+ /** 扩展 id → 它的详情面板。 */
24
+ const panels = new Map<string, FC>()
25
+
26
+ /**
27
+ * 给某个扩展登记详情面板。同 id 后登记的**顶掉**先登记的(热重载/重复 apply
28
+ * 时的常见情形);返回清理函数。
29
+ *
30
+ * @param extId - 扩展 id(`router.ext` 表里的键)
31
+ * @param panel - 详情面板组件。**不收 props**:面板的渲染点只有详情页一处
32
+ * (`ExtDetail`),没有第二种调用形状。
33
+ */
34
+ export function registerExtPanel(extId: string, panel: FC): () => void {
35
+ panels.set(extId, panel)
36
+ return () => {
37
+ if (panels.get(extId) === panel) panels.delete(extId)
38
+ }
39
+ }
40
+
41
+ /** 某个扩展是否自带详情面板。 */
42
+ export function hasExtPanel(extId: string): boolean {
43
+ return panels.has(extId)
44
+ }
45
+
46
+ /** 取某个扩展的详情面板;没登记返回 undefined(调用方据此走通用只读页)。 */
47
+ export function extPanel(extId: string): FC | undefined {
48
+ return panels.get(extId)
49
+ }