dsh-model-doctor 0.4.0 → 0.5.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/lib/client/index.js +322 -72
- package/package.json +2 -2
package/lib/client/index.js
CHANGED
|
@@ -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,9 +189,49 @@ 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
|
|
231
|
+
/** 预编译的 token 正则(先粗筛文本节点,避免对每个节点做全量比对)。 */
|
|
232
|
+
let tokenRegex = null
|
|
233
|
+
/** [token, entries] 按 token 长度降序:优先匹配更具体的名字。 */
|
|
234
|
+
let tokenPairs = []
|
|
184
235
|
|
|
185
236
|
function ensureTokenIndex() {
|
|
186
237
|
if (tokenIndex !== null && Date.now() - tokenIndexAt < 15000) return tokenIndex
|
|
@@ -203,6 +254,10 @@ window.__ModuleLoader__.load({
|
|
|
203
254
|
}
|
|
204
255
|
tokenIndex = index
|
|
205
256
|
tokenIndexAt = Date.now()
|
|
257
|
+
tokenPairs = Array.from(index.entries()).sort((a, b) => b[0].length - a[0].length)
|
|
258
|
+
tokenRegex = tokenPairs.length === 0
|
|
259
|
+
? null
|
|
260
|
+
: new RegExp(tokenPairs.map(([token]) => token.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')).join('|'), 'u')
|
|
206
261
|
return index
|
|
207
262
|
}
|
|
208
263
|
|
|
@@ -253,48 +308,117 @@ window.__ModuleLoader__.load({
|
|
|
253
308
|
}
|
|
254
309
|
|
|
255
310
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
311
|
+
* 全文档找出「命中了已知模型名」的最小行容器。
|
|
312
|
+
* 不再依赖任何菜单根选择器:先用预编译正则粗筛文本节点,再逐一确认 token 命中,
|
|
313
|
+
* 然后向上取最近的可点击祖先。
|
|
258
314
|
*/
|
|
259
|
-
function
|
|
315
|
+
function minimalMatches(root) {
|
|
260
316
|
const index = ensureTokenIndex()
|
|
261
|
-
if (index.size === 0) return []
|
|
262
317
|
const found = new Map()
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
318
|
+
if (index.size === 0 || tokenRegex === null) return found
|
|
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)
|
|
279
340
|
}
|
|
280
341
|
}
|
|
281
342
|
}
|
|
282
343
|
}
|
|
344
|
+
node = walker.nextNode()
|
|
283
345
|
}
|
|
284
|
-
|
|
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])
|
|
354
|
+
}
|
|
355
|
+
foldedPairs.sort((a, b) => b[0].length - a[0].length)
|
|
356
|
+
if (foldedPairs.length > 0) scan(foldedPairs, true)
|
|
285
357
|
}
|
|
286
358
|
// 排除「容器型行」:行文本里出现两个以上不同模型 → 它装的是列表,不是单行。
|
|
287
359
|
for (const [row] of Array.from(found.entries())) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
360
|
+
if (countDistinctModels(row, index) > 1) found.delete(row)
|
|
361
|
+
}
|
|
362
|
+
return found
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 一个元素里出现了几个不同模型(>1 说明它是容器)。 */
|
|
366
|
+
function countDistinctModels(el, index) {
|
|
367
|
+
const text = normText(el.textContent)
|
|
368
|
+
const distinct = new Set()
|
|
369
|
+
for (const [token, entries] of index) {
|
|
370
|
+
if (!text.includes(token)) continue
|
|
371
|
+
for (const entry of entries) distinct.add(`${entry.providerId}::${entry.modelId}`)
|
|
372
|
+
if (distinct.size > 1) return distinct.size
|
|
373
|
+
}
|
|
374
|
+
return distinct.size
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 容器是否像弹层(绝对定位或带菜单语义),用于排除正文里碰巧出现的模型名。 */
|
|
378
|
+
function isPopoverish(el) {
|
|
379
|
+
let node = el
|
|
380
|
+
let depth = 0
|
|
381
|
+
while (node !== null && node !== document.body && depth < 4) {
|
|
382
|
+
const role = typeof node.getAttribute === 'function' ? node.getAttribute('role') : null
|
|
383
|
+
if (typeof role === 'string' && /^(menu|listbox|dialog|alertdialog)$/u.test(role)) return true
|
|
384
|
+
try {
|
|
385
|
+
const position = window.getComputedStyle(node).position
|
|
386
|
+
if (position === 'absolute' || position === 'fixed') return true
|
|
387
|
+
} catch { /* 忽略计算样式失败 */ }
|
|
388
|
+
node = node.parentElement
|
|
389
|
+
depth += 1
|
|
390
|
+
}
|
|
391
|
+
return false
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 把命中的行按容器聚类:同一父节点下 ≥2 行才算一个菜单。 */
|
|
395
|
+
function clusterRows(found) {
|
|
396
|
+
const byParent = new Map()
|
|
397
|
+
for (const [row, hit] of found.entries()) {
|
|
398
|
+
const parent = row.parentElement ?? document.body
|
|
399
|
+
const list = byParent.get(parent) ?? []
|
|
400
|
+
list.push({ row, ...hit })
|
|
401
|
+
byParent.set(parent, list)
|
|
402
|
+
}
|
|
403
|
+
const clusters = []
|
|
404
|
+
const orphans = []
|
|
405
|
+
for (const [parent, list] of byParent.entries()) {
|
|
406
|
+
if (list.length >= 1) clusters.push({ host: parent, rows: list })
|
|
407
|
+
else orphans.push(...list)
|
|
408
|
+
}
|
|
409
|
+
if (orphans.length >= 1) {
|
|
410
|
+
const byGrand = new Map()
|
|
411
|
+
for (const hit of orphans) {
|
|
412
|
+
const grand = hit.row.parentElement?.parentElement ?? document.body
|
|
413
|
+
const list = byGrand.get(grand) ?? []
|
|
414
|
+
list.push(hit)
|
|
415
|
+
byGrand.set(grand, list)
|
|
416
|
+
}
|
|
417
|
+
for (const [grand, list] of byGrand.entries()) {
|
|
418
|
+
if (list.length >= 1) clusters.push({ host: grand, rows: list })
|
|
294
419
|
}
|
|
295
|
-
if (distinct.size > 1) found.delete(row)
|
|
296
420
|
}
|
|
297
|
-
return
|
|
421
|
+
return clusters
|
|
298
422
|
}
|
|
299
423
|
|
|
300
424
|
/** 根容器是否可见(不可见的弹层跳过,避免误伤收起的菜单)。 */
|
|
@@ -630,13 +754,16 @@ window.__ModuleLoader__.load({
|
|
|
630
754
|
return { tag: el.tagName, role: el.getAttribute('role') ?? '', cls, text }
|
|
631
755
|
}
|
|
632
756
|
|
|
633
|
-
function reportDiagnostics(state) {
|
|
757
|
+
function reportDiagnostics(state, force = false) {
|
|
634
758
|
if (doctor === null) return
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
759
|
+
const { at, ...rest } = state
|
|
760
|
+
const signature = JSON.stringify(rest)
|
|
761
|
+
const now = Date.now()
|
|
762
|
+
// 状态没变时:菜单开着也最多 5 秒写一次,避免刷屏
|
|
763
|
+
if (signature === reportSignature && now - reportAt < (force ? 5000 : 1200)) return
|
|
764
|
+
if (now - reportAt < 400) return
|
|
638
765
|
reportSignature = signature
|
|
639
|
-
reportAt =
|
|
766
|
+
reportAt = now
|
|
640
767
|
void doctor.clientReport(state)
|
|
641
768
|
}
|
|
642
769
|
|
|
@@ -644,7 +771,65 @@ window.__ModuleLoader__.load({
|
|
|
644
771
|
/* 菜单增强主逻辑 */
|
|
645
772
|
/* ---------------------------------------------------------------- */
|
|
646
773
|
|
|
647
|
-
/**
|
|
774
|
+
/** 官方模型行的名字:优先 title 属性(官方就是 model.name),再 _modelName 文本。 */
|
|
775
|
+
function officialRowName(row) {
|
|
776
|
+
const title = row.getAttribute('title')
|
|
777
|
+
if (typeof title === 'string' && title.trim().length > 0) return title.trim()
|
|
778
|
+
const nameEl = row.querySelector(`[class*="${OFFICIAL_NAME_SUFFIX}"]`)
|
|
779
|
+
const text = nameEl?.textContent?.trim()
|
|
780
|
+
if (text !== undefined && text.length > 0) return text
|
|
781
|
+
return (row.textContent ?? '').trim()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* 官方路径(首选):直接按官方 DOM 契约定位模型行,不靠文本猜。
|
|
786
|
+
* 行 = `button[role="menuitemradio"]`(类名含 modelOption,或名字能对上目录);
|
|
787
|
+
* 名字取 `title` 属性 → 与权威目录比对得到 provider/model。
|
|
788
|
+
*/
|
|
789
|
+
function enhanceOfficial() {
|
|
790
|
+
const rows = Array.from(document.querySelectorAll(OFFICIAL_ROW_SEL)).filter((row) => {
|
|
791
|
+
if (hasClassToken(row, OFFICIAL_OPTION_SUFFIX)) return true
|
|
792
|
+
// 没有 modelOption 类时(契约变化)退一步:名字能对上目录才算模型行,
|
|
793
|
+
// 这样 effort(力度)面板里的 menuitemradio 不会被误加按钮。
|
|
794
|
+
return findModelsByName(officialRowName(row)).length > 0
|
|
795
|
+
})
|
|
796
|
+
if (rows.length === 0) return { rows: 0, wired: 0, samples: [], menuFound: false }
|
|
797
|
+
const samples = []
|
|
798
|
+
let wired = 0
|
|
799
|
+
let unmatched = 0
|
|
800
|
+
for (const row of rows) {
|
|
801
|
+
const name = officialRowName(row)
|
|
802
|
+
const hits = findModelsByName(name)
|
|
803
|
+
if (samples.length < 8) {
|
|
804
|
+
samples.push({ cls: String(row.className ?? '').slice(0, 70), title: name.slice(0, 40), hits: hits.length })
|
|
805
|
+
}
|
|
806
|
+
if (hits.length === 0) {
|
|
807
|
+
unmatched += 1
|
|
808
|
+
continue
|
|
809
|
+
}
|
|
810
|
+
const entry = pickEntry(hits, row)
|
|
811
|
+
const isNew = row.querySelector('.dmd-actions') === null
|
|
812
|
+
rowAnchor.set(row, row.querySelector(`[class*="${OFFICIAL_NAME_SUFFIX}"]`) ?? row)
|
|
813
|
+
enhanceRow(row, entry.providerId, { id: entry.modelId, name: entry.modelName })
|
|
814
|
+
if (isNew) wired += 1
|
|
815
|
+
}
|
|
816
|
+
// 状态条插到菜单顶层(一次),而不是每个供应商分组各插一条
|
|
817
|
+
const menuRoot = rows[0].closest(`[class*="${OFFICIAL_MENU_SUFFIX}"]`)
|
|
818
|
+
?? rows[0].closest('[role="menu"]')
|
|
819
|
+
?? rows[0].parentElement
|
|
820
|
+
let hint = ''
|
|
821
|
+
if (directory === null) {
|
|
822
|
+
hint = directoryFailedAt !== 0
|
|
823
|
+
? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
|
|
824
|
+
: '模型医生:目录获取中…'
|
|
825
|
+
} else if (unmatched > 0) {
|
|
826
|
+
hint = `模型医生:${unmatched} 个模型未匹配上目录`
|
|
827
|
+
}
|
|
828
|
+
ensureAllBar(menuRoot, hint, menuRoot?.firstChild ?? null)
|
|
829
|
+
return { rows: rows.length, wired, samples, unmatched, menuFound: true }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** 官方路径:dsh-reasoning-effort 的 `.re-model-menu` 结构。 */
|
|
648
833
|
function enhanceLegacy(menu) {
|
|
649
834
|
let wired = 0
|
|
650
835
|
const panes = menu.querySelectorAll(PANE_SEL)
|
|
@@ -698,56 +883,105 @@ window.__ModuleLoader__.load({
|
|
|
698
883
|
|
|
699
884
|
/**
|
|
700
885
|
* 通用路径:任何菜单渲染器(官方原生 / dsh-effort-slider / 其他弹层)。
|
|
701
|
-
*
|
|
886
|
+
* 依据「已知模型名」在全文定位行,按容器聚类成菜单,再逐行增强。
|
|
702
887
|
*/
|
|
703
888
|
function enhanceGeneric(legacyWired) {
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const samples = roots.slice(0, 6).map(describe)
|
|
889
|
+
const index = ensureTokenIndex()
|
|
890
|
+
const roots = Array.from(document.querySelectorAll(MENU_ROOT_SEL)).filter((root) => isVisible(root))
|
|
891
|
+
const hits = minimalMatches(document.body)
|
|
892
|
+
const clusters = clusterRows(hits)
|
|
709
893
|
let wired = 0
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
for (const hit of hits) {
|
|
894
|
+
let registered = 0
|
|
895
|
+
let unmatched = 0
|
|
896
|
+
const clusterStats = []
|
|
897
|
+
for (const cluster of clusters) {
|
|
898
|
+
let newlyWired = 0
|
|
899
|
+
for (const hit of cluster.rows) {
|
|
717
900
|
const entry = pickEntry(hit.entries, hit.row)
|
|
718
|
-
if (entry === undefined)
|
|
901
|
+
if (entry === undefined) {
|
|
902
|
+
unmatched += 1
|
|
903
|
+
continue
|
|
904
|
+
}
|
|
905
|
+
const isNew = hit.row.querySelector('.dmd-actions') === null
|
|
719
906
|
rowAnchor.set(hit.row, hit.anchor)
|
|
720
907
|
enhanceRow(hit.row, entry.providerId, { id: entry.modelId, name: entry.modelName })
|
|
721
|
-
|
|
908
|
+
registered += 1
|
|
909
|
+
if (isNew) newlyWired += 1
|
|
722
910
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
911
|
+
clusterStats.push({
|
|
912
|
+
host: describe(cluster.host),
|
|
913
|
+
rows: cluster.rows.length,
|
|
914
|
+
wired: newlyWired,
|
|
915
|
+
})
|
|
916
|
+
if (cluster.rows.length >= MIN_GENERIC_ROWS) {
|
|
917
|
+
const first = cluster.rows[0]
|
|
726
918
|
let hint = ''
|
|
727
919
|
if (directory === null) {
|
|
728
920
|
hint = directoryFailedAt !== 0
|
|
729
921
|
? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
|
|
730
922
|
: '模型医生:目录获取中…'
|
|
923
|
+
} else if (newlyWired === 0 && first.row.querySelector('.dmd-actions') === null) {
|
|
924
|
+
hint = '模型医生:模型行未匹配上目录'
|
|
731
925
|
}
|
|
732
|
-
|
|
926
|
+
const barHost = first.row.parentElement ?? cluster.host
|
|
927
|
+
ensureAllBar(barHost, hint, first.row)
|
|
733
928
|
}
|
|
734
929
|
}
|
|
735
|
-
|
|
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),
|
|
930
|
+
lastGenericStats = {
|
|
741
931
|
legacyWired,
|
|
742
932
|
genericWired: wired,
|
|
743
|
-
|
|
744
|
-
|
|
933
|
+
registered,
|
|
934
|
+
unmatched,
|
|
935
|
+
hitCount: hits.size,
|
|
936
|
+
visibleRoots: roots.length,
|
|
937
|
+
clusters: clusterStats.slice(0, 8),
|
|
938
|
+
samples: roots.slice(0, 5).map(describe),
|
|
745
939
|
}
|
|
746
|
-
return wired
|
|
940
|
+
return registered > 0 ? Math.max(1, wired) : 0
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** 菜单结构快照(仅菜单可见时生成,便于远程确认 DOM 契约是否变化)。 */
|
|
944
|
+
function dumpMenu() {
|
|
945
|
+
const menuRoot = document.querySelector(`[class*="${OFFICIAL_MENU_SUFFIX}"]`)
|
|
946
|
+
?? document.querySelector(MENU_SEL)
|
|
947
|
+
?? document.querySelector('[role="menu"]')
|
|
948
|
+
if (menuRoot === null || !isVisible(menuRoot)) return null
|
|
949
|
+
const nodes = []
|
|
950
|
+
const walk = (el, depth) => {
|
|
951
|
+
if (nodes.length >= 40 || depth > 4) return
|
|
952
|
+
nodes.push({
|
|
953
|
+
d: depth,
|
|
954
|
+
tag: el.tagName,
|
|
955
|
+
cls: (typeof el.className === 'string' ? el.className : '').slice(0, 70),
|
|
956
|
+
role: el.getAttribute('role') ?? '',
|
|
957
|
+
title: (el.getAttribute('title') ?? '').slice(0, 40),
|
|
958
|
+
text: normText(el.textContent).slice(0, 36),
|
|
959
|
+
})
|
|
960
|
+
for (const child of Array.from(el.children).slice(0, 12)) walk(child, depth + 1)
|
|
961
|
+
}
|
|
962
|
+
walk(menuRoot, 0)
|
|
963
|
+
return { root: describe(menuRoot), nodes }
|
|
747
964
|
}
|
|
748
965
|
|
|
749
966
|
function enhance() {
|
|
750
967
|
if (disposed) return
|
|
968
|
+
try {
|
|
969
|
+
enhanceInner()
|
|
970
|
+
} catch (error) {
|
|
971
|
+
// 任何异常都要留痕:否则菜单开着时崩在中间,诊断文件会一直停留在旧状态
|
|
972
|
+
reportDiagnostics({
|
|
973
|
+
at: Date.now(),
|
|
974
|
+
mode: 'error',
|
|
975
|
+
error: error instanceof Error ? error.message : String(error),
|
|
976
|
+
stack: error instanceof Error
|
|
977
|
+
? String(error.stack ?? '').split('\n').slice(0, 5).join(' | ').slice(0, 500)
|
|
978
|
+
: '',
|
|
979
|
+
href: window.location.href.slice(0, 120),
|
|
980
|
+
}, true)
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function enhanceInner() {
|
|
751
985
|
if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
|
|
752
986
|
void fetchDirectory()
|
|
753
987
|
}
|
|
@@ -755,18 +989,34 @@ window.__ModuleLoader__.load({
|
|
|
755
989
|
rowByModel.clear()
|
|
756
990
|
rowAnchor = new WeakMap()
|
|
757
991
|
|
|
992
|
+
// ① 官方路径(首选,按官方 DOM 契约精确取行)
|
|
993
|
+
const official = enhanceOfficial()
|
|
994
|
+
lastOfficialStats = official
|
|
995
|
+
// ② 兼容路径(dsh-reasoning-effort)
|
|
758
996
|
const menu = document.querySelector(MENU_SEL)
|
|
759
997
|
const legacyWired = menu === null ? 0 : enhanceLegacy(menu)
|
|
760
|
-
|
|
998
|
+
// ③ 通用兜底(未知渲染器):前两条都没命中时才跑全文文本聚类
|
|
999
|
+
const genericWired = official.menuFound || legacyWired > 0 ? 0 : enhanceGeneric(legacyWired)
|
|
1000
|
+
if (official.menuFound || legacyWired > 0) lastGenericStats = null
|
|
761
1001
|
|
|
762
|
-
|
|
763
|
-
if (
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1002
|
+
const wired = official.wired + legacyWired + genericWired
|
|
1003
|
+
if (wired > 0) paintLastRun('上次结果:')
|
|
1004
|
+
|
|
1005
|
+
const menuVisible = official.menuFound || menu !== null
|
|
1006
|
+
reportDiagnostics({
|
|
1007
|
+
at: Date.now(),
|
|
1008
|
+
href: window.location.href.slice(0, 120),
|
|
1009
|
+
tokens: ensureTokenIndex().size,
|
|
1010
|
+
providerCount: directory?.providers?.length ?? 0,
|
|
1011
|
+
modelCount: (directory?.providers ?? []).reduce((sum, p) => sum + p.models.length, 0),
|
|
1012
|
+
directoryError,
|
|
1013
|
+
official: lastOfficialStats,
|
|
1014
|
+
legacyWired,
|
|
1015
|
+
generic: lastGenericStats,
|
|
1016
|
+
menuDump: dumpMenu(),
|
|
1017
|
+
barCount: document.querySelectorAll('.dmd-allbar').length,
|
|
1018
|
+
actionCount: document.querySelectorAll('.dmd-actions').length,
|
|
1019
|
+
}, menuVisible)
|
|
770
1020
|
}
|
|
771
1021
|
|
|
772
1022
|
function scheduleEnhance() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-doctor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——直接适配 DSH 官方模型菜单(按官方 DOM 契约精确定位,不依赖任何菜单渲染插件),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
7
7
|
"deepseek-harness",
|