dsh-model-doctor 0.3.0 → 0.4.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/README.md +13 -3
- package/lib/client/index.js +371 -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,203 @@ 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
|
+
/** 预编译的 token 正则(先粗筛文本节点,避免对每个节点做全量比对)。 */
|
|
185
|
+
let tokenRegex = null
|
|
186
|
+
/** [token, entries] 按 token 长度降序:优先匹配更具体的名字。 */
|
|
187
|
+
let tokenPairs = []
|
|
188
|
+
|
|
189
|
+
function ensureTokenIndex() {
|
|
190
|
+
if (tokenIndex !== null && Date.now() - tokenIndexAt < 15000) return tokenIndex
|
|
191
|
+
const index = new Map()
|
|
192
|
+
for (const provider of directory?.providers ?? []) {
|
|
193
|
+
for (const model of provider.models) {
|
|
194
|
+
const tokens = new Set([normText(model.name), normText(model.id)])
|
|
195
|
+
for (const token of tokens) {
|
|
196
|
+
if (token.length < 2) continue
|
|
197
|
+
const list = index.get(token) ?? []
|
|
198
|
+
list.push({
|
|
199
|
+
providerId: provider.id,
|
|
200
|
+
providerName: provider.name,
|
|
201
|
+
modelId: model.id,
|
|
202
|
+
modelName: model.name,
|
|
203
|
+
})
|
|
204
|
+
index.set(token, list)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
tokenIndex = index
|
|
209
|
+
tokenIndexAt = Date.now()
|
|
210
|
+
tokenPairs = Array.from(index.entries()).sort((a, b) => b[0].length - a[0].length)
|
|
211
|
+
tokenRegex = tokenPairs.length === 0
|
|
212
|
+
? null
|
|
213
|
+
: new RegExp(tokenPairs.map(([token]) => token.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')).join('|'), 'u')
|
|
214
|
+
return index
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 元素是否像个可点击的「行」。 */
|
|
218
|
+
function looksLikeRow(el) {
|
|
219
|
+
const tag = el.tagName
|
|
220
|
+
if (tag === 'BUTTON' || tag === 'LI' || tag === 'A') return true
|
|
221
|
+
const role = el.getAttribute('role')
|
|
222
|
+
if (role !== null && /^(option|menuitem|menuitemradio|listitem|treeitem|button)$/u.test(role)) return true
|
|
223
|
+
if (el.hasAttribute('data-value') || el.hasAttribute('cmdk-item')) return true
|
|
224
|
+
if (el.hasAttribute('tabindex')) return true
|
|
225
|
+
try {
|
|
226
|
+
if (window.getComputedStyle(el).cursor === 'pointer') return true
|
|
227
|
+
} catch { /* 忽略计算样式失败 */ }
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 从文本节点往上找最近的可点击行容器。 */
|
|
232
|
+
function clickableRow(anchor, root) {
|
|
233
|
+
let el = anchor
|
|
234
|
+
let depth = 0
|
|
235
|
+
while (el !== null && el !== root && depth < 8) {
|
|
236
|
+
if (looksLikeRow(el)) return el
|
|
237
|
+
el = el.parentElement
|
|
238
|
+
depth += 1
|
|
239
|
+
}
|
|
240
|
+
return looksLikeRow(root) ? root : null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 候选条目里挑一个:多供应商同名时用上下文里的供应商名消歧。 */
|
|
244
|
+
function pickEntry(entries, row) {
|
|
245
|
+
if (entries.length === 1) return entries[0]
|
|
246
|
+
let context = normText(row.textContent)
|
|
247
|
+
let el = row.parentElement
|
|
248
|
+
let depth = 0
|
|
249
|
+
while (el !== null && depth < 3) {
|
|
250
|
+
context += ` ${normText(el.textContent).slice(0, 400)}`
|
|
251
|
+
el = el.parentElement
|
|
252
|
+
depth += 1
|
|
253
|
+
}
|
|
254
|
+
const matched = entries.filter(
|
|
255
|
+
(entry) => typeof entry.providerName === 'string'
|
|
256
|
+
&& entry.providerName.length > 0
|
|
257
|
+
&& context.includes(normText(entry.providerName)),
|
|
258
|
+
)
|
|
259
|
+
if (matched.length === 1) return matched[0]
|
|
260
|
+
return entries[0]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 全文档找出「命中了已知模型名」的最小行容器。
|
|
265
|
+
* 不再依赖任何菜单根选择器:先用预编译正则粗筛文本节点,再逐一确认 token 命中,
|
|
266
|
+
* 然后向上取最近的可点击祖先。
|
|
267
|
+
*/
|
|
268
|
+
function minimalMatches(root) {
|
|
269
|
+
const index = ensureTokenIndex()
|
|
270
|
+
const found = new Map()
|
|
271
|
+
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 })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
node = walker.nextNode()
|
|
294
|
+
}
|
|
295
|
+
// 排除「容器型行」:行文本里出现两个以上不同模型 → 它装的是列表,不是单行。
|
|
296
|
+
for (const [row] of Array.from(found.entries())) {
|
|
297
|
+
if (countDistinctModels(row, index) > 1) found.delete(row)
|
|
298
|
+
}
|
|
299
|
+
return found
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 一个元素里出现了几个不同模型(>1 说明它是容器)。 */
|
|
303
|
+
function countDistinctModels(el, index) {
|
|
304
|
+
const text = normText(el.textContent)
|
|
305
|
+
const distinct = new Set()
|
|
306
|
+
for (const [token, entries] of index) {
|
|
307
|
+
if (!text.includes(token)) continue
|
|
308
|
+
for (const entry of entries) distinct.add(`${entry.providerId}::${entry.modelId}`)
|
|
309
|
+
if (distinct.size > 1) return distinct.size
|
|
310
|
+
}
|
|
311
|
+
return distinct.size
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 容器是否像弹层(绝对定位或带菜单语义),用于排除正文里碰巧出现的模型名。 */
|
|
315
|
+
function isPopoverish(el) {
|
|
316
|
+
let node = el
|
|
317
|
+
let depth = 0
|
|
318
|
+
while (node !== null && node !== document.body && depth < 4) {
|
|
319
|
+
const role = typeof node.getAttribute === 'function' ? node.getAttribute('role') : null
|
|
320
|
+
if (typeof role === 'string' && /^(menu|listbox|dialog|alertdialog)$/u.test(role)) return true
|
|
321
|
+
try {
|
|
322
|
+
const position = window.getComputedStyle(node).position
|
|
323
|
+
if (position === 'absolute' || position === 'fixed') return true
|
|
324
|
+
} catch { /* 忽略计算样式失败 */ }
|
|
325
|
+
node = node.parentElement
|
|
326
|
+
depth += 1
|
|
327
|
+
}
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 把命中的行按容器聚类:同一父节点下 ≥2 行才算一个菜单。 */
|
|
332
|
+
function clusterRows(found) {
|
|
333
|
+
const byParent = new Map()
|
|
334
|
+
for (const [row, hit] of found.entries()) {
|
|
335
|
+
const parent = row.parentElement ?? document.body
|
|
336
|
+
const list = byParent.get(parent) ?? []
|
|
337
|
+
list.push({ row, ...hit })
|
|
338
|
+
byParent.set(parent, list)
|
|
339
|
+
}
|
|
340
|
+
const clusters = []
|
|
341
|
+
const orphans = []
|
|
342
|
+
for (const [parent, list] of byParent.entries()) {
|
|
343
|
+
if (list.length >= 2) clusters.push({ host: parent, rows: list })
|
|
344
|
+
else orphans.push(...list)
|
|
345
|
+
}
|
|
346
|
+
// 行被分组标题/包装层隔开时,退一级按祖父容器聚合
|
|
347
|
+
if (orphans.length >= 2) {
|
|
348
|
+
const byGrand = new Map()
|
|
349
|
+
for (const hit of orphans) {
|
|
350
|
+
const grand = hit.row.parentElement?.parentElement ?? document.body
|
|
351
|
+
const list = byGrand.get(grand) ?? []
|
|
352
|
+
list.push(hit)
|
|
353
|
+
byGrand.set(grand, list)
|
|
354
|
+
}
|
|
355
|
+
for (const [grand, list] of byGrand.entries()) {
|
|
356
|
+
if (list.length >= 2) clusters.push({ host: grand, rows: list })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return clusters.filter((cluster) => isPopoverish(cluster.host))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 根容器是否可见(不可见的弹层跳过,避免误伤收起的菜单)。 */
|
|
363
|
+
function isVisible(el) {
|
|
364
|
+
if (!el.isConnected) return false
|
|
365
|
+
if (el.getClientRects().length > 0) return true
|
|
366
|
+
return el.offsetParent !== null
|
|
367
|
+
}
|
|
368
|
+
|
|
152
369
|
/* ---------------------------------------------------------------- */
|
|
153
370
|
/* 结果快照:加载 / 保存 / 回填 */
|
|
154
371
|
/* ---------------------------------------------------------------- */
|
|
@@ -300,13 +517,23 @@ window.__ModuleLoader__.load({
|
|
|
300
517
|
button.addEventListener('keydown', (event) => event.stopPropagation())
|
|
301
518
|
}
|
|
302
519
|
|
|
520
|
+
/** 行里作为「模型名」的锚点:通用路径记录文本节点父元素,兼容路径用名字元素。 */
|
|
521
|
+
function labelAnchor(row) {
|
|
522
|
+
const mapped = rowAnchor.get(row)
|
|
523
|
+
if (mapped !== undefined && mapped.isConnected) return mapped
|
|
524
|
+
return row.querySelector(NAME_SEL)
|
|
525
|
+
}
|
|
526
|
+
|
|
303
527
|
/** 把结果徽章插到模型名后面(随文字行内排,不占右侧按钮的位置)。 */
|
|
304
528
|
function paintChip(row, result) {
|
|
305
529
|
row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
|
|
306
530
|
const chip = chipOf(result)
|
|
307
|
-
const
|
|
308
|
-
if (
|
|
309
|
-
|
|
531
|
+
const anchor = labelAnchor(row)
|
|
532
|
+
if (anchor !== null && anchor !== undefined && anchor.parentNode !== null) {
|
|
533
|
+
anchor.parentNode.insertBefore(chip, anchor.nextSibling)
|
|
534
|
+
} else {
|
|
535
|
+
row.appendChild(chip)
|
|
536
|
+
}
|
|
310
537
|
return chip
|
|
311
538
|
}
|
|
312
539
|
|
|
@@ -368,8 +595,15 @@ window.__ModuleLoader__.load({
|
|
|
368
595
|
/* 「全部检测」条 */
|
|
369
596
|
/* ---------------------------------------------------------------- */
|
|
370
597
|
|
|
371
|
-
|
|
372
|
-
|
|
598
|
+
/**
|
|
599
|
+
* 在 host 里放一条「全部检测」。
|
|
600
|
+
* @param host 承载容器
|
|
601
|
+
* @param hint 初始状态文案
|
|
602
|
+
* @param before 插入到该子节点之前(省略则插到最前)
|
|
603
|
+
*/
|
|
604
|
+
function ensureAllBar(host, hint, before = null) {
|
|
605
|
+
if (host === null || host === undefined) return
|
|
606
|
+
const existing = host.querySelector('.dmd-allbar')
|
|
373
607
|
if (existing !== null) {
|
|
374
608
|
if (hint) {
|
|
375
609
|
const status = existing.querySelector('.dmd-status')
|
|
@@ -377,8 +611,6 @@ window.__ModuleLoader__.load({
|
|
|
377
611
|
}
|
|
378
612
|
return
|
|
379
613
|
}
|
|
380
|
-
const firstSection = pane.querySelector('section')
|
|
381
|
-
if (firstSection === null) return
|
|
382
614
|
const bar = document.createElement('div')
|
|
383
615
|
bar.className = 'dmd-allbar'
|
|
384
616
|
const button = document.createElement('button')
|
|
@@ -437,7 +669,8 @@ window.__ModuleLoader__.load({
|
|
|
437
669
|
bar.appendChild(status)
|
|
438
670
|
bar.appendChild(balance)
|
|
439
671
|
if (hint) status.textContent = hint
|
|
440
|
-
|
|
672
|
+
if (before !== null && before.parentNode === host) host.insertBefore(bar, before)
|
|
673
|
+
else host.insertBefore(bar, host.firstChild)
|
|
441
674
|
}
|
|
442
675
|
|
|
443
676
|
function providerLabel(providerId) {
|
|
@@ -446,25 +679,40 @@ window.__ModuleLoader__.load({
|
|
|
446
679
|
}
|
|
447
680
|
|
|
448
681
|
/* ---------------------------------------------------------------- */
|
|
449
|
-
/*
|
|
682
|
+
/* 诊断上报(写进宿主文件,便于远程定位 DOM 结构,不影响功能) */
|
|
450
683
|
/* ---------------------------------------------------------------- */
|
|
451
684
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const menu = document.querySelector(MENU_SEL)
|
|
455
|
-
if (menu === null) return
|
|
685
|
+
let reportSignature = ''
|
|
686
|
+
let reportAt = 0
|
|
456
687
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
688
|
+
function describe(el) {
|
|
689
|
+
if (el === null || el === undefined) return null
|
|
690
|
+
const cls = typeof el.className === 'string' ? el.className.trim().slice(0, 80) : ''
|
|
691
|
+
const text = normText(el.textContent).slice(0, 50)
|
|
692
|
+
return { tag: el.tagName, role: el.getAttribute('role') ?? '', cls, text }
|
|
693
|
+
}
|
|
460
694
|
|
|
461
|
-
|
|
462
|
-
|
|
695
|
+
function reportDiagnostics(state) {
|
|
696
|
+
if (doctor === null) return
|
|
697
|
+
const signature = JSON.stringify(state)
|
|
698
|
+
if (signature === reportSignature) return
|
|
699
|
+
if (Date.now() - reportAt < 1200) return
|
|
700
|
+
reportSignature = signature
|
|
701
|
+
reportAt = Date.now()
|
|
702
|
+
void doctor.clientReport(state)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* ---------------------------------------------------------------- */
|
|
706
|
+
/* 菜单增强主逻辑 */
|
|
707
|
+
/* ---------------------------------------------------------------- */
|
|
708
|
+
|
|
709
|
+
/** 兼容路径:dsh-reasoning-effort 的 `.re-model-menu` 结构。 */
|
|
710
|
+
function enhanceLegacy(menu) {
|
|
711
|
+
let wired = 0
|
|
463
712
|
const panes = menu.querySelectorAll(PANE_SEL)
|
|
464
713
|
panes.forEach((pane) => {
|
|
465
714
|
const sections = pane.querySelectorAll('section')
|
|
466
715
|
if (sections.length === 0) return
|
|
467
|
-
let wired = 0
|
|
468
716
|
sections.forEach((section) => {
|
|
469
717
|
const title = section.querySelector(TITLE_SEL)?.textContent?.trim()
|
|
470
718
|
if (title === undefined || title.length === 0) return
|
|
@@ -475,6 +723,7 @@ window.__ModuleLoader__.load({
|
|
|
475
723
|
const model = group.models[index]
|
|
476
724
|
const name = row.querySelector(NAME_SEL)?.textContent?.trim()
|
|
477
725
|
if (name !== undefined && name !== model.name) return
|
|
726
|
+
rowAnchor.set(row, row.querySelector(NAME_SEL) ?? row)
|
|
478
727
|
enhanceRow(row, group.id, model)
|
|
479
728
|
wired += 1
|
|
480
729
|
})
|
|
@@ -491,6 +740,7 @@ window.__ModuleLoader__.load({
|
|
|
491
740
|
}
|
|
492
741
|
}
|
|
493
742
|
if (hits.length !== 1) return
|
|
743
|
+
rowAnchor.set(row, row.querySelector(NAME_SEL) ?? row)
|
|
494
744
|
enhanceRow(row, hits[0].provider.id, hits[0].model)
|
|
495
745
|
wired += 1
|
|
496
746
|
})
|
|
@@ -503,9 +753,100 @@ window.__ModuleLoader__.load({
|
|
|
503
753
|
} else if (wired === 0) {
|
|
504
754
|
hint = '模型医生:未匹配到模型行(目录与菜单不一致)'
|
|
505
755
|
}
|
|
506
|
-
ensureAllBar(pane, hint)
|
|
507
|
-
if (wired > 0) paintLastRun('上次结果:')
|
|
756
|
+
ensureAllBar(pane, hint, pane.querySelector('section'))
|
|
508
757
|
})
|
|
758
|
+
return wired
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* 通用路径:任何菜单渲染器(官方原生 / dsh-effort-slider / 其他弹层)。
|
|
763
|
+
* 依据「已知模型名」在全文定位行,按容器聚类成菜单,再逐行增强。
|
|
764
|
+
*/
|
|
765
|
+
function enhanceGeneric(legacyWired) {
|
|
766
|
+
const index = ensureTokenIndex()
|
|
767
|
+
const roots = Array.from(document.querySelectorAll(MENU_ROOT_SEL)).filter((root) => isVisible(root))
|
|
768
|
+
const hits = minimalMatches(document.body)
|
|
769
|
+
const clusters = clusterRows(hits)
|
|
770
|
+
let wired = 0
|
|
771
|
+
let registered = 0
|
|
772
|
+
let unmatched = 0
|
|
773
|
+
const clusterStats = []
|
|
774
|
+
for (const cluster of clusters) {
|
|
775
|
+
let newlyWired = 0
|
|
776
|
+
for (const hit of cluster.rows) {
|
|
777
|
+
const entry = pickEntry(hit.entries, hit.row)
|
|
778
|
+
if (entry === undefined) {
|
|
779
|
+
unmatched += 1
|
|
780
|
+
continue
|
|
781
|
+
}
|
|
782
|
+
const isNew = hit.row.querySelector('.dmd-actions') === null
|
|
783
|
+
rowAnchor.set(hit.row, hit.anchor)
|
|
784
|
+
enhanceRow(hit.row, entry.providerId, { id: entry.modelId, name: entry.modelName })
|
|
785
|
+
registered += 1
|
|
786
|
+
if (isNew) newlyWired += 1
|
|
787
|
+
}
|
|
788
|
+
clusterStats.push({
|
|
789
|
+
host: describe(cluster.host),
|
|
790
|
+
rows: cluster.rows.length,
|
|
791
|
+
wired: newlyWired,
|
|
792
|
+
})
|
|
793
|
+
if (cluster.rows.length >= MIN_GENERIC_ROWS) {
|
|
794
|
+
const first = cluster.rows[0]
|
|
795
|
+
let hint = ''
|
|
796
|
+
if (directory === null) {
|
|
797
|
+
hint = directoryFailedAt !== 0
|
|
798
|
+
? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
|
|
799
|
+
: '模型医生:目录获取中…'
|
|
800
|
+
} else if (newlyWired === 0 && first.row.querySelector('.dmd-actions') === null) {
|
|
801
|
+
hint = '模型医生:模型行未匹配上目录'
|
|
802
|
+
}
|
|
803
|
+
const barHost = first.row.parentElement ?? cluster.host
|
|
804
|
+
ensureAllBar(barHost, hint, first.row)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
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,
|
|
815
|
+
legacyWired,
|
|
816
|
+
genericWired: wired,
|
|
817
|
+
registered,
|
|
818
|
+
unmatched,
|
|
819
|
+
hitCount: hits.size,
|
|
820
|
+
visibleRoots: roots.length,
|
|
821
|
+
roots: clusterStats.slice(0, 8),
|
|
822
|
+
samples: roots.slice(0, 5).map(describe),
|
|
823
|
+
barCount: document.querySelectorAll('.dmd-allbar').length,
|
|
824
|
+
actionCount: document.querySelectorAll('.dmd-actions').length,
|
|
825
|
+
}
|
|
826
|
+
return registered > 0 ? Math.max(1, wired) : 0
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function enhance() {
|
|
830
|
+
if (disposed) return
|
|
831
|
+
if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
|
|
832
|
+
void fetchDirectory()
|
|
833
|
+
}
|
|
834
|
+
rowByKey.clear()
|
|
835
|
+
rowByModel.clear()
|
|
836
|
+
rowAnchor = new WeakMap()
|
|
837
|
+
|
|
838
|
+
const menu = document.querySelector(MENU_SEL)
|
|
839
|
+
const legacyWired = menu === null ? 0 : enhanceLegacy(menu)
|
|
840
|
+
const genericWired = enhanceGeneric(legacyWired)
|
|
841
|
+
|
|
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
|
+
}
|
|
509
850
|
}
|
|
510
851
|
|
|
511
852
|
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.1",
|
|
4
|
+
"description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——不依赖任何菜单渲染插件(官方原生菜单即可),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
7
7
|
"deepseek-harness",
|