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.
- package/lib/client/index.js +223 -48
- 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,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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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 >=
|
|
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 >=
|
|
418
|
+
if (list.length >= 1) clusters.push({ host: grand, rows: list })
|
|
357
419
|
}
|
|
358
420
|
}
|
|
359
|
-
return clusters
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1003
|
+
// ③ 通用兜底(未知渲染器):前两条都没命中时才跑全文文本聚类
|
|
1004
|
+
const genericWired = official.menuFound || legacyWired > 0 ? 0 : enhanceGeneric(legacyWired)
|
|
1005
|
+
if (official.menuFound || legacyWired > 0) lastGenericStats = null
|
|
841
1006
|
|
|
842
|
-
|
|
843
|
-
if (
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——直接适配 DSH 官方模型菜单(按官方 DOM 契约精确定位,不依赖任何菜单渲染插件),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
7
7
|
"deepseek-harness",
|