dsh-router-core 0.4.0 → 0.4.2

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 (99) hide show
  1. package/README.md +174 -38
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +29 -6
  4. package/docs/suppliers.md +28 -5
  5. package/lib/client-registry.js +939 -132
  6. package/lib/client.js +939 -132
  7. package/lib/ext-test/index.js +47 -0
  8. package/lib/ext-test/locale/en.json +6 -0
  9. package/lib/ext-test/locale/zh.json +6 -0
  10. package/lib/ext-test/package.json +5 -0
  11. package/lib/http-state-C1vb3gW2.js +48 -0
  12. package/lib/index.js +838 -89
  13. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  14. package/lib/suppliers/nvidia/locale/en.json +6 -0
  15. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  16. package/lib/suppliers/nvidia/package.json +5 -0
  17. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  18. package/lib/suppliers/opencode/locale/en.json +6 -0
  19. package/lib/suppliers/opencode/locale/zh.json +6 -0
  20. package/lib/suppliers/opencode/package.json +5 -0
  21. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  22. package/lib/suppliers/openrouter/locale/en.json +6 -0
  23. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  24. package/lib/suppliers/openrouter/package.json +5 -0
  25. package/lib/types/ext/contract.d.ts +9 -0
  26. package/lib/types/ext-test/index.d.ts +21 -0
  27. package/lib/types/ext-test/plugin.d.ts +23 -0
  28. package/lib/types/host-base-url.d.ts +31 -0
  29. package/lib/types/llm/adapter.d.ts +40 -4
  30. package/lib/types/router/index.d.ts +61 -8
  31. package/lib/types/shared.d.ts +12 -0
  32. package/lib/types/supplier-config.d.ts +15 -0
  33. package/lib/types/suppliers/contract.d.ts +35 -2
  34. package/lib/types/suppliers/http-state.d.ts +23 -0
  35. package/lib/types/suppliers/loader.d.ts +6 -4
  36. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  37. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  38. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  39. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  40. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  41. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  42. package/lib/types/suppliers/probe.d.ts +160 -0
  43. package/lib/types/suppliers/registry.d.ts +3 -1
  44. package/package.json +39 -4
  45. package/src/client/ExtDetail.tsx +21 -22
  46. package/src/client/ExtTab.tsx +25 -42
  47. package/src/client/ExtTestPanel.tsx +392 -0
  48. package/src/client/RouterComponentsSection.tsx +241 -0
  49. package/src/client/RouterView.tsx +12 -5
  50. package/src/client/comp-button-scope.test.ts +58 -0
  51. package/src/client/comp-panel-layout.test.ts +58 -0
  52. package/src/client/env.d.ts +38 -1
  53. package/src/client/ext-panels.test.ts +44 -0
  54. package/src/client/ext-panels.ts +49 -0
  55. package/src/client/index.tsx +41 -0
  56. package/src/client/model-choice.test.ts +41 -0
  57. package/src/client/model-choice.ts +37 -0
  58. package/src/client/router-components.test.ts +114 -0
  59. package/src/client/router-components.ts +104 -0
  60. package/src/client/router.css +490 -9
  61. package/src/client/supplier-visibility.test.ts +57 -0
  62. package/src/client/supplier-visibility.ts +63 -0
  63. package/src/ext/contract.ts +9 -0
  64. package/src/ext-test/ext-test.test.ts +60 -0
  65. package/src/ext-test/index.ts +35 -0
  66. package/src/ext-test/locale/en.json +6 -0
  67. package/src/ext-test/locale/zh.json +6 -0
  68. package/src/ext-test/plugin.ts +34 -0
  69. package/src/ext-test/row.json +5 -0
  70. package/src/host-base-url.test.ts +129 -0
  71. package/src/host-base-url.ts +43 -0
  72. package/src/index.ts +46 -11
  73. package/src/llm/adapter.test.ts +101 -4
  74. package/src/llm/adapter.ts +89 -20
  75. package/src/router/disconnect-leak.test.ts +7 -12
  76. package/src/router/first-byte-budget.test.ts +160 -0
  77. package/src/router/index.ts +255 -35
  78. package/src/router/models-cache.test.ts +45 -0
  79. package/src/router/supplier-switch.test.ts +220 -0
  80. package/src/shared.ts +12 -0
  81. package/src/supplier-config.ts +32 -1
  82. package/src/suppliers/contract.ts +54 -2
  83. package/src/suppliers/loader.test.ts +36 -2
  84. package/src/suppliers/loader.ts +33 -18
  85. package/src/suppliers/nvidia/index.ts +27 -0
  86. package/src/suppliers/nvidia/locale/en.json +6 -0
  87. package/src/suppliers/nvidia/locale/zh.json +6 -0
  88. package/src/suppliers/nvidia/row.json +5 -0
  89. package/src/suppliers/opencode/index.ts +27 -0
  90. package/src/suppliers/opencode/locale/en.json +6 -0
  91. package/src/suppliers/opencode/locale/zh.json +6 -0
  92. package/src/suppliers/opencode/row.json +5 -0
  93. package/src/suppliers/openrouter/index.ts +27 -0
  94. package/src/suppliers/openrouter/locale/en.json +6 -0
  95. package/src/suppliers/openrouter/locale/zh.json +6 -0
  96. package/src/suppliers/openrouter/row.json +5 -0
  97. package/src/suppliers/probe.test.ts +266 -0
  98. package/src/suppliers/probe.ts +475 -0
  99. package/src/suppliers/registry.ts +102 -3
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * 扩展详情页 —— 从设置 → 路由 → 扩展 的卡片点进来。
3
3
  *
4
- * 布局同供应商详情:返回链接 + 图标 + 名称/副行,头部右侧是启用开关;下面跟扩展说明
5
- * 卡与未就绪横幅。有 `api`() 支持的自定义扩展可在未来按需加独立面板,此处不内置任何
6
- * 具体扩展的专属视图。
4
+ * **只读:开关不在这一层。** 启停的唯一入口是官方插件页(设置 → 插件 →
5
+ * dsh-router-core 详情页的「路由组件」一节),那里直接调核心的
6
+ * `PATCH /router/api/ext`,自检与持久化都由核心裁决。同一件事只留一个开关,
7
+ * 否则两处状态各说各话。
8
+ *
9
+ * 列表页只列**已启用**的扩展(关闭的连卡片都不出现),所以这里也不必再显示
10
+ * 「已启用/未启用」——那行字只能是常量。布局同供应商详情:返回链接 + 图标 +
11
+ * 名称/副行,下面跟扩展说明卡与未就绪横幅。有 `api()` 支持的自定义扩展可在未来
12
+ * 按需加独立面板,此处不内置任何具体扩展的专属视图。
7
13
  */
8
14
  import type { RouterExtItem } from '../shared.ts'
15
+ import { extPanel } from './ext-panels.ts'
9
16
 
10
17
  function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
11
18
  return (
@@ -23,11 +30,13 @@ const I = {
23
30
  interface ExtDetailProps {
24
31
  item: RouterExtItem
25
32
  onBack: () => void
26
- /** 开关切换(由列表页持有状态与 PATCH;这里只做触发)。 */
27
- onToggle: (item: RouterExtItem, value: boolean) => void
28
33
  }
29
34
 
30
- export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Element {
35
+ export function ExtDetail({ item, onBack }: ExtDetailProps): JSX.Element {
36
+ // 扩展**自带**详情面板时用它(插件自检就是这么做的:选供应商/模型/连接 + 跑测试)。
37
+ // 没有就退回下面这张通用只读页 —— 保留返回链接与标题,导航形状与有没有自定义
38
+ // 面板无关,用户点进去的预期是稳定的。
39
+ const Custom = extPanel(item.id)
31
40
  return (
32
41
  <div className="dshr-tabBody">
33
42
  {/* Header(同供应商详情:返回链接 + 图标 + 名称/副行) */}
@@ -54,27 +63,15 @@ export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Eleme
54
63
  <h1 className="dshr-providerName">{item.name}</h1>
55
64
  <p className="dshr-providerCount">
56
65
  <span className="dshr-mono">{item.id}</span>
57
- {item.enabled ? ' · 已启用' : ' · 未启用'}
58
66
  {item.ready === false ? ' · 未就绪' : ''}
59
67
  </p>
60
68
  </div>
61
- {/* 开关放在标题行右侧(同供应商详情的编辑按钮位) */}
62
- <button
63
- type="button"
64
- className={`dshr-toggle dshr-extDetailToggle ${item.enabled ? 'dshr-toggle-on' : ''}`}
65
- role="switch"
66
- aria-checked={!!item.enabled}
67
- disabled={item.ready === false && !item.enabled}
68
- onClick={() => void onToggle(item, !item.enabled)}
69
- title={item.ready === false && !item.enabled
70
- ? '当前不可用,无法开启'
71
- : item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
72
- >
73
- <span className="dshr-toggleKnob" />
74
- </button>
75
69
  </div>
76
70
  </div>
77
71
 
72
+ {/* 扩展自带的面板(注册表里没有就落回下面这两块通用内容) */}
73
+ {Custom !== undefined ? <Custom /> : (
74
+ <>
78
75
  {/* 扩展说明 */}
79
76
  {item.description !== undefined && item.description !== '' && (
80
77
  <section className="dshr-card">
@@ -89,6 +86,8 @@ export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Eleme
89
86
  <span>{item.detail !== undefined && item.detail !== '' ? item.detail : '当前不可用'}</span>
90
87
  </div>
91
88
  )}
89
+ </>
90
+ )}
92
91
  </div>
93
92
  )
94
- }
93
+ }
@@ -1,12 +1,16 @@
1
1
  /**
2
- * 扩展 (Ext) 页 —— 列出所有注册进 `router.ext` 的扩展插件,每个一个开关。
2
+ * 扩展 (Ext) 页 —— 列出**已启用**的扩展插件,只读。
3
3
  *
4
- * 数据来自 `/router/api/ext`:核心把自己存的开关(`ext.json`)+ 扩展器报的
5
- * 运行时事实(`ready` / `detail`)合并后给面板。扩展器只实现具体能力(如 RTK),
6
- * 开关与渲染都在核心。
4
+ * 数据来自 `/router/api/ext`:核心把自己存的开关(`ext.json`)+ 扩展器报的
5
+ * 运行时事实(`ready` / `detail`)合并后给面板。扩展器只实现具体能力(如 RTK)。
7
6
  *
8
- * 布局贴近 EndpointTab 的「Require API key」开关行:每个扩展器一张卡片。
9
- * 未装任何扩展 → 空状态提示。
7
+ * 两个刻意的形状(与「开关在官方插件页」这一决定配套):
8
+ * 1. **只列已启用的** —— 关闭的扩展连卡片都不出现。这里是「正在生效的东西」的
9
+ * 展台,不是配置页;要去改开关,去设置 → 插件 → dsh-router-core 详情页的
10
+ * 「路由组件」一节。
11
+ * 2. **本页不发 PATCH** —— 启停只有一个入口,避免两处状态各说各话。
12
+ *
13
+ * 未装任何扩展,或装了但都没启用 → 同一条空状态提示。
10
14
  */
11
15
  import { useEffect, useRef, useState } from 'react'
12
16
  import { ROUTER_API_BASE, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
@@ -21,8 +25,8 @@ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
21
25
  }
22
26
 
23
27
  const I = {
24
- bolt: 'M13 2L4 14h6l-1 8 9-12h-6l1-8z',
25
- puzzle: 'M4 7h3a2 2 0 0 1 4 0h5a1 1 0 0 1 1 1v4a2 2 0 0 0 0 4v4H8v-3a2 2 0 0 1 0-4H4V7z',
28
+ bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
29
+ puzzle: 'M4 7h3a2 2 0 0 1 4 0h5a2 2 0 0 1 2 1v4a2 2 0 0 0 0 4v4H8v-3a2 2 0 0 1 0-4H4V7z',
26
30
  }
27
31
 
28
32
  export function ExtTab(): JSX.Element {
@@ -55,36 +59,13 @@ export function ExtTab(): JSX.Element {
55
59
  }
56
60
  }
57
61
 
58
- const toggle = async (item: RouterExtItem, value: boolean): Promise<void> => {
59
- // 乐观更新
60
- setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: value } : i))
61
- setError('')
62
- try {
63
- const response = await fetch(`${ROUTER_API_BASE}/ext`, {
64
- method: 'PATCH',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ id: item.id, enabled: value }),
67
- cache: 'no-store',
68
- })
69
- const data = await response.json() as RouterExtResponse
70
- if (mounted.current && data.enhancers) {
71
- setItems(data.enhancers)
72
- } else if (mounted.current && !data.ok) {
73
- setError(data.error ?? '切换失败')
74
- setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
75
- }
76
- } catch (err) {
77
- if (mounted.current) {
78
- setError((err as Error).message)
79
- setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
80
- }
81
- }
82
- }
62
+ // 只展已启用的。`enabled` 缺失按未启用读(与插件页同一口径:没拿到的事实不当真)。
63
+ const shown = items.filter(item => item.enabled === true)
83
64
 
84
- // 打开详情视图:用 live item(否则详情里的开关状态不会随列表更新)。
85
- const selected = selectedId !== null ? items.find((i) => i.id === selectedId) ?? null : null
65
+ // 打开详情视图:用 live item(详情里显示的是加载那一刻的运行时事实)。
66
+ const selected = selectedId !== null ? shown.find((i) => i.id === selectedId) ?? null : null
86
67
  if (selected !== null) {
87
- return <ExtDetail item={selected} onBack={() => setSelectedId(null)} onToggle={toggle} />
68
+ return <ExtDetail item={selected} onBack={() => setSelectedId(null)} />
88
69
  }
89
70
 
90
71
  return (
@@ -95,17 +76,18 @@ export function ExtTab(): JSX.Element {
95
76
 
96
77
  {!loaded ? (
97
78
  <div className="dshr-empty">加载中…</div>
98
- ) : items.length === 0 ? (
79
+ ) : shown.length === 0 ? (
99
80
  <div className="dshr-keyEmpty">
100
81
  <span className="dshr-keyEmptyIcon"><Icon d={I.puzzle} size={30} /></span>
101
- <p className="dshr-keyEmptyTitle">暂无扩展</p>
82
+ <p className="dshr-keyEmptyTitle">暂无启用的扩展</p>
102
83
  <p className="dshr-keyEmptyDesc">
103
- 安装扩展插件(如 <code>dsh-router-ext-watch</code>)后,这里会出现开关
84
+ 开关在 <strong>设置 → 插件 → dsh-router-core</strong> 的「路由组件」一节;
85
+ 启用后这里会列出它
104
86
  </p>
105
87
  </div>
106
88
  ) : (
107
89
  <div className="dshr-supplierGrid">
108
- {items.map(item => {
90
+ {shown.map(item => {
109
91
  const notReady = item.ready === false
110
92
  return (
111
93
  <section key={item.id} className="dshr-supplierCard" onClick={() => setSelectedId(item.id)}>
@@ -130,7 +112,8 @@ export function ExtTab(): JSX.Element {
130
112
  )}
131
113
 
132
114
  <div className="dshr-supplierMeta">
133
- <span className="dshr-muted">{item.enabled ? '已启用' : '未启用'}</span>
115
+ {/* 描述留在详情页(卡片放不下);这一行放稳定的 id,够短不会撑破布局 */}
116
+ <span className="dshr-mono dshr-muted">{item.id}</span>
134
117
  <span className="dshr-chevron">›</span>
135
118
  </div>
136
119
  </section>
@@ -140,4 +123,4 @@ export function ExtTab(): JSX.Element {
140
123
  )}
141
124
  </div>
142
125
  )
143
- }
126
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * 插件自检面板 —— 扩展「插件自检」(`dsh-router-ext-test` / 扩展 id `test`) 的详情内容。
3
+ *
4
+ * **入口只有一处**:设置 → 路由 → 扩展 → 点开「插件自检」那张卡片(`ExtDetail` 查
5
+ * `ext-panels.ts` 的注册表拿到本组件)。
6
+ *
7
+ * 刻意**不给插件页那一行也挂一个详情页**:那一行的宿主行开关已经能开/关它,再点进去
8
+ * 看同一块面板就是同一个东西有两个入口 —— 与「内置扩展不在自绘节里重复列」同理。
9
+ * 插件页那一行只负责开关。
10
+ *
11
+ * 视觉上照着宿主的插件页写(`--dsw-alias-*` 那套 token),**不复用设置页的
12
+ * `dshr-*` 组件样式**:那些样式依赖 `--rs-*` 变量,而那套变量只定义在
13
+ * `.dshr-settings` / `.dshr-shell` 下,插件页不在其祖先里(未定义的 `var()` 会让
14
+ * 整条声明失效)。按钮直接用宿主的 `Button` 原语 —— 配色由原语自己的 token 家族
15
+ * 决定,不必在这里猜「主色底上该配什么字色」。
16
+ */
17
+ import { useEffect, useState } from 'react'
18
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
19
+ import { ROUTER_API_BASE, type RouterAccount, type RouterHealthResponse } from '../shared.ts'
20
+ import { modelChoice, type ModelRow } from './model-choice.ts'
21
+ import type { ProbeReport } from '../suppliers/probe.ts'
22
+
23
+ /** 一次测试的结果。`ok` 为 false 时 `error` 是核心给的真实原因(上游响应 / chatOnce message)。 */
24
+ interface TestResult {
25
+ ok: boolean
26
+ error?: string
27
+ /** 实际测的是哪个连接(空串 = 池内任选,与面板「测试」按钮同义)。 */
28
+ uid: string
29
+ /** 测的时候选的是哪三样,回显出来 —— 一条「通过」必须可归因。 */
30
+ supplier: string
31
+ model: string
32
+ }
33
+
34
+ /** 结果区的一个状态。`network` 是请求本身失败,与「上游拒了」分开。 */
35
+ type Notice =
36
+ | { tone: 'ok'; result: TestResult }
37
+ | { tone: 'fail'; result: TestResult }
38
+ | { tone: 'network'; text: string }
39
+
40
+ /** 每个契约成员状态的字形(颜色由 data-state 给)。 */
41
+ /** 模型启用状态那一行属于核心侧,没在 members 里,状态由「拿不拿得到」决定。 */
42
+ function coreModelsState(report: ProbeReport): ProbeReport['members'][number]['state'] {
43
+ return report.core.models.total === null ? 'unverified' : 'ok'
44
+ }
45
+
46
+ const MEMBER_MARK: Record<ProbeReport['members'][number]['state'], string> = {
47
+ ok: '✓',
48
+ fail: '✕',
49
+ absent: '–',
50
+ unverified: '⊘',
51
+ }
52
+
53
+ const TONE_ICON: Record<Notice['tone'], string> = {
54
+ ok: 'M20 6L9 17l-5-5',
55
+ fail: 'M18 6L6 18M6 6l12 12',
56
+ network: 'M12 9v4m0 4h.01M10.3 3.9L2 18a2 2 0 003 2h16a2 2 0 002-2L13.7 3.9a2 2 0 00-3.4 0z',
57
+ }
58
+
59
+ function Glyph({ d }: { d: string }): JSX.Element {
60
+ return (
61
+ <svg className="dshr-compNoticeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
62
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
63
+ <path d={d} />
64
+ </svg>
65
+ )
66
+ }
67
+
68
+ /** 三个下拉里的一栏:标题行(可带右侧小注)+ 控件。`emptyHint` 在没得选时说明「为什么没有」。 */
69
+ function Picker(props: {
70
+ label: string
71
+ /** 标题右侧的小注(这一栏的用法要点,比塞进顶部那段长文字更容易被读到)。 */
72
+ note?: string
73
+ value: string
74
+ options: Array<{ value: string; label: string }>
75
+ emptyHint: string
76
+ disabled?: boolean
77
+ onChange: (value: string) => void
78
+ }): JSX.Element {
79
+ const empty = props.options.length === 0
80
+ return (
81
+ <div className="dshr-compField">
82
+ <div className="dshr-compFieldHead">
83
+ <label className="dshr-compFieldLabel">{props.label}</label>
84
+ {props.note !== undefined && <span className="dshr-compFieldNote">{props.note}</span>}
85
+ </div>
86
+ <select
87
+ className="dshr-compSelect"
88
+ value={props.value}
89
+ disabled={props.disabled === true || empty}
90
+ aria-label={props.label}
91
+ onChange={(e) => { props.onChange(e.target.value) }}
92
+ >
93
+ {empty
94
+ ? <option value="">{props.emptyHint}</option>
95
+ : <>
96
+ <option value="">请选择</option>
97
+ {props.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
98
+ </>}
99
+ </select>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ export function ExtTestPanel(): JSX.Element {
105
+ const [suppliers, setSuppliers] = useState<NonNullable<RouterHealthResponse['suppliers']>>([])
106
+ const [accounts, setAccounts] = useState<RouterAccount[]>([])
107
+ const [models, setModels] = useState<{ ids: string[]; empty?: 'none' | 'all-disabled' }>({ ids: [] })
108
+ const [supplierId, setSupplierId] = useState('')
109
+ const [modelId, setModelId] = useState('')
110
+ const [uid, setUid] = useState('')
111
+ const [busy, setBusy] = useState(false)
112
+ const [notice, setNotice] = useState<Notice | null>(null)
113
+ const [loaded, setLoaded] = useState(false)
114
+ // 契约体检:只针对**外部供应商插件**(内置供应商随核心分发,没有独立插件可体检)
115
+ const [pluginId, setPluginId] = useState('')
116
+ const [probing, setProbing] = useState(false)
117
+ const [report, setReport] = useState<ProbeReport | null>(null)
118
+ const [probeError, setProbeError] = useState('')
119
+
120
+ // 供应商与连接各读一次:/health 给供应商(开着的那批,与面板一致),
121
+ // /status 给账号(uid + 昵称 + 冷却状态)。
122
+ useEffect(() => {
123
+ let live = true
124
+ void (async () => {
125
+ try {
126
+ const [health, status] = await Promise.all([
127
+ fetch(`${ROUTER_API_BASE}/health`, { cache: 'no-store' }).then((r) => r.json() as Promise<RouterHealthResponse>),
128
+ fetch(`${ROUTER_API_BASE}/status`, { cache: 'no-store' }).then((r) => r.json() as Promise<{ ok: boolean; accounts?: RouterAccount[] }>),
129
+ ])
130
+ if (!live) return
131
+ setSuppliers((health.suppliers ?? []).filter((s) => s.enabled !== false))
132
+ setAccounts(status.ok ? (status.accounts ?? []) : [])
133
+ } catch (err) {
134
+ if (live) setNotice({ tone: 'network', text: (err as Error).message })
135
+ } finally {
136
+ if (live) setLoaded(true)
137
+ }
138
+ })()
139
+ return () => { live = false }
140
+ }, [])
141
+
142
+ // 换供应商 → 重拉模型、清掉模型/连接选择与旧结果(旧结果对新选择没有意义,
143
+ // 留着会被误读成「刚测过这个」)。
144
+ useEffect(() => {
145
+ setModelId('')
146
+ setUid('')
147
+ setNotice(null)
148
+ if (supplierId === '') { setModels({ ids: [] }); return }
149
+ let live = true
150
+ void (async () => {
151
+ try {
152
+ const data = await fetch(`${ROUTER_API_BASE}/suppliers/${encodeURIComponent(supplierId)}/models`, { cache: 'no-store' })
153
+ .then((r) => r.json() as Promise<{ ok: boolean; models?: ModelRow[] }>)
154
+ // 端点给的是**全部**模型(含用户停用的),这里只取可用的 —— 见 model-choice.ts
155
+ if (live) setModels(data.ok ? modelChoice(data.models ?? []) : { ids: [] })
156
+ } catch {
157
+ if (live) setModels({ ids: [] })
158
+ }
159
+ })()
160
+ return () => { live = false }
161
+ }, [supplierId])
162
+
163
+ // 外部供应商插件 = source 为 external 的那些(内置随核心分发,不进这个下拉)
164
+ const pluginSuppliers = suppliers.filter((x) => x.source === 'external')
165
+
166
+ const runProbe = async (): Promise<void> => {
167
+ if (pluginId === '' || probing) return
168
+ setProbing(true)
169
+ setProbeError('')
170
+ setReport(null)
171
+ try {
172
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${encodeURIComponent(pluginId)}/probe`, {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({}),
176
+ cache: 'no-store',
177
+ })
178
+ const data = await response.json() as { ok: boolean; error?: string; report?: ProbeReport }
179
+ if (data.ok && data.report !== undefined) setReport(data.report)
180
+ else setProbeError(data.error ?? '体检失败')
181
+ } catch (err) {
182
+ setProbeError((err as Error).message)
183
+ } finally {
184
+ setProbing(false)
185
+ }
186
+ }
187
+
188
+ const links = accounts.filter((a) => a.supplier === supplierId)
189
+ const runnable = supplierId !== '' && modelId !== ''
190
+ const supplierName = suppliers.find((s) => s.id === supplierId)?.name ?? supplierId
191
+
192
+ const run = async (): Promise<void> => {
193
+ if (!runnable || busy) return
194
+ setBusy(true)
195
+ setNotice(null)
196
+ try {
197
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${encodeURIComponent(supplierId)}/models/test`, {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ // uid 留空 = 池内任选可用号(与面板「测试」按钮同义);选了 = 只测它。
201
+ body: JSON.stringify({ id: modelId, uid }),
202
+ cache: 'no-store',
203
+ })
204
+ const data = await response.json() as { ok: boolean; error?: string }
205
+ const result: TestResult = {
206
+ ok: data.ok,
207
+ uid,
208
+ supplier: supplierName,
209
+ model: modelId,
210
+ ...(data.error === undefined ? {} : { error: data.error }),
211
+ }
212
+ setNotice({ tone: data.ok ? 'ok' : 'fail', result })
213
+ } catch (err) {
214
+ setNotice({ tone: 'network', text: (err as Error).message })
215
+ } finally {
216
+ setBusy(false)
217
+ }
218
+ }
219
+
220
+ return (
221
+ <div className="dshr-tabBody dshr-comp">
222
+ {/* 两节各自有标题:两个按钮都叫「测试」,没有标题就分不清点的是哪一个 */}
223
+ <div className="dshr-compHead">
224
+ <h4 className="dshr-compSectionTitle">连接测试</h4>
225
+ <span className="dshr-compSectionNote">选供应商 / 模型 / 连接</span>
226
+ </div>
227
+
228
+ <div className="dshr-compForm">
229
+ <Picker
230
+ label="供应商"
231
+ value={supplierId}
232
+ options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
233
+ emptyHint={loaded ? '没有已启用的供应商' : '加载中…'}
234
+ onChange={setSupplierId}
235
+ />
236
+ <Picker
237
+ label="模型"
238
+ note="仅可用"
239
+ value={modelId}
240
+ options={models.ids.map((id) => ({ value: id, label: id }))}
241
+ emptyHint={supplierId === ''
242
+ ? '先选供应商'
243
+ : models.empty === 'all-disabled'
244
+ ? '模型都被停用了(在供应商详情里开启)'
245
+ : models.empty === 'none' ? '没有模型(可先在供应商详情里拉取)' : '请选择'}
246
+ disabled={supplierId === ''}
247
+ onChange={(v) => { setModelId(v); setNotice(null) }}
248
+ />
249
+ <Picker
250
+ label="连接"
251
+ note="留空 = 池内任选"
252
+ value={uid}
253
+ options={links.map((a) => ({
254
+ value: a.uid,
255
+ label: `${a.nickname ?? a.uid}${a.cooling ? '(冷却中)' : ''}`,
256
+ }))}
257
+ emptyHint={supplierId === '' ? '先选供应商' : '没有连接(留空则不指定)'}
258
+ disabled={supplierId === ''}
259
+ onChange={(v) => { setUid(v); setNotice(null) }}
260
+ />
261
+ </div>
262
+
263
+ <div className="dshr-compActions dshr-compBtnRow">
264
+ <Button
265
+ variant="primary"
266
+ size="md"
267
+ disabled={!runnable || busy}
268
+ onClick={() => { void run() }}
269
+ >
270
+ {busy ? '测试中…' : '测试'}
271
+ </Button>
272
+ {!runnable && <span className="dshr-compHint">先选供应商与模型</span>}
273
+ </div>
274
+
275
+ {notice !== null && (
276
+ <div className="dshr-compNotice" data-tone={notice.tone} role="status">
277
+ <Glyph d={TONE_ICON[notice.tone]} />
278
+ <div className="dshr-compNoticeBody">
279
+ {notice.tone === 'network' ? (
280
+ <>
281
+ <div className="dshr-compNoticeTitle">请求失败</div>
282
+ <div className="dshr-compNoticeText">{notice.text}</div>
283
+ </>
284
+ ) : (
285
+ <>
286
+ <div className="dshr-compNoticeTitle">
287
+ {notice.tone === 'ok' ? '测试通过' : '测试失败'}
288
+ </div>
289
+ <div className="dshr-compNoticeText">
290
+ {notice.tone === 'ok'
291
+ ? '这个连接能正常访问该模型。'
292
+ : (notice.result.error ?? '上游没有给出原因')}
293
+ </div>
294
+ {/* 回显测的是哪三样:一条「通过」如果不带对象,就是没法归因的一串绿字。 */}
295
+ <div className="dshr-compNoticeMeta">
296
+ {notice.result.supplier} · {notice.result.model} · {notice.result.uid === '' ? '池内任选' : notice.result.uid}
297
+ </div>
298
+ </>
299
+ )}
300
+ </div>
301
+ </div>
302
+ )}
303
+
304
+ {/* ---- 契约体检:外部供应商插件的每个契约成员 ---- */}
305
+ <div className="dshr-compSection">
306
+ <div className="dshr-compHead">
307
+ <h4 className="dshr-compSectionTitle">插件测试</h4>
308
+ <span className="dshr-compSectionNote">外部供应商插件</span>
309
+ </div>
310
+ <p className="dshr-compIntro">前提:连接池里先有一个可用 token,剩下交给它。</p>
311
+ <div className="dshr-compProbeRow">
312
+ <Picker
313
+ label="供应商插件"
314
+ value={pluginId}
315
+ options={pluginSuppliers.map((x) => ({ value: x.id, label: x.name }))}
316
+ emptyHint={pluginSuppliers.length === 0 ? '没有装外部供应商插件' : '请选择'}
317
+ onChange={(v) => { setPluginId(v); setReport(null); setProbeError('') }}
318
+ />
319
+ <div className="dshr-compProbeBtn dshr-compBtnRow">
320
+ <Button variant="primary" size="md" disabled={pluginId === '' || probing} onClick={() => { void runProbe() }}>
321
+ {probing ? '测试中…' : '测试'}
322
+ </Button>
323
+ </div>
324
+ </div>
325
+
326
+ {probeError !== '' && (
327
+ <div className="dshr-compNotice" data-tone="network" role="status">
328
+ <Glyph d={TONE_ICON.network} />
329
+ <div className="dshr-compNoticeBody">
330
+ <div className="dshr-compNoticeTitle">体检失败</div>
331
+ <div className="dshr-compNoticeText">{probeError}</div>
332
+ </div>
333
+ </div>
334
+ )}
335
+
336
+ {report !== null && (
337
+ <div className="dshr-compReport">
338
+ <div className="dshr-compReportHead">
339
+ <span className="dshr-compReportName">{report.name}</span>
340
+ {/* 只报数字,不下判词。合格与否是判断,应当由看报告的人自己做 ——
341
+ 工具给数字和逐条事实就够,替人定性既越位,也会在判据本身有偏差
342
+ 时把话说死。 */}
343
+ <span className="dshr-compReportSum">
344
+ 共 {report.summary.total} 个 · 实现 {report.summary.implemented} · 实跑 {report.summary.ran} · 通过 {report.summary.ok} · 失败 {report.summary.fail} · 未验 {report.summary.unverified} · 未实现 {report.summary.absent}
345
+ {report.summary.bulk === 'fail' ? ' · 全部启用/禁用有问题' : report.summary.bulk === 'ok' ? ' · 全部启用/禁用通过' : ''}
346
+ </span>
347
+ </div>
348
+ <ul className="dshr-compMembers">
349
+ {/* 核心侧那两项与契约成员**同构**(同一个列表、同一种行),只是它们不属于
350
+ 插件契约(是核心代劳)。原先给它们单画了个带框的区块,反而让它们看着
351
+ 像是另一个模块、比真问题还显眼。 */}
352
+ <li className="dshr-compMember" data-state={coreModelsState(report)}>
353
+ <span className="dshr-compMemberState" aria-hidden="true">{MEMBER_MARK[coreModelsState(report)]}</span>
354
+ <span className="dshr-compMemberName">
355
+ 模型启用状态
356
+ <code className="dshr-compMemberKey">core</code>
357
+ </span>
358
+ <span className="dshr-compMemberDetail">
359
+ {report.core.models.total === null
360
+ ? (report.core.models.note ?? '拿不到')
361
+ : `共 ${report.core.models.total} 个 · 启用 ${report.core.models.enabled} · 停用 ${report.core.models.disabled}`}
362
+ </span>
363
+ </li>
364
+ {report.core.operations.map((o) => (
365
+ <li key={o.key} className="dshr-compMember" data-state={o.ran === false ? 'unverified' : o.ok === true ? 'ok' : 'fail'}>
366
+ <span className="dshr-compMemberState" aria-hidden="true">
367
+ {MEMBER_MARK[o.ran === false ? 'unverified' : o.ok === true ? 'ok' : 'fail']}
368
+ </span>
369
+ <span className="dshr-compMemberName">
370
+ {o.label}
371
+ <code className="dshr-compMemberKey">core</code>
372
+ </span>
373
+ <span className="dshr-compMemberDetail">{o.detail}</span>
374
+ </li>
375
+ ))}
376
+ {report.members.map((m) => (
377
+ <li key={m.key} className="dshr-compMember" data-state={m.state}>
378
+ <span className="dshr-compMemberState" aria-hidden="true">{MEMBER_MARK[m.state]}</span>
379
+ <span className="dshr-compMemberName">
380
+ {m.label}
381
+ <code className="dshr-compMemberKey">{m.key}</code>
382
+ </span>
383
+ <span className="dshr-compMemberDetail">{m.detail ?? ''}</span>
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ </div>
388
+ )}
389
+ </div>
390
+ </div>
391
+ )
392
+ }