dsh-model-doctor 0.4.1 → 0.5.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.
Files changed (2) hide show
  1. package/lib/client/index.js +223 -48
  2. package/package.json +2 -2
@@ -38,6 +38,13 @@ window.__ModuleLoader__.load({
38
38
  '[role="dialog"]',
39
39
  '[data-state="open"][aria-modal]',
40
40
  ].join(',')
41
+ /** 官方菜单(@deepseek-ai/dsh-client-ui-model-selection):类名是 CSS Module 哈希
42
+ * (形如 `wq12jW_option wq12jW_modelOption`),但局部名稳定,用后缀匹配;
43
+ * 模型行是 `button[role="menuitemradio"]`,且 `title` 属性就是模型名。 */
44
+ const OFFICIAL_ROW_SEL = '[role="menuitemradio"]'
45
+ const OFFICIAL_MENU_SUFFIX = '_menu'
46
+ const OFFICIAL_OPTION_SUFFIX = 'modelOption'
47
+ const OFFICIAL_NAME_SUFFIX = 'modelName'
41
48
  const DIRECTORY_TTL_MS = 30000
42
49
  const DIRECTORY_RETRY_MS = 10000
43
50
  /** 一个菜单里至少要有这么多个模型行,才认定它是模型菜单(防误伤其他弹层)。 */
@@ -139,6 +146,10 @@ window.__ModuleLoader__.load({
139
146
  let rowAnchor = new WeakMap()
140
147
  /** 本次扫描的通用路径诊断信息(上报给宿主,便于远程定位 DOM 结构)。 */
141
148
  let lastDiagnostics = null
149
+ /** 通用(文本聚类)路径统计。 */
150
+ let lastGenericStats = null
151
+ /** 官方路径统计。 */
152
+ let lastOfficialStats = null
142
153
 
143
154
  /** 本页会话内已删除的模型键,防目录未刷新时行又冒出来。 */
144
155
  const deletedKeys = new Set()
@@ -178,6 +189,42 @@ window.__ModuleLoader__.load({
178
189
  return String(value ?? '').replace(/\s+/gu, ' ').trim().toLowerCase()
179
190
  }
180
191
 
192
+ /** 折叠文本:去掉一切非字母数字(含空格、连字符、点),用于宽松名字比对。
193
+ * 「gpt-4o mini」与「gpt-4o-mini」折叠后同为 gpt4omini。 */
194
+ function foldText(value) {
195
+ return String(value ?? '').toLowerCase().replace(/[^0-9a-z\u4e00-\u9fff]+/gu, '')
196
+ }
197
+
198
+ /** 元素类名里是否含某个局部名(CSS Module 哈希前缀无关)。 */
199
+ function hasClassToken(el, token) {
200
+ const cls = typeof el.className === 'string' ? el.className : ''
201
+ return cls.toLowerCase().includes(token.toLowerCase())
202
+ }
203
+
204
+ /** 目录里按 名字/ID 找模型:先精确原名,再折叠名。 */
205
+ function findModelsByName(name) {
206
+ const raw = normText(name)
207
+ const folded = foldText(name)
208
+ const hits = []
209
+ if (raw.length === 0) return hits
210
+ for (const provider of directory?.providers ?? []) {
211
+ for (const model of provider.models) {
212
+ const exact = normText(model.name) === raw || normText(model.id) === raw
213
+ const loose = !exact
214
+ && folded.length >= 3
215
+ && (foldText(model.name) === folded || foldText(model.id) === folded)
216
+ if (!exact && !loose) continue
217
+ hits.push({
218
+ providerId: provider.id,
219
+ providerName: provider.name,
220
+ modelId: model.id,
221
+ modelName: model.name,
222
+ })
223
+ }
224
+ }
225
+ return hits
226
+ }
227
+
181
228
  /** 模型名/ID -> 候选条目索引;15 秒内复用。 */
182
229
  let tokenIndex = null
183
230
  let tokenIndexAt = 0
@@ -269,28 +316,44 @@ window.__ModuleLoader__.load({
269
316
  const index = ensureTokenIndex()
270
317
  const found = new Map()
271
318
  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 })
319
+ const record = (row, token, entries, anchor) => {
320
+ const current = found.get(row)
321
+ if (current === undefined || token.length > current.token.length) {
322
+ found.set(row, { token, entries, anchor })
323
+ }
324
+ }
325
+ const scan = (pairs, folded) => {
326
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
327
+ let node = walker.nextNode()
328
+ while (node !== null) {
329
+ const raw = node.nodeValue
330
+ if (raw !== null && raw.length > 1 && raw.length <= 200) {
331
+ const text = folded ? foldText(raw) : normText(raw)
332
+ if (text.length >= 2 && (folded || tokenRegex.test(text))) {
333
+ const anchor = node.parentElement
334
+ if (anchor !== null) {
335
+ for (const [token, entries] of pairs) {
336
+ if (token.length > text.length || !text.includes(token)) continue
337
+ const row = clickableRow(anchor, root)
338
+ if (row === null) continue
339
+ record(row, token, entries, anchor)
288
340
  }
289
341
  }
290
342
  }
291
343
  }
344
+ node = walker.nextNode()
345
+ }
346
+ }
347
+ scan(tokenPairs, false)
348
+ // 原名对不上时(菜单显示名与目录写法不同,如空格 vs 连字符)再做一遍折叠匹配
349
+ if (found.size < 2) {
350
+ const foldedPairs = []
351
+ for (const [token, entries] of tokenPairs) {
352
+ const folded = foldText(token)
353
+ if (folded.length >= 3) foldedPairs.push([folded, entries])
292
354
  }
293
- node = walker.nextNode()
355
+ foldedPairs.sort((a, b) => b[0].length - a[0].length)
356
+ if (foldedPairs.length > 0) scan(foldedPairs, true)
294
357
  }
295
358
  // 排除「容器型行」:行文本里出现两个以上不同模型 → 它装的是列表,不是单行。
296
359
  for (const [row] of Array.from(found.entries())) {
@@ -340,11 +403,10 @@ window.__ModuleLoader__.load({
340
403
  const clusters = []
341
404
  const orphans = []
342
405
  for (const [parent, list] of byParent.entries()) {
343
- if (list.length >= 2) clusters.push({ host: parent, rows: list })
406
+ if (list.length >= 1) clusters.push({ host: parent, rows: list })
344
407
  else orphans.push(...list)
345
408
  }
346
- // 行被分组标题/包装层隔开时,退一级按祖父容器聚合
347
- if (orphans.length >= 2) {
409
+ if (orphans.length >= 1) {
348
410
  const byGrand = new Map()
349
411
  for (const hit of orphans) {
350
412
  const grand = hit.row.parentElement?.parentElement ?? document.body
@@ -353,10 +415,10 @@ window.__ModuleLoader__.load({
353
415
  byGrand.set(grand, list)
354
416
  }
355
417
  for (const [grand, list] of byGrand.entries()) {
356
- if (list.length >= 2) clusters.push({ host: grand, rows: list })
418
+ if (list.length >= 1) clusters.push({ host: grand, rows: list })
357
419
  }
358
420
  }
359
- return clusters.filter((cluster) => isPopoverish(cluster.host))
421
+ return clusters
360
422
  }
361
423
 
362
424
  /** 根容器是否可见(不可见的弹层跳过,避免误伤收起的菜单)。 */
@@ -684,6 +746,8 @@ window.__ModuleLoader__.load({
684
746
 
685
747
  let reportSignature = ''
686
748
  let reportAt = 0
749
+ /** 最近一次写入的报告是否包含「菜单已打开」的现场。 */
750
+ let lastReportHadMenu = false
687
751
 
688
752
  function describe(el) {
689
753
  if (el === null || el === undefined) return null
@@ -692,13 +756,19 @@ window.__ModuleLoader__.load({
692
756
  return { tag: el.tagName, role: el.getAttribute('role') ?? '', cls, text }
693
757
  }
694
758
 
695
- function reportDiagnostics(state) {
759
+ function reportDiagnostics(state, menuVisible = false) {
696
760
  if (doctor === null) return
697
- const signature = JSON.stringify(state)
698
- if (signature === reportSignature) return
699
- if (Date.now() - reportAt < 1200) return
761
+ const { at, ...rest } = state
762
+ const signature = JSON.stringify(rest)
763
+ const now = Date.now()
764
+ const isError = typeof state.error === 'string'
765
+ // 报告里已经留下「菜单打开」的现场时,不要被随后「菜单关闭」的空扫描覆盖(错误除外)
766
+ if (!menuVisible && !isError && lastReportHadMenu) return
767
+ if (!isError && signature === reportSignature && now - reportAt < (menuVisible ? 5000 : 1200)) return
768
+ if (now - reportAt < 400) return
700
769
  reportSignature = signature
701
- reportAt = Date.now()
770
+ reportAt = now
771
+ lastReportHadMenu = menuVisible
702
772
  void doctor.clientReport(state)
703
773
  }
704
774
 
@@ -706,7 +776,65 @@ window.__ModuleLoader__.load({
706
776
  /* 菜单增强主逻辑 */
707
777
  /* ---------------------------------------------------------------- */
708
778
 
709
- /** 兼容路径:dsh-reasoning-effort 的 `.re-model-menu` 结构。 */
779
+ /** 官方模型行的名字:优先 title 属性(官方就是 model.name),再 _modelName 文本。 */
780
+ function officialRowName(row) {
781
+ const title = row.getAttribute('title')
782
+ if (typeof title === 'string' && title.trim().length > 0) return title.trim()
783
+ const nameEl = row.querySelector(`[class*="${OFFICIAL_NAME_SUFFIX}"]`)
784
+ const text = nameEl?.textContent?.trim()
785
+ if (text !== undefined && text.length > 0) return text
786
+ return (row.textContent ?? '').trim()
787
+ }
788
+
789
+ /**
790
+ * 官方路径(首选):直接按官方 DOM 契约定位模型行,不靠文本猜。
791
+ * 行 = `button[role="menuitemradio"]`(类名含 modelOption,或名字能对上目录);
792
+ * 名字取 `title` 属性 → 与权威目录比对得到 provider/model。
793
+ */
794
+ function enhanceOfficial() {
795
+ const rows = Array.from(document.querySelectorAll(OFFICIAL_ROW_SEL)).filter((row) => {
796
+ if (hasClassToken(row, OFFICIAL_OPTION_SUFFIX)) return true
797
+ // 没有 modelOption 类时(契约变化)退一步:名字能对上目录才算模型行,
798
+ // 这样 effort(力度)面板里的 menuitemradio 不会被误加按钮。
799
+ return findModelsByName(officialRowName(row)).length > 0
800
+ })
801
+ if (rows.length === 0) return { rows: 0, wired: 0, samples: [], menuFound: false }
802
+ const samples = []
803
+ let wired = 0
804
+ let unmatched = 0
805
+ for (const row of rows) {
806
+ const name = officialRowName(row)
807
+ const hits = findModelsByName(name)
808
+ if (samples.length < 8) {
809
+ samples.push({ cls: String(row.className ?? '').slice(0, 70), title: name.slice(0, 40), hits: hits.length })
810
+ }
811
+ if (hits.length === 0) {
812
+ unmatched += 1
813
+ continue
814
+ }
815
+ const entry = pickEntry(hits, row)
816
+ const isNew = row.querySelector('.dmd-actions') === null
817
+ rowAnchor.set(row, row.querySelector(`[class*="${OFFICIAL_NAME_SUFFIX}"]`) ?? row)
818
+ enhanceRow(row, entry.providerId, { id: entry.modelId, name: entry.modelName })
819
+ if (isNew) wired += 1
820
+ }
821
+ // 状态条插到菜单顶层(一次),而不是每个供应商分组各插一条
822
+ const menuRoot = rows[0].closest(`[class*="${OFFICIAL_MENU_SUFFIX}"]`)
823
+ ?? rows[0].closest('[role="menu"]')
824
+ ?? rows[0].parentElement
825
+ let hint = ''
826
+ if (directory === null) {
827
+ hint = directoryFailedAt !== 0
828
+ ? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
829
+ : '模型医生:目录获取中…'
830
+ } else if (unmatched > 0) {
831
+ hint = `模型医生:${unmatched} 个模型未匹配上目录`
832
+ }
833
+ ensureAllBar(menuRoot, hint, menuRoot?.firstChild ?? null)
834
+ return { rows: rows.length, wired, samples, unmatched, menuFound: true }
835
+ }
836
+
837
+ /** 官方路径:dsh-reasoning-effort 的 `.re-model-menu` 结构。 */
710
838
  function enhanceLegacy(menu) {
711
839
  let wired = 0
712
840
  const panes = menu.querySelectorAll(PANE_SEL)
@@ -804,30 +932,61 @@ window.__ModuleLoader__.load({
804
932
  ensureAllBar(barHost, hint, first.row)
805
933
  }
806
934
  }
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,
935
+ lastGenericStats = {
815
936
  legacyWired,
816
937
  genericWired: wired,
817
938
  registered,
818
939
  unmatched,
819
940
  hitCount: hits.size,
820
941
  visibleRoots: roots.length,
821
- roots: clusterStats.slice(0, 8),
942
+ clusters: clusterStats.slice(0, 8),
822
943
  samples: roots.slice(0, 5).map(describe),
823
- barCount: document.querySelectorAll('.dmd-allbar').length,
824
- actionCount: document.querySelectorAll('.dmd-actions').length,
825
944
  }
826
945
  return registered > 0 ? Math.max(1, wired) : 0
827
946
  }
828
947
 
948
+ /** 菜单结构快照(仅菜单可见时生成,便于远程确认 DOM 契约是否变化)。 */
949
+ function dumpMenu() {
950
+ const menuRoot = document.querySelector(`[class*="${OFFICIAL_MENU_SUFFIX}"]`)
951
+ ?? document.querySelector(MENU_SEL)
952
+ ?? document.querySelector('[role="menu"]')
953
+ if (menuRoot === null || !isVisible(menuRoot)) return null
954
+ const nodes = []
955
+ const walk = (el, depth) => {
956
+ if (nodes.length >= 40 || depth > 4) return
957
+ nodes.push({
958
+ d: depth,
959
+ tag: el.tagName,
960
+ cls: (typeof el.className === 'string' ? el.className : '').slice(0, 70),
961
+ role: el.getAttribute('role') ?? '',
962
+ title: (el.getAttribute('title') ?? '').slice(0, 40),
963
+ text: normText(el.textContent).slice(0, 36),
964
+ })
965
+ for (const child of Array.from(el.children).slice(0, 12)) walk(child, depth + 1)
966
+ }
967
+ walk(menuRoot, 0)
968
+ return { root: describe(menuRoot), nodes }
969
+ }
970
+
829
971
  function enhance() {
830
972
  if (disposed) return
973
+ try {
974
+ enhanceInner()
975
+ } catch (error) {
976
+ // 任何异常都要留痕:否则菜单开着时崩在中间,诊断文件会一直停留在旧状态
977
+ reportDiagnostics({
978
+ at: Date.now(),
979
+ mode: 'error',
980
+ error: error instanceof Error ? error.message : String(error),
981
+ stack: error instanceof Error
982
+ ? String(error.stack ?? '').split('\n').slice(0, 5).join(' | ').slice(0, 500)
983
+ : '',
984
+ href: window.location.href.slice(0, 120),
985
+ }, true)
986
+ }
987
+ }
988
+
989
+ function enhanceInner() {
831
990
  if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
832
991
  void fetchDirectory()
833
992
  }
@@ -835,18 +994,34 @@ window.__ModuleLoader__.load({
835
994
  rowByModel.clear()
836
995
  rowAnchor = new WeakMap()
837
996
 
997
+ // ① 官方路径(首选,按官方 DOM 契约精确取行)
998
+ const official = enhanceOfficial()
999
+ lastOfficialStats = official
1000
+ // ② 兼容路径(dsh-reasoning-effort)
838
1001
  const menu = document.querySelector(MENU_SEL)
839
1002
  const legacyWired = menu === null ? 0 : enhanceLegacy(menu)
840
- const genericWired = enhanceGeneric(legacyWired)
1003
+ // ③ 通用兜底(未知渲染器):前两条都没命中时才跑全文文本聚类
1004
+ const genericWired = official.menuFound || legacyWired > 0 ? 0 : enhanceGeneric(legacyWired)
1005
+ if (official.menuFound || legacyWired > 0) lastGenericStats = null
841
1006
 
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
- }
1007
+ const wired = official.wired + legacyWired + genericWired
1008
+ if (wired > 0) paintLastRun('上次结果:')
1009
+
1010
+ const menuVisible = official.menuFound || menu !== null
1011
+ reportDiagnostics({
1012
+ at: Date.now(),
1013
+ href: window.location.href.slice(0, 120),
1014
+ tokens: ensureTokenIndex().size,
1015
+ providerCount: directory?.providers?.length ?? 0,
1016
+ modelCount: (directory?.providers ?? []).reduce((sum, p) => sum + p.models.length, 0),
1017
+ directoryError,
1018
+ official: lastOfficialStats,
1019
+ legacyWired,
1020
+ generic: lastGenericStats,
1021
+ menuDump: dumpMenu(),
1022
+ barCount: document.querySelectorAll('.dmd-allbar').length,
1023
+ actionCount: document.querySelectorAll('.dmd-actions').length,
1024
+ }, menuVisible)
850
1025
  }
851
1026
 
852
1027
  function scheduleEnhance() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-model-doctor",
3
- "version": "0.4.1",
4
- "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——不依赖任何菜单渲染插件(官方原生菜单即可),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
3
+ "version": "0.5.1",
4
+ "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——直接适配 DSH 官方模型菜单(按官方 DOM 契约精确定位,不依赖任何菜单渲染插件),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
5
5
  "keywords": [
6
6
  "deepseek",
7
7
  "deepseek-harness",