dsh-model-doctor 0.5.2 → 0.5.4

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 CHANGED
@@ -76,6 +76,14 @@ DSH 桌面客户端插件:在对话输入区的**模型选择菜单**里,为
76
76
 
77
77
  ## 版本
78
78
 
79
+ - v0.5.3 — **修复滚动时菜单跳位**:官方菜单是 `width:max-content` + 底部锚定,而 `place()`
80
+ 只在滚动/缩放时才重算;改为让新增元素尽量不改变菜单尺寸(徽章作为同一行的弹性项、
81
+ 状态条限宽),并在改动后立刻派发一次 `scroll` 让官方重新锚定
82
+ - v0.5.2 — 修复点「全部检测」会关掉菜单(`disabled` 导致焦点落到 body,触发官方 `onBlur` 关闭)
83
+ - v0.5.1 — 菜单打开时的诊断现场不再被关闭后的空扫描覆盖
84
+ - v0.5.0 — **精确适配官方模型菜单**:按官方 DOM 契约(`role=menuitemradio` + `modelOption`)
85
+ 定位行,用行上的 `title` 取模型名;新增折叠名匹配
86
+ - v0.4.1 — 全文档聚类检测(正则粗筛 + 弹层特征过滤 + 祖父级聚类兜底)
79
87
  - v0.4.0 — **解耦菜单渲染插件**:新增「已知模型名 + 可点击行」通用定位路径,官方原生菜单可用;
80
88
  不再依赖也不冲突于 dsh-reasoning-effort / dsh-effort-slider;新增客户端 DOM 自检上报
81
89
  - v0.3.0 — 检测结果持久化(跨菜单开关与重启保留,只留最近一次)
@@ -67,20 +67,23 @@ window.__ModuleLoader__.load({
67
67
  }
68
68
 
69
69
  const CSS = `
70
- .dmd-actions{position:absolute;right:8px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;gap:5px;z-index:2}
71
- .dmd-btn{width:27px;height:27px;line-height:26px;text-align:center;font-size:16px;border-radius:8px;cursor:pointer;user-select:none;color:inherit;background:color-mix(in srgb,currentColor 8%,transparent);opacity:.72;flex-shrink:0}
70
+ .dmd-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;gap:4px;z-index:2}
71
+ .dmd-btn{width:24px;height:24px;line-height:23px;text-align:center;font-size:15px;border-radius:8px;cursor:pointer;user-select:none;color:inherit;background:color-mix(in srgb,currentColor 8%,transparent);opacity:.72;flex-shrink:0}
72
72
  .dmd-btn:hover{opacity:1;background:color-mix(in srgb,currentColor 18%,transparent)}
73
73
  .dmd-btn.dmd-refresh.is-busy{opacity:1;animation:dmd-spin 0.9s linear infinite}
74
74
  .dmd-btn.dmd-delete:hover{color:#e5484d}
75
75
  .dmd-btn.is-armed{width:auto;padding:0 9px;color:#e5484d;opacity:1;font-size:12px;font-weight:600;line-height:26px}
76
76
  @keyframes dmd-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
77
- .dmd-chip{font-size:11px;line-height:17px;padding:0 7px;margin-left:7px;border-radius:999px;white-space:nowrap;vertical-align:middle;display:inline-block}
77
+ .dmd-chip{font-size:11px;line-height:17px;padding:0 7px;margin-left:2px;border-radius:999px;white-space:nowrap;vertical-align:middle;display:inline-block;flex:none;box-sizing:border-box;width:84px;text-align:center;overflow:hidden;text-overflow:ellipsis}
78
+ .dmd-chip.is-stale{opacity:.45}
78
79
  .dmd-chip.is-ok{color:#2fa96e;background:color-mix(in srgb,#2fa96e 16%,transparent)}
79
80
  .dmd-chip.is-bad{color:#e5484d;background:color-mix(in srgb,#e5484d 14%,transparent)}
80
81
  .dmd-chip.is-warn{color:#d08700;background:color-mix(in srgb,#d08700 16%,transparent)}
81
- .dmd-allbar{display:flex;align-items:center;gap:8px;padding:6px 12px;margin:2px 6px;font-size:11px;border-radius:8px;background:color-mix(in srgb,currentColor 6%,transparent)}
82
- .dmd-allbar button{font:inherit;font-size:11px;line-height:16px;padding:2px 9px;border-radius:6px;border:1px solid color-mix(in srgb,currentColor 25%,transparent);background:transparent;color:inherit;cursor:pointer}
82
+ /* max-width 是关键:官方菜单是 width:max-content,状态条文字不能把菜单撑宽 */
83
+ .dmd-allbar{display:flex;align-items:center;flex-wrap:wrap;gap:4px 8px;padding:6px 10px;margin:2px 4px;font-size:11px;border-radius:8px;background:color-mix(in srgb,currentColor 6%,transparent);max-width:208px}
84
+ .dmd-allbar button{font:inherit;font-size:11px;line-height:16px;padding:2px 9px;border-radius:6px;border:1px solid color-mix(in srgb,currentColor 25%,transparent);background:transparent;color:inherit;cursor:pointer;flex:none}
83
85
  .dmd-allbar button:disabled,.dmd-allbar button.is-busy{opacity:.45;cursor:default}
86
+ .dmd-allbar .dmd-status,.dmd-allbar .dmd-balance{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
84
87
  .dmd-allbar .dmd-status{opacity:.65}
85
88
  .dmd-allbar .dmd-balance{color:#2fa96e}
86
89
  `
@@ -150,6 +153,11 @@ window.__ModuleLoader__.load({
150
153
  let lastGenericStats = null
151
154
  /** 官方路径统计。 */
152
155
  let lastOfficialStats = null
156
+ /** 本轮扫描是否改动过菜单 DOM(用于决定要不要让官方菜单立刻重新锚定)。 */
157
+ let mutatedThisPass = false
158
+ let reanchorAt = 0
159
+ /** 上一轮看到的官方菜单尺寸(宽x高):尺寸一变就立刻让官方重新锚定。 */
160
+ let lastMenuSize = ''
153
161
 
154
162
  /** 本页会话内已删除的模型键,防目录未刷新时行又冒出来。 */
155
163
  const deletedKeys = new Set()
@@ -564,6 +572,7 @@ window.__ModuleLoader__.load({
564
572
  // 所以先把焦点转移到相邻的模型行再移除。
565
573
  const hadFocus = row === document.activeElement
566
574
  const neighbour = row.nextElementSibling ?? row.previousElementSibling
575
+ mutatedThisPass = true
567
576
  row.remove()
568
577
  if (section !== null && section.querySelectorAll(ROW_SEL).length === 0) section.remove()
569
578
  if (hadFocus) {
@@ -599,6 +608,15 @@ window.__ModuleLoader__.load({
599
608
  function paintChip(row, result) {
600
609
  row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
601
610
  const chip = chipOf(result)
611
+ mutatedThisPass = true
612
+ // 官方行是 flex 行(align-items:center / nowrap)。徽章必须作为同一行的弹性项插在
613
+ // 按钮之前;插进 .optionCopy(纵向 flex)会多出一行、把行高和菜单尺寸一起改掉,
614
+ // 那样官方 place() 下次滚动才会重算位置 —— 表现就是「一滚动菜单就跳位」。
615
+ const actions = row.querySelector(':scope > .dmd-actions')
616
+ if (actions !== null) {
617
+ row.insertBefore(chip, actions)
618
+ return chip
619
+ }
602
620
  const anchor = labelAnchor(row)
603
621
  if (anchor !== null && anchor !== undefined && anchor.parentNode !== null) {
604
622
  anchor.parentNode.insertBefore(chip, anchor.nextSibling)
@@ -624,7 +642,7 @@ window.__ModuleLoader__.load({
624
642
  // 按钮钉在选项右侧:行本身需要是定位上下文,并留出右侧空间。
625
643
  const computed = window.getComputedStyle(row)
626
644
  if (computed.position === 'static') row.style.position = 'relative'
627
- if (Number.parseFloat(computed.paddingRight) < 88) row.style.paddingRight = '88px'
645
+ if (Number.parseFloat(computed.paddingRight) < 62) row.style.paddingRight = '62px'
628
646
 
629
647
  const actions = document.createElement('span')
630
648
  actions.className = 'dmd-actions'
@@ -660,6 +678,7 @@ window.__ModuleLoader__.load({
660
678
  actions.appendChild(refresh)
661
679
  actions.appendChild(del)
662
680
  row.appendChild(actions)
681
+ mutatedThisPass = true
663
682
  }
664
683
 
665
684
  /* ---------------------------------------------------------------- */
@@ -684,6 +703,7 @@ window.__ModuleLoader__.load({
684
703
  }
685
704
  const bar = document.createElement('div')
686
705
  bar.className = 'dmd-allbar'
706
+ mutatedThisPass = true
687
707
  const button = document.createElement('button')
688
708
  button.type = 'button'
689
709
  button.textContent = '⚡ 全部检测'
@@ -710,8 +730,10 @@ window.__ModuleLoader__.load({
710
730
  barBusy = true
711
731
  status.textContent = '探测中…'
712
732
  balance.textContent = ''
733
+ // 只把旧结果「变灰」,绝不删除:删徽章会让行变窄、菜单跟着变窄,
734
+ // 而官方 place() 要等下次滚动才重算 —— 表现就是检测期间一滚动菜单就右移。
713
735
  rowByKey.forEach((row) => {
714
- row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
736
+ row.querySelectorAll('.dmd-chip').forEach((chip) => chip.classList.add('is-stale'))
715
737
  })
716
738
  doctor.checkAll().then((result) => {
717
739
  if (disposed) return
@@ -743,6 +765,8 @@ window.__ModuleLoader__.load({
743
765
  .map(([providerId, value]) => `${providerLabel(providerId)}: ¥${value}`)
744
766
  balance.textContent = parts.join(' · ')
745
767
  }
768
+ // 状态条文案变长也会改菜单尺寸:补一次重锚定
769
+ reanchorOfficialMenu(true)
746
770
  })
747
771
  })
748
772
  // preventDefault 阻止焦点转移到本按钮:焦点一旦离开菜单,官方 onBlur 就会关闭菜单
@@ -1010,6 +1034,43 @@ window.__ModuleLoader__.load({
1010
1034
  }
1011
1035
  }
1012
1036
 
1037
+ /**
1038
+ * 让官方菜单立刻按当前尺寸重新锚定。
1039
+ *
1040
+ * 官方 place() 只在 window 的 scroll(捕获)与 resize 时重算,而它把菜单放在
1041
+ * 「触发按钮上方、底部对齐」的位置(y = trigger.top - 8 - 菜单高,x = trigger.right - 菜单宽)。
1042
+ * 我们的按钮/徽章/状态条会让菜单变宽,如果不在改动后立刻重算,位置就要等用户
1043
+ * 下次滚动才更新 —— 表现就是「一往下滑菜单就跳位」。
1044
+ * 这里向菜单派发一个 scroll 事件:它不冒泡,所以只会在捕获阶段命中官方监听,
1045
+ * 不会惊动其它监听 window 滚动(冒泡阶段)的组件。
1046
+ */
1047
+ function reanchorOfficialMenu(force = false) {
1048
+ const menu = document.querySelector(`[class*="${OFFICIAL_MENU_SUFFIX}"]`)
1049
+ if (menu === null) return
1050
+ const now = Date.now()
1051
+ if (!force && now - reanchorAt < 200) return
1052
+ reanchorAt = now
1053
+ try {
1054
+ // 官方 place() 同时监听 window 的 scroll(捕获阶段)与 resize:
1055
+ // 前者精准(不冒泡,不会惊动其它滚动监听),后者作为保底。
1056
+ menu.dispatchEvent(new Event('scroll'))
1057
+ window.dispatchEvent(new Event('resize'))
1058
+ } catch { /* 忽略派发失败 */ }
1059
+ }
1060
+
1061
+ /** 菜单尺寸(宽x高)是否与上一轮不同;不同就说明位置需要立刻重算。 */
1062
+ function officialMenuSizeChanged() {
1063
+ const menu = document.querySelector(`[class*="${OFFICIAL_MENU_SUFFIX}"]`)
1064
+ if (menu === null) {
1065
+ lastMenuSize = ''
1066
+ return false
1067
+ }
1068
+ const size = `${menu.offsetWidth}x${menu.offsetHeight}`
1069
+ if (size === lastMenuSize) return false
1070
+ lastMenuSize = size
1071
+ return true
1072
+ }
1073
+
1013
1074
  function enhanceInner() {
1014
1075
  if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
1015
1076
  void fetchDirectory()
@@ -1017,6 +1078,7 @@ window.__ModuleLoader__.load({
1017
1078
  rowByKey.clear()
1018
1079
  rowByModel.clear()
1019
1080
  rowAnchor = new WeakMap()
1081
+ mutatedThisPass = false
1020
1082
 
1021
1083
  // ① 官方路径(首选,按官方 DOM 契约精确取行)
1022
1084
  const official = enhanceOfficial()
@@ -1030,6 +1092,12 @@ window.__ModuleLoader__.load({
1030
1092
 
1031
1093
  const wired = official.wired + legacyWired + genericWired
1032
1094
  if (wired > 0) paintLastRun('上次结果:')
1095
+ if (official.menuFound) {
1096
+ // 兜底:无论尺寸变化是谁造成的(我们加/删元素、官方自身重渲染、状态条文案变长),
1097
+ // 只要实测尺寸变了,就立刻让官方重新锚定,绝不把这个问题留给用户的下一次滚动。
1098
+ const sizeChanged = officialMenuSizeChanged()
1099
+ if (mutatedThisPass || sizeChanged) reanchorOfficialMenu(sizeChanged)
1100
+ }
1033
1101
 
1034
1102
  const menuVisible = official.menuFound || menu !== null
1035
1103
  reportDiagnostics({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-doctor",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——直接适配 DSH 官方模型菜单(按官方 DOM 契约精确定位,不依赖任何菜单渲染插件),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
5
5
  "keywords": [
6
6
  "deepseek",