dsh-model-doctor 0.5.1 → 0.5.3

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,22 @@ 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;max-width:110px;overflow:hidden;text-overflow:ellipsis}
78
78
  .dmd-chip.is-ok{color:#2fa96e;background:color-mix(in srgb,#2fa96e 16%,transparent)}
79
79
  .dmd-chip.is-bad{color:#e5484d;background:color-mix(in srgb,#e5484d 14%,transparent)}
80
80
  .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}
83
- .dmd-allbar button:disabled{opacity:.45;cursor:default}
81
+ /* max-width 是关键:官方菜单是 width:max-content,状态条文字不能把菜单撑宽 */
82
+ .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}
83
+ .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}
84
+ .dmd-allbar button:disabled,.dmd-allbar button.is-busy{opacity:.45;cursor:default}
85
+ .dmd-allbar .dmd-status,.dmd-allbar .dmd-balance{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
84
86
  .dmd-allbar .dmd-status{opacity:.65}
85
87
  .dmd-allbar .dmd-balance{color:#2fa96e}
86
88
  `
@@ -150,6 +152,9 @@ window.__ModuleLoader__.load({
150
152
  let lastGenericStats = null
151
153
  /** 官方路径统计。 */
152
154
  let lastOfficialStats = null
155
+ /** 本轮扫描是否改动过菜单 DOM(用于决定要不要让官方菜单立刻重新锚定)。 */
156
+ let mutatedThisPass = false
157
+ let reanchorAt = 0
153
158
 
154
159
  /** 本页会话内已删除的模型键,防目录未刷新时行又冒出来。 */
155
160
  const deletedKeys = new Set()
@@ -560,8 +565,18 @@ window.__ModuleLoader__.load({
560
565
  if (result && result.ok === true) {
561
566
  deletedKeys.add(keyOf(provider, model))
562
567
  const section = row.closest('section')
568
+ // 焦点若在被删行上,移除后焦点会落到 body → 官方 onBlur 会关闭菜单,
569
+ // 所以先把焦点转移到相邻的模型行再移除。
570
+ const hadFocus = row === document.activeElement
571
+ const neighbour = row.nextElementSibling ?? row.previousElementSibling
572
+ mutatedThisPass = true
563
573
  row.remove()
564
574
  if (section !== null && section.querySelectorAll(ROW_SEL).length === 0) section.remove()
575
+ if (hadFocus) {
576
+ const target = (neighbour !== null && neighbour.isConnected ? neighbour : null)
577
+ ?? document.querySelector('[role="menuitemradio"]')
578
+ if (target !== null && typeof target.focus === 'function') target.focus()
579
+ }
565
580
  fetchDirectory(true)
566
581
  } else {
567
582
  const message = result && typeof result.error === 'string' ? result.error : '删除失败'
@@ -590,6 +605,15 @@ window.__ModuleLoader__.load({
590
605
  function paintChip(row, result) {
591
606
  row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
592
607
  const chip = chipOf(result)
608
+ mutatedThisPass = true
609
+ // 官方行是 flex 行(align-items:center / nowrap)。徽章必须作为同一行的弹性项插在
610
+ // 按钮之前;插进 .optionCopy(纵向 flex)会多出一行、把行高和菜单尺寸一起改掉,
611
+ // 那样官方 place() 下次滚动才会重算位置 —— 表现就是「一滚动菜单就跳位」。
612
+ const actions = row.querySelector(':scope > .dmd-actions')
613
+ if (actions !== null) {
614
+ row.insertBefore(chip, actions)
615
+ return chip
616
+ }
593
617
  const anchor = labelAnchor(row)
594
618
  if (anchor !== null && anchor !== undefined && anchor.parentNode !== null) {
595
619
  anchor.parentNode.insertBefore(chip, anchor.nextSibling)
@@ -615,7 +639,7 @@ window.__ModuleLoader__.load({
615
639
  // 按钮钉在选项右侧:行本身需要是定位上下文,并留出右侧空间。
616
640
  const computed = window.getComputedStyle(row)
617
641
  if (computed.position === 'static') row.style.position = 'relative'
618
- if (Number.parseFloat(computed.paddingRight) < 88) row.style.paddingRight = '88px'
642
+ if (Number.parseFloat(computed.paddingRight) < 62) row.style.paddingRight = '62px'
619
643
 
620
644
  const actions = document.createElement('span')
621
645
  actions.className = 'dmd-actions'
@@ -651,6 +675,7 @@ window.__ModuleLoader__.load({
651
675
  actions.appendChild(refresh)
652
676
  actions.appendChild(del)
653
677
  row.appendChild(actions)
678
+ mutatedThisPass = true
654
679
  }
655
680
 
656
681
  /* ---------------------------------------------------------------- */
@@ -675,6 +700,7 @@ window.__ModuleLoader__.load({
675
700
  }
676
701
  const bar = document.createElement('div')
677
702
  bar.className = 'dmd-allbar'
703
+ mutatedThisPass = true
678
704
  const button = document.createElement('button')
679
705
  button.type = 'button'
680
706
  button.textContent = '⚡ 全部检测'
@@ -683,11 +709,21 @@ window.__ModuleLoader__.load({
683
709
  status.className = 'dmd-status'
684
710
  const balance = document.createElement('span')
685
711
  balance.className = 'dmd-balance'
712
+ // 忙碌用标记而不是 disabled:被禁用的聚焦元素会把焦点丢给 body,
713
+ // 而官方菜单在根节点上监听 onBlur(portal 的事件仍走 React 树冒泡),
714
+ // 焦点一离开菜单就会 setOpen(false) —— 表现就是「点全部检测菜单被关掉」。
715
+ let busy = false
716
+ const setBusy = (value) => {
717
+ busy = value
718
+ button.classList.toggle('is-busy', value)
719
+ button.setAttribute('aria-busy', value ? 'true' : 'false')
720
+ button.style.pointerEvents = value ? 'none' : ''
721
+ }
686
722
  button.addEventListener('click', (event) => {
687
723
  event.stopPropagation()
688
724
  event.preventDefault()
689
- if (doctor === null || button.disabled) return
690
- button.disabled = true
725
+ if (doctor === null || busy) return
726
+ setBusy(true)
691
727
  barBusy = true
692
728
  status.textContent = '探测中…'
693
729
  balance.textContent = ''
@@ -696,7 +732,7 @@ window.__ModuleLoader__.load({
696
732
  })
697
733
  doctor.checkAll().then((result) => {
698
734
  if (disposed) return
699
- button.disabled = false
735
+ setBusy(false)
700
736
  barBusy = false
701
737
  if (!result || result.ok === false || !isRecord(result.results)) {
702
738
  status.textContent = `失败:${result && typeof result.error === 'string' ? result.error : '通道异常'}`
@@ -726,7 +762,12 @@ window.__ModuleLoader__.load({
726
762
  }
727
763
  })
728
764
  })
729
- button.addEventListener('mousedown', (event) => event.stopPropagation())
765
+ // preventDefault 阻止焦点转移到本按钮:焦点一旦离开菜单,官方 onBlur 就会关闭菜单
766
+ button.addEventListener('mousedown', (event) => {
767
+ event.preventDefault()
768
+ event.stopPropagation()
769
+ })
770
+ button.addEventListener('pointerdown', (event) => event.stopPropagation())
730
771
  bar.appendChild(button)
731
772
  bar.appendChild(status)
732
773
  bar.appendChild(balance)
@@ -986,6 +1027,27 @@ window.__ModuleLoader__.load({
986
1027
  }
987
1028
  }
988
1029
 
1030
+ /**
1031
+ * 让官方菜单立刻按当前尺寸重新锚定。
1032
+ *
1033
+ * 官方 place() 只在 window 的 scroll(捕获)与 resize 时重算,而它把菜单放在
1034
+ * 「触发按钮上方、底部对齐」的位置(y = trigger.top - 8 - 菜单高,x = trigger.right - 菜单宽)。
1035
+ * 我们的按钮/徽章/状态条会让菜单变宽,如果不在改动后立刻重算,位置就要等用户
1036
+ * 下次滚动才更新 —— 表现就是「一往下滑菜单就跳位」。
1037
+ * 这里向菜单派发一个 scroll 事件:它不冒泡,所以只会在捕获阶段命中官方监听,
1038
+ * 不会惊动其它监听 window 滚动(冒泡阶段)的组件。
1039
+ */
1040
+ function reanchorOfficialMenu() {
1041
+ const menu = document.querySelector(`[class*="${OFFICIAL_MENU_SUFFIX}"]`)
1042
+ if (menu === null) return
1043
+ const now = Date.now()
1044
+ if (now - reanchorAt < 200) return
1045
+ reanchorAt = now
1046
+ try {
1047
+ menu.dispatchEvent(new Event('scroll'))
1048
+ } catch { /* 忽略派发失败 */ }
1049
+ }
1050
+
989
1051
  function enhanceInner() {
990
1052
  if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
991
1053
  void fetchDirectory()
@@ -993,6 +1055,7 @@ window.__ModuleLoader__.load({
993
1055
  rowByKey.clear()
994
1056
  rowByModel.clear()
995
1057
  rowAnchor = new WeakMap()
1058
+ mutatedThisPass = false
996
1059
 
997
1060
  // ① 官方路径(首选,按官方 DOM 契约精确取行)
998
1061
  const official = enhanceOfficial()
@@ -1006,6 +1069,7 @@ window.__ModuleLoader__.load({
1006
1069
 
1007
1070
  const wired = official.wired + legacyWired + genericWired
1008
1071
  if (wired > 0) paintLastRun('上次结果:')
1072
+ if (mutatedThisPass && official.menuFound) reanchorOfficialMenu()
1009
1073
 
1010
1074
  const menuVisible = official.menuFound || menu !== null
1011
1075
  reportDiagnostics({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-doctor",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "模型医生:在对话输入区的模型选择菜单里,为每个模型选项追加「连通检测」与「删除」按钮——直接适配 DSH 官方模型菜单(按官方 DOM 契约精确定位,不依赖任何菜单渲染插件),检测直连各网关探测连通性/欠费/额度,删除直接从 llm-pi-ai 配置移除模型(自动备份配置文档)。",
5
5
  "keywords": [
6
6
  "deepseek",