dsh-model-health 0.2.0 → 0.2.2

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/dist/client.js +142 -73
  2. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -17,7 +17,7 @@ window.__ModuleLoader__.load({
17
17
  var exports = module.exports;
18
18
 
19
19
  const React = require("react");
20
- const { useState, useEffect, useCallback } = React;
20
+ const { useState, useEffect, useCallback, useRef } = React;
21
21
 
22
22
  // ── 多语言词典(locale 服务,语言跟随 harness)─────────────────────────
23
23
  const NS = "dsh-model-health";
@@ -48,6 +48,8 @@ window.__ModuleLoader__.load({
48
48
  statusOk: "可用",
49
49
  statusFail: "不可用",
50
50
  statusSkip: "跳过",
51
+ testOne: "测试",
52
+ testOneTip: "点击测试此模型",
51
53
  };
52
54
  const en = {
53
55
  tab: "Model Health",
@@ -76,6 +78,8 @@ window.__ModuleLoader__.load({
76
78
  statusOk: "Available",
77
79
  statusFail: "Unavailable",
78
80
  statusSkip: "Skipped",
81
+ testOne: "Test",
82
+ testOneTip: "Click to test this model",
79
83
  };
80
84
 
81
85
  // 内联样式(适配 DSH 明暗主题的 CSS 变量)
@@ -139,6 +143,20 @@ window.__ModuleLoader__.load({
139
143
  opacity: 0.5,
140
144
  cursor: "not-allowed",
141
145
  },
146
+ // 状态列里的"测试"小按钮(未测试的模型显示它代替"未测试"文字)
147
+ btnTestSmall: {
148
+ boxSizing: "border-box",
149
+ height: 22,
150
+ padding: "0 12px",
151
+ fontSize: 12,
152
+ lineHeight: "16px",
153
+ cursor: "pointer",
154
+ border: "1px solid var(--dsw-alias-button-primary-fill, #4f46e5)",
155
+ borderRadius: 11,
156
+ background: "transparent",
157
+ color: "var(--dsw-alias-button-primary-fill, #4f46e5)",
158
+ whiteSpace: "nowrap",
159
+ },
142
160
  progress: {
143
161
  fontSize: 12,
144
162
  color: "var(--dsw-alias-label-tertiary, #888)",
@@ -179,33 +197,49 @@ window.__ModuleLoader__.load({
179
197
  fontSize: 12,
180
198
  fontFamily: "var(--ds-font-family-code, monospace)",
181
199
  },
182
- badge: {
183
- display: "inline-block",
184
- padding: "2px 8px",
185
- borderRadius: 10,
200
+ statusWrap: {
201
+ display: "inline-flex",
202
+ alignItems: "center",
203
+ gap: 6,
186
204
  fontSize: 12,
187
- fontWeight: 500,
188
205
  whiteSpace: "nowrap",
189
206
  },
190
- badgeOk: {
191
- background: "var(--dsw-alias-state-success-bg, #e6f4ea)",
207
+ statusTextIdle: {
208
+ color: "var(--dsw-alias-label-tertiary, #999)",
209
+ },
210
+ statusTextTesting: {
211
+ color: "var(--dsw-alias-state-info-primary, #1967d2)",
212
+ },
213
+ statusTextOk: {
192
214
  color: "var(--dsw-alias-state-success-primary, #1e8e3e)",
193
215
  },
194
- badgeFail: {
195
- background: "var(--dsw-alias-state-error-bg, #fce8e6)",
216
+ statusTextFail: {
196
217
  color: "var(--dsw-alias-state-error-primary, #d32f2f)",
197
218
  },
198
- badgeTesting: {
199
- background: "var(--dsw-alias-state-info-bg, #e8f0fe)",
200
- color: "var(--dsw-alias-state-info-primary, #1967d2)",
201
- },
202
- badgeSkip: {
203
- background: "var(--dsw-alias-bg-layer-2, #f0f0f0)",
219
+ statusTextSkip: {
204
220
  color: "var(--dsw-alias-label-tertiary, #888)",
205
221
  },
206
- badgeIdle: {
207
- background: "var(--dsw-alias-bg-layer-2, #f0f0f0)",
208
- color: "var(--dsw-alias-label-tertiary, #999)",
222
+ dot: {
223
+ width: 6,
224
+ height: 6,
225
+ borderRadius: "50%",
226
+ flex: "none",
227
+ },
228
+ dotIdle: {
229
+ background: "var(--dsw-alias-label-dimmed, #bbb)",
230
+ },
231
+ dotTesting: {
232
+ background: "var(--dsw-alias-state-info-primary, #1967d2)",
233
+ animation: "dsmh-pulse 1s ease-in-out infinite",
234
+ },
235
+ dotOk: {
236
+ background: "var(--dsw-alias-state-success-primary, #1e8e3e)",
237
+ },
238
+ dotFail: {
239
+ background: "var(--dsw-alias-state-error-primary, #d32f2f)",
240
+ },
241
+ dotSkip: {
242
+ background: "var(--dsw-alias-label-dimmed, #bbb)",
209
243
  },
210
244
  center: {
211
245
  textAlign: "center",
@@ -265,13 +299,31 @@ window.__ModuleLoader__.load({
265
299
  fail: "statusFail",
266
300
  skip: "statusSkip",
267
301
  };
268
- const STATUS_STYLE = {
269
- idle: STYLES.badgeIdle,
270
- testing: STYLES.badgeTesting,
271
- ok: STYLES.badgeOk,
272
- fail: STYLES.badgeFail,
273
- skip: STYLES.badgeSkip,
302
+ // 状态 → 圆点颜色 / 文字颜色映射(圆点 + 彩色文字;测试中圆点带脉冲动画)
303
+ const STATUS_DOT_STYLE = {
304
+ idle: STYLES.dotIdle,
305
+ testing: STYLES.dotTesting,
306
+ ok: STYLES.dotOk,
307
+ fail: STYLES.dotFail,
308
+ skip: STYLES.dotSkip,
274
309
  };
310
+ const STATUS_TEXT_STYLE = {
311
+ idle: STYLES.statusTextIdle,
312
+ testing: STYLES.statusTextTesting,
313
+ ok: STYLES.statusTextOk,
314
+ fail: STYLES.statusTextFail,
315
+ skip: STYLES.statusTextSkip,
316
+ };
317
+
318
+ /** 注入测试中圆点的脉冲动画 keyframes(内联样式无法定义 @keyframes)。 */
319
+ function ensureStatusCss() {
320
+ const id = "dsh-model-health-status-style";
321
+ if (document.getElementById(id)) return;
322
+ const el = document.createElement("style");
323
+ el.id = id;
324
+ el.textContent = "@keyframes dsmh-pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }";
325
+ document.head.appendChild(el);
326
+ }
275
327
 
276
328
  /**
277
329
  * 模型列表 section 组件。
@@ -321,6 +373,9 @@ window.__ModuleLoader__.load({
321
373
  // 初始值从 localStorage 读取,实现跨刷新持久化
322
374
  const [testResults, setTestResults] = useState(loadStoredResults);
323
375
  const [testing, setTesting] = useState(false);
376
+ // 请求序号:同一模型重复触发测试(单测 + 测试全部并发)时,
377
+ // 只有最新一次请求的结果允许写入,避免旧响应覆盖新状态
378
+ const seqRef = useRef({});
324
379
  const [progress, setProgress] = useState({ done: 0, total: 0 });
325
380
  // 可选列显示状态:{ [colKey]: boolean },默认 false
326
381
  const [optionalCols, setOptionalCols] = useState({});
@@ -352,6 +407,42 @@ window.__ModuleLoader__.load({
352
407
  loadData();
353
408
  }, [loadData]);
354
409
 
410
+ // 测试单个模型:该行置为 testing → POST /api/model-health/test → 写回结果
411
+ // ("测试全部"与状态列的"测试"按钮共用此逻辑)
412
+ const testModel = useCallback(async (key) => {
413
+ const seq = (seqRef.current[key] || 0) + 1;
414
+ seqRef.current[key] = seq;
415
+ setTestResults((s) => ({ ...s, [key]: { status: "testing" } }));
416
+
417
+ let result;
418
+ try {
419
+ const resp = await fetch("/api/model-health/test", {
420
+ method: "POST",
421
+ headers: { "Content-Type": "application/json" },
422
+ body: JSON.stringify({ key }),
423
+ cache: "no-store",
424
+ });
425
+ const json = await resp.json();
426
+ result = !json.ok
427
+ ? { status: "fail", error: json.error }
428
+ : {
429
+ status: json.status || "fail",
430
+ latency: json.latency,
431
+ error: json.error,
432
+ };
433
+ } catch (e) {
434
+ result = { status: "fail", error: e.message };
435
+ }
436
+
437
+ // 期间若同一模型又发起了更新的测试,丢弃这份过期结果
438
+ if (seqRef.current[key] !== seq) return;
439
+ setTestResults((s) => {
440
+ const next = { ...s, [key]: result };
441
+ saveStoredResults(next);
442
+ return next;
443
+ });
444
+ }, []);
445
+
355
446
  // 测试全部:并发发起所有测试请求,每个完成即更新对应行状态
356
447
  const testAll = useCallback(async () => {
357
448
  const data = state.data;
@@ -359,59 +450,20 @@ window.__ModuleLoader__.load({
359
450
  const total = data.models.length;
360
451
  setTesting(true);
361
452
  setProgress({ done: 0, total });
362
- // 初始化所有为 testing(按 provider/modelId key)
363
- const init = {};
364
- for (const m of data.models) init[m.key] = { status: "testing" };
365
- setTestResults(init);
366
- saveStoredResults(init);
367
453
 
368
454
  let done = 0;
369
455
  // 并发测试,但限制并发数避免压垮 host/网络
370
456
  const CONCURRENCY = 6;
371
457
  const queue = data.models.map((m) => m.key);
372
458
 
373
- // 更新单个结果并同步到 localStorage
374
- function updateResult(key, result) {
375
- setTestResults((s) => {
376
- const next = { ...s, [key]: result };
377
- saveStoredResults(next);
378
- return next;
379
- });
380
- }
381
-
382
- async function runOne(key) {
383
- try {
384
- const resp = await fetch("/api/model-health/test", {
385
- method: "POST",
386
- headers: { "Content-Type": "application/json" },
387
- body: JSON.stringify({ key }),
388
- cache: "no-store",
389
- });
390
- const json = await resp.json();
391
- if (!json.ok) {
392
- updateResult(key, { status: "fail", error: json.error });
393
- } else {
394
- updateResult(key, {
395
- status: json.status || "fail",
396
- latency: json.latency,
397
- error: json.error,
398
- });
399
- }
400
- } catch (e) {
401
- updateResult(key, { status: "fail", error: e.message });
402
- } finally {
403
- done++;
404
- setProgress({ done, total });
405
- if (done >= total) setTesting(false);
406
- }
407
- }
408
-
409
- // 限制并发的 worker pool
410
459
  async function worker() {
411
460
  while (queue.length > 0) {
412
461
  const key = queue.shift();
413
462
  if (key === undefined) break;
414
- await runOne(key);
463
+ await testModel(key);
464
+ done++;
465
+ setProgress({ done, total });
466
+ if (done >= total) setTesting(false);
415
467
  }
416
468
  }
417
469
  const workers = Array.from(
@@ -419,7 +471,7 @@ window.__ModuleLoader__.load({
419
471
  () => worker()
420
472
  );
421
473
  await Promise.all(workers);
422
- }, [state.data]);
474
+ }, [state.data, testModel]);
423
475
 
424
476
  const h = React.createElement;
425
477
 
@@ -562,7 +614,8 @@ window.__ModuleLoader__.load({
562
614
  const tr = testResults[row.key] || { status: "idle" };
563
615
  const labelKey = STATUS_LABEL[tr.status];
564
616
  const statusLabel = labelKey ? t(labelKey) : tr.status;
565
- const statusStyle = STATUS_STYLE[tr.status] || STYLES.badgeIdle;
617
+ const dotStyle = STATUS_DOT_STYLE[tr.status] || STYLES.dotIdle;
618
+ const textStyle = STATUS_TEXT_STYLE[tr.status] || STYLES.statusTextIdle;
566
619
  const latencyText = tr.latency != null ? `${tr.latency}ms` : "-";
567
620
  return h("tr", { key: (row.modelId || "") + i },
568
621
  visibleCols.map((col) => {
@@ -576,6 +629,19 @@ window.__ModuleLoader__.load({
576
629
  }, row.provider);
577
630
  }
578
631
  if (col.key === "_status") {
632
+ // 未测试:显示可点击的"测试"按钮,点击即测单个模型
633
+ if (tr.status === "idle") {
634
+ return h("td", {
635
+ key: col.key,
636
+ style: STYLES.td,
637
+ },
638
+ h("button", {
639
+ style: STYLES.btnTestSmall,
640
+ title: t("testOneTip"),
641
+ onClick: () => testModel(row.key),
642
+ }, t("testOne"))
643
+ );
644
+ }
579
645
  const hasError = tr.error && tr.status === "fail";
580
646
  return h("td", {
581
647
  key: col.key,
@@ -583,8 +649,8 @@ window.__ModuleLoader__.load({
583
649
  },
584
650
  h("span", {
585
651
  style: {
586
- ...STYLES.badge,
587
- ...statusStyle,
652
+ ...STYLES.statusWrap,
653
+ ...textStyle,
588
654
  cursor: hasError ? "help" : "default",
589
655
  },
590
656
  onMouseEnter: hasError
@@ -607,7 +673,9 @@ window.__ModuleLoader__.load({
607
673
  onMouseLeave: hasError
608
674
  ? () => setTooltip(null)
609
675
  : undefined,
610
- }, statusLabel)
676
+ },
677
+ h("span", { style: { ...STYLES.dot, ...dotStyle } }),
678
+ statusLabel)
611
679
  );
612
680
  }
613
681
  if (col.key === "_latency") {
@@ -662,6 +730,7 @@ window.__ModuleLoader__.load({
662
730
  * @param {import('@deepseek-ai/dsh-client-runtime/client').ClientContext} ctx
663
731
  */
664
732
  function apply(ctx) {
733
+ ensureStatusCss();
665
734
  // 注册 zh/en 词典;t 每次调用读取当前激活语言
666
735
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-model-health: dictionaries");
667
736
  const t = ctx.locale.bind(NS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-health",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {