dsh-model-health 0.2.4 → 0.2.6

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 (3) hide show
  1. package/README.md +4 -2
  2. package/dist/client.js +134 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,7 +47,7 @@ dsh --profile web --dump-config | grep dsh-model-health # 配置层含本行
47
47
  | # | 形式 | 入口 | 说明 |
48
48
  |---|------|------|------|
49
49
  | 1 | 「模型健康」面板 | Web UI → 设置 → 模型健康 | React 表格:Provider / 模型 ID / 名称 / 上下文窗口 / 最大输出 / 输入模态 / API 协议 / BaseURL,可选列可切换显示 |
50
- | 2 | 测试全部 | 面板内「测试全部」按钮 | 并发(上限 6)对每个模型发一次 `max_tokens=1` 的最小 chat completions 请求,10s 超时;逐行显示 可用 / 不可用 / 跳过 / 延迟,失败原因悬停查看,结果持久化到 localStorage |
50
+ | 2 | 测试全部 | 面板内「测试全部」按钮 | 并发(上限 6)对每个模型发一次 `max_tokens=1` 的最小 chat completions 请求,10s 超时;逐行显示 可用 / 不可用 / 跳过 / 延迟,失败原因悬停查看,结果持久化到 localStorage。已出结果的徽章可点击,单独重测该模型(悬停显示下划线) |
51
51
  | 3 | Tool 插件 `list_models` | 对话中调用 | 返回 Markdown 表格,供模型在对话中查看模型清单 |
52
52
 
53
53
  **特性一览:**
@@ -56,6 +56,8 @@ dsh --profile web --dump-config | grep dsh-model-health # 配置层含本行
56
56
  - 可用性测试支持 `openai-completions` 与 `deepseek` 协议,其余协议自动标记「跳过」
57
57
  - API Key 通过 DSH credential service(`ctx.credentials.resolve`)解析,密钥不会输出到浏览器
58
58
  - 测试结果(状态/延迟/错误)持久化到 localStorage,刷新页面后仍可见
59
+ - 状态列的 可用 / 不可用 / 跳过 徽章随时可点击,对单个模型重新测试,原地刷新状态与延迟
60
+ - 设置导航使用脉搏线(Activity)图标——宿主图标表没有「健康」类目,参考 dsh-better-sidebar 的方案,JS 精准打 marker,CSS 用 `::before` + `mask` 把齿轮 svg 原地替换;颜色随主题走
59
61
 
60
62
  ## 实现方式 / How it works
61
63
 
@@ -100,7 +102,7 @@ dsh-model-health/
100
102
  ├── src/host/ # host 实现:config / models / markdown / http / model-test
101
103
  ├── src/client.tsx # client 入口壳:注入 React,装配插件导出
102
104
  ├── src/client/ # client 实现:types / runtime / i18n / storage / api /
103
- │ # styles / columns / use-test-results / apply
105
+ │ # styles / columns / use-test-results / nav-icon / apply
104
106
  │ └── components/ # ModelListSection / StatusCell / ErrorTooltip
105
107
  ├── cordis.patch.yml # bundle 层声明(id/name 走包名解析)
106
108
  └── dist/ # 构建产物(发布包含在 files 字段中)
package/dist/client.js CHANGED
@@ -43,7 +43,8 @@
43
43
  statusFail: "不可用",
44
44
  statusSkip: "跳过",
45
45
  testOne: "测试",
46
- testOneTip: "点击测试此模型"
46
+ testOneTip: "点击测试此模型",
47
+ retestTip: "点击重新测试此模型"
47
48
  };
48
49
  const en = {
49
50
  tab: "Model Health",
@@ -73,7 +74,8 @@
73
74
  statusFail: "Unavailable",
74
75
  statusSkip: "Skipped",
75
76
  testOne: "Test",
76
- testOneTip: "Click to test this model"
77
+ testOneTip: "Click to test this model",
78
+ retestTip: "Click to re-test this model"
77
79
  };
78
80
 
79
81
  //#endregion
@@ -172,19 +174,31 @@
172
174
  opacity: .5,
173
175
  cursor: "not-allowed"
174
176
  },
175
- btnTestSmall: {
176
- boxSizing: "border-box",
177
- height: 22,
178
- padding: "0 12px",
177
+ testLink: {
178
+ border: "none",
179
+ background: "transparent",
180
+ padding: 0,
181
+ margin: 0,
182
+ font: "inherit",
179
183
  fontSize: 12,
180
184
  lineHeight: "16px",
185
+ textAlign: "left",
186
+ color: "var(--dsw-alias-label-secondary, #555)",
187
+ textDecoration: "underline",
188
+ textUnderlineOffset: "3px",
181
189
  cursor: "pointer",
182
- border: "1px solid var(--dsw-alias-button-primary-fill, #4f46e5)",
183
- borderRadius: 11,
184
- background: "transparent",
185
- color: "var(--dsw-alias-button-primary-fill, #4f46e5)",
186
190
  whiteSpace: "nowrap"
187
191
  },
192
+ retestBtn: {
193
+ border: "none",
194
+ background: "transparent",
195
+ padding: 0,
196
+ margin: 0,
197
+ font: "inherit",
198
+ fontSize: 12,
199
+ lineHeight: "16px",
200
+ textAlign: "left"
201
+ },
188
202
  progress: {
189
203
  fontSize: 12,
190
204
  color: "var(--dsw-alias-label-tertiary, #888)"
@@ -476,33 +490,51 @@
476
490
  const { modelKey, result: tr, t, onTest, onErrorTip, onClearTip } = props;
477
491
  if (tr.status === "idle") return /* @__PURE__ */ React.createElement("td", { style: STYLES.td }, /* @__PURE__ */ React.createElement("button", {
478
492
  type: "button",
479
- style: STYLES.btnTestSmall,
493
+ className: "dsmh-test-link",
494
+ style: STYLES.testLink,
480
495
  title: t("testOneTip"),
481
496
  onClick: () => onTest(modelKey)
482
497
  }, t("testOne")));
483
498
  const hasError = Boolean(tr.error) && tr.status === "fail";
484
499
  const labelKey = STATUS_LABEL[tr.status];
485
500
  const statusLabel = labelKey ? t(labelKey) : tr.status;
501
+ const showTip = hasError ? (e) => {
502
+ const rowEl = e.currentTarget.closest("tr");
503
+ const tableEl = e.currentTarget.closest("table");
504
+ if (rowEl && tableEl) {
505
+ const rowRect = rowEl.getBoundingClientRect();
506
+ const tableRect = tableEl.getBoundingClientRect();
507
+ onErrorTip({
508
+ left: tableRect.left,
509
+ width: tableRect.width,
510
+ rowTop: rowRect.top,
511
+ error: tr.error || ""
512
+ });
513
+ }
514
+ } : void 0;
515
+ if (tr.status !== "testing") return /* @__PURE__ */ React.createElement("td", { style: STYLES.td }, /* @__PURE__ */ React.createElement("button", {
516
+ type: "button",
517
+ className: "dsmh-retest",
518
+ title: t("retestTip"),
519
+ style: {
520
+ ...STYLES.statusWrap,
521
+ ...STYLES.retestBtn,
522
+ ...STATUS_TEXT_STYLE[tr.status]
523
+ },
524
+ onClick: () => onTest(modelKey),
525
+ onMouseEnter: showTip,
526
+ onMouseLeave: hasError ? onClearTip : void 0
527
+ }, /* @__PURE__ */ React.createElement("span", { style: {
528
+ ...STYLES.dot,
529
+ ...STATUS_DOT_STYLE[tr.status]
530
+ } }), /* @__PURE__ */ React.createElement("span", { className: "dsmh-status-label" }, statusLabel)));
486
531
  return /* @__PURE__ */ React.createElement("td", { style: STYLES.td }, /* @__PURE__ */ React.createElement("span", {
487
532
  style: {
488
533
  ...STYLES.statusWrap,
489
534
  ...STATUS_TEXT_STYLE[tr.status],
490
535
  cursor: hasError ? "help" : "default"
491
536
  },
492
- onMouseEnter: hasError ? (e) => {
493
- const rowEl = e.currentTarget.closest("tr");
494
- const tableEl = e.currentTarget.closest("table");
495
- if (rowEl && tableEl) {
496
- const rowRect = rowEl.getBoundingClientRect();
497
- const tableRect = tableEl.getBoundingClientRect();
498
- onErrorTip({
499
- left: tableRect.left,
500
- width: tableRect.width,
501
- rowTop: rowRect.top,
502
- error: tr.error || ""
503
- });
504
- }
505
- } : void 0,
537
+ onMouseEnter: showTip,
506
538
  onMouseLeave: hasError ? onClearTip : void 0
507
539
  }, /* @__PURE__ */ React.createElement("span", { style: {
508
540
  ...STYLES.dot,
@@ -667,27 +699,102 @@
667
699
  })))), tooltip && /* @__PURE__ */ React.createElement(ErrorTooltip, { tip: tooltip }));
668
700
  }
669
701
 
702
+ //#endregion
703
+ //#region src/client/nav-icon.ts
704
+ /** 本插件导航行按钮的 marker(参考 better-sidebar 的命名风格) */
705
+ const SETTINGS_NAV_MARKER = "data-dsh-model-health-settings-nav";
706
+ /**
707
+ * 在设置弹窗挂载期间,给本插件的导航行按钮持续打上 marker。
708
+ * label 解析器返回当前激活语言的 tab 文案(i18n 切换会重新求值),与导航行
709
+ * 按钮的 textContent 严格相等才算匹配——保证不会误伤其他插件的同名/相似 section。
710
+ * @param label - locale-aware label resolver used by the section registration
711
+ * @returns disposer: HMR / 插件卸载时断开观察器并清理已打的 marker
712
+ */
713
+ function registerSettingsNavIcon(label) {
714
+ let disposed = false;
715
+ const sync = () => {
716
+ if (disposed) return;
717
+ const currentLabel = label().trim();
718
+ const buttons = document.querySelectorAll("[role=\"dialog\"] nav button");
719
+ for (let i = 0; i < buttons.length; i++) {
720
+ const button = buttons[i];
721
+ if (currentLabel.length > 0 && button.textContent && button.textContent.trim() === currentLabel) button.setAttribute(SETTINGS_NAV_MARKER, "");
722
+ else button.removeAttribute(SETTINGS_NAV_MARKER);
723
+ }
724
+ };
725
+ sync();
726
+ const observer = new MutationObserver(sync);
727
+ observer.observe(document.body, {
728
+ childList: true,
729
+ subtree: true,
730
+ characterData: true
731
+ });
732
+ return () => {
733
+ disposed = true;
734
+ observer.disconnect();
735
+ document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`).forEach((el) => {
736
+ el.removeAttribute(SETTINGS_NAV_MARKER);
737
+ });
738
+ };
739
+ }
740
+ /** 注入图标样式:隐藏 marker 行的齿轮 svg,用 ::before 注入脉搏线(mask 方案)。
741
+ * 16px 与宿主图标节奏一致;background: currentColor 让颜色跟随 nav 文案色
742
+ * (普通态 / hover / 选中态 / 明暗主题全自动)。 */
743
+ function ensureNavIconCss() {
744
+ const id = "dsh-model-health-nav-icon-style";
745
+ if (document.getElementById(id)) return;
746
+ const el = document.createElement("style");
747
+ el.id = id;
748
+ const mask = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='22 12 18 12 15 21 9 3 6 12 2 12'/%3E%3C/svg%3E\") center / contain no-repeat";
749
+ el.textContent = `
750
+ [data-dsh-model-health-settings-nav] > svg:first-child { display: none; }
751
+ [data-dsh-model-health-settings-nav]::before {
752
+ content: '';
753
+ flex: none;
754
+ width: 16px;
755
+ height: 16px;
756
+ background: currentColor;
757
+ -webkit-mask: ${mask};
758
+ mask: ${mask};
759
+ }
760
+ `;
761
+ document.head.appendChild(el);
762
+ }
763
+
670
764
  //#endregion
671
765
  //#region src/client/apply.tsx
672
766
  /** client 侧依赖:slots(注册 UI slot 的服务)+ locale(多语言词典) */
673
767
  const inject = ["slots", "locale"];
674
- /** 注入测试中圆点的脉冲动画 keyframes(内联样式无法定义 @keyframes)。 */
768
+ /** 注入内联样式无法表达的规则:@keyframes 脉冲动画 + 可重测状态徽章的 hover 反馈。 */
675
769
  function ensureStatusCss() {
676
770
  const id = "dsh-model-health-status-style";
677
771
  if (document.getElementById(id)) return;
678
772
  const el = document.createElement("style");
679
773
  el.id = id;
680
- el.textContent = "@keyframes dsmh-pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }";
774
+ el.textContent = `
775
+ @keyframes dsmh-pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }
776
+ /* 可点击重测的状态徽章:hover/聚焦 下划线提示可点 */
777
+ .dsmh-retest { cursor: pointer; }
778
+ .dsmh-retest:hover .dsmh-status-label,
779
+ .dsmh-retest:focus-visible .dsmh-status-label { text-decoration: underline; text-underline-offset: 3px; }
780
+ .dsmh-retest:focus-visible { outline: 1px solid currentColor; outline-offset: 2px; border-radius: 2px; }
781
+ /* 未测试行的"测试"文字链:hover 提亮为主题色 */
782
+ .dsmh-test-link { transition: color .15s ease; }
783
+ .dsmh-test-link:hover,
784
+ .dsmh-test-link:focus-visible { color: var(--dsw-alias-button-primary-fill, #4f46e5); }
785
+ `;
681
786
  document.head.appendChild(el);
682
787
  }
683
788
  function apply(ctx) {
684
789
  const React = getReact();
685
790
  ensureStatusCss();
791
+ ensureNavIconCss();
686
792
  ctx.effect(() => ctx.locale.register(NS, {
687
793
  zh,
688
794
  en
689
795
  }), "dsh-model-health: dictionaries");
690
796
  const t = ctx.locale.bind(NS);
797
+ ctx.effect(() => registerSettingsNavIcon(() => t("tab")), "dsh-model-health: settings navigation icon");
691
798
  ctx.slots.inject("settings.section", () => ctx.slots.register({
692
799
  name: "settings.section",
693
800
  id: "model-health",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-health",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "DSH plugin that lists all configured models with availability/latency testing, by reading $DSH_HOME/settings.yaml.",
5
5
  "license": "MIT",
6
6
  "repository": {