dsh-model-doctor 0.3.0 → 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 CHANGED
@@ -30,13 +30,21 @@ DSH 桌面客户端插件:在对话输入区的**模型选择菜单**里,为
30
30
 
31
31
  ## 依赖与适配
32
32
 
33
- - 模型菜单本体由 **dsh-reasoning-effort** 插件渲染(`.re-model-*` 行结构)。本插件是
34
- 行内 DOM 增强:在既有行上追加自持元素并 `stopPropagation`,不替换任何 React 节点、
35
- 不抢 `conversation.input.model` 座位。禁用 dsh-reasoning-effort 后按钮不出现(安全无副作用)。
33
+ - **不依赖任何菜单渲染插件**:官方原生模型菜单、`dsh-effort-slider`、`dsh-reasoning-effort`,
34
+ 或将来任何第三方菜单,本插件都能工作。
35
+ - 通用路径:从宿主半取回权威模型目录(provider/model 名称),在弹层里按「文本命中已知
36
+ 模型名」定位模型行,再向上取最近的可点击祖先作为行容器;
37
+ - 兼容路径:若检测到 `dsh-reasoning-effort` 的 `.re-model-menu` 结构,则改用其
38
+ section/group 精确分组匹配(更稳,优先使用)。
39
+ - 只做行内 DOM 增强:在既有行上追加自持元素并 `stopPropagation`,不替换任何 React 节点、
40
+ 不抢 `conversation.input.model` 座位,因此不会与滑块类插件冲突。
36
41
  - 宿主↔浏览器通道走 `ctx.connection.fetch.register` 注册到共享 `/api` 通道
37
42
  (客户端裸 `fetch('/api/model-doctor/...')`),这是桌面端实测可达的机制。
38
43
  - `package.json` 的 `dsh.client.immediately: true` 必须保留:客户端脚本包默认懒加载,
39
44
  没有它纯 DOM 增强插件永远不会被激活。
45
+ - 遇到界面不显示时,客户端半会把 DOM 自检结果上报到
46
+ `%USERPROFILE%\.dsh\model-doctor-client-report.json`(记录命中的弹层根、行数、样本类名),
47
+ 便于定位菜单结构变化。
40
48
 
41
49
  ## 安装
42
50
 
@@ -68,6 +76,8 @@ DSH 桌面客户端插件:在对话输入区的**模型选择菜单**里,为
68
76
 
69
77
  ## 版本
70
78
 
79
+ - v0.4.0 — **解耦菜单渲染插件**:新增「已知模型名 + 可点击行」通用定位路径,官方原生菜单可用;
80
+ 不再依赖也不冲突于 dsh-reasoning-effort / dsh-effort-slider;新增客户端 DOM 自检上报
71
81
  - v0.3.0 — 检测结果持久化(跨菜单开关与重启保留,只留最近一次)
72
82
  - v0.2.1 — 按钮放大到 27px
73
83
  - v0.2.0 — 修复结果回填映射(重扫重新入表 + 模型 id 兜底匹配)、按钮钉在选项右侧、
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * dsh-model-doctor — 浏览器半(无框架,纯 DOM 增强)。
3
3
  *
4
- * 监听模型选择菜单(dsh-reasoning-effort 渲染的 .re-model-menu)的出现,
5
- * 在每个模型选项行末尾追加两枚小按钮:
6
- * ⟳ 连通检测 —— 经回环 RPC 让 Host 直连网关探测该模型(连通/欠费/限流/鉴权),
7
- * 结果以行内小徽章呈现(悬停看完整报错);
8
- * 🗑 删除 —— 两段式确认后让 Host 从 llm-pi-ai 配置移除该模型(Host 自动备份
9
- * 配置文档),随后立即移除该行,菜单重开时即为新目录。
4
+ * 【解耦设计】本插件不依赖任何菜单渲染插件(dsh-reasoning-effort / dsh-effort-slider /
5
+ * 官方原生菜单都可以):
6
+ * 1. 首选路径:从宿主半取回权威模型目录(provider/model 名称),
7
+ * 在文档里按「文本命中已知模型名」定位模型行,再向上找到可点击祖先作为行容器;
8
+ * 2. 兼容路径:若检测到 `.re-model-menu`(dsh-reasoning-effort 的菜单),
9
+ * 走其 section/group 结构做精确分组匹配(更稳,优先使用)。
10
+ * 两条路径都只做「行内追加自持元素」:不替换 React 节点、不抢座位、事件 stopPropagation。
10
11
  *
11
- * 菜单顶部追加「全部检测」按钮与供应商余额展示(siliconflow)。
12
- * 本半不替换任何 React 节点、不与 dsh-reasoning-effort 抢座位:全部增强都在
13
- * 既有行内追加自持元素,事件上 stopPropagation 阻止冒泡到选项本身。
12
+ * 每个模型行追加两枚按钮:
13
+ * ⟳ 连通检测 —— 让 Host 直连网关探测该模型(连通/欠费/限流/鉴权),结果行内徽章呈现;
14
+ * 🗑 删除 —— 两段式确认后让 Host 从 llm-pi-ai 配置移除该模型(Host 自动备份配置文档)。
15
+ * 菜单顶部追加「全部检测」按钮与账户余额展示。
14
16
  *
15
17
  * @module dsh-model-doctor/client
16
18
  */
@@ -22,13 +24,24 @@ window.__ModuleLoader__.load({
22
24
  const exports = module.exports
23
25
 
24
26
  const API_BASE = '/api/model-doctor'
27
+ /** 兼容路径:dsh-reasoning-effort 渲染的菜单。 */
25
28
  const MENU_SEL = '.re-model-menu'
26
29
  const PANE_SEL = '.re-model-pane'
27
30
  const ROW_SEL = '.re-model-option'
28
31
  const NAME_SEL = '.re-model-option-name'
29
32
  const TITLE_SEL = '.re-model-group-title'
33
+ /** 通用路径:各类弹层菜单可能的根容器(官方菜单 / 第三方滑块插件都在其中)。 */
34
+ const MENU_ROOT_SEL = [
35
+ '[role="menu"]',
36
+ '[role="listbox"]',
37
+ '[data-radix-popper-content-wrapper]',
38
+ '[role="dialog"]',
39
+ '[data-state="open"][aria-modal]',
40
+ ].join(',')
30
41
  const DIRECTORY_TTL_MS = 30000
31
42
  const DIRECTORY_RETRY_MS = 10000
43
+ /** 一个菜单里至少要有这么多个模型行,才认定它是模型菜单(防误伤其他弹层)。 */
44
+ const MIN_GENERIC_ROWS = 2
32
45
 
33
46
  /** 客户端半不需要上下文服务:宿主通道走 /api 裸 fetch。 */
34
47
  const inject = []
@@ -91,6 +104,7 @@ window.__ModuleLoader__.load({
91
104
  deleteModel: (provider, model) => call('deleteModel', { provider, model }),
92
105
  resultsLoad: () => call('resultsLoad'),
93
106
  resultsSave: (snapshot) => call('resultsSave', snapshot),
107
+ clientReport: (state) => call('clientReport', state),
94
108
  }
95
109
  }
96
110
 
@@ -121,6 +135,11 @@ window.__ModuleLoader__.load({
121
135
  const rowByKey = new Map()
122
136
  /** 模型 id -> 行元素(全表唯一时可用):供应商 id 空间对不上时的兜底匹配。 */
123
137
  const rowByModel = new Map()
138
+ /** 行元素 -> 名字锚点(通用路径下用于把徽章插到模型名后面)。 */
139
+ let rowAnchor = new WeakMap()
140
+ /** 本次扫描的通用路径诊断信息(上报给宿主,便于远程定位 DOM 结构)。 */
141
+ let lastDiagnostics = null
142
+
124
143
  /** 本页会话内已删除的模型键,防目录未刷新时行又冒出来。 */
125
144
  const deletedKeys = new Set()
126
145
 
@@ -138,6 +157,7 @@ window.__ModuleLoader__.load({
138
157
  directoryAt = Date.now()
139
158
  directoryFailedAt = 0
140
159
  directoryError = ''
160
+ tokenIndexAt = 0
141
161
  scheduleEnhance()
142
162
  } else {
143
163
  directoryFailedAt = Date.now()
@@ -149,6 +169,203 @@ window.__ModuleLoader__.load({
149
169
  })
150
170
  }
151
171
 
172
+ /* ---------------------------------------------------------------- */
173
+ /* 通用行定位:不依赖任何菜单插件的类名 */
174
+ /* ---------------------------------------------------------------- */
175
+
176
+ /** 归一化文本:折叠空白 + 小写,用于名字比对。 */
177
+ function normText(value) {
178
+ return String(value ?? '').replace(/\s+/gu, ' ').trim().toLowerCase()
179
+ }
180
+
181
+ /** 模型名/ID -> 候选条目索引;15 秒内复用。 */
182
+ let tokenIndex = null
183
+ let tokenIndexAt = 0
184
+ /** 预编译的 token 正则(先粗筛文本节点,避免对每个节点做全量比对)。 */
185
+ let tokenRegex = null
186
+ /** [token, entries] 按 token 长度降序:优先匹配更具体的名字。 */
187
+ let tokenPairs = []
188
+
189
+ function ensureTokenIndex() {
190
+ if (tokenIndex !== null && Date.now() - tokenIndexAt < 15000) return tokenIndex
191
+ const index = new Map()
192
+ for (const provider of directory?.providers ?? []) {
193
+ for (const model of provider.models) {
194
+ const tokens = new Set([normText(model.name), normText(model.id)])
195
+ for (const token of tokens) {
196
+ if (token.length < 2) continue
197
+ const list = index.get(token) ?? []
198
+ list.push({
199
+ providerId: provider.id,
200
+ providerName: provider.name,
201
+ modelId: model.id,
202
+ modelName: model.name,
203
+ })
204
+ index.set(token, list)
205
+ }
206
+ }
207
+ }
208
+ tokenIndex = index
209
+ tokenIndexAt = Date.now()
210
+ tokenPairs = Array.from(index.entries()).sort((a, b) => b[0].length - a[0].length)
211
+ tokenRegex = tokenPairs.length === 0
212
+ ? null
213
+ : new RegExp(tokenPairs.map(([token]) => token.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')).join('|'), 'u')
214
+ return index
215
+ }
216
+
217
+ /** 元素是否像个可点击的「行」。 */
218
+ function looksLikeRow(el) {
219
+ const tag = el.tagName
220
+ if (tag === 'BUTTON' || tag === 'LI' || tag === 'A') return true
221
+ const role = el.getAttribute('role')
222
+ if (role !== null && /^(option|menuitem|menuitemradio|listitem|treeitem|button)$/u.test(role)) return true
223
+ if (el.hasAttribute('data-value') || el.hasAttribute('cmdk-item')) return true
224
+ if (el.hasAttribute('tabindex')) return true
225
+ try {
226
+ if (window.getComputedStyle(el).cursor === 'pointer') return true
227
+ } catch { /* 忽略计算样式失败 */ }
228
+ return false
229
+ }
230
+
231
+ /** 从文本节点往上找最近的可点击行容器。 */
232
+ function clickableRow(anchor, root) {
233
+ let el = anchor
234
+ let depth = 0
235
+ while (el !== null && el !== root && depth < 8) {
236
+ if (looksLikeRow(el)) return el
237
+ el = el.parentElement
238
+ depth += 1
239
+ }
240
+ return looksLikeRow(root) ? root : null
241
+ }
242
+
243
+ /** 候选条目里挑一个:多供应商同名时用上下文里的供应商名消歧。 */
244
+ function pickEntry(entries, row) {
245
+ if (entries.length === 1) return entries[0]
246
+ let context = normText(row.textContent)
247
+ let el = row.parentElement
248
+ let depth = 0
249
+ while (el !== null && depth < 3) {
250
+ context += ` ${normText(el.textContent).slice(0, 400)}`
251
+ el = el.parentElement
252
+ depth += 1
253
+ }
254
+ const matched = entries.filter(
255
+ (entry) => typeof entry.providerName === 'string'
256
+ && entry.providerName.length > 0
257
+ && context.includes(normText(entry.providerName)),
258
+ )
259
+ if (matched.length === 1) return matched[0]
260
+ return entries[0]
261
+ }
262
+
263
+ /**
264
+ * 全文档找出「命中了已知模型名」的最小行容器。
265
+ * 不再依赖任何菜单根选择器:先用预编译正则粗筛文本节点,再逐一确认 token 命中,
266
+ * 然后向上取最近的可点击祖先。
267
+ */
268
+ function minimalMatches(root) {
269
+ const index = ensureTokenIndex()
270
+ const found = new Map()
271
+ if (index.size === 0 || tokenRegex === null) return found
272
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
273
+ let node = walker.nextNode()
274
+ while (node !== null) {
275
+ const raw = node.nodeValue
276
+ if (raw !== null && raw.length > 1 && raw.length <= 200) {
277
+ const text = normText(raw)
278
+ if (text.length >= 2 && tokenRegex.test(text)) {
279
+ const anchor = node.parentElement
280
+ if (anchor !== null) {
281
+ for (const [token, entries] of tokenPairs) {
282
+ if (token.length > text.length || !text.includes(token)) continue
283
+ const row = clickableRow(anchor, root)
284
+ if (row === null) continue
285
+ const current = found.get(row)
286
+ if (current === undefined || token.length > current.token.length) {
287
+ found.set(row, { token, entries, anchor })
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+ node = walker.nextNode()
294
+ }
295
+ // 排除「容器型行」:行文本里出现两个以上不同模型 → 它装的是列表,不是单行。
296
+ for (const [row] of Array.from(found.entries())) {
297
+ if (countDistinctModels(row, index) > 1) found.delete(row)
298
+ }
299
+ return found
300
+ }
301
+
302
+ /** 一个元素里出现了几个不同模型(>1 说明它是容器)。 */
303
+ function countDistinctModels(el, index) {
304
+ const text = normText(el.textContent)
305
+ const distinct = new Set()
306
+ for (const [token, entries] of index) {
307
+ if (!text.includes(token)) continue
308
+ for (const entry of entries) distinct.add(`${entry.providerId}::${entry.modelId}`)
309
+ if (distinct.size > 1) return distinct.size
310
+ }
311
+ return distinct.size
312
+ }
313
+
314
+ /** 容器是否像弹层(绝对定位或带菜单语义),用于排除正文里碰巧出现的模型名。 */
315
+ function isPopoverish(el) {
316
+ let node = el
317
+ let depth = 0
318
+ while (node !== null && node !== document.body && depth < 4) {
319
+ const role = typeof node.getAttribute === 'function' ? node.getAttribute('role') : null
320
+ if (typeof role === 'string' && /^(menu|listbox|dialog|alertdialog)$/u.test(role)) return true
321
+ try {
322
+ const position = window.getComputedStyle(node).position
323
+ if (position === 'absolute' || position === 'fixed') return true
324
+ } catch { /* 忽略计算样式失败 */ }
325
+ node = node.parentElement
326
+ depth += 1
327
+ }
328
+ return false
329
+ }
330
+
331
+ /** 把命中的行按容器聚类:同一父节点下 ≥2 行才算一个菜单。 */
332
+ function clusterRows(found) {
333
+ const byParent = new Map()
334
+ for (const [row, hit] of found.entries()) {
335
+ const parent = row.parentElement ?? document.body
336
+ const list = byParent.get(parent) ?? []
337
+ list.push({ row, ...hit })
338
+ byParent.set(parent, list)
339
+ }
340
+ const clusters = []
341
+ const orphans = []
342
+ for (const [parent, list] of byParent.entries()) {
343
+ if (list.length >= 2) clusters.push({ host: parent, rows: list })
344
+ else orphans.push(...list)
345
+ }
346
+ // 行被分组标题/包装层隔开时,退一级按祖父容器聚合
347
+ if (orphans.length >= 2) {
348
+ const byGrand = new Map()
349
+ for (const hit of orphans) {
350
+ const grand = hit.row.parentElement?.parentElement ?? document.body
351
+ const list = byGrand.get(grand) ?? []
352
+ list.push(hit)
353
+ byGrand.set(grand, list)
354
+ }
355
+ for (const [grand, list] of byGrand.entries()) {
356
+ if (list.length >= 2) clusters.push({ host: grand, rows: list })
357
+ }
358
+ }
359
+ return clusters.filter((cluster) => isPopoverish(cluster.host))
360
+ }
361
+
362
+ /** 根容器是否可见(不可见的弹层跳过,避免误伤收起的菜单)。 */
363
+ function isVisible(el) {
364
+ if (!el.isConnected) return false
365
+ if (el.getClientRects().length > 0) return true
366
+ return el.offsetParent !== null
367
+ }
368
+
152
369
  /* ---------------------------------------------------------------- */
153
370
  /* 结果快照:加载 / 保存 / 回填 */
154
371
  /* ---------------------------------------------------------------- */
@@ -300,13 +517,23 @@ window.__ModuleLoader__.load({
300
517
  button.addEventListener('keydown', (event) => event.stopPropagation())
301
518
  }
302
519
 
520
+ /** 行里作为「模型名」的锚点:通用路径记录文本节点父元素,兼容路径用名字元素。 */
521
+ function labelAnchor(row) {
522
+ const mapped = rowAnchor.get(row)
523
+ if (mapped !== undefined && mapped.isConnected) return mapped
524
+ return row.querySelector(NAME_SEL)
525
+ }
526
+
303
527
  /** 把结果徽章插到模型名后面(随文字行内排,不占右侧按钮的位置)。 */
304
528
  function paintChip(row, result) {
305
529
  row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
306
530
  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)
531
+ const anchor = labelAnchor(row)
532
+ if (anchor !== null && anchor !== undefined && anchor.parentNode !== null) {
533
+ anchor.parentNode.insertBefore(chip, anchor.nextSibling)
534
+ } else {
535
+ row.appendChild(chip)
536
+ }
310
537
  return chip
311
538
  }
312
539
 
@@ -368,8 +595,15 @@ window.__ModuleLoader__.load({
368
595
  /* 「全部检测」条 */
369
596
  /* ---------------------------------------------------------------- */
370
597
 
371
- function ensureAllBar(pane, hint) {
372
- const existing = pane.querySelector('.dmd-allbar')
598
+ /**
599
+ * 在 host 里放一条「全部检测」。
600
+ * @param host 承载容器
601
+ * @param hint 初始状态文案
602
+ * @param before 插入到该子节点之前(省略则插到最前)
603
+ */
604
+ function ensureAllBar(host, hint, before = null) {
605
+ if (host === null || host === undefined) return
606
+ const existing = host.querySelector('.dmd-allbar')
373
607
  if (existing !== null) {
374
608
  if (hint) {
375
609
  const status = existing.querySelector('.dmd-status')
@@ -377,8 +611,6 @@ window.__ModuleLoader__.load({
377
611
  }
378
612
  return
379
613
  }
380
- const firstSection = pane.querySelector('section')
381
- if (firstSection === null) return
382
614
  const bar = document.createElement('div')
383
615
  bar.className = 'dmd-allbar'
384
616
  const button = document.createElement('button')
@@ -437,7 +669,8 @@ window.__ModuleLoader__.load({
437
669
  bar.appendChild(status)
438
670
  bar.appendChild(balance)
439
671
  if (hint) status.textContent = hint
440
- firstSection.parentNode.insertBefore(bar, firstSection)
672
+ if (before !== null && before.parentNode === host) host.insertBefore(bar, before)
673
+ else host.insertBefore(bar, host.firstChild)
441
674
  }
442
675
 
443
676
  function providerLabel(providerId) {
@@ -446,25 +679,40 @@ window.__ModuleLoader__.load({
446
679
  }
447
680
 
448
681
  /* ---------------------------------------------------------------- */
449
- /* 菜单增强主逻辑 */
682
+ /* 诊断上报(写进宿主文件,便于远程定位 DOM 结构,不影响功能) */
450
683
  /* ---------------------------------------------------------------- */
451
684
 
452
- function enhance() {
453
- if (disposed) return
454
- const menu = document.querySelector(MENU_SEL)
455
- if (menu === null) return
685
+ let reportSignature = ''
686
+ let reportAt = 0
456
687
 
457
- if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
458
- void fetchDirectory()
459
- }
688
+ function describe(el) {
689
+ if (el === null || el === undefined) return null
690
+ const cls = typeof el.className === 'string' ? el.className.trim().slice(0, 80) : ''
691
+ const text = normText(el.textContent).slice(0, 50)
692
+ return { tag: el.tagName, role: el.getAttribute('role') ?? '', cls, text }
693
+ }
460
694
 
461
- rowByKey.clear()
462
- rowByModel.clear()
695
+ function reportDiagnostics(state) {
696
+ if (doctor === null) return
697
+ const signature = JSON.stringify(state)
698
+ if (signature === reportSignature) return
699
+ if (Date.now() - reportAt < 1200) return
700
+ reportSignature = signature
701
+ reportAt = Date.now()
702
+ void doctor.clientReport(state)
703
+ }
704
+
705
+ /* ---------------------------------------------------------------- */
706
+ /* 菜单增强主逻辑 */
707
+ /* ---------------------------------------------------------------- */
708
+
709
+ /** 兼容路径:dsh-reasoning-effort 的 `.re-model-menu` 结构。 */
710
+ function enhanceLegacy(menu) {
711
+ let wired = 0
463
712
  const panes = menu.querySelectorAll(PANE_SEL)
464
713
  panes.forEach((pane) => {
465
714
  const sections = pane.querySelectorAll('section')
466
715
  if (sections.length === 0) return
467
- let wired = 0
468
716
  sections.forEach((section) => {
469
717
  const title = section.querySelector(TITLE_SEL)?.textContent?.trim()
470
718
  if (title === undefined || title.length === 0) return
@@ -475,6 +723,7 @@ window.__ModuleLoader__.load({
475
723
  const model = group.models[index]
476
724
  const name = row.querySelector(NAME_SEL)?.textContent?.trim()
477
725
  if (name !== undefined && name !== model.name) return
726
+ rowAnchor.set(row, row.querySelector(NAME_SEL) ?? row)
478
727
  enhanceRow(row, group.id, model)
479
728
  wired += 1
480
729
  })
@@ -491,6 +740,7 @@ window.__ModuleLoader__.load({
491
740
  }
492
741
  }
493
742
  if (hits.length !== 1) return
743
+ rowAnchor.set(row, row.querySelector(NAME_SEL) ?? row)
494
744
  enhanceRow(row, hits[0].provider.id, hits[0].model)
495
745
  wired += 1
496
746
  })
@@ -503,9 +753,100 @@ window.__ModuleLoader__.load({
503
753
  } else if (wired === 0) {
504
754
  hint = '模型医生:未匹配到模型行(目录与菜单不一致)'
505
755
  }
506
- ensureAllBar(pane, hint)
507
- if (wired > 0) paintLastRun('上次结果:')
756
+ ensureAllBar(pane, hint, pane.querySelector('section'))
508
757
  })
758
+ return wired
759
+ }
760
+
761
+ /**
762
+ * 通用路径:任何菜单渲染器(官方原生 / dsh-effort-slider / 其他弹层)。
763
+ * 依据「已知模型名」在全文定位行,按容器聚类成菜单,再逐行增强。
764
+ */
765
+ function enhanceGeneric(legacyWired) {
766
+ const index = ensureTokenIndex()
767
+ const roots = Array.from(document.querySelectorAll(MENU_ROOT_SEL)).filter((root) => isVisible(root))
768
+ const hits = minimalMatches(document.body)
769
+ const clusters = clusterRows(hits)
770
+ let wired = 0
771
+ let registered = 0
772
+ let unmatched = 0
773
+ const clusterStats = []
774
+ for (const cluster of clusters) {
775
+ let newlyWired = 0
776
+ for (const hit of cluster.rows) {
777
+ const entry = pickEntry(hit.entries, hit.row)
778
+ if (entry === undefined) {
779
+ unmatched += 1
780
+ continue
781
+ }
782
+ const isNew = hit.row.querySelector('.dmd-actions') === null
783
+ rowAnchor.set(hit.row, hit.anchor)
784
+ enhanceRow(hit.row, entry.providerId, { id: entry.modelId, name: entry.modelName })
785
+ registered += 1
786
+ if (isNew) newlyWired += 1
787
+ }
788
+ clusterStats.push({
789
+ host: describe(cluster.host),
790
+ rows: cluster.rows.length,
791
+ wired: newlyWired,
792
+ })
793
+ if (cluster.rows.length >= MIN_GENERIC_ROWS) {
794
+ const first = cluster.rows[0]
795
+ let hint = ''
796
+ if (directory === null) {
797
+ hint = directoryFailedAt !== 0
798
+ ? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
799
+ : '模型医生:目录获取中…'
800
+ } else if (newlyWired === 0 && first.row.querySelector('.dmd-actions') === null) {
801
+ hint = '模型医生:模型行未匹配上目录'
802
+ }
803
+ const barHost = first.row.parentElement ?? cluster.host
804
+ ensureAllBar(barHost, hint, first.row)
805
+ }
806
+ }
807
+ lastDiagnostics = {
808
+ at: Date.now(),
809
+ mode: legacyWired > 0 ? 'legacy+generic' : 'generic',
810
+ href: window.location.href.slice(0, 120),
811
+ tokens: index.size,
812
+ providerCount: directory?.providers?.length ?? 0,
813
+ modelCount: (directory?.providers ?? []).reduce((sum, p) => sum + p.models.length, 0),
814
+ directoryError,
815
+ legacyWired,
816
+ genericWired: wired,
817
+ registered,
818
+ unmatched,
819
+ hitCount: hits.size,
820
+ visibleRoots: roots.length,
821
+ roots: clusterStats.slice(0, 8),
822
+ samples: roots.slice(0, 5).map(describe),
823
+ barCount: document.querySelectorAll('.dmd-allbar').length,
824
+ actionCount: document.querySelectorAll('.dmd-actions').length,
825
+ }
826
+ return registered > 0 ? Math.max(1, wired) : 0
827
+ }
828
+
829
+ function enhance() {
830
+ if (disposed) return
831
+ if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
832
+ void fetchDirectory()
833
+ }
834
+ rowByKey.clear()
835
+ rowByModel.clear()
836
+ rowAnchor = new WeakMap()
837
+
838
+ const menu = document.querySelector(MENU_SEL)
839
+ const legacyWired = menu === null ? 0 : enhanceLegacy(menu)
840
+ const genericWired = enhanceGeneric(legacyWired)
841
+
842
+ if (legacyWired + genericWired > 0) paintLastRun('上次结果:')
843
+ if (lastDiagnostics !== null) {
844
+ reportDiagnostics({
845
+ ...lastDiagnostics,
846
+ barCount: document.querySelectorAll('.dmd-allbar').length,
847
+ actionCount: document.querySelectorAll('.dmd-actions').length,
848
+ })
849
+ }
509
850
  }
510
851
 
511
852
  function scheduleEnhance() {
package/lib/index.js CHANGED
@@ -325,6 +325,26 @@ async function writeStoredResults(snapshot) {
325
325
  }
326
326
  }
327
327
 
328
+ /* ------------------------------------------------------------------ */
329
+ /* 客户端自检上报(浏览器半把 DOM 定位结果写到这里,便于远程诊断) */
330
+ /* ------------------------------------------------------------------ */
331
+
332
+ function clientReportFile() {
333
+ const base = process.env.DSH_HOME || join(homedir(), '.dsh')
334
+ return join(base, 'model-doctor-client-report.json')
335
+ }
336
+
337
+ async function writeClientReport(state) {
338
+ try {
339
+ const file = clientReportFile()
340
+ await mkdir(dirname(file), { recursive: true })
341
+ await writeFile(file, JSON.stringify(state), 'utf8')
342
+ return true
343
+ } catch {
344
+ return false
345
+ }
346
+ }
347
+
328
348
  /* ------------------------------------------------------------------ */
329
349
  /* 权威模型目录 */
330
350
  /* ------------------------------------------------------------------ */
@@ -614,6 +634,10 @@ export function apply(ctx) {
614
634
  const saved = await writeStoredResults(snapshot)
615
635
  return okResult({ ok: saved, at: snapshot.at })
616
636
  }
637
+ case 'clientReport': {
638
+ const saved = await writeClientReport(isRecord(request) ? request : {})
639
+ return okResult({ saved })
640
+ }
617
641
  default:
618
642
  return failResult('not-found', `未知端点 ${JSON.stringify(endpoint)}`)
619
643
  }
@@ -626,7 +650,7 @@ export function apply(ctx) {
626
650
  if (fetchRegistry === undefined || typeof fetchRegistry.register !== 'function') return
627
651
  routeCtx.effect(() => {
628
652
  const disposers = []
629
- for (const endpoint of ['directory', 'check', 'checkAll', 'deleteModel', 'resultsLoad', 'resultsSave']) {
653
+ for (const endpoint of ['directory', 'check', 'checkAll', 'deleteModel', 'resultsLoad', 'resultsSave', 'clientReport']) {
630
654
  try {
631
655
  disposers.push(fetchRegistry.register({
632
656
  path: `${API_BASE}/${endpoint}`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-model-doctor",
3
- "version": "0.3.0",
4
- "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
3
+ "version": "0.4.1",
4
+ "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——不依赖任何菜单渲染插件(官方原生菜单即可),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
5
5
  "keywords": [
6
6
  "deepseek",
7
7
  "deepseek-harness",