@dsh-plugins/dsh-llm-hub 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +374 -0
- package/LICENSE +21 -0
- package/README.en.md +266 -0
- package/README.md +260 -0
- package/cordis.patch.yml +55 -0
- package/lib/client.js +862 -0
- package/lib/harness.js +436 -0
- package/lib/index.js +1481 -0
- package/package.json +70 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-llm-hub —— Client(浏览器)半。
|
|
3
|
+
*
|
|
4
|
+
* 在 **Models 设置页的 provider 卡片里** 显示 DeepSeek 账户余额与可用性;
|
|
5
|
+
* 并为官方 pi-ai 路由(`llm-pi-ai` 段里的 modelgo / minimax / zai-coding-cn …)
|
|
6
|
+
* 旁路补上网关可达性探测与目录拉取 —— 官方适配器占着自己的 discovery 坑、
|
|
7
|
+
* 其 LISTABLE_PROTOCOLS 又不含 anthropic-messages,那部分缺口只能旁路补。
|
|
8
|
+
*
|
|
9
|
+
* ## 挂在哪
|
|
10
|
+
*
|
|
11
|
+
* 官方为"本仓库之外分发的插件"留了两个扩展位,本插件用的是
|
|
12
|
+
* `settings.models.provider-card` —— 它**按 settings 命名空间做 key 分发**
|
|
13
|
+
* (`entryKey = settingsNs`),所以注册 `key: 'llm-deepseek'` 就能收到
|
|
14
|
+
* DeepSeek 官方直连那一张卡片的每次渲染,而 Models 页本身完全不知道我们是谁。
|
|
15
|
+
* 官方文档(`slot-contract.d.ts`)原话:这两个座位就是给外部分发插件用的。
|
|
16
|
+
*
|
|
17
|
+
* 收到的 owner props 是 `{ provider, configured, keyConfigured }`,其中
|
|
18
|
+
* `keyConfigured` 是本页的凭据 join 结果 —— 它决定我们要不要去查余额。
|
|
19
|
+
*
|
|
20
|
+
* ## 形态
|
|
21
|
+
*
|
|
22
|
+
* `classic script`:DSH 的 client 插件以 classic script 加载并由
|
|
23
|
+
* `window.__ModuleLoader__.load({ id, factory })` 注册;**产物里不能出现顶层
|
|
24
|
+
* import/export**,而 `factory(require)` 里的 `require` 才是拿 React 等模块的正路。
|
|
25
|
+
* 因此本文件是源码即产物(无构建步骤,零依赖)。
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-llm-hub/client
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
window.__ModuleLoader__.load({
|
|
31
|
+
// **必须是完整包名**(含 scope),宿主按 package.json 的 name 找这个注册 ——
|
|
32
|
+
// 对不上就是 "loaded without registering ... via __ModuleLoader__.load",
|
|
33
|
+
// 而且整个 client bundle 一起失败(DSH 把所有插件打进一个 bundle),
|
|
34
|
+
// 表现成「Failed to load plugins」,看不出是哪个插件的锅。
|
|
35
|
+
//
|
|
36
|
+
// 2026-09-17 踩到:上一版把包名迁到 @webkubor/ scope(commit f907589),
|
|
37
|
+
// package.json 改了、这里漏了。同仓的 dsh-bloom-theme 用的是
|
|
38
|
+
// PLUGIN_ID = "@webkubor/dsh-bloom-theme",那个写法才是对的。
|
|
39
|
+
id: '@dsh-plugins/dsh-llm-hub',
|
|
40
|
+
factory: (require) => {
|
|
41
|
+
var module = { exports: {} }
|
|
42
|
+
var exports = module.exports
|
|
43
|
+
|
|
44
|
+
const React = require('react')
|
|
45
|
+
const h = React.createElement
|
|
46
|
+
|
|
47
|
+
/** 与 host 半一致:官方直连适配器拥有的 settings 命名空间。 */
|
|
48
|
+
const NS = 'dsh-llm-hub'
|
|
49
|
+
/** 本插件注册进 provider-card 的 key —— 必须等于适配器的 settingsNs。 */
|
|
50
|
+
const CARD_KEY = 'llm-deepseek'
|
|
51
|
+
/** 官方直连的 provider id(见 dsh-llm-deepseek 的 PROVIDER)。 */
|
|
52
|
+
const DEEPSEEK_PROVIDER = 'deepseek-official'
|
|
53
|
+
/** host 半注册的余额路由。 */
|
|
54
|
+
const BALANCE_URL = '/api/dsh-llm-hub/balance'
|
|
55
|
+
/** 本插件为 pi-ai 路由注册进 provider-card 的 key —— 等于 pi-ai 的 settingsNs。 */
|
|
56
|
+
const PIAI_CARD_KEY = 'llm-pi-ai'
|
|
57
|
+
/** host 半的 pi-ai 旁路路由前缀。 */
|
|
58
|
+
const PIAI_BASE = '/api/dsh-llm-hub/pi-ai'
|
|
59
|
+
/** 插件元信息端点(版本 / 仓库 / 反馈)。 */
|
|
60
|
+
const META_URL = '/api/dsh-llm-hub/meta'
|
|
61
|
+
|
|
62
|
+
/** 模型可用性:读缓存 / 强制全量重探(host 半同名路由)。 */
|
|
63
|
+
const AVAILABILITY_URL = '/api/dsh-llm-hub/availability'
|
|
64
|
+
const AVAILABILITY_RECHECK_URL = '/api/dsh-llm-hub/availability/recheck'
|
|
65
|
+
|
|
66
|
+
const LOCALES = {
|
|
67
|
+
zh: {
|
|
68
|
+
balance: '余额',
|
|
69
|
+
loading: '查询中…',
|
|
70
|
+
refresh: '刷新余额',
|
|
71
|
+
retry: '重试',
|
|
72
|
+
noKey: '未配置 API Key',
|
|
73
|
+
noKeyHint: '在下方填入 DeepSeek API Key 后即可查询余额。',
|
|
74
|
+
granted: '赠送',
|
|
75
|
+
toppedUp: '充值',
|
|
76
|
+
unavailable: '账户不可用',
|
|
77
|
+
failed: '查询失败',
|
|
78
|
+
probe: '探测网关',
|
|
79
|
+
probing: '探测中…',
|
|
80
|
+
reachable: '可达',
|
|
81
|
+
unreachable: '不可达',
|
|
82
|
+
remoteCount: '网关在售',
|
|
83
|
+
configuredModels: '已配',
|
|
84
|
+
modelsUnit: '个模型',
|
|
85
|
+
keyMissing: '未配 Key',
|
|
86
|
+
noBaseURLHint: '没填服务地址,探测不了;模型只能手填',
|
|
87
|
+
pullCatalog: '拉取目录',
|
|
88
|
+
pick: '选择要用的模型',
|
|
89
|
+
save: '保存到配置',
|
|
90
|
+
saving: '保存中…',
|
|
91
|
+
saved: '已保存',
|
|
92
|
+
saveFail: '保存失败',
|
|
93
|
+
configured: '已配置',
|
|
94
|
+
viewProject: 'GitHub',
|
|
95
|
+
feedback: '问题反馈',
|
|
96
|
+
share: '分享插件',
|
|
97
|
+
shareCopied: '安装命令已复制',
|
|
98
|
+
protocol: '协议',
|
|
99
|
+
endpoint: '接入地址',
|
|
100
|
+
pulling: '拉取中…',
|
|
101
|
+
copyIds: '复制全部 id',
|
|
102
|
+
copied: '已复制 id',
|
|
103
|
+
copyFail: '复制失败',
|
|
104
|
+
statusFail: '状态读取失败',
|
|
105
|
+
statusLoading: '读取中…',
|
|
106
|
+
remain: '余',
|
|
107
|
+
used: '已用',
|
|
108
|
+
week: '周',
|
|
109
|
+
planQuota: '配额',
|
|
110
|
+
availabilityRecheck: '重新探测全部',
|
|
111
|
+
availabilityRechecking: '探测中…',
|
|
112
|
+
availabilityHiddenCount: '已隐藏',
|
|
113
|
+
availabilityUnavailable: '已从下拉隐藏',
|
|
114
|
+
availabilityFailed: '可用性读取失败'
|
|
115
|
+
},
|
|
116
|
+
en: {
|
|
117
|
+
balance: 'Balance',
|
|
118
|
+
loading: 'Checking…',
|
|
119
|
+
refresh: 'Refresh balance',
|
|
120
|
+
retry: 'Retry',
|
|
121
|
+
noKey: 'No API key',
|
|
122
|
+
noKeyHint: 'Add a DeepSeek API key below to see the balance.',
|
|
123
|
+
granted: 'Granted',
|
|
124
|
+
toppedUp: 'Topped up',
|
|
125
|
+
unavailable: 'Account unavailable',
|
|
126
|
+
failed: 'Balance check failed',
|
|
127
|
+
probe: 'Probe gateway',
|
|
128
|
+
probing: 'Probing…',
|
|
129
|
+
reachable: 'Reachable',
|
|
130
|
+
unreachable: 'Unreachable',
|
|
131
|
+
remoteCount: 'remote',
|
|
132
|
+
configuredModels: 'configured',
|
|
133
|
+
modelsUnit: 'models',
|
|
134
|
+
keyMissing: 'No key',
|
|
135
|
+
noBaseURLHint: 'No service address configured — probing unavailable, models are hand-typed',
|
|
136
|
+
pullCatalog: 'Fetch catalog',
|
|
137
|
+
pick: 'Pick models',
|
|
138
|
+
save: 'Save to config',
|
|
139
|
+
saving: 'Saving…',
|
|
140
|
+
saved: 'Saved',
|
|
141
|
+
saveFail: 'Save failed',
|
|
142
|
+
configured: 'configured',
|
|
143
|
+
viewProject: 'GitHub',
|
|
144
|
+
feedback: 'Report an issue',
|
|
145
|
+
share: 'Share',
|
|
146
|
+
shareCopied: 'Install command copied',
|
|
147
|
+
protocol: 'Protocol',
|
|
148
|
+
endpoint: 'Endpoint',
|
|
149
|
+
pulling: 'Fetching…',
|
|
150
|
+
copyIds: 'Copy all ids',
|
|
151
|
+
copied: 'Ids copied',
|
|
152
|
+
copyFail: 'Copy failed',
|
|
153
|
+
statusFail: 'Status check failed',
|
|
154
|
+
statusLoading: 'Loading…',
|
|
155
|
+
remain: 'left',
|
|
156
|
+
used: 'used',
|
|
157
|
+
week: 'wk',
|
|
158
|
+
planQuota: 'quota',
|
|
159
|
+
availabilityRecheck: 'Re-check all',
|
|
160
|
+
availabilityRechecking: 'Checking…',
|
|
161
|
+
availabilityHiddenCount: 'hidden',
|
|
162
|
+
availabilityUnavailable: 'hidden from dropdown',
|
|
163
|
+
availabilityFailed: 'Availability read failed'
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 词典未就绪时的兜底(键原样返回,绝不显示 undefined)。 */
|
|
168
|
+
const fallbackT = (key) => (LOCALES.zh[key] ?? key)
|
|
169
|
+
|
|
170
|
+
/** 注入一次卡片样式;用 DSH 主题变量,明暗自适应。 */
|
|
171
|
+
function ensureStyle() {
|
|
172
|
+
const id = 'dsh-llm-hub-style'
|
|
173
|
+
if (document.getElementById(id) !== null) return
|
|
174
|
+
const style = document.createElement('style')
|
|
175
|
+
style.id = id
|
|
176
|
+
style.textContent = [
|
|
177
|
+
'.dsh-llm-hub-balance{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
|
|
178
|
+
'margin-top:8px;padding:8px 10px;border-radius:8px;',
|
|
179
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
|
|
180
|
+
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
181
|
+
'.dsh-llm-hub-balance__label{color:var(--dsw-alias-label-secondary)}',
|
|
182
|
+
'.dsh-llm-hub-balance__amount{color:var(--dsw-alias-label-primary);font-weight:600;',
|
|
183
|
+
'font-variant-numeric:tabular-nums}',
|
|
184
|
+
'.dsh-llm-hub-balance__breakdown{color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
185
|
+
'.dsh-llm-hub-balance__warn{color:var(--dsw-alias-state-warn-primary)}',
|
|
186
|
+
'.dsh-llm-hub-balance__error{color:var(--dsw-alias-state-error-primary)}',
|
|
187
|
+
'.dsh-llm-hub-balance__spacer{flex:1 1 auto}',
|
|
188
|
+
// 一张卡一个盒子:事实行 + 动作条。原来是两个盒子,白吃一行高度。
|
|
189
|
+
'.dsh-llm-hub-balance--stack{flex-direction:column;align-items:stretch;gap:6px}',
|
|
190
|
+
'.dsh-llm-hub-balance__facts{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}',
|
|
191
|
+
'.dsh-llm-hub-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
192
|
+
'.dsh-llm-hub-actions__button{box-sizing:border-box;height:24px;padding:0 10px;font:inherit;',
|
|
193
|
+
'font-size:12px;line-height:16px;color:var(--dsw-alias-label-primary);background:0 0;cursor:pointer;',
|
|
194
|
+
'border:.5px solid var(--dsw-alias-border-l3);border-radius:12px;white-space:nowrap}',
|
|
195
|
+
'.dsh-llm-hub-actions__button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
196
|
+
'.dsh-llm-hub-actions__button:disabled{cursor:default;opacity:.5}',
|
|
197
|
+
// 目录选择器:沿用卡片本身的 token,不引入新配色
|
|
198
|
+
'.dsh-llm-hub-picker{margin-top:6px;padding:8px 10px;border-radius:8px;',
|
|
199
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}',
|
|
200
|
+
'.dsh-llm-hub-picker__head{display:flex;align-items:center;justify-content:space-between;',
|
|
201
|
+
'gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary);margin-bottom:6px}',
|
|
202
|
+
'.dsh-llm-hub-picker__list{max-height:220px;overflow-y:auto;display:flex;',
|
|
203
|
+
'flex-direction:column;gap:2px}',
|
|
204
|
+
'.dsh-llm-hub-picker__item{display:flex;align-items:center;gap:6px;font-size:12px;',
|
|
205
|
+
'line-height:20px;color:var(--dsw-alias-label-primary);cursor:pointer}',
|
|
206
|
+
'.dsh-llm-hub-picker__id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
|
|
207
|
+
'.dsh-llm-hub-foot{display:flex;align-items:center;gap:12px;margin-top:10px;',
|
|
208
|
+
'padding:2px 2px;font-size:12px;color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
209
|
+
'.dsh-llm-hub-foot__name{font-variant-numeric:tabular-nums}',
|
|
210
|
+
'.dsh-llm-hub-foot__link{color:var(--dsw-alias-label-link);text-decoration:none;',
|
|
211
|
+
'border:0;background:0 0;padding:0;font:inherit;cursor:pointer}',
|
|
212
|
+
'.dsh-llm-hub-foot__link:hover{text-decoration:underline}',
|
|
213
|
+
'.dsh-llm-hub-picker__tag{font-size:11px;padding:0 5px;border-radius:4px;',
|
|
214
|
+
'color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-3)}',
|
|
215
|
+
'.dsh-llm-hub-balance__button{border:0;background:0 0;padding:0;cursor:pointer;',
|
|
216
|
+
'font-size:12px;color:var(--dsw-alias-brand-primary)}',
|
|
217
|
+
'.dsh-llm-hub-balance__button:disabled{cursor:default;opacity:.5}',
|
|
218
|
+
'.dsh-llm-hub-balance__tag{padding:1px 6px;border-radius:6px;',
|
|
219
|
+
'border:.5px solid var(--dsw-alias-border-l1);',
|
|
220
|
+
'color:var(--dsw-alias-label-secondary);white-space:nowrap}',
|
|
221
|
+
// 被摘掉的 provider:在它自己的卡片动作条里标一枚小片,不另开面板
|
|
222
|
+
// (2026-09-16 owner:「这不是很多余吗,上面不都是显示了吗」)。
|
|
223
|
+
'.dsh-llm-hub-chip{box-sizing:border-box;display:inline-flex;align-items:center;height:24px;padding:0 8px;',
|
|
224
|
+
'border-radius:12px;font-size:12px;line-height:16px;white-space:nowrap;',
|
|
225
|
+
'border:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary)}',
|
|
226
|
+
'.dsh-llm-hub-chip--hidden{border-color:var(--dsw-alias-state-error-primary);',
|
|
227
|
+
'color:var(--dsw-alias-state-error-primary)}',
|
|
228
|
+
'.dsh-llm-hub-foot__hidden{color:var(--dsw-alias-state-error-primary)}',
|
|
229
|
+
].join('')
|
|
230
|
+
document.head.appendChild(style)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 余额卡:挂载即查,可手动刷新。
|
|
235
|
+
* @param props - owner props(provider/configured/keyConfigured)+ 注入的 `t`。
|
|
236
|
+
* @returns 一张紧凑的余额行。
|
|
237
|
+
*/
|
|
238
|
+
function BalanceCard(props) {
|
|
239
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
240
|
+
const keyConfigured = props.keyConfigured !== false
|
|
241
|
+
const [state, setState] = React.useState({ status: 'idle' })
|
|
242
|
+
// 官方直连也可能被判不可用(key 被拒 / 账户欠费)。同一个状态片,一致的处理。
|
|
243
|
+
const availability = props.availability
|
|
244
|
+
const availabilityState = availability === undefined
|
|
245
|
+
? { providers: [] }
|
|
246
|
+
: React.useSyncExternalStore(availability.subscribe, availability.snapshot)
|
|
247
|
+
const verdict = Array.isArray(availabilityState.providers)
|
|
248
|
+
? availabilityState.providers.find((entry) => entry.provider === DEEPSEEK_PROVIDER)
|
|
249
|
+
: undefined
|
|
250
|
+
const hiddenFromDropdown = verdict !== undefined && verdict.state === 'unavailable'
|
|
251
|
+
|
|
252
|
+
const load = React.useCallback(() => {
|
|
253
|
+
if (!keyConfigured) {
|
|
254
|
+
setState({ status: 'nokey' })
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
setState({ status: 'loading' })
|
|
258
|
+
fetch(BALANCE_URL, { headers: { accept: 'application/json' } })
|
|
259
|
+
.then(async (response) => {
|
|
260
|
+
let body
|
|
261
|
+
try {
|
|
262
|
+
body = await response.json()
|
|
263
|
+
} catch {
|
|
264
|
+
throw new Error(`HTTP ${response.status}`)
|
|
265
|
+
}
|
|
266
|
+
if (body == null || body.ok !== true) {
|
|
267
|
+
throw new Error(typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`)
|
|
268
|
+
}
|
|
269
|
+
setState({ status: 'ok', isAvailable: body.isAvailable === true, balances: Array.isArray(body.balances) ? body.balances : [] })
|
|
270
|
+
})
|
|
271
|
+
.catch((error) => {
|
|
272
|
+
setState({ status: 'error', error: error instanceof Error ? error.message : String(error) })
|
|
273
|
+
})
|
|
274
|
+
}, [keyConfigured])
|
|
275
|
+
|
|
276
|
+
React.useEffect(() => {
|
|
277
|
+
load()
|
|
278
|
+
}, [load])
|
|
279
|
+
|
|
280
|
+
const children = []
|
|
281
|
+
children.push(h('span', { key: 'label', className: 'dsh-llm-hub-balance__label' }, t('balance')))
|
|
282
|
+
if (hiddenFromDropdown) {
|
|
283
|
+
children.push(h('span', {
|
|
284
|
+
key: 'hidden-chip',
|
|
285
|
+
className: 'dsh-llm-hub-chip dsh-llm-hub-chip--hidden',
|
|
286
|
+
title: typeof verdict.reason === 'string' ? verdict.reason : ''
|
|
287
|
+
}, t('availabilityUnavailable')))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (state.status === 'nokey') {
|
|
291
|
+
children.push(h('span', { key: 'nokey', className: 'dsh-llm-hub-balance__warn' }, t('noKey')))
|
|
292
|
+
children.push(h('span', { key: 'hint', className: 'dsh-llm-hub-balance__breakdown' }, t('noKeyHint')))
|
|
293
|
+
return h('div', { className: 'dsh-llm-hub-balance' }, children)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (state.status === 'loading' || state.status === 'idle') {
|
|
297
|
+
children.push(h('span', { key: 'loading', className: 'dsh-llm-hub-balance__breakdown' }, t('loading')))
|
|
298
|
+
return h('div', { className: 'dsh-llm-hub-balance' }, children)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (state.status === 'error') {
|
|
302
|
+
children.push(h('span', { key: 'error', className: 'dsh-llm-hub-balance__error' }, `${t('failed')}: ${state.error}`))
|
|
303
|
+
children.push(h('span', { key: 'spacer', className: 'dsh-llm-hub-balance__spacer' }))
|
|
304
|
+
children.push(h('button', {
|
|
305
|
+
key: 'retry',
|
|
306
|
+
type: 'button',
|
|
307
|
+
className: 'dsh-llm-hub-balance__button',
|
|
308
|
+
onClick: load
|
|
309
|
+
}, t('retry')))
|
|
310
|
+
return h('div', { className: 'dsh-llm-hub-balance' }, children)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// status === 'ok'
|
|
314
|
+
const primary = state.balances[0]
|
|
315
|
+
if (primary === undefined) {
|
|
316
|
+
children.push(h('span', { key: 'empty', className: 'dsh-llm-hub-balance__breakdown' }, t('loading')))
|
|
317
|
+
return h('div', { className: 'dsh-llm-hub-balance' }, children)
|
|
318
|
+
}
|
|
319
|
+
const symbol = primary.currency === 'CNY' ? '¥' : primary.currency === 'USD' ? '$' : `${primary.currency} `
|
|
320
|
+
children.push(h('span', { key: 'amount', className: 'dsh-llm-hub-balance__amount' }, `${symbol}${primary.total}`))
|
|
321
|
+
if (!state.isAvailable) children.push(h('span', { key: 'unavail', className: 'dsh-llm-hub-balance__warn' }, t('unavailable')))
|
|
322
|
+
const breakdown = []
|
|
323
|
+
if (primary.granted !== '0' && primary.granted !== '0.00') breakdown.push(`${t('granted')} ${symbol}${primary.granted}`)
|
|
324
|
+
if (primary.toppedUp !== '0' && primary.toppedUp !== '0.00') breakdown.push(`${t('toppedUp')} ${symbol}${primary.toppedUp}`)
|
|
325
|
+
if (breakdown.length > 0) {
|
|
326
|
+
children.push(h('span', { key: 'breakdown', className: 'dsh-llm-hub-balance__breakdown' }, breakdown.join(' · ')))
|
|
327
|
+
}
|
|
328
|
+
children.push(h('span', { key: 'spacer', className: 'dsh-llm-hub-balance__spacer' }))
|
|
329
|
+
children.push(h('button', {
|
|
330
|
+
key: 'refresh',
|
|
331
|
+
type: 'button',
|
|
332
|
+
className: 'dsh-llm-hub-balance__button',
|
|
333
|
+
title: t('refresh'),
|
|
334
|
+
onClick: load
|
|
335
|
+
}, t('refresh')))
|
|
336
|
+
return h('div', { className: 'dsh-llm-hub-balance' }, children)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 把 host 半的余额信封折成一行短文本;未加载 / 不支持时返回 null。
|
|
341
|
+
* reason 原样透传,因为 host 半保证它一定是人话("未填写服务地址,查不了余额"、
|
|
342
|
+
* "连不上服务商"、上游自己的"当前用户不存在coding plan")。技术细节在 detail 里,
|
|
343
|
+
* 这里**不展示** —— 设置页是给用人看的,2026-09-15 之前这里印过 fetch 的原始异常
|
|
344
|
+
* 「could not reach /api/...: Failed to parse URL from ...」,读的人既不知道发生了
|
|
345
|
+
* 什么,也不知道该做什么。
|
|
346
|
+
* @param balance - `/pi-ai/balance` 的载荷。
|
|
347
|
+
* @param t - 词典函数。
|
|
348
|
+
* @returns 短文本;不可展示时为 null。
|
|
349
|
+
*/
|
|
350
|
+
function renderBalance(balance, t) {
|
|
351
|
+
if (balance === null || typeof balance !== 'object') return null
|
|
352
|
+
if (balance.ok !== true || balance.supported === false) return null
|
|
353
|
+
// 上游说「这个账号没有套餐」时,原样把它那句话印在卡片上是没有信息量的
|
|
354
|
+
// (owner 2026-09-15:「当前用户不存在 coding plan 显示这个没意义」)——
|
|
355
|
+
// 读到的人既不知道发生了什么,也不知道该做什么。没有配额就不显示配额,
|
|
356
|
+
// 这一行上真正有用的是协议和接入地址,它们照常在。原因留给 title,排查时还能看到。
|
|
357
|
+
if (balance.available === false) return null
|
|
358
|
+
const items = Array.isArray(balance.items) ? balance.items : []
|
|
359
|
+
if (items.length === 0) return null
|
|
360
|
+
if (balance.kind === 'cash') {
|
|
361
|
+
const first = items[0]
|
|
362
|
+
const symbol = first.currency === 'CNY' ? '¥' : `${first.currency} `
|
|
363
|
+
const voucher = typeof first.voucher === 'string' && first.voucher !== '0' && first.voucher !== '0.00' ? ` (${t('granted')} ${first.voucher})` : ''
|
|
364
|
+
return `${t('balance')} ${symbol}${first.amount}${voucher}`
|
|
365
|
+
}
|
|
366
|
+
const meaning = balance.meaning === 'used' ? t('used') : t('remain')
|
|
367
|
+
const parts = []
|
|
368
|
+
for (const item of items.slice(0, 2)) {
|
|
369
|
+
let piece = `${item.label} ${meaning} ${item.percent}%`
|
|
370
|
+
if (typeof item.weeklyPercent === 'number') piece += ` · ${t('week')} ${meaning} ${item.weeklyPercent}%`
|
|
371
|
+
parts.push(piece)
|
|
372
|
+
}
|
|
373
|
+
const level = typeof balance.level === 'string' && balance.level.length > 0 ? `${balance.level} · ` : ''
|
|
374
|
+
return `${t('planQuota')} ${level}${parts.join(' / ')}`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* pi-ai 旁路卡:挂在 `llm-pi-ai` 段的每个 provider 行上。
|
|
379
|
+
*
|
|
380
|
+
* 官方 pi-ai 适配器占着自己的 discovery 坑,本卡不与之竞争 —— 只通过
|
|
381
|
+
* host 半的旁路路由读网关目录与探测可达性。owner props 仍是
|
|
382
|
+
* `{ provider, configured, keyConfigured }`,`provider` 是段内键名;
|
|
383
|
+
* 「添加 provider」的草稿行没有 provider,此时渲染 null。
|
|
384
|
+
* @param props - owner props + 注入的 `t`。
|
|
385
|
+
* @returns 旁路状态行;无 provider 时为 null。
|
|
386
|
+
*/
|
|
387
|
+
function PiAiCard(props) {
|
|
388
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
389
|
+
// 宿主传进来的 `provider` 是 **entry 对象**(`{ provider, displayName, settingsNs,
|
|
390
|
+
// settingsPath, declared }`),不是 provider id 字符串 —— 见宿主
|
|
391
|
+
// dsh-client-ui-settings-models/lib/client.js 的三处 renderSlot:
|
|
392
|
+
// renderSlot('settings.models.provider-card', { provider: row.entry, ... })
|
|
393
|
+
// 原来这里只认字符串,拿到对象就落进 '' 分支、直接 return null,于是整张
|
|
394
|
+
// pi-ai 行静默消失:API 三个端点全通、插件版本也对,页面上就是什么都没有。
|
|
395
|
+
// DeepSeek 那张卡看不出问题,因为它不读这个字段。
|
|
396
|
+
// 两种形态都接:宿主将来改回字符串也不会再坏。
|
|
397
|
+
const provider = typeof props.provider === 'string'
|
|
398
|
+
? props.provider
|
|
399
|
+
: (props.provider && typeof props.provider.provider === 'string' ? props.provider.provider : '')
|
|
400
|
+
const [status, setStatus] = React.useState(null)
|
|
401
|
+
const [probe, setProbe] = React.useState(null)
|
|
402
|
+
const [catalog, setCatalog] = React.useState(null)
|
|
403
|
+
// 勾选集合与保存态。selected 为 null 表示「还没开始挑」,此时用 status.modelIds
|
|
404
|
+
// 作为初始勾选 —— 已经在用的模型默认勾上,人只需要动增量。
|
|
405
|
+
const [selected, setSelected] = React.useState(null)
|
|
406
|
+
const [saveState, setSaveState] = React.useState(null)
|
|
407
|
+
const [copied, setCopied] = React.useState(false)
|
|
408
|
+
const [balance, setBalance] = React.useState(null)
|
|
409
|
+
// 被摘掉的分组,在它自己的卡片上直接标出来 —— 动作条里一枚小片,不占额外行。
|
|
410
|
+
const availability = props.availability
|
|
411
|
+
const availabilityState = availability === undefined
|
|
412
|
+
? { hidden: [], providers: [] }
|
|
413
|
+
: React.useSyncExternalStore(availability.subscribe, availability.snapshot)
|
|
414
|
+
const verdict = Array.isArray(availabilityState.providers)
|
|
415
|
+
? availabilityState.providers.find((entry) => entry.provider === provider)
|
|
416
|
+
: undefined
|
|
417
|
+
const hiddenFromDropdown = verdict !== undefined && verdict.state === 'unavailable'
|
|
418
|
+
|
|
419
|
+
React.useEffect(() => {
|
|
420
|
+
if (provider === '' || status === null || status.ok !== true || !status.balanceAdapter) return undefined
|
|
421
|
+
let alive = true
|
|
422
|
+
fetch(`${PIAI_BASE}/balance?provider=${encodeURIComponent(provider)}`, { headers: { accept: 'application/json' } })
|
|
423
|
+
.then(async (response) => response.json())
|
|
424
|
+
.then((body) => {
|
|
425
|
+
if (alive) setBalance(body !== null && typeof body === 'object' ? body : { ok: false })
|
|
426
|
+
})
|
|
427
|
+
.catch(() => {
|
|
428
|
+
if (alive) setBalance({ ok: false })
|
|
429
|
+
})
|
|
430
|
+
return () => {
|
|
431
|
+
alive = false
|
|
432
|
+
}
|
|
433
|
+
}, [provider, status])
|
|
434
|
+
|
|
435
|
+
React.useEffect(() => {
|
|
436
|
+
if (provider === '') return undefined
|
|
437
|
+
let alive = true
|
|
438
|
+
fetch(`${PIAI_BASE}/status?provider=${encodeURIComponent(provider)}`, { headers: { accept: 'application/json' } })
|
|
439
|
+
.then(async (response) => response.json())
|
|
440
|
+
.then((body) => {
|
|
441
|
+
if (alive) setStatus(body !== null && typeof body === 'object' && body.ok === true ? body : { ok: false, error: body?.error })
|
|
442
|
+
})
|
|
443
|
+
.catch(() => {
|
|
444
|
+
if (alive) setStatus({ ok: false })
|
|
445
|
+
})
|
|
446
|
+
return () => {
|
|
447
|
+
alive = false
|
|
448
|
+
}
|
|
449
|
+
}, [provider])
|
|
450
|
+
|
|
451
|
+
const runProbe = React.useCallback(() => {
|
|
452
|
+
if (provider === '') return
|
|
453
|
+
setProbe({ phase: 'loading' })
|
|
454
|
+
fetch(`${PIAI_BASE}/probe?provider=${encodeURIComponent(provider)}`, { headers: { accept: 'application/json' } })
|
|
455
|
+
.then(async (response) => response.json())
|
|
456
|
+
.then((body) => setProbe({ phase: 'done', body }))
|
|
457
|
+
.catch((error) => setProbe({ phase: 'done', body: { ok: false, error: error instanceof Error ? error.message : String(error) } }))
|
|
458
|
+
}, [provider])
|
|
459
|
+
|
|
460
|
+
const pullCatalog = React.useCallback(() => {
|
|
461
|
+
if (provider === '') return
|
|
462
|
+
setCatalog({ phase: 'loading' })
|
|
463
|
+
setCopied(false)
|
|
464
|
+
fetch(`${PIAI_BASE}/catalog?provider=${encodeURIComponent(provider)}`, { headers: { accept: 'application/json' } })
|
|
465
|
+
.then(async (response) => response.json())
|
|
466
|
+
.then((body) => setCatalog({ phase: 'done', body }))
|
|
467
|
+
.catch((error) => setCatalog({ phase: 'done', body: { ok: false, error: error instanceof Error ? error.message : String(error) } }))
|
|
468
|
+
}, [provider])
|
|
469
|
+
|
|
470
|
+
const copyIds = React.useCallback(() => {
|
|
471
|
+
const body = catalog?.phase === 'done' ? catalog.body : undefined
|
|
472
|
+
const models = body?.ok === true && Array.isArray(body.models) ? body.models : []
|
|
473
|
+
if (models.length === 0) return
|
|
474
|
+
navigator.clipboard?.writeText(models.map((model) => model.id).join('\n'))
|
|
475
|
+
.then(() => setCopied(true))
|
|
476
|
+
.catch(() => setCopied(false))
|
|
477
|
+
}, [catalog])
|
|
478
|
+
|
|
479
|
+
const saveModels = React.useCallback(() => {
|
|
480
|
+
const ids = selected === null ? [] : [...selected]
|
|
481
|
+
setSaveState('saving')
|
|
482
|
+
fetch(`${PIAI_BASE}/models`, {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
485
|
+
body: JSON.stringify({ provider, ids })
|
|
486
|
+
})
|
|
487
|
+
.then((res) => res.json())
|
|
488
|
+
.then((body) => setSaveState(body?.ok === true ? 'done' : 'fail'))
|
|
489
|
+
.catch(() => setSaveState('fail'))
|
|
490
|
+
}, [provider, selected])
|
|
491
|
+
|
|
492
|
+
if (provider === '') return null
|
|
493
|
+
|
|
494
|
+
// 三态要分开:null 是「还没拉回来」,不是「失败」。
|
|
495
|
+
// 2026-09-15 owner 报「为啥都显示状态读取失败」—— 页面一打开、或 DSH 刚重启
|
|
496
|
+
// 导致这次 fetch 断掉时,三行全是这句,看着像插件坏了,其实只是还没加载完。
|
|
497
|
+
if (status === null) {
|
|
498
|
+
return h('div', { className: 'dsh-llm-hub-balance' },
|
|
499
|
+
h('span', { className: 'dsh-llm-hub-balance__label' }, 'pi-ai'),
|
|
500
|
+
h('span', { className: 'dsh-llm-hub-balance__breakdown' }, t('statusLoading')))
|
|
501
|
+
}
|
|
502
|
+
if (status.ok !== true) {
|
|
503
|
+
return h('div', { className: 'dsh-llm-hub-balance' },
|
|
504
|
+
h('span', { className: 'dsh-llm-hub-balance__label' }, 'pi-ai'),
|
|
505
|
+
h('span', { className: 'dsh-llm-hub-balance__breakdown' }, typeof status.error === 'string' ? status.error : t('statusFail')))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 事实行:一行装得下就一行,装不下让它自己换行。
|
|
509
|
+
const facts = []
|
|
510
|
+
facts.push(h('span', { key: 'label', className: 'dsh-llm-hub-balance__label' }, 'pi-ai'))
|
|
511
|
+
facts.push(h('span', { key: 'name', className: 'dsh-llm-hub-balance__amount' }, status.displayName))
|
|
512
|
+
facts.push(h('span', { key: 'count', className: 'dsh-llm-hub-balance__tag' }, `${t('configuredModels')} ${status.modelCount} ${t('modelsUnit')}`))
|
|
513
|
+
facts.push(h('span', { key: 'key', className: 'dsh-llm-hub-balance__tag' }, status.keyConfigured === true ? 'Key ✓' : t('keyMissing')))
|
|
514
|
+
// 协议与接入地址(2026-09-14 owner:「接入的协议和 apiurl 也需要可以看」)。
|
|
515
|
+
// 这两项决定了这个 provider 到底连去哪、用哪套报文;出问题时第一眼要看的就是它们,
|
|
516
|
+
// 之前只能去翻 settings.yaml。地址只显示域名,完整 URL 放 title —— 一行放不下,
|
|
517
|
+
// 而域名已经够回答「连的是不是我以为的那个网关」。
|
|
518
|
+
if (typeof status.api === 'string' && status.api !== '') {
|
|
519
|
+
facts.push(h('span', { key: 'api', className: 'dsh-llm-hub-balance__tag' }, `${t('protocol')} ${status.api}`))
|
|
520
|
+
}
|
|
521
|
+
if (typeof status.baseURL === 'string' && status.baseURL !== '') {
|
|
522
|
+
let host = status.baseURL
|
|
523
|
+
try { host = new URL(status.baseURL).host } catch {}
|
|
524
|
+
facts.push(h('span', {
|
|
525
|
+
key: 'base',
|
|
526
|
+
className: 'dsh-llm-hub-balance__tag',
|
|
527
|
+
title: `${t('endpoint')}: ${status.baseURL}`
|
|
528
|
+
}, host))
|
|
529
|
+
}
|
|
530
|
+
const balanceText = renderBalance(balance, t)
|
|
531
|
+
if (balanceText !== null) facts.push(h('span', { key: 'bal', className: 'dsh-llm-hub-balance__tag' }, balanceText))
|
|
532
|
+
|
|
533
|
+
// 动作行:**所有按钮挤在同一行**。
|
|
534
|
+
//
|
|
535
|
+
// 2026-09-16 owner 指着截图:「UI 是不是优化下,有点浪费空间,有的就是一个文字占一行」。
|
|
536
|
+
// 原来是两个各自带边框+内边距的盒子:事实行末尾挂「探测网关」(宽度不够就换行),
|
|
537
|
+
// 再另起一盒放「拉取目录」。一张卡白吃两行多。现在合成一个盒子、按钮排成一条工具条。
|
|
538
|
+
const actions = []
|
|
539
|
+
if (hiddenFromDropdown) {
|
|
540
|
+
actions.push(h('span', {
|
|
541
|
+
key: 'hidden-chip',
|
|
542
|
+
className: 'dsh-llm-hub-chip dsh-llm-hub-chip--hidden',
|
|
543
|
+
title: typeof verdict.reason === 'string' ? verdict.reason : ''
|
|
544
|
+
}, t('availabilityUnavailable')))
|
|
545
|
+
}
|
|
546
|
+
const noBaseURL = status.baseURL === undefined || status.baseURL === ''
|
|
547
|
+
const probeButton = (key, label, busy, onClick) => h('button', {
|
|
548
|
+
key,
|
|
549
|
+
type: 'button',
|
|
550
|
+
className: 'dsh-llm-hub-actions__button',
|
|
551
|
+
disabled: busy,
|
|
552
|
+
onClick
|
|
553
|
+
}, label)
|
|
554
|
+
if (noBaseURL) {
|
|
555
|
+
actions.push(h('span', { key: 'nobase', className: 'dsh-llm-hub-balance__breakdown' }, t('noBaseURLHint')))
|
|
556
|
+
} else {
|
|
557
|
+
const probing = probe !== null && probe.phase === 'loading'
|
|
558
|
+
actions.push(probeButton('probe', probing ? t('probing') : t('probe'), probing, runProbe))
|
|
559
|
+
if (probe !== null && probe.phase === 'done') {
|
|
560
|
+
const body = probe.body
|
|
561
|
+
if (body?.ok === true && body.reachable === true) {
|
|
562
|
+
actions.push(h('span', { key: 'probe-ok', className: 'dsh-llm-hub-balance__breakdown' }, `${t('reachable')} · ${body.latencyMs}ms · ${t('remoteCount')} ${body.remoteCount}`))
|
|
563
|
+
} else {
|
|
564
|
+
actions.push(h('span', { key: 'probe-bad', className: 'dsh-llm-hub-balance__error' }, `${t('unreachable')}: ${body?.error ?? ''}`))
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// 目录拉取原先只对 modelgo 开放(它走 anthropic-messages,官方发现天然失效)。
|
|
568
|
+
// 但「网关上到底有哪些模型」对每个 provider 都有用 —— 智谱手填 8 个、网关在售
|
|
569
|
+
// 16 个,不拉一次根本不知道漏了什么。改为所有 pi-ai provider 都能拉。
|
|
570
|
+
// 探测不到的(没 baseURL 又不在已知网关表里)点了会得到明确提示,不会静默。
|
|
571
|
+
const pulling = catalog !== null && catalog.phase === 'loading'
|
|
572
|
+
actions.push(probeButton('catalog', pulling ? t('pulling') : t('pullCatalog'), pulling, pullCatalog))
|
|
573
|
+
}
|
|
574
|
+
// 配额不可用时的上游原因不进正文,只留在 title 里(见 renderBalance 的注释)。
|
|
575
|
+
const quotaNote = balance !== null && typeof balance === 'object' && balance.ok === true
|
|
576
|
+
&& balance.available === false && typeof balance.reason === 'string' && balance.reason.length > 0
|
|
577
|
+
? balance.reason
|
|
578
|
+
: undefined
|
|
579
|
+
|
|
580
|
+
let picker = null
|
|
581
|
+
if (!noBaseURL && catalog !== null && catalog.phase === 'done') {
|
|
582
|
+
const body = catalog.body
|
|
583
|
+
if (body?.ok === true && Array.isArray(body.models)) {
|
|
584
|
+
actions.push(h('span', { key: 'cat-count', className: 'dsh-llm-hub-balance__breakdown' }, `${t('remoteCount')} ${body.models.length} · ${body.latencyMs}ms`))
|
|
585
|
+
actions.push(probeButton('copy', copied ? t('copied') : t('copyIds'), false, copyIds))
|
|
586
|
+
|
|
587
|
+
// 拉到目录就把它摆出来。之前这里只报一个数字加「复制全部 id」,
|
|
588
|
+
// 等于让人把 71 个 id 粘到配置文件里自己挑 —— 最后一公里留给了人。
|
|
589
|
+
// 已配置的默认勾上,人只需要动增量。
|
|
590
|
+
const configured = new Set(Array.isArray(status.modelIds) ? status.modelIds : [])
|
|
591
|
+
const picked = selected === null ? configured : selected
|
|
592
|
+
const toggle = (id) => {
|
|
593
|
+
const next = new Set(picked)
|
|
594
|
+
if (next.has(id)) next.delete(id)
|
|
595
|
+
else next.add(id)
|
|
596
|
+
setSelected(next)
|
|
597
|
+
setSaveState(null)
|
|
598
|
+
}
|
|
599
|
+
picker = h('div', { className: 'dsh-llm-hub-picker' },
|
|
600
|
+
h('div', { className: 'dsh-llm-hub-picker__head' },
|
|
601
|
+
h('span', null, `${t('pick')}(${picked.size}/${body.models.length})`),
|
|
602
|
+
probeButton('save', saveState === 'saving' ? t('saving') : saveState === 'done' ? t('saved') : saveState === 'fail' ? t('saveFail') : t('save'), saveState === 'saving', saveModels)),
|
|
603
|
+
h('div', { className: 'dsh-llm-hub-picker__list' },
|
|
604
|
+
body.models.map((model) => h('label', { key: model.id, className: 'dsh-llm-hub-picker__item' },
|
|
605
|
+
h('input', { type: 'checkbox', checked: picked.has(model.id), onChange: () => toggle(model.id) }),
|
|
606
|
+
h('span', { className: 'dsh-llm-hub-picker__id' }, model.id),
|
|
607
|
+
configured.has(model.id) ? h('span', { className: 'dsh-llm-hub-picker__tag' }, t('configured')) : null))))
|
|
608
|
+
} else {
|
|
609
|
+
actions.push(h('span', { key: 'cat-bad', className: 'dsh-llm-hub-balance__error' }, `${t('failed')}: ${body?.error ?? ''}`))
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return h('div', null,
|
|
613
|
+
h('div', { className: 'dsh-llm-hub-balance dsh-llm-hub-balance--stack', title: quotaNote },
|
|
614
|
+
h('div', { key: 'facts', className: 'dsh-llm-hub-balance__facts' }, facts),
|
|
615
|
+
h('div', { key: 'actions', className: 'dsh-llm-hub-actions' }, actions)),
|
|
616
|
+
picker)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* 模型页页脚:插件版本 + 仓库 + 反馈入口。
|
|
621
|
+
*
|
|
622
|
+
* 2026-09-15 owner:「人家写的插件都有,你也加上,不然别人没有反馈,没法闭环」。
|
|
623
|
+
* 放在 settings.models.footer 而不是每张 provider 卡片上 —— 卡片有几个就会重复
|
|
624
|
+
* 几次,而这行信息整页只需要一份。
|
|
625
|
+
* @param props - 注入的 `t`。
|
|
626
|
+
* @returns 页脚节点;元信息读不到时返回 null(不占位、不报错)。
|
|
627
|
+
*/
|
|
628
|
+
function HubFooter(props) {
|
|
629
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
630
|
+
const availability = props.availability
|
|
631
|
+
const availabilityState = availability === undefined
|
|
632
|
+
? { hidden: [], probing: false }
|
|
633
|
+
: React.useSyncExternalStore(availability.subscribe, availability.snapshot)
|
|
634
|
+
const [meta, setMeta] = React.useState(null)
|
|
635
|
+
const [shared, setShared] = React.useState(false)
|
|
636
|
+
const [busy, setBusy] = React.useState(false)
|
|
637
|
+
React.useEffect(() => {
|
|
638
|
+
let alive = true
|
|
639
|
+
fetch(META_URL, { headers: { accept: 'application/json' } })
|
|
640
|
+
.then((res) => res.json())
|
|
641
|
+
.then((body) => { if (alive) setMeta(body) })
|
|
642
|
+
.catch(() => { if (alive) setMeta({ ok: false }) })
|
|
643
|
+
return () => { alive = false }
|
|
644
|
+
}, [])
|
|
645
|
+
if (meta === null || meta.ok !== true) return null
|
|
646
|
+
const links = []
|
|
647
|
+
// 一键分享:复制的是**能直接跑的安装命令**,不是一个仓库链接 ——
|
|
648
|
+
// 拿到链接的人还要自己翻 README 找怎么装,等于把最后一步又推给了对方。
|
|
649
|
+
links.push(h('button', {
|
|
650
|
+
key: 'share',
|
|
651
|
+
type: 'button',
|
|
652
|
+
className: 'dsh-llm-hub-foot__link',
|
|
653
|
+
onClick: () => {
|
|
654
|
+
// 别写死我自己的 profile 名,也别只给一条 npm i —— 装进 node_modules
|
|
655
|
+
// 不等于接进 boot graph,少了 bundles 那行插件根本不会加载。
|
|
656
|
+
const text = [
|
|
657
|
+
`${meta.name} —— DSH 模型页增强:网关可达性探测、余额/配额、模型目录勾选写回配置。`,
|
|
658
|
+
'装到你的 DSH(profile 名按自己的改,默认 web):',
|
|
659
|
+
` 1) dsh plugin --profile web add ${meta.name}`,
|
|
660
|
+
` 2) 把 "${meta.name}" 加进 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles`,
|
|
661
|
+
' 3) 重启 DSH',
|
|
662
|
+
typeof meta.homepage === 'string' ? `仓库:${meta.homepage}` : ''
|
|
663
|
+
].filter((line) => line !== '').join('\n')
|
|
664
|
+
navigator.clipboard?.writeText(text).then(() => setShared(true)).catch(() => setShared(false))
|
|
665
|
+
}
|
|
666
|
+
}, shared ? t('shareCopied') : t('share')))
|
|
667
|
+
if (typeof meta.homepage === 'string') {
|
|
668
|
+
links.push(h('a', { key: 'repo', href: meta.homepage, target: '_blank', rel: 'noreferrer', className: 'dsh-llm-hub-foot__link' }, t('viewProject')))
|
|
669
|
+
}
|
|
670
|
+
if (typeof meta.issues === 'string') {
|
|
671
|
+
links.push(h('a', { key: 'issues', href: meta.issues, target: '_blank', rel: 'noreferrer', className: 'dsh-llm-hub-foot__link' }, t('feedback')))
|
|
672
|
+
}
|
|
673
|
+
const hiddenCount = Array.isArray(availabilityState.hidden) ? availabilityState.hidden.length : 0
|
|
674
|
+
return h('div', { className: 'dsh-llm-hub-foot' },
|
|
675
|
+
h('span', { className: 'dsh-llm-hub-foot__name' }, `${meta.name} v${meta.version}`),
|
|
676
|
+
// 可用性只在页脚留这一处全局信息:被摘掉几个 + 一键重探。
|
|
677
|
+
// 逐 provider 的判定不在这里重复 —— 上面每张卡片自己会说(2026-09-16 owner:
|
|
678
|
+
// 「这不是很多余吗,上面不都是显示了吗」)。
|
|
679
|
+
hiddenCount > 0
|
|
680
|
+
? h('span', { key: 'hidden', className: 'dsh-llm-hub-foot__hidden' }, `${t('availabilityHiddenCount')} ${hiddenCount}`)
|
|
681
|
+
: null,
|
|
682
|
+
availability === undefined ? null : h('button', {
|
|
683
|
+
key: 'recheck',
|
|
684
|
+
type: 'button',
|
|
685
|
+
className: 'dsh-llm-hub-foot__link',
|
|
686
|
+
disabled: busy,
|
|
687
|
+
onClick: () => {
|
|
688
|
+
setBusy(true)
|
|
689
|
+
availability.recheck().finally(() => setBusy(false))
|
|
690
|
+
}
|
|
691
|
+
}, busy ? t('availabilityRechecking') : t('availabilityRecheck')),
|
|
692
|
+
links)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* 可用性判定缓存 —— 所有会话共用一份(同一个 host、同一份设置)。
|
|
697
|
+
*
|
|
698
|
+
* 存在的意义:composer 的模型下拉里,不可用的分组是**静默消失**的(过滤发生在
|
|
699
|
+
* host 半的 llm.listProviders),所以「谁被摘掉了、为什么」必须另有一个地方
|
|
700
|
+
* 能看 —— 就是 设置 → 模型 的 footer 面板。
|
|
701
|
+
* @returns 带订阅的可用性存储。
|
|
702
|
+
*/
|
|
703
|
+
function createAvailabilityStore() {
|
|
704
|
+
let state = { status: 'idle', checkedAt: 0, probing: false, providers: [], hidden: [], error: null }
|
|
705
|
+
let retry = null
|
|
706
|
+
const listeners = new Set()
|
|
707
|
+
|
|
708
|
+
const publish = (next) => {
|
|
709
|
+
state = next
|
|
710
|
+
for (const listener of [...listeners]) {
|
|
711
|
+
try {
|
|
712
|
+
listener()
|
|
713
|
+
} catch {
|
|
714
|
+
// 某个订阅者自己炸了不该拖垮其他订阅者
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
let retryAttempt = 0
|
|
720
|
+
const accept = (body) => {
|
|
721
|
+
const providers = Array.isArray(body.providers) ? body.providers : []
|
|
722
|
+
const probing = body.probing === true
|
|
723
|
+
publish({
|
|
724
|
+
status: 'ready',
|
|
725
|
+
checkedAt: typeof body.checkedAt === 'number' ? body.checkedAt : 0,
|
|
726
|
+
probing,
|
|
727
|
+
providers,
|
|
728
|
+
hidden: Array.isArray(body.hidden) ? body.hidden : [],
|
|
729
|
+
error: null
|
|
730
|
+
})
|
|
731
|
+
// host 还在探(启动首轮,或某家网关卡住):跟一次,别让人停在半份名单上。
|
|
732
|
+
// 退避且封顶 —— 探针最长允许在途 STUCK_PROBE_MS,不能 1.5s 一次打满。
|
|
733
|
+
if (!probing) {
|
|
734
|
+
retryAttempt = 0
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (retry !== null || retryAttempt >= 5) return
|
|
738
|
+
retryAttempt += 1
|
|
739
|
+
retry = setTimeout(() => {
|
|
740
|
+
retry = null
|
|
741
|
+
load()
|
|
742
|
+
}, 1500 * retryAttempt)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// 读缓存与强制重探各有一条在途链:共用一条的话,点「重新探测全部」时若正好有
|
|
746
|
+
// 一次读取在途,重探会被静默降级成读缓存 —— 按钮点了没反应,最难查的那种 bug。
|
|
747
|
+
const inflight = { load: null, recheck: null }
|
|
748
|
+
const request = (kind, url, init) => {
|
|
749
|
+
if (inflight[kind] !== null) return inflight[kind]
|
|
750
|
+
inflight[kind] = fetch(url, init)
|
|
751
|
+
.then(async (response) => ({ response, body: await response.json().catch(() => null) }))
|
|
752
|
+
.then(({ response, body }) => {
|
|
753
|
+
if (body === null || typeof body !== 'object' || body.ok !== true) {
|
|
754
|
+
throw new Error(typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`)
|
|
755
|
+
}
|
|
756
|
+
accept(body)
|
|
757
|
+
})
|
|
758
|
+
.catch((error) => {
|
|
759
|
+
publish({ ...state, status: 'error', error: error instanceof Error ? error.message : String(error) })
|
|
760
|
+
})
|
|
761
|
+
.finally(() => {
|
|
762
|
+
inflight[kind] = null
|
|
763
|
+
})
|
|
764
|
+
return inflight[kind]
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const load = () => request('load', AVAILABILITY_URL, { headers: { accept: 'application/json' } })
|
|
768
|
+
const recheck = () => request('recheck', AVAILABILITY_RECHECK_URL, { method: 'POST', headers: { accept: 'application/json' } })
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
load,
|
|
772
|
+
recheck,
|
|
773
|
+
subscribe(listener) {
|
|
774
|
+
listeners.add(listener)
|
|
775
|
+
return () => listeners.delete(listener)
|
|
776
|
+
},
|
|
777
|
+
/** 快照身份只在 publish 时变化 —— useSyncExternalStore 要求这一点。 */
|
|
778
|
+
snapshot: () => state,
|
|
779
|
+
dispose() {
|
|
780
|
+
if (retry !== null) clearTimeout(retry)
|
|
781
|
+
retry = null
|
|
782
|
+
listeners.clear()
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const inject = ['slots', 'locale']
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* 注册 provider 卡片扩展与词典。
|
|
791
|
+
* @param ctx - 插件上下文。
|
|
792
|
+
*/
|
|
793
|
+
function apply(ctx) {
|
|
794
|
+
ensureStyle()
|
|
795
|
+
ctx.effect(() => ctx.locale.register(NS, LOCALES), 'dsh-llm-hub: dictionaries')
|
|
796
|
+
const t = ctx.locale.bind(NS)
|
|
797
|
+
|
|
798
|
+
// 可用性判定:启动即拉一次(host 侧读缓存,很快),不能让用户先看到
|
|
799
|
+
// 一堆本该隐藏的分组。
|
|
800
|
+
const availability = createAvailabilityStore()
|
|
801
|
+
availability.load()
|
|
802
|
+
ctx.effect(() => () => availability.dispose(), 'dsh-llm-hub: availability cache')
|
|
803
|
+
|
|
804
|
+
// 设置/凭据一变就重读判定:host 那边已把探针缓存作废,这一次读取会带回重探结果。
|
|
805
|
+
// 少了这一步,在设置页改完 key 之后,面板会停在旧结论 —— 它只在挂载时读一次。
|
|
806
|
+
ctx.inject(['remote'], (scope) => {
|
|
807
|
+
const events = ['settings/document-updated', 'credentials/reference-updated', 'llm/adapters-updated']
|
|
808
|
+
// 用插件自身的 ctx.effect 收尾,不依赖注入 scope 上也提供 effect
|
|
809
|
+
ctx.effect(() => {
|
|
810
|
+
const disposers = []
|
|
811
|
+
for (const event of events) {
|
|
812
|
+
try {
|
|
813
|
+
const off = scope.remote.$on(event, () => {
|
|
814
|
+
availability.load()
|
|
815
|
+
})
|
|
816
|
+
if (typeof off === 'function') disposers.push(off)
|
|
817
|
+
} catch {
|
|
818
|
+
// 该事件在这套组合里不可订阅:跳过,不影响其余刷新路径
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return () => {
|
|
822
|
+
for (const off of disposers) {
|
|
823
|
+
try {
|
|
824
|
+
off()
|
|
825
|
+
} catch {
|
|
826
|
+
// 取消订阅失败无需处理
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}, 'dsh-llm-hub: availability reload on settings/credential change')
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
ctx.slots.inject('settings.models.provider-card', () => ctx.slots.register({
|
|
834
|
+
name: 'settings.models.provider-card',
|
|
835
|
+
key: CARD_KEY,
|
|
836
|
+
locale: NS,
|
|
837
|
+
inject: () => ({ t, availability })
|
|
838
|
+
}, BalanceCard))
|
|
839
|
+
ctx.slots.inject('settings.models.provider-card', () => ctx.slots.register({
|
|
840
|
+
name: 'settings.models.provider-card',
|
|
841
|
+
key: PIAI_CARD_KEY,
|
|
842
|
+
locale: NS,
|
|
843
|
+
inject: () => ({ t, availability })
|
|
844
|
+
}, PiAiCard))
|
|
845
|
+
// settings.models.footer 的契约是 kind:'list'(见宿主 dsh-client-ui-settings-models
|
|
846
|
+
// 的 children 声明),list 类 slot 注册**必须带 id** —— 参照宿主自己的
|
|
847
|
+
// settings.onboarding 注册(id:'welcome-notice', order:-100)。
|
|
848
|
+
// 第一版漏了 id,注册被静默丢弃:接口通、组件在、页面上什么都没有。
|
|
849
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
850
|
+
name: 'settings.models.footer',
|
|
851
|
+
id: 'dsh-llm-hub-footer',
|
|
852
|
+
order: 100,
|
|
853
|
+
locale: NS,
|
|
854
|
+
inject: () => ({ t, availability })
|
|
855
|
+
}, HubFooter))
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
exports.apply = apply
|
|
859
|
+
exports.inject = inject
|
|
860
|
+
return module.exports
|
|
861
|
+
}
|
|
862
|
+
})
|