dsh-model-doctor 0.4.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.
Files changed (2) hide show
  1. package/lib/client/index.js +119 -39
  2. package/package.json +1 -1
@@ -181,6 +181,10 @@ window.__ModuleLoader__.load({
181
181
  /** 模型名/ID -> 候选条目索引;15 秒内复用。 */
182
182
  let tokenIndex = null
183
183
  let tokenIndexAt = 0
184
+ /** 预编译的 token 正则(先粗筛文本节点,避免对每个节点做全量比对)。 */
185
+ let tokenRegex = null
186
+ /** [token, entries] 按 token 长度降序:优先匹配更具体的名字。 */
187
+ let tokenPairs = []
184
188
 
185
189
  function ensureTokenIndex() {
186
190
  if (tokenIndex !== null && Date.now() - tokenIndexAt < 15000) return tokenIndex
@@ -203,6 +207,10 @@ window.__ModuleLoader__.load({
203
207
  }
204
208
  tokenIndex = index
205
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')
206
214
  return index
207
215
  }
208
216
 
@@ -253,24 +261,25 @@ window.__ModuleLoader__.load({
253
261
  }
254
262
 
255
263
  /**
256
- * 在一个候选根容器里找出模型行:对每个文本节点做已知模型名命中,
257
- * 向上取最近的可点击祖先;若某「行」文本里出现过两个以上不同模型,则它是容器,排除。
264
+ * 全文档找出「命中了已知模型名」的最小行容器。
265
+ * 不再依赖任何菜单根选择器:先用预编译正则粗筛文本节点,再逐一确认 token 命中,
266
+ * 然后向上取最近的可点击祖先。
258
267
  */
259
- function genericRowsIn(root) {
268
+ function minimalMatches(root) {
260
269
  const index = ensureTokenIndex()
261
- if (index.size === 0) return []
262
270
  const found = new Map()
271
+ if (index.size === 0 || tokenRegex === null) return found
263
272
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
264
273
  let node = walker.nextNode()
265
274
  while (node !== null) {
266
275
  const raw = node.nodeValue
267
- if (raw !== null && raw.length > 0 && raw.length <= 200) {
276
+ if (raw !== null && raw.length > 1 && raw.length <= 200) {
268
277
  const text = normText(raw)
269
- if (text.length >= 2) {
278
+ if (text.length >= 2 && tokenRegex.test(text)) {
270
279
  const anchor = node.parentElement
271
280
  if (anchor !== null) {
272
- for (const [token, entries] of index) {
273
- if (!text.includes(token)) continue
281
+ for (const [token, entries] of tokenPairs) {
282
+ if (token.length > text.length || !text.includes(token)) continue
274
283
  const row = clickableRow(anchor, root)
275
284
  if (row === null) continue
276
285
  const current = found.get(row)
@@ -285,16 +294,69 @@ window.__ModuleLoader__.load({
285
294
  }
286
295
  // 排除「容器型行」:行文本里出现两个以上不同模型 → 它装的是列表,不是单行。
287
296
  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
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 })
294
357
  }
295
- if (distinct.size > 1) found.delete(row)
296
358
  }
297
- return Array.from(found.entries()).map(([row, hit]) => ({ row, ...hit }))
359
+ return clusters.filter((cluster) => isPopoverish(cluster.host))
298
360
  }
299
361
 
300
362
  /** 根容器是否可见(不可见的弹层跳过,避免误伤收起的菜单)。 */
@@ -698,52 +760,70 @@ window.__ModuleLoader__.load({
698
760
 
699
761
  /**
700
762
  * 通用路径:任何菜单渲染器(官方原生 / dsh-effort-slider / 其他弹层)。
701
- * 依据「已知模型名」定位行,向上取可点击祖先。
763
+ * 依据「已知模型名」在全文定位行,按容器聚类成菜单,再逐行增强。
702
764
  */
703
765
  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)
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)
709
770
  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) {
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) {
717
777
  const entry = pickEntry(hit.entries, hit.row)
718
- if (entry === undefined) continue
778
+ if (entry === undefined) {
779
+ unmatched += 1
780
+ continue
781
+ }
782
+ const isNew = hit.row.querySelector('.dmd-actions') === null
719
783
  rowAnchor.set(hit.row, hit.anchor)
720
784
  enhanceRow(hit.row, entry.providerId, { id: entry.modelId, name: entry.modelName })
721
- containerWired += 1
785
+ registered += 1
786
+ if (isNew) newlyWired += 1
722
787
  }
723
- if (containerWired > 0) {
724
- wired += containerWired
725
- const barHost = hits[0].row.parentElement ?? root
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]
726
795
  let hint = ''
727
796
  if (directory === null) {
728
797
  hint = directoryFailedAt !== 0
729
798
  ? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
730
799
  : '模型医生:目录获取中…'
800
+ } else if (newlyWired === 0 && first.row.querySelector('.dmd-actions') === null) {
801
+ hint = '模型医生:模型行未匹配上目录'
731
802
  }
732
- ensureAllBar(barHost, hint, hits[0].row)
803
+ const barHost = first.row.parentElement ?? cluster.host
804
+ ensureAllBar(barHost, hint, first.row)
733
805
  }
734
806
  }
735
807
  lastDiagnostics = {
736
808
  at: Date.now(),
737
809
  mode: legacyWired > 0 ? 'legacy+generic' : 'generic',
738
- tokens: ensureTokenIndex().size,
810
+ href: window.location.href.slice(0, 120),
811
+ tokens: index.size,
739
812
  providerCount: directory?.providers?.length ?? 0,
740
813
  modelCount: (directory?.providers ?? []).reduce((sum, p) => sum + p.models.length, 0),
814
+ directoryError,
741
815
  legacyWired,
742
816
  genericWired: wired,
743
- roots: rootStats.slice(0, 8),
744
- samples,
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,
745
825
  }
746
- return wired
826
+ return registered > 0 ? Math.max(1, wired) : 0
747
827
  }
748
828
 
749
829
  function enhance() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-doctor",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——不依赖任何菜单渲染插件(官方原生菜单即可),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
5
5
  "keywords": [
6
6
  "deepseek",