dsh-model-doctor 0.3.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/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # dsh-model-doctor · 模型医生
2
+
3
+ DSH 桌面客户端插件:在对话输入区的**模型选择菜单**里,为每个模型选项追加两枚按钮。
4
+
5
+ | 按钮 | 功能 |
6
+ | --- | --- |
7
+ | ⟳ 连通检测 | 宿主直连该模型的网关发一次 1-token 探测,行内徽章显示结果:`✓ 832ms`(连通)/ `✗ 402 鉴权/欠费` / `✗ 429 额度/限流` / `✗ 404 不存在` / `✗ 网络/超时`;悬停徽章看完整报错原文 |
8
+ | 🗑 删除 | 两段式确认(先点变「确认删除?」再点执行),从 `llm-pi-ai` 用户配置里移除该模型;写配置前自动备份配置文档(保留最近 5 份 `.dsh-model-doctor-*.bak`) |
9
+
10
+ 菜单顶部是「⚡ 全部检测」条:并发(4 路)探测菜单里出现的**全部**模型,汇总「N 通 / M 异常」,
11
+ 并显示 siliconflow 与 DeepSeek 官方的**账户余额**。
12
+
13
+ ## 结果会保留
14
+
15
+ - 检测结果(含单模型 ⟳ 的结果)落盘为单个快照文件:
16
+ `%USERPROFILE%\.dsh\model-doctor-results.json`
17
+ - **关掉菜单再打开**:自动回填徽章,状态条显示 `上次结果:X 通 / Y 异常(共 N)· 14:32`
18
+ - **重启软件**:客户端启动时读快照,开菜单即回填,无需重新检测
19
+ - **只保留最近一次**:单文件覆盖写,每次检测直接盖掉上一次,更早的自动消失
20
+
21
+ ## 探测范围与欠费判定
22
+
23
+ 1. `cordis.patch.yml` 里 `llm-pi-ai` 配置的供应商(智谱 / 硅基流动 / 阿里云百炼…);
24
+ 2. **DeepSeek 官方**(不在 llm-pi-ai 配置里,走内置兜底路由
25
+ `https://api.deepseek.com/v1` + 凭据 `DEEPSEEK_API_KEY`)。
26
+
27
+ 状态码含义:`200` 连通;`401/402/403` 鉴权失败或欠费(余额不足);`429` 额度耗尽或限流;
28
+ `404` 模型不存在;`5xx` 服务端错误;无响应记为超时。
29
+ 密钥从 `~/.dsh/.credentials.yaml` 的 refs 段或进程环境解析,只在内存里拼进请求头,不落盘、不打印。
30
+
31
+ ## 依赖与适配
32
+
33
+ - 模型菜单本体由 **dsh-reasoning-effort** 插件渲染(`.re-model-*` 行结构)。本插件是
34
+ 行内 DOM 增强:在既有行上追加自持元素并 `stopPropagation`,不替换任何 React 节点、
35
+ 不抢 `conversation.input.model` 座位。禁用 dsh-reasoning-effort 后按钮不出现(安全无副作用)。
36
+ - 宿主↔浏览器通道走 `ctx.connection.fetch.register` 注册到共享 `/api` 通道
37
+ (客户端裸 `fetch('/api/model-doctor/...')`),这是桌面端实测可达的机制。
38
+ - `package.json` 的 `dsh.client.immediately: true` 必须保留:客户端脚本包默认懒加载,
39
+ 没有它纯 DOM 增强插件永远不会被激活。
40
+
41
+ ## 安装
42
+
43
+ 本目录即完整插件(无构建步骤、零依赖、无需 npm install)。
44
+
45
+ **方式一(从本包安装)**:把 `dsh-model-doctor` 整个目录放进
46
+ `C:\Users\lxy33\.dsh\profiles\desktop\node_modules\`,并在 profile 的
47
+ `C:\Users\lxy33\.dsh\profiles\desktop\cordis.patch.yml` 末尾追加:
48
+
49
+ ```yaml
50
+ - insert:
51
+ - id: model-doctor
52
+ name: 'dsh-model-doctor'
53
+ ```
54
+
55
+ ⚠️ 必须是 `insert:` 列表格式;写成 `- id: model-doctor / name: dsh-model-doctor`
56
+ (config 覆盖格式)会被装载器**静默忽略**,插件不会装载且没有任何报错。
57
+
58
+ 宿主半热加载即时生效;客户端半刷新 GUI 页面(`Ctrl+R`)后生效。
59
+
60
+ **方式二(从桌面 zip)**:解压 `dsh-model-doctor-v0.3.0.zip` 后按方式一放置即可。
61
+
62
+ ## 恢复误删的模型
63
+
64
+ - 打开 `C:\Users\lxy33\.dsh\profiles\desktop\cordis.patch.yml.dsh-model-doctor-*.bak`
65
+ (时间戳最新的一份),把对应模型条目复制回 `models:` 列表;
66
+ - 或在 DSH 设置 → 模型供应商里重新添加;
67
+ - 官方内置目录(如 DeepSeek 官方)的模型不受配置文件管辖,点 🗑 会提示无法删除——这是预期行为。
68
+
69
+ ## 版本
70
+
71
+ - v0.3.0 — 检测结果持久化(跨菜单开关与重启保留,只留最近一次)
72
+ - v0.2.1 — 按钮放大到 27px
73
+ - v0.2.0 — 修复结果回填映射(重扫重新入表 + 模型 id 兜底匹配)、按钮钉在选项右侧、
74
+ 探测范围扩展到权威目录、新增 DeepSeek 官方兜底路由与余额
75
+ - v0.1.2/0.1.3 — 切换到 `/api` 共享通道;修复 `llm.listProviders()` 同步返回被误挂 `.catch`
@@ -0,0 +1,4 @@
1
+ # dsh-model-doctor bundle patch: mounts the plugin into a profile layer stack.
2
+ - insert:
3
+ - id: model-doctor
4
+ name: 'dsh-model-doctor'
@@ -0,0 +1,556 @@
1
+ /**
2
+ * dsh-model-doctor — 浏览器半(无框架,纯 DOM 增强)。
3
+ *
4
+ * 监听模型选择菜单(dsh-reasoning-effort 渲染的 .re-model-menu)的出现,
5
+ * 在每个模型选项行末尾追加两枚小按钮:
6
+ * ⟳ 连通检测 —— 经回环 RPC 让 Host 直连网关探测该模型(连通/欠费/限流/鉴权),
7
+ * 结果以行内小徽章呈现(悬停看完整报错);
8
+ * 🗑 删除 —— 两段式确认后让 Host 从 llm-pi-ai 配置移除该模型(Host 自动备份
9
+ * 配置文档),随后立即移除该行,菜单重开时即为新目录。
10
+ *
11
+ * 菜单顶部追加「全部检测」按钮与供应商余额展示(siliconflow)。
12
+ * 本半不替换任何 React 节点、不与 dsh-reasoning-effort 抢座位:全部增强都在
13
+ * 既有行内追加自持元素,事件上 stopPropagation 阻止冒泡到选项本身。
14
+ *
15
+ * @module dsh-model-doctor/client
16
+ */
17
+
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-model-doctor',
20
+ factory: (require) => {
21
+ const module = { exports: {} }
22
+ const exports = module.exports
23
+
24
+ const API_BASE = '/api/model-doctor'
25
+ const MENU_SEL = '.re-model-menu'
26
+ const PANE_SEL = '.re-model-pane'
27
+ const ROW_SEL = '.re-model-option'
28
+ const NAME_SEL = '.re-model-option-name'
29
+ const TITLE_SEL = '.re-model-group-title'
30
+ const DIRECTORY_TTL_MS = 30000
31
+ const DIRECTORY_RETRY_MS = 10000
32
+
33
+ /** 客户端半不需要上下文服务:宿主通道走 /api 裸 fetch。 */
34
+ const inject = []
35
+
36
+ const KIND_LABEL = {
37
+ ok: '连通',
38
+ auth: '鉴权/欠费',
39
+ quota: '额度/限流',
40
+ notfound: '不存在',
41
+ badrequest: '请求被拒',
42
+ server: '服务端错误',
43
+ network: '网络/超时',
44
+ config: '未配置',
45
+ unsupported: '无法探测',
46
+ channel: '通道异常',
47
+ }
48
+
49
+ const CSS = `
50
+ .dmd-actions{position:absolute;right:8px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;gap:5px;z-index:2}
51
+ .dmd-btn{width:27px;height:27px;line-height:26px;text-align:center;font-size:16px;border-radius:8px;cursor:pointer;user-select:none;color:inherit;background:color-mix(in srgb,currentColor 8%,transparent);opacity:.72;flex-shrink:0}
52
+ .dmd-btn:hover{opacity:1;background:color-mix(in srgb,currentColor 18%,transparent)}
53
+ .dmd-btn.dmd-refresh.is-busy{opacity:1;animation:dmd-spin 0.9s linear infinite}
54
+ .dmd-btn.dmd-delete:hover{color:#e5484d}
55
+ .dmd-btn.is-armed{width:auto;padding:0 9px;color:#e5484d;opacity:1;font-size:12px;font-weight:600;line-height:26px}
56
+ @keyframes dmd-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
57
+ .dmd-chip{font-size:11px;line-height:17px;padding:0 7px;margin-left:7px;border-radius:999px;white-space:nowrap;vertical-align:middle;display:inline-block}
58
+ .dmd-chip.is-ok{color:#2fa96e;background:color-mix(in srgb,#2fa96e 16%,transparent)}
59
+ .dmd-chip.is-bad{color:#e5484d;background:color-mix(in srgb,#e5484d 14%,transparent)}
60
+ .dmd-chip.is-warn{color:#d08700;background:color-mix(in srgb,#d08700 16%,transparent)}
61
+ .dmd-allbar{display:flex;align-items:center;gap:8px;padding:6px 12px;margin:2px 6px;font-size:11px;border-radius:8px;background:color-mix(in srgb,currentColor 6%,transparent)}
62
+ .dmd-allbar button{font:inherit;font-size:11px;line-height:16px;padding:2px 9px;border-radius:6px;border:1px solid color-mix(in srgb,currentColor 25%,transparent);background:transparent;color:inherit;cursor:pointer}
63
+ .dmd-allbar button:disabled{opacity:.45;cursor:default}
64
+ .dmd-allbar .dmd-status{opacity:.65}
65
+ .dmd-allbar .dmd-balance{color:#2fa96e}
66
+ `
67
+
68
+ /** 调用宿主半的 /api 路由(与 dsh-memory-board 同款裸 fetch 机制);失败也返回结构化结果。 */
69
+ function makeDoctor() {
70
+ const call = async (endpoint, payload) => {
71
+ try {
72
+ const response = await fetch(`${API_BASE}/${endpoint}`, {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/json' },
75
+ body: JSON.stringify(payload ?? {}),
76
+ })
77
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}` }
78
+ const result = await response.json()
79
+ if (result && typeof result === 'object' && 'ok' in result) {
80
+ return result.ok ? result.value : { ok: false, error: result.error?.message ?? '请求失败' }
81
+ }
82
+ return { ok: false, error: '响应格式异常' }
83
+ } catch (error) {
84
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
85
+ }
86
+ }
87
+ return {
88
+ directory: () => call('directory'),
89
+ check: (provider, model) => call('check', { provider, model }),
90
+ checkAll: () => call('checkAll'),
91
+ deleteModel: (provider, model) => call('deleteModel', { provider, model }),
92
+ resultsLoad: () => call('resultsLoad'),
93
+ resultsSave: (snapshot) => call('resultsSave', snapshot),
94
+ }
95
+ }
96
+
97
+ /* ---------------------------------------------------------------- */
98
+ /* 增强状态 */
99
+ /* ---------------------------------------------------------------- */
100
+
101
+ let doctor = null
102
+ let ctxRef = null
103
+ let styleEl = null
104
+ let observer = null
105
+ let rafId = 0
106
+ let disposed = false
107
+
108
+ /** 权威目录缓存:{ providers: [{id,name,models:[{id,name}]}] } */
109
+ let directory = null
110
+ let directoryAt = 0
111
+ let directoryFailedAt = 0
112
+ /** 最近一次目录获取失败的原文(透出到状态条,便于定位卡点)。 */
113
+ let directoryError = ''
114
+
115
+ /** 最近一次检测结果快照 { results, balances, at };跨菜单开关与软件重启保留。 */
116
+ let lastRun = null
117
+ /** 全部检测进行中:状态条不被缓存回填覆盖。 */
118
+ let barBusy = false
119
+
120
+ /** 行键 -> 模型行元素,供全部检测回填徽章。 */
121
+ const rowByKey = new Map()
122
+ /** 模型 id -> 行元素(全表唯一时可用):供应商 id 空间对不上时的兜底匹配。 */
123
+ const rowByModel = new Map()
124
+ /** 本页会话内已删除的模型键,防目录未刷新时行又冒出来。 */
125
+ const deletedKeys = new Set()
126
+
127
+ const keyOf = (provider, model) => `${provider}::${model}`
128
+
129
+ function fetchDirectory(force = false) {
130
+ if (doctor === null) return Promise.resolve()
131
+ const now = Date.now()
132
+ if (!force && directory !== null && now - directoryAt < DIRECTORY_TTL_MS) return Promise.resolve()
133
+ if (!force && directoryFailedAt !== 0 && now - directoryFailedAt < DIRECTORY_RETRY_MS) return Promise.resolve()
134
+ return doctor.directory().then((result) => {
135
+ if (disposed) return
136
+ if (result && result.ok !== false && Array.isArray(result.providers)) {
137
+ directory = result
138
+ directoryAt = Date.now()
139
+ directoryFailedAt = 0
140
+ directoryError = ''
141
+ scheduleEnhance()
142
+ } else {
143
+ directoryFailedAt = Date.now()
144
+ directoryError = typeof result?.error === 'string' ? result.error : '响应里没有 providers'
145
+ }
146
+ }).catch((error) => {
147
+ directoryFailedAt = Date.now()
148
+ directoryError = error instanceof Error ? error.message : String(error)
149
+ })
150
+ }
151
+
152
+ /* ---------------------------------------------------------------- */
153
+ /* 结果快照:加载 / 保存 / 回填 */
154
+ /* ---------------------------------------------------------------- */
155
+
156
+ function timeLabel(at) {
157
+ try {
158
+ return new Date(at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
159
+ } catch {
160
+ return ''
161
+ }
162
+ }
163
+
164
+ function summarize(results) {
165
+ let ok = 0
166
+ let bad = 0
167
+ for (const value of Object.values(results ?? {})) {
168
+ if (value && value.ok === true) ok += 1
169
+ else bad += 1
170
+ }
171
+ return { ok, bad, total: ok + bad }
172
+ }
173
+
174
+ function updateBar(text) {
175
+ if (barBusy) return
176
+ document.querySelectorAll('.dmd-allbar .dmd-status').forEach((node) => { node.textContent = text })
177
+ }
178
+
179
+ function updateBalance(balances) {
180
+ if (!isRecord(balances)) return
181
+ const parts = Object.entries(balances)
182
+ .map(([providerId, value]) => `${providerLabel(providerId)}: ¥${value}`)
183
+ document.querySelectorAll('.dmd-allbar .dmd-balance').forEach((node) => { node.textContent = parts.join(' · ') })
184
+ }
185
+
186
+ /** 把上次结果回填到当前菜单的行与状态条上;返回回填的行数。 */
187
+ function paintLastRun(prefix) {
188
+ if (barBusy || lastRun === null || !isRecord(lastRun.results)) return 0
189
+ let painted = 0
190
+ for (const [key, single] of Object.entries(lastRun.results)) {
191
+ const separator = key.lastIndexOf('::')
192
+ const modelId = separator >= 0 ? key.slice(separator + 2) : key
193
+ const row = rowByKey.get(key) ?? rowByModel.get(modelId) ?? null
194
+ if (row === null) continue
195
+ paintChip(row, single)
196
+ painted += 1
197
+ }
198
+ if (painted > 0) {
199
+ const { ok, bad, total } = summarize(lastRun.results)
200
+ const when = timeLabel(lastRun.at)
201
+ updateBar(`${prefix}${ok} 通 / ${bad} 异常(共 ${total})${when ? ` · ${when}` : ''}`)
202
+ updateBalance(lastRun.balances)
203
+ }
204
+ return painted
205
+ }
206
+
207
+ /** 记住一次检测结果(覆盖式:永远只保留最近一次)并落盘。 */
208
+ function rememberResults(results, balances) {
209
+ lastRun = { results, balances: isRecord(balances) ? balances : {}, at: Date.now() }
210
+ if (doctor !== null) void doctor.resultsSave(lastRun)
211
+ }
212
+
213
+ function loadResults() {
214
+ if (doctor === null) return Promise.resolve()
215
+ return doctor.resultsLoad().then((snapshot) => {
216
+ if (disposed) return
217
+ if (snapshot && isRecord(snapshot.results)) {
218
+ lastRun = snapshot
219
+ scheduleEnhance()
220
+ }
221
+ })
222
+ }
223
+
224
+ /* ---------------------------------------------------------------- */
225
+ /* 行内元素构造 */
226
+ /* ---------------------------------------------------------------- */
227
+
228
+ function chipOf(result) {
229
+ const chip = document.createElement('span')
230
+ if (!result || typeof result !== 'object' || result.ok === undefined) {
231
+ chip.className = 'dmd-chip is-bad'
232
+ chip.textContent = '✗ 通道异常'
233
+ return chip
234
+ }
235
+ if (result.ok === true) {
236
+ chip.className = 'dmd-chip is-ok'
237
+ chip.textContent = `✓ ${Number.isFinite(result.ms) ? result.ms : '?'}ms`
238
+ chip.title = '连通正常(1-token 探测)'
239
+ return chip
240
+ }
241
+ const kind = typeof result.kind === 'string'
242
+ ? result.kind
243
+ : (typeof result.error === 'string' ? 'channel' : 'unsupported')
244
+ const label = KIND_LABEL[kind] ?? '失败'
245
+ const code = typeof result.status === 'number' && result.status > 0 ? ` ${result.status}` : ''
246
+ chip.className = 'dmd-chip is-bad'
247
+ chip.textContent = `✗${code} ${label}`
248
+ const detail = typeof result.message === 'string' && result.message.length > 0
249
+ ? ` · ${result.message}`
250
+ : (typeof result.error === 'string' ? ` · ${result.error}` : '')
251
+ chip.title = `HTTP ${result.status || '-'}${detail}`
252
+ return chip
253
+ }
254
+
255
+ function wireDelete(button, provider, model, row) {
256
+ let armed = false
257
+ let timer = 0
258
+ const disarm = () => {
259
+ armed = false
260
+ button.classList.remove('is-armed')
261
+ button.textContent = '🗑'
262
+ button.title = '从列表删除该模型(二次点击确认)'
263
+ }
264
+ button.addEventListener('click', (event) => {
265
+ event.stopPropagation()
266
+ event.preventDefault()
267
+ if (doctor === null) return
268
+ if (!armed) {
269
+ armed = true
270
+ button.classList.add('is-armed')
271
+ button.textContent = '确认删除?'
272
+ clearTimeout(timer)
273
+ timer = window.setTimeout(disarm, 2600)
274
+ return
275
+ }
276
+ clearTimeout(timer)
277
+ button.classList.remove('is-armed')
278
+ button.textContent = '…'
279
+ doctor.deleteModel(provider, model).then((result) => {
280
+ if (disposed) return
281
+ if (result && result.ok === true) {
282
+ deletedKeys.add(keyOf(provider, model))
283
+ const section = row.closest('section')
284
+ row.remove()
285
+ if (section !== null && section.querySelectorAll(ROW_SEL).length === 0) section.remove()
286
+ fetchDirectory(true)
287
+ } else {
288
+ const message = result && typeof result.error === 'string' ? result.error : '删除失败'
289
+ const chip = document.createElement('span')
290
+ chip.className = 'dmd-chip is-bad'
291
+ chip.textContent = '✗ 删除失败'
292
+ chip.title = message
293
+ button.replaceWith(chip)
294
+ window.setTimeout(() => chip.remove(), 4000)
295
+ disarm()
296
+ }
297
+ })
298
+ })
299
+ button.addEventListener('mousedown', (event) => event.stopPropagation())
300
+ button.addEventListener('keydown', (event) => event.stopPropagation())
301
+ }
302
+
303
+ /** 把结果徽章插到模型名后面(随文字行内排,不占右侧按钮的位置)。 */
304
+ function paintChip(row, result) {
305
+ row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
306
+ const chip = chipOf(result)
307
+ const name = row.querySelector(NAME_SEL)
308
+ if (name !== null && name.parentNode !== null) name.parentNode.insertBefore(chip, name.nextSibling)
309
+ else row.appendChild(chip)
310
+ return chip
311
+ }
312
+
313
+ function enhanceRow(row, providerId, model) {
314
+ const key = keyOf(providerId, model.id)
315
+ if (deletedKeys.has(key)) {
316
+ row.remove()
317
+ return
318
+ }
319
+ // 映射登记放在早退之前:已挂过按钮的行每次扫描也要重新入表。
320
+ rowByKey.set(key, row)
321
+ const previous = rowByModel.get(model.id)
322
+ if (previous === undefined) rowByModel.set(model.id, row)
323
+ else if (previous !== row) rowByModel.set(model.id, null)
324
+ if (row.querySelector('.dmd-actions') !== null) return
325
+
326
+ // 按钮钉在选项右侧:行本身需要是定位上下文,并留出右侧空间。
327
+ const computed = window.getComputedStyle(row)
328
+ if (computed.position === 'static') row.style.position = 'relative'
329
+ if (Number.parseFloat(computed.paddingRight) < 88) row.style.paddingRight = '88px'
330
+
331
+ const actions = document.createElement('span')
332
+ actions.className = 'dmd-actions'
333
+ actions.dataset.dmdKey = key
334
+
335
+ const refresh = document.createElement('span')
336
+ refresh.className = 'dmd-btn dmd-refresh'
337
+ refresh.setAttribute('role', 'button')
338
+ refresh.textContent = '⟳'
339
+ refresh.title = '检测该模型连通状况 / 是否欠费'
340
+ refresh.addEventListener('click', (event) => {
341
+ event.stopPropagation()
342
+ event.preventDefault()
343
+ if (doctor === null || refresh.classList.contains('is-busy')) return
344
+ refresh.classList.add('is-busy')
345
+ row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
346
+ doctor.check(providerId, model.id).then((result) => {
347
+ if (disposed) return
348
+ refresh.classList.remove('is-busy')
349
+ paintChip(row, result)
350
+ rememberResults({ ...(lastRun?.results ?? {}), [key]: result }, lastRun?.balances)
351
+ })
352
+ })
353
+ refresh.addEventListener('mousedown', (event) => event.stopPropagation())
354
+
355
+ const del = document.createElement('span')
356
+ del.className = 'dmd-btn dmd-delete'
357
+ del.setAttribute('role', 'button')
358
+ del.textContent = '🗑'
359
+ del.title = '从列表删除该模型(二次点击确认)'
360
+ wireDelete(del, providerId, model.id, row)
361
+
362
+ actions.appendChild(refresh)
363
+ actions.appendChild(del)
364
+ row.appendChild(actions)
365
+ }
366
+
367
+ /* ---------------------------------------------------------------- */
368
+ /* 「全部检测」条 */
369
+ /* ---------------------------------------------------------------- */
370
+
371
+ function ensureAllBar(pane, hint) {
372
+ const existing = pane.querySelector('.dmd-allbar')
373
+ if (existing !== null) {
374
+ if (hint) {
375
+ const status = existing.querySelector('.dmd-status')
376
+ if (status !== null && status.textContent === '') status.textContent = hint
377
+ }
378
+ return
379
+ }
380
+ const firstSection = pane.querySelector('section')
381
+ if (firstSection === null) return
382
+ const bar = document.createElement('div')
383
+ bar.className = 'dmd-allbar'
384
+ const button = document.createElement('button')
385
+ button.type = 'button'
386
+ button.textContent = '⚡ 全部检测'
387
+ button.title = '并发探测列表里全部模型的连通状况与额度'
388
+ const status = document.createElement('span')
389
+ status.className = 'dmd-status'
390
+ const balance = document.createElement('span')
391
+ balance.className = 'dmd-balance'
392
+ button.addEventListener('click', (event) => {
393
+ event.stopPropagation()
394
+ event.preventDefault()
395
+ if (doctor === null || button.disabled) return
396
+ button.disabled = true
397
+ barBusy = true
398
+ status.textContent = '探测中…'
399
+ balance.textContent = ''
400
+ rowByKey.forEach((row) => {
401
+ row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
402
+ })
403
+ doctor.checkAll().then((result) => {
404
+ if (disposed) return
405
+ button.disabled = false
406
+ barBusy = false
407
+ if (!result || result.ok === false || !isRecord(result.results)) {
408
+ status.textContent = `失败:${result && typeof result.error === 'string' ? result.error : '通道异常'}`
409
+ return
410
+ }
411
+ rememberResults(result.results, result.balances)
412
+ let okCount = 0
413
+ let badCount = 0
414
+ let unmatched = 0
415
+ const entries = Object.entries(result.results)
416
+ for (const [key, single] of entries) {
417
+ const separator = key.lastIndexOf('::')
418
+ const modelId = separator >= 0 ? key.slice(separator + 2) : key
419
+ const row = rowByKey.get(key) ?? rowByModel.get(modelId) ?? null
420
+ if (row === null) unmatched += 1
421
+ else paintChip(row, single)
422
+ if (single && single.ok === true) okCount += 1
423
+ else badCount += 1
424
+ }
425
+ const suffix = unmatched > 0 ? ` · 未匹配 ${unmatched}` : ''
426
+ const when = timeLabel(lastRun?.at ?? Date.now())
427
+ status.textContent = `完成:${okCount} 通 / ${badCount} 异常(共 ${entries.length})${suffix}${when ? ` · ${when}` : ''}`
428
+ if (isRecord(result.balances)) {
429
+ const parts = Object.entries(result.balances)
430
+ .map(([providerId, value]) => `${providerLabel(providerId)}: ¥${value}`)
431
+ balance.textContent = parts.join(' · ')
432
+ }
433
+ })
434
+ })
435
+ button.addEventListener('mousedown', (event) => event.stopPropagation())
436
+ bar.appendChild(button)
437
+ bar.appendChild(status)
438
+ bar.appendChild(balance)
439
+ if (hint) status.textContent = hint
440
+ firstSection.parentNode.insertBefore(bar, firstSection)
441
+ }
442
+
443
+ function providerLabel(providerId) {
444
+ const provider = directory?.providers?.find((p) => p.id === providerId)
445
+ return provider?.name ?? providerId
446
+ }
447
+
448
+ /* ---------------------------------------------------------------- */
449
+ /* 菜单增强主逻辑 */
450
+ /* ---------------------------------------------------------------- */
451
+
452
+ function enhance() {
453
+ if (disposed) return
454
+ const menu = document.querySelector(MENU_SEL)
455
+ if (menu === null) return
456
+
457
+ if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
458
+ void fetchDirectory()
459
+ }
460
+
461
+ rowByKey.clear()
462
+ rowByModel.clear()
463
+ const panes = menu.querySelectorAll(PANE_SEL)
464
+ panes.forEach((pane) => {
465
+ const sections = pane.querySelectorAll('section')
466
+ if (sections.length === 0) return
467
+ let wired = 0
468
+ sections.forEach((section) => {
469
+ const title = section.querySelector(TITLE_SEL)?.textContent?.trim()
470
+ if (title === undefined || title.length === 0) return
471
+ const rows = Array.from(section.querySelectorAll(ROW_SEL))
472
+ const group = directory?.providers?.find((p) => p.name === title || p.id === title)
473
+ if (group !== undefined && rows.length === group.models.length) {
474
+ rows.forEach((row, index) => {
475
+ const model = group.models[index]
476
+ const name = row.querySelector(NAME_SEL)?.textContent?.trim()
477
+ if (name !== undefined && name !== model.name) return
478
+ enhanceRow(row, group.id, model)
479
+ wired += 1
480
+ })
481
+ return
482
+ }
483
+ // 兜底:供应商显示名对不上时,按「模型显示名在全目录里唯一」反查
484
+ rows.forEach((row) => {
485
+ const name = row.querySelector(NAME_SEL)?.textContent?.trim()
486
+ if (name === undefined || name.length === 0) return
487
+ const hits = []
488
+ for (const provider of directory?.providers ?? []) {
489
+ for (const model of provider.models) {
490
+ if (model.name === name) hits.push({ provider, model })
491
+ }
492
+ }
493
+ if (hits.length !== 1) return
494
+ enhanceRow(row, hits[0].provider.id, hits[0].model)
495
+ wired += 1
496
+ })
497
+ })
498
+ let hint = ''
499
+ if (directory === null) {
500
+ hint = directoryFailedAt !== 0
501
+ ? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
502
+ : '模型医生:目录获取中…'
503
+ } else if (wired === 0) {
504
+ hint = '模型医生:未匹配到模型行(目录与菜单不一致)'
505
+ }
506
+ ensureAllBar(pane, hint)
507
+ if (wired > 0) paintLastRun('上次结果:')
508
+ })
509
+ }
510
+
511
+ function scheduleEnhance() {
512
+ if (rafId !== 0 || disposed) return
513
+ rafId = window.requestAnimationFrame(() => {
514
+ rafId = 0
515
+ enhance()
516
+ })
517
+ }
518
+
519
+ /* ---------------------------------------------------------------- */
520
+ /* apply */
521
+ /* ---------------------------------------------------------------- */
522
+
523
+ function isRecord(value) {
524
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
525
+ }
526
+
527
+ function apply(ctx) {
528
+ ctxRef = ctx
529
+ doctor = makeDoctor()
530
+
531
+ ctx.effect(() => {
532
+ styleEl = document.createElement('style')
533
+ styleEl.dataset.plugin = 'dsh-model-doctor'
534
+ styleEl.textContent = CSS
535
+ document.head.appendChild(styleEl)
536
+ return () => styleEl?.remove()
537
+ }, 'model-doctor: styles')
538
+
539
+ ctx.effect(() => {
540
+ observer = new MutationObserver(scheduleEnhance)
541
+ observer.observe(document.body, { childList: true, subtree: true })
542
+ scheduleEnhance()
543
+ return () => {
544
+ disposed = true
545
+ observer?.disconnect()
546
+ if (rafId !== 0) window.cancelAnimationFrame(rafId)
547
+ }
548
+ }, 'model-doctor: observer')
549
+
550
+ void loadResults()
551
+ }
552
+
553
+ module.exports = { apply, inject }
554
+ return module.exports
555
+ },
556
+ })