dsh-model-doctor 0.3.0 → 0.4.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 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,141 @@ 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
+
185
+ function ensureTokenIndex() {
186
+ if (tokenIndex !== null && Date.now() - tokenIndexAt < 15000) return tokenIndex
187
+ const index = new Map()
188
+ for (const provider of directory?.providers ?? []) {
189
+ for (const model of provider.models) {
190
+ const tokens = new Set([normText(model.name), normText(model.id)])
191
+ for (const token of tokens) {
192
+ if (token.length < 2) continue
193
+ const list = index.get(token) ?? []
194
+ list.push({
195
+ providerId: provider.id,
196
+ providerName: provider.name,
197
+ modelId: model.id,
198
+ modelName: model.name,
199
+ })
200
+ index.set(token, list)
201
+ }
202
+ }
203
+ }
204
+ tokenIndex = index
205
+ tokenIndexAt = Date.now()
206
+ return index
207
+ }
208
+
209
+ /** 元素是否像个可点击的「行」。 */
210
+ function looksLikeRow(el) {
211
+ const tag = el.tagName
212
+ if (tag === 'BUTTON' || tag === 'LI' || tag === 'A') return true
213
+ const role = el.getAttribute('role')
214
+ if (role !== null && /^(option|menuitem|menuitemradio|listitem|treeitem|button)$/u.test(role)) return true
215
+ if (el.hasAttribute('data-value') || el.hasAttribute('cmdk-item')) return true
216
+ if (el.hasAttribute('tabindex')) return true
217
+ try {
218
+ if (window.getComputedStyle(el).cursor === 'pointer') return true
219
+ } catch { /* 忽略计算样式失败 */ }
220
+ return false
221
+ }
222
+
223
+ /** 从文本节点往上找最近的可点击行容器。 */
224
+ function clickableRow(anchor, root) {
225
+ let el = anchor
226
+ let depth = 0
227
+ while (el !== null && el !== root && depth < 8) {
228
+ if (looksLikeRow(el)) return el
229
+ el = el.parentElement
230
+ depth += 1
231
+ }
232
+ return looksLikeRow(root) ? root : null
233
+ }
234
+
235
+ /** 候选条目里挑一个:多供应商同名时用上下文里的供应商名消歧。 */
236
+ function pickEntry(entries, row) {
237
+ if (entries.length === 1) return entries[0]
238
+ let context = normText(row.textContent)
239
+ let el = row.parentElement
240
+ let depth = 0
241
+ while (el !== null && depth < 3) {
242
+ context += ` ${normText(el.textContent).slice(0, 400)}`
243
+ el = el.parentElement
244
+ depth += 1
245
+ }
246
+ const matched = entries.filter(
247
+ (entry) => typeof entry.providerName === 'string'
248
+ && entry.providerName.length > 0
249
+ && context.includes(normText(entry.providerName)),
250
+ )
251
+ if (matched.length === 1) return matched[0]
252
+ return entries[0]
253
+ }
254
+
255
+ /**
256
+ * 在一个候选根容器里找出模型行:对每个文本节点做已知模型名命中,
257
+ * 向上取最近的可点击祖先;若某「行」文本里出现过两个以上不同模型,则它是容器,排除。
258
+ */
259
+ function genericRowsIn(root) {
260
+ const index = ensureTokenIndex()
261
+ if (index.size === 0) return []
262
+ const found = new Map()
263
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
264
+ let node = walker.nextNode()
265
+ while (node !== null) {
266
+ const raw = node.nodeValue
267
+ if (raw !== null && raw.length > 0 && raw.length <= 200) {
268
+ const text = normText(raw)
269
+ if (text.length >= 2) {
270
+ const anchor = node.parentElement
271
+ if (anchor !== null) {
272
+ for (const [token, entries] of index) {
273
+ if (!text.includes(token)) continue
274
+ const row = clickableRow(anchor, root)
275
+ if (row === null) continue
276
+ const current = found.get(row)
277
+ if (current === undefined || token.length > current.token.length) {
278
+ found.set(row, { token, entries, anchor })
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+ node = walker.nextNode()
285
+ }
286
+ // 排除「容器型行」:行文本里出现两个以上不同模型 → 它装的是列表,不是单行。
287
+ for (const [row] of Array.from(found.entries())) {
288
+ const rowText = normText(row.textContent)
289
+ const distinct = new Set()
290
+ for (const [token, entries] of index) {
291
+ if (!rowText.includes(token)) continue
292
+ for (const entry of entries) distinct.add(`${entry.providerId}::${entry.modelId}`)
293
+ if (distinct.size > 1) break
294
+ }
295
+ if (distinct.size > 1) found.delete(row)
296
+ }
297
+ return Array.from(found.entries()).map(([row, hit]) => ({ row, ...hit }))
298
+ }
299
+
300
+ /** 根容器是否可见(不可见的弹层跳过,避免误伤收起的菜单)。 */
301
+ function isVisible(el) {
302
+ if (!el.isConnected) return false
303
+ if (el.getClientRects().length > 0) return true
304
+ return el.offsetParent !== null
305
+ }
306
+
152
307
  /* ---------------------------------------------------------------- */
153
308
  /* 结果快照:加载 / 保存 / 回填 */
154
309
  /* ---------------------------------------------------------------- */
@@ -300,13 +455,23 @@ window.__ModuleLoader__.load({
300
455
  button.addEventListener('keydown', (event) => event.stopPropagation())
301
456
  }
302
457
 
458
+ /** 行里作为「模型名」的锚点:通用路径记录文本节点父元素,兼容路径用名字元素。 */
459
+ function labelAnchor(row) {
460
+ const mapped = rowAnchor.get(row)
461
+ if (mapped !== undefined && mapped.isConnected) return mapped
462
+ return row.querySelector(NAME_SEL)
463
+ }
464
+
303
465
  /** 把结果徽章插到模型名后面(随文字行内排,不占右侧按钮的位置)。 */
304
466
  function paintChip(row, result) {
305
467
  row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
306
468
  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)
469
+ const anchor = labelAnchor(row)
470
+ if (anchor !== null && anchor !== undefined && anchor.parentNode !== null) {
471
+ anchor.parentNode.insertBefore(chip, anchor.nextSibling)
472
+ } else {
473
+ row.appendChild(chip)
474
+ }
310
475
  return chip
311
476
  }
312
477
 
@@ -368,8 +533,15 @@ window.__ModuleLoader__.load({
368
533
  /* 「全部检测」条 */
369
534
  /* ---------------------------------------------------------------- */
370
535
 
371
- function ensureAllBar(pane, hint) {
372
- const existing = pane.querySelector('.dmd-allbar')
536
+ /**
537
+ * 在 host 里放一条「全部检测」。
538
+ * @param host 承载容器
539
+ * @param hint 初始状态文案
540
+ * @param before 插入到该子节点之前(省略则插到最前)
541
+ */
542
+ function ensureAllBar(host, hint, before = null) {
543
+ if (host === null || host === undefined) return
544
+ const existing = host.querySelector('.dmd-allbar')
373
545
  if (existing !== null) {
374
546
  if (hint) {
375
547
  const status = existing.querySelector('.dmd-status')
@@ -377,8 +549,6 @@ window.__ModuleLoader__.load({
377
549
  }
378
550
  return
379
551
  }
380
- const firstSection = pane.querySelector('section')
381
- if (firstSection === null) return
382
552
  const bar = document.createElement('div')
383
553
  bar.className = 'dmd-allbar'
384
554
  const button = document.createElement('button')
@@ -437,7 +607,8 @@ window.__ModuleLoader__.load({
437
607
  bar.appendChild(status)
438
608
  bar.appendChild(balance)
439
609
  if (hint) status.textContent = hint
440
- firstSection.parentNode.insertBefore(bar, firstSection)
610
+ if (before !== null && before.parentNode === host) host.insertBefore(bar, before)
611
+ else host.insertBefore(bar, host.firstChild)
441
612
  }
442
613
 
443
614
  function providerLabel(providerId) {
@@ -446,25 +617,40 @@ window.__ModuleLoader__.load({
446
617
  }
447
618
 
448
619
  /* ---------------------------------------------------------------- */
449
- /* 菜单增强主逻辑 */
620
+ /* 诊断上报(写进宿主文件,便于远程定位 DOM 结构,不影响功能) */
450
621
  /* ---------------------------------------------------------------- */
451
622
 
452
- function enhance() {
453
- if (disposed) return
454
- const menu = document.querySelector(MENU_SEL)
455
- if (menu === null) return
623
+ let reportSignature = ''
624
+ let reportAt = 0
456
625
 
457
- if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
458
- void fetchDirectory()
459
- }
626
+ function describe(el) {
627
+ if (el === null || el === undefined) return null
628
+ const cls = typeof el.className === 'string' ? el.className.trim().slice(0, 80) : ''
629
+ const text = normText(el.textContent).slice(0, 50)
630
+ return { tag: el.tagName, role: el.getAttribute('role') ?? '', cls, text }
631
+ }
460
632
 
461
- rowByKey.clear()
462
- rowByModel.clear()
633
+ function reportDiagnostics(state) {
634
+ if (doctor === null) return
635
+ const signature = JSON.stringify(state)
636
+ if (signature === reportSignature) return
637
+ if (Date.now() - reportAt < 1200) return
638
+ reportSignature = signature
639
+ reportAt = Date.now()
640
+ void doctor.clientReport(state)
641
+ }
642
+
643
+ /* ---------------------------------------------------------------- */
644
+ /* 菜单增强主逻辑 */
645
+ /* ---------------------------------------------------------------- */
646
+
647
+ /** 兼容路径:dsh-reasoning-effort 的 `.re-model-menu` 结构。 */
648
+ function enhanceLegacy(menu) {
649
+ let wired = 0
463
650
  const panes = menu.querySelectorAll(PANE_SEL)
464
651
  panes.forEach((pane) => {
465
652
  const sections = pane.querySelectorAll('section')
466
653
  if (sections.length === 0) return
467
- let wired = 0
468
654
  sections.forEach((section) => {
469
655
  const title = section.querySelector(TITLE_SEL)?.textContent?.trim()
470
656
  if (title === undefined || title.length === 0) return
@@ -475,6 +661,7 @@ window.__ModuleLoader__.load({
475
661
  const model = group.models[index]
476
662
  const name = row.querySelector(NAME_SEL)?.textContent?.trim()
477
663
  if (name !== undefined && name !== model.name) return
664
+ rowAnchor.set(row, row.querySelector(NAME_SEL) ?? row)
478
665
  enhanceRow(row, group.id, model)
479
666
  wired += 1
480
667
  })
@@ -491,6 +678,7 @@ window.__ModuleLoader__.load({
491
678
  }
492
679
  }
493
680
  if (hits.length !== 1) return
681
+ rowAnchor.set(row, row.querySelector(NAME_SEL) ?? row)
494
682
  enhanceRow(row, hits[0].provider.id, hits[0].model)
495
683
  wired += 1
496
684
  })
@@ -503,9 +691,82 @@ window.__ModuleLoader__.load({
503
691
  } else if (wired === 0) {
504
692
  hint = '模型医生:未匹配到模型行(目录与菜单不一致)'
505
693
  }
506
- ensureAllBar(pane, hint)
507
- if (wired > 0) paintLastRun('上次结果:')
694
+ ensureAllBar(pane, hint, pane.querySelector('section'))
508
695
  })
696
+ return wired
697
+ }
698
+
699
+ /**
700
+ * 通用路径:任何菜单渲染器(官方原生 / dsh-effort-slider / 其他弹层)。
701
+ * 依据「已知模型名」定位行,向上取可点击祖先。
702
+ */
703
+ function enhanceGeneric(legacyWired) {
704
+ const roots = Array.from(document.querySelectorAll(MENU_ROOT_SEL))
705
+ .filter((root) => isVisible(root))
706
+ .filter((root) => root.closest('.dmd-allbar') === null)
707
+ .filter((root) => !root.classList.contains('dmd-allbar'))
708
+ const samples = roots.slice(0, 6).map(describe)
709
+ let wired = 0
710
+ const rootStats = []
711
+ for (const root of roots) {
712
+ const hits = genericRowsIn(root)
713
+ rootStats.push({ root: describe(root), rows: hits.length })
714
+ if (hits.length < MIN_GENERIC_ROWS) continue
715
+ let containerWired = 0
716
+ for (const hit of hits) {
717
+ const entry = pickEntry(hit.entries, hit.row)
718
+ if (entry === undefined) continue
719
+ rowAnchor.set(hit.row, hit.anchor)
720
+ enhanceRow(hit.row, entry.providerId, { id: entry.modelId, name: entry.modelName })
721
+ containerWired += 1
722
+ }
723
+ if (containerWired > 0) {
724
+ wired += containerWired
725
+ const barHost = hits[0].row.parentElement ?? root
726
+ let hint = ''
727
+ if (directory === null) {
728
+ hint = directoryFailedAt !== 0
729
+ ? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
730
+ : '模型医生:目录获取中…'
731
+ }
732
+ ensureAllBar(barHost, hint, hits[0].row)
733
+ }
734
+ }
735
+ lastDiagnostics = {
736
+ at: Date.now(),
737
+ mode: legacyWired > 0 ? 'legacy+generic' : 'generic',
738
+ tokens: ensureTokenIndex().size,
739
+ providerCount: directory?.providers?.length ?? 0,
740
+ modelCount: (directory?.providers ?? []).reduce((sum, p) => sum + p.models.length, 0),
741
+ legacyWired,
742
+ genericWired: wired,
743
+ roots: rootStats.slice(0, 8),
744
+ samples,
745
+ }
746
+ return wired
747
+ }
748
+
749
+ function enhance() {
750
+ if (disposed) return
751
+ if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
752
+ void fetchDirectory()
753
+ }
754
+ rowByKey.clear()
755
+ rowByModel.clear()
756
+ rowAnchor = new WeakMap()
757
+
758
+ const menu = document.querySelector(MENU_SEL)
759
+ const legacyWired = menu === null ? 0 : enhanceLegacy(menu)
760
+ const genericWired = enhanceGeneric(legacyWired)
761
+
762
+ if (legacyWired + genericWired > 0) paintLastRun('上次结果:')
763
+ if (lastDiagnostics !== null) {
764
+ reportDiagnostics({
765
+ ...lastDiagnostics,
766
+ barCount: document.querySelectorAll('.dmd-allbar').length,
767
+ actionCount: document.querySelectorAll('.dmd-actions').length,
768
+ })
769
+ }
509
770
  }
510
771
 
511
772
  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.0",
4
+ "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——不依赖任何菜单渲染插件(官方原生菜单即可),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
5
5
  "keywords": [
6
6
  "deepseek",
7
7
  "deepseek-harness",