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 +13 -3
- package/lib/client/index.js +291 -30
- package/lib/index.js +25 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,13 +30,21 @@ DSH 桌面客户端插件:在对话输入区的**模型选择菜单**里,为
|
|
|
30
30
|
|
|
31
31
|
## 依赖与适配
|
|
32
32
|
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
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 兜底匹配)、按钮钉在选项右侧、
|
package/lib/client/index.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-model-doctor — 浏览器半(无框架,纯 DOM 增强)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
308
|
-
if (
|
|
309
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
const menu = document.querySelector(MENU_SEL)
|
|
455
|
-
if (menu === null) return
|
|
623
|
+
let reportSignature = ''
|
|
624
|
+
let reportAt = 0
|
|
456
625
|
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
462
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——不依赖任何菜单渲染插件(官方原生菜单即可),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
7
7
|
"deepseek-harness",
|