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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输入框上方的「最近命中」徽章 —— 走 DSH 官方 `conversation.input.dock` 座位
|
|
3
|
+
* (scope: session,见 dsh-client-ui-conversation 的 slots 契约)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么用 slot 而不是认领 DOM:
|
|
6
|
+
* - slot 的 session scope 会**注入 `sessionId`**(SessionStandardProps),
|
|
7
|
+
* 于是能按当前会话取「最近命中」—— team 多会话下不会显示别的成员;
|
|
8
|
+
* - slot 是稳定契约,宿主 hash 类名(如之前的 `.JObwrW_root`)随版本会变。
|
|
9
|
+
* 参照实现:@deepseek-ai/dsh-client-ui-goal 的 GoalDock 同挂这个座位,
|
|
10
|
+
* 组件 props 由宿主注入,`inject(sessionId)` 拿会话身份。
|
|
11
|
+
*
|
|
12
|
+
* 数据源:核心 `/router/api/last-hit?session=`(按会话取一条轻记录)。
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from 'react'
|
|
15
|
+
import { ROUTER_API_BASE } from '../shared.ts'
|
|
16
|
+
import { getLastHit, subscribeLastHit, type LastHit } from './last-hit-store.ts'
|
|
17
|
+
|
|
18
|
+
/** 路由字形(纯 currentColor 描边,14px 与相邻控件对齐)。 */
|
|
19
|
+
const ICON = (
|
|
20
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
21
|
+
<circle cx="5" cy="5" r="2.4" />
|
|
22
|
+
<circle cx="19" cy="5" r="2.4" />
|
|
23
|
+
<circle cx="12" cy="19" r="2.4" />
|
|
24
|
+
<path d="M7.4 5h9.2" />
|
|
25
|
+
<path d="M5 7.4v6.2c0 2.6 3 3.4 4.6 3.8" />
|
|
26
|
+
<path d="M19 7.4v6.2c0 2.6-3 3.4-4.6 3.8" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
/** 账号名:别名 → uid → 「无连接」。 */
|
|
31
|
+
function connectionName(hit: LastHit): string {
|
|
32
|
+
const name = hit.account !== undefined && hit.account !== '' ? hit.account : hit.uid
|
|
33
|
+
return name === '' ? '无连接' : name
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 积分展示。**未知就是未知**:`-1` / 缺省一律显示「未知」,
|
|
38
|
+
* 绝不编一个 0 —— 沿用 store 那条「不用 0 冒充未知」的纪律。
|
|
39
|
+
*/
|
|
40
|
+
function creditsText(hit: LastHit): string {
|
|
41
|
+
return hit.credits === undefined || hit.credits < 0 ? '未知' : `${Math.round(hit.credits)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 模型短名(去掉 `prefix/`;前缀在「服务商」行已单独列出)。 */
|
|
45
|
+
function modelShort(hit: LastHit): string {
|
|
46
|
+
const full = hit.model === '' ? hit.supplier : hit.model
|
|
47
|
+
const slash = full.lastIndexOf('/')
|
|
48
|
+
return slash > 0 ? full.slice(slash + 1) : full
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 宿主注入的 props(只声明用到的)。 */
|
|
52
|
+
interface LastHitDockProps {
|
|
53
|
+
/** 宿主注入的会话身份;宿主未注入时为 undefined。 */
|
|
54
|
+
sessionId?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 输入框上方的「最近命中」徽章:图标 + 模型短名;点击展开连接/积分卡片。
|
|
59
|
+
*
|
|
60
|
+
* @param props 宿主注入的会话身份
|
|
61
|
+
*/
|
|
62
|
+
export function LastHitDock({ sessionId }: LastHitDockProps): JSX.Element {
|
|
63
|
+
const [hit, setHit] = useState<LastHit | null>(() => getLastHit(sessionId))
|
|
64
|
+
const [open, setOpen] = useState(false)
|
|
65
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
// 诊断:确认 slot 真挂上了、以及宿主是否注入了 sessionId
|
|
69
|
+
console.info('[dsh-router] last-hit dock mounted, sessionId =', sessionId)
|
|
70
|
+
return subscribeLastHit(sessionId, setHit)
|
|
71
|
+
}, [sessionId])
|
|
72
|
+
|
|
73
|
+
// 点外部收起:对齐宿主原生 popover 的关闭语义(primitives.Menu /
|
|
74
|
+
// useDismissOnOutsidePointer)——document 级 `pointerdown` 冒泡相位(早于
|
|
75
|
+
// click,避免"先选中后关闭")、根节点含自身则忽略;另补 Escape 关闭。
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!open) return
|
|
78
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
79
|
+
if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
|
|
80
|
+
setOpen(false)
|
|
81
|
+
}
|
|
82
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
83
|
+
if (e.key === 'Escape') setOpen(false)
|
|
84
|
+
}
|
|
85
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
86
|
+
document.addEventListener('keydown', onKeyDown)
|
|
87
|
+
return () => {
|
|
88
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
89
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
90
|
+
}
|
|
91
|
+
}, [open])
|
|
92
|
+
|
|
93
|
+
// 无数据时**仍渲染占位**(不返回 null):否则「挂载成功但暂无记录」看起来
|
|
94
|
+
// 就像"整个控件不见了",无法与"根本没挂上"区分。
|
|
95
|
+
const rows: Array<[string, string]> = hit === null
|
|
96
|
+
? []
|
|
97
|
+
: [
|
|
98
|
+
['服务商', hit.supplier],
|
|
99
|
+
['模型', modelShort(hit)],
|
|
100
|
+
['连接', connectionName(hit)],
|
|
101
|
+
['积分', creditsText(hit)],
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="dshr-lastHit" ref={rootRef}>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className={`dshr-lastHit-btn${hit !== null && !hit.ok ? ' dshr-lastHit-fail' : ''}`}
|
|
109
|
+
aria-label="最近一次命中"
|
|
110
|
+
aria-expanded={open}
|
|
111
|
+
title={hit === null ? '暂无命中记录' : `${hit.model === '' ? hit.supplier : hit.model} · ${connectionName(hit)}`}
|
|
112
|
+
onClick={() => setOpen((v) => !v)}
|
|
113
|
+
>
|
|
114
|
+
{ICON}
|
|
115
|
+
<span className="dshr-lastHit-label">{hit === null ? '暂无' : modelShort(hit)}</span>
|
|
116
|
+
</button>
|
|
117
|
+
{open && (
|
|
118
|
+
<div className="dshr-lastHit-card">
|
|
119
|
+
<div className="dshr-lastHit-title">
|
|
120
|
+
<span className="dshr-lastHit-titleLabel">
|
|
121
|
+
{ICON}
|
|
122
|
+
<span>路由</span>
|
|
123
|
+
</span>
|
|
124
|
+
<span className="dshr-lastHit-titleValue" title={hit?.requested}>
|
|
125
|
+
{hit === null || hit.requested === '' ? '—' : hit.requested}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="dshr-lastHit-titleRule" />
|
|
129
|
+
{rows.length === 0
|
|
130
|
+
? <div className="dshr-lastHit-row"><span className="dshr-lastHit-key">暂无命中记录</span></div>
|
|
131
|
+
: rows.map(([k, v]) => (
|
|
132
|
+
<div key={k} className="dshr-lastHit-row">
|
|
133
|
+
<span className="dshr-lastHit-key">{k}</span>
|
|
134
|
+
<span className="dshr-lastHit-val">{v}</span>
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|