dsh-model-health 0.1.3 → 0.2.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.
package/dist/client.js CHANGED
@@ -7,6 +7,9 @@
7
7
  // 模型列表 JSON,然后用 React 直接渲染表格。
8
8
  // 支持"测试全部":并发 POST /api/model-health/test,逐个更新测试状态。
9
9
  //
10
+ // 多语言:通过官方 locale 服务注册 zh/en 词典(与 dsh-plugin-mgr 同模式),
11
+ // 语言跟随 harness 设置(设置 → 通用 → 语言),切换语言即时生效。
12
+ //
10
13
  window.__ModuleLoader__.load({
11
14
  id: "dsh-model-health",
12
15
  factory: (require) => {
@@ -16,6 +19,65 @@ window.__ModuleLoader__.load({
16
19
  const React = require("react");
17
20
  const { useState, useEffect, useCallback } = React;
18
21
 
22
+ // ── 多语言词典(locale 服务,语言跟随 harness)─────────────────────────
23
+ const NS = "dsh-model-health";
24
+ const zh = {
25
+ tab: "模型健康",
26
+ title: "已配置模型",
27
+ updatedAt: "更新时间:",
28
+ testAll: "测试全部",
29
+ testingBtn: "测试中...",
30
+ showCols: "显示列:",
31
+ loading: "加载中...",
32
+ loadError: "读取模型列表失败:",
33
+ retry: "重试",
34
+ unknownError: "未知错误",
35
+ empty: "未配置任何模型,请在 设置 → 模型 中添加。",
36
+ colProvider: "Provider",
37
+ colModelId: "模型 ID",
38
+ colName: "名称",
39
+ colContextWindow: "上下文窗口",
40
+ colMaxTokens: "最大输出",
41
+ colInput: "输入模态",
42
+ colApi: "API 协议",
43
+ colBaseURL: "BaseURL",
44
+ colStatus: "状态",
45
+ colLatency: "延迟",
46
+ statusIdle: "未测试",
47
+ statusTesting: "测试中",
48
+ statusOk: "可用",
49
+ statusFail: "不可用",
50
+ statusSkip: "跳过",
51
+ };
52
+ const en = {
53
+ tab: "Model Health",
54
+ title: "Configured Models",
55
+ updatedAt: "Updated: ",
56
+ testAll: "Test All",
57
+ testingBtn: "Testing...",
58
+ showCols: "Columns: ",
59
+ loading: "Loading...",
60
+ loadError: "Failed to load model list: ",
61
+ retry: "Retry",
62
+ unknownError: "Unknown error",
63
+ empty: "No models configured. Add one in Settings → Models.",
64
+ colProvider: "Provider",
65
+ colModelId: "Model ID",
66
+ colName: "Name",
67
+ colContextWindow: "Context Window",
68
+ colMaxTokens: "Max Output",
69
+ colInput: "Modalities",
70
+ colApi: "API Protocol",
71
+ colBaseURL: "BaseURL",
72
+ colStatus: "Status",
73
+ colLatency: "Latency",
74
+ statusIdle: "Untested",
75
+ statusTesting: "Testing",
76
+ statusOk: "Available",
77
+ statusFail: "Unavailable",
78
+ statusSkip: "Skipped",
79
+ };
80
+
19
81
  // 内联样式(适配 DSH 明暗主题的 CSS 变量)
20
82
  const STYLES = {
21
83
  wrap: {
@@ -117,33 +179,49 @@ window.__ModuleLoader__.load({
117
179
  fontSize: 12,
118
180
  fontFamily: "var(--ds-font-family-code, monospace)",
119
181
  },
120
- badge: {
121
- display: "inline-block",
122
- padding: "2px 8px",
123
- borderRadius: 10,
182
+ statusWrap: {
183
+ display: "inline-flex",
184
+ alignItems: "center",
185
+ gap: 6,
124
186
  fontSize: 12,
125
- fontWeight: 500,
126
187
  whiteSpace: "nowrap",
127
188
  },
128
- badgeOk: {
129
- background: "var(--dsw-alias-state-success-bg, #e6f4ea)",
189
+ statusTextIdle: {
190
+ color: "var(--dsw-alias-label-tertiary, #999)",
191
+ },
192
+ statusTextTesting: {
193
+ color: "var(--dsw-alias-state-info-primary, #1967d2)",
194
+ },
195
+ statusTextOk: {
130
196
  color: "var(--dsw-alias-state-success-primary, #1e8e3e)",
131
197
  },
132
- badgeFail: {
133
- background: "var(--dsw-alias-state-error-bg, #fce8e6)",
198
+ statusTextFail: {
134
199
  color: "var(--dsw-alias-state-error-primary, #d32f2f)",
135
200
  },
136
- badgeTesting: {
137
- background: "var(--dsw-alias-state-info-bg, #e8f0fe)",
138
- color: "var(--dsw-alias-state-info-primary, #1967d2)",
139
- },
140
- badgeSkip: {
141
- background: "var(--dsw-alias-bg-layer-2, #f0f0f0)",
201
+ statusTextSkip: {
142
202
  color: "var(--dsw-alias-label-tertiary, #888)",
143
203
  },
144
- badgeIdle: {
145
- background: "var(--dsw-alias-bg-layer-2, #f0f0f0)",
146
- color: "var(--dsw-alias-label-tertiary, #999)",
204
+ dot: {
205
+ width: 6,
206
+ height: 6,
207
+ borderRadius: "50%",
208
+ flex: "none",
209
+ },
210
+ dotIdle: {
211
+ background: "var(--dsw-alias-label-dimmed, #bbb)",
212
+ },
213
+ dotTesting: {
214
+ background: "var(--dsw-alias-state-info-primary, #1967d2)",
215
+ animation: "dsmh-pulse 1s ease-in-out infinite",
216
+ },
217
+ dotOk: {
218
+ background: "var(--dsw-alias-state-success-primary, #1e8e3e)",
219
+ },
220
+ dotFail: {
221
+ background: "var(--dsw-alias-state-error-primary, #d32f2f)",
222
+ },
223
+ dotSkip: {
224
+ background: "var(--dsw-alias-label-dimmed, #bbb)",
147
225
  },
148
226
  center: {
149
227
  textAlign: "center",
@@ -181,40 +259,60 @@ window.__ModuleLoader__.load({
181
259
  },
182
260
  };
183
261
 
184
- // 列定义:optional=true 的列默认不显示,由多选框控制
262
+ // 列定义:optional=true 的列默认不显示,由多选框控制;label 为词典 key
185
263
  const COLUMNS = [
186
- { key: "provider", label: "Provider" },
187
- { key: "modelId", label: "模型 ID", code: true, optional: true },
188
- { key: "modelName", label: "名称" },
189
- { key: "contextWindow", label: "上下文窗口", optional: true },
190
- { key: "maxTokens", label: "最大输出", optional: true },
191
- { key: "input", label: "输入模态", optional: true },
192
- { key: "api", label: "API 协议", optional: true },
193
- { key: "baseURL", label: "BaseURL", code: true, optional: true },
194
- { key: "_status", label: "状态" },
195
- { key: "_latency", label: "延迟" },
264
+ { key: "provider", label: "colProvider" },
265
+ { key: "modelId", label: "colModelId", code: true, optional: true },
266
+ { key: "modelName", label: "colName" },
267
+ { key: "contextWindow", label: "colContextWindow", optional: true },
268
+ { key: "maxTokens", label: "colMaxTokens", optional: true },
269
+ { key: "input", label: "colInput", optional: true },
270
+ { key: "api", label: "colApi", optional: true },
271
+ { key: "baseURL", label: "colBaseURL", code: true, optional: true },
272
+ { key: "_status", label: "colStatus" },
273
+ { key: "_latency", label: "colLatency" },
196
274
  ];
197
275
 
198
- // 状态标签映射
276
+ // 状态标签映射(值为词典 key)
199
277
  const STATUS_LABEL = {
200
- idle: "未测试",
201
- testing: "测试中",
202
- ok: "可用",
203
- fail: "不可用",
204
- skip: "跳过",
278
+ idle: "statusIdle",
279
+ testing: "statusTesting",
280
+ ok: "statusOk",
281
+ fail: "statusFail",
282
+ skip: "statusSkip",
205
283
  };
206
- const STATUS_STYLE = {
207
- idle: STYLES.badgeIdle,
208
- testing: STYLES.badgeTesting,
209
- ok: STYLES.badgeOk,
210
- fail: STYLES.badgeFail,
211
- skip: STYLES.badgeSkip,
284
+ // 状态 → 圆点颜色 / 文字颜色映射(圆点 + 彩色文字;测试中圆点带脉冲动画)
285
+ const STATUS_DOT_STYLE = {
286
+ idle: STYLES.dotIdle,
287
+ testing: STYLES.dotTesting,
288
+ ok: STYLES.dotOk,
289
+ fail: STYLES.dotFail,
290
+ skip: STYLES.dotSkip,
291
+ };
292
+ const STATUS_TEXT_STYLE = {
293
+ idle: STYLES.statusTextIdle,
294
+ testing: STYLES.statusTextTesting,
295
+ ok: STYLES.statusTextOk,
296
+ fail: STYLES.statusTextFail,
297
+ skip: STYLES.statusTextSkip,
212
298
  };
213
299
 
300
+ /** 注入测试中圆点的脉冲动画 keyframes(内联样式无法定义 @keyframes)。 */
301
+ function ensureStatusCss() {
302
+ const id = "dsh-model-health-status-style";
303
+ if (document.getElementById(id)) return;
304
+ const el = document.createElement("style");
305
+ el.id = id;
306
+ el.textContent = "@keyframes dsmh-pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }";
307
+ document.head.appendChild(el);
308
+ }
309
+
214
310
  /**
215
311
  * 模型列表 section 组件。
216
312
  * fetch /api/model-health/json 获取数据 → React 渲染表格。
217
313
  * "测试全部" → 并发 POST /api/model-health/test,逐个更新状态。
314
+ * props: { t, locale } — t 为绑定了词典命名空间的翻译函数,
315
+ * locale 用于订阅语言变化触发重渲染。
218
316
  */
219
317
  // localStorage 持久化测试结果的 key
220
318
  const STORAGE_KEY = "dsh-model-health:test-results";
@@ -222,7 +320,17 @@ window.__ModuleLoader__.load({
222
320
  function loadStoredResults() {
223
321
  try {
224
322
  const raw = localStorage.getItem(STORAGE_KEY);
225
- return raw ? JSON.parse(raw) : {};
323
+ const parsed = raw ? JSON.parse(raw) : {};
324
+ const cleaned = {};
325
+ // 清理:残留的 testing(测试中刷新页面)归一为 idle;
326
+ // 旧版按数组下标存的纯数字 key 直接丢弃
327
+ for (const k in parsed) {
328
+ if (/^\d+$/.test(k)) continue;
329
+ const v = parsed[k];
330
+ if (!v || typeof v !== "object") continue;
331
+ cleaned[k] = v.status === "testing" ? { status: "idle" } : v;
332
+ }
333
+ return cleaned;
226
334
  } catch (e) {
227
335
  return {};
228
336
  }
@@ -236,13 +344,14 @@ window.__ModuleLoader__.load({
236
344
  }
237
345
  }
238
346
 
239
- function ModelListSection() {
347
+ function ModelListSection({ t, locale }) {
240
348
  const [state, setState] = useState({
241
349
  loading: true,
242
350
  error: null,
243
351
  data: null,
244
352
  });
245
- // testResults: { [index]: { status, latency?, error? } }
353
+ // testResults: { [modelKey]: { status, latency?, error? } }
354
+ // key 为 provider/modelId(与 host 侧一致),settings 增删/排序不错位;
246
355
  // 初始值从 localStorage 读取,实现跨刷新持久化
247
356
  const [testResults, setTestResults] = useState(loadStoredResults);
248
357
  const [testing, setTesting] = useState(false);
@@ -251,6 +360,13 @@ window.__ModuleLoader__.load({
251
360
  const [optionalCols, setOptionalCols] = useState({});
252
361
  // hover tooltip 状态:{ index, error } | null
253
362
  const [tooltip, setTooltip] = useState(null);
363
+ // 语言切换时强制重渲染(t 每次调用读取当前激活词典)
364
+ const [localeTick, setLocaleTick] = useState(0);
365
+
366
+ useEffect(() => {
367
+ if (!locale || typeof locale.subscribe !== "function") return;
368
+ return locale.subscribe(() => setLocaleTick((n) => n + 1));
369
+ }, [locale]);
254
370
 
255
371
  const loadData = useCallback(async () => {
256
372
  setState((s) => ({ ...s, loading: true, error: null }));
@@ -258,7 +374,7 @@ window.__ModuleLoader__.load({
258
374
  const resp = await fetch("/api/model-health/json", { cache: "no-store" });
259
375
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
260
376
  const json = await resp.json();
261
- if (!json.ok) throw new Error(json.error || "未知错误");
377
+ if (!json.ok) throw new Error(json.error || t("unknownError"));
262
378
  setState({ loading: false, error: null, data: json });
263
379
  // 不清空 testResults,保留上次测试结果
264
380
  } catch (e) {
@@ -277,47 +393,46 @@ window.__ModuleLoader__.load({
277
393
  const total = data.models.length;
278
394
  setTesting(true);
279
395
  setProgress({ done: 0, total });
280
- // 初始化所有为 testing
396
+ // 初始化所有为 testing(按 provider/modelId key)
281
397
  const init = {};
282
- for (let i = 0; i < total; i++) init[i] = { status: "testing" };
398
+ for (const m of data.models) init[m.key] = { status: "testing" };
283
399
  setTestResults(init);
284
400
  saveStoredResults(init);
285
401
 
286
402
  let done = 0;
287
403
  // 并发测试,但限制并发数避免压垮 host/网络
288
404
  const CONCURRENCY = 6;
289
- const indices = Array.from({ length: total }, (_, i) => i);
290
- const queue = [...indices];
405
+ const queue = data.models.map((m) => m.key);
291
406
 
292
407
  // 更新单个结果并同步到 localStorage
293
- function updateResult(idx, result) {
408
+ function updateResult(key, result) {
294
409
  setTestResults((s) => {
295
- const next = { ...s, [idx]: result };
410
+ const next = { ...s, [key]: result };
296
411
  saveStoredResults(next);
297
412
  return next;
298
413
  });
299
414
  }
300
415
 
301
- async function runOne(idx) {
416
+ async function runOne(key) {
302
417
  try {
303
418
  const resp = await fetch("/api/model-health/test", {
304
419
  method: "POST",
305
420
  headers: { "Content-Type": "application/json" },
306
- body: JSON.stringify({ index: idx }),
421
+ body: JSON.stringify({ key }),
307
422
  cache: "no-store",
308
423
  });
309
424
  const json = await resp.json();
310
425
  if (!json.ok) {
311
- updateResult(idx, { status: "fail", error: json.error });
426
+ updateResult(key, { status: "fail", error: json.error });
312
427
  } else {
313
- updateResult(idx, {
428
+ updateResult(key, {
314
429
  status: json.status || "fail",
315
430
  latency: json.latency,
316
431
  error: json.error,
317
432
  });
318
433
  }
319
434
  } catch (e) {
320
- updateResult(idx, { status: "fail", error: e.message });
435
+ updateResult(key, { status: "fail", error: e.message });
321
436
  } finally {
322
437
  done++;
323
438
  setProgress({ done, total });
@@ -328,9 +443,9 @@ window.__ModuleLoader__.load({
328
443
  // 限制并发的 worker pool
329
444
  async function worker() {
330
445
  while (queue.length > 0) {
331
- const idx = queue.shift();
332
- if (idx === undefined) break;
333
- await runOne(idx);
446
+ const key = queue.shift();
447
+ if (key === undefined) break;
448
+ await runOne(key);
334
449
  }
335
450
  }
336
451
  const workers = Array.from(
@@ -344,25 +459,32 @@ window.__ModuleLoader__.load({
344
459
 
345
460
  if (state.loading) {
346
461
  return h("div", { style: STYLES.wrap },
347
- h("div", { style: STYLES.loading }, "加载中...")
462
+ h("div", { style: STYLES.loading }, t("loading"))
348
463
  );
349
464
  }
350
465
 
351
466
  if (state.error) {
352
467
  return h("div", { style: STYLES.wrap },
353
468
  h("div", { style: STYLES.error },
354
- "读取模型列表失败:", state.error
469
+ t("loadError"), state.error
355
470
  ),
356
471
  h("button", {
357
472
  style: STYLES.btn,
358
473
  onClick: loadData,
359
- }, "重试")
474
+ }, t("retry"))
360
475
  );
361
476
  }
362
477
 
363
478
  const data = state.data || { models: [], count: 0, source: "", updatedAt: "" };
364
479
  const models = data.models || [];
365
- const updatedAt = new Date(data.updatedAt || Date.now()).toLocaleString("zh-CN");
480
+ // 日期格式跟随当前语言(zh → zh-CN,en → en-US)
481
+ const activeLang =
482
+ locale && typeof locale.getLocale === "function"
483
+ ? locale.getLocale().active
484
+ : "zh";
485
+ const updatedAt = new Date(data.updatedAt || Date.now()).toLocaleString(
486
+ activeLang === "en" ? "en-US" : "zh-CN"
487
+ );
366
488
 
367
489
  // 统计测试结果
368
490
  const stats = { ok: 0, fail: 0, skip: 0, testing: 0 };
@@ -386,12 +508,12 @@ window.__ModuleLoader__.load({
386
508
 
387
509
  return h("div", { style: STYLES.wrap },
388
510
  h("div", { style: STYLES.head },
389
- h("h2", { style: STYLES.title }, "已配置模型"),
511
+ h("h2", { style: STYLES.title }, t("title")),
390
512
  h("span", { style: STYLES.count }, data.count || models.length)
391
513
  ),
392
514
  h("div", { style: STYLES.toolbar },
393
515
  h("span", { style: STYLES.meta },
394
- "更新时间:", updatedAt
516
+ t("updatedAt"), updatedAt
395
517
  ),
396
518
  // 测试统计
397
519
  (stats.ok + stats.fail + stats.skip > 0) && h("span", { style: STYLES.progress },
@@ -410,12 +532,12 @@ window.__ModuleLoader__.load({
410
532
  },
411
533
  disabled: testing || models.length === 0,
412
534
  onClick: testAll,
413
- }, testing ? "测试中..." : "测试全部")
535
+ }, testing ? t("testingBtn") : t("testAll"))
414
536
  ),
415
537
  // 可选列 toggle 按钮组
416
538
  h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
417
539
  h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-tertiary, #888)" } },
418
- "显示列:"
540
+ t("showCols")
419
541
  ),
420
542
  optionalColumns.map((col) => {
421
543
  const active = !!optionalCols[col.key];
@@ -439,12 +561,12 @@ window.__ModuleLoader__.load({
439
561
  ? "var(--dsw-alias-label-primary-foreground, #fff)"
440
562
  : "var(--dsw-alias-label-secondary, #555)",
441
563
  },
442
- }, col.label);
564
+ }, t(col.label));
443
565
  })
444
566
  ),
445
567
  models.length === 0
446
568
  ? h("div", { style: STYLES.center },
447
- "未配置任何模型,请在 设置 → 模型 中添加。"
569
+ t("empty")
448
570
  )
449
571
  : h("div", { style: STYLES.tableWrap },
450
572
  h("table", { style: STYLES.table },
@@ -454,7 +576,7 @@ window.__ModuleLoader__.load({
454
576
  h("th", {
455
577
  key: col.key,
456
578
  style: STYLES.th,
457
- }, col.label)
579
+ }, t(col.label))
458
580
  )
459
581
  )
460
582
  ),
@@ -471,9 +593,11 @@ window.__ModuleLoader__.load({
471
593
  providerSpan[start] = k - start;
472
594
  }
473
595
  return models.map((row, i) => {
474
- const tr = testResults[i] || { status: "idle" };
475
- const statusLabel = STATUS_LABEL[tr.status] || tr.status;
476
- const statusStyle = STATUS_STYLE[tr.status] || STYLES.badgeIdle;
596
+ const tr = testResults[row.key] || { status: "idle" };
597
+ const labelKey = STATUS_LABEL[tr.status];
598
+ const statusLabel = labelKey ? t(labelKey) : tr.status;
599
+ const dotStyle = STATUS_DOT_STYLE[tr.status] || STYLES.dotIdle;
600
+ const textStyle = STATUS_TEXT_STYLE[tr.status] || STYLES.statusTextIdle;
477
601
  const latencyText = tr.latency != null ? `${tr.latency}ms` : "-";
478
602
  return h("tr", { key: (row.modelId || "") + i },
479
603
  visibleCols.map((col) => {
@@ -494,8 +618,8 @@ window.__ModuleLoader__.load({
494
618
  },
495
619
  h("span", {
496
620
  style: {
497
- ...STYLES.badge,
498
- ...statusStyle,
621
+ ...STYLES.statusWrap,
622
+ ...textStyle,
499
623
  cursor: hasError ? "help" : "default",
500
624
  },
501
625
  onMouseEnter: hasError
@@ -518,7 +642,9 @@ window.__ModuleLoader__.load({
518
642
  onMouseLeave: hasError
519
643
  ? () => setTooltip(null)
520
644
  : undefined,
521
- }, statusLabel)
645
+ },
646
+ h("span", { style: { ...STYLES.dot, ...dotStyle } }),
647
+ statusLabel)
522
648
  );
523
649
  }
524
650
  if (col.key === "_latency") {
@@ -566,22 +692,29 @@ window.__ModuleLoader__.load({
566
692
  );
567
693
  }
568
694
 
569
- // client 侧依赖:slots(注册 UI slot 的服务)
570
- const inject = ["slots"];
695
+ // client 侧依赖:slots(注册 UI slot 的服务)+ locale(多语言词典)
696
+ const inject = ["slots", "locale"];
571
697
 
572
698
  /**
573
699
  * @param {import('@deepseek-ai/dsh-client-runtime/client').ClientContext} ctx
574
700
  */
575
701
  function apply(ctx) {
702
+ ensureStatusCss();
703
+ // 注册 zh/en 词典;t 每次调用读取当前激活语言
704
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-model-health: dictionaries");
705
+ const t = ctx.locale.bind(NS);
706
+ const h = React.createElement;
707
+
576
708
  ctx.slots.inject("settings.section", () =>
577
709
  ctx.slots.register(
578
710
  {
579
711
  name: "settings.section",
580
712
  id: "model-health",
581
713
  order: 50,
582
- label: "模型健康",
714
+ label: () => t("tab"),
715
+ locale: NS,
583
716
  },
584
- ModelListSection
717
+ () => h(ModelListSection, { t, locale: ctx.locale })
585
718
  )
586
719
  );
587
720
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { load as parseYaml } from 'js-yaml';
@@ -32,6 +32,23 @@ function readSettings() {
32
32
  const cfg = parseYaml(raw);
33
33
  return cfg && typeof cfg === 'object' ? cfg : {};
34
34
  }
35
+ /** 按 mtime 缓存解析结果:并发测试时同一份 settings 只解析一次,文件变了自动失效。 */
36
+ let settingsCache = null;
37
+ function readSettingsCached() {
38
+ const settingsPath = join(dshHome(), 'settings.yaml');
39
+ try {
40
+ const mtimeMs = statSync(settingsPath).mtimeMs;
41
+ if (settingsCache && settingsCache.mtimeMs === mtimeMs)
42
+ return settingsCache.cfg;
43
+ const cfg = readSettings();
44
+ settingsCache = { mtimeMs, cfg };
45
+ return cfg;
46
+ }
47
+ catch {
48
+ settingsCache = null;
49
+ return readSettings();
50
+ }
51
+ }
35
52
  /** 从 settings 配置对象中收集所有已配置的模型。 */
36
53
  export function collectModels(cfg) {
37
54
  const rows = [];
@@ -45,6 +62,7 @@ export function collectModels(cfg) {
45
62
  const models = Array.isArray(p.models) ? p.models : [];
46
63
  for (const m of models) {
47
64
  rows.push({
65
+ key: `${route}/${m.id || ''}`,
48
66
  provider: route,
49
67
  displayName: p.displayName || route,
50
68
  modelId: m.id || '',
@@ -66,6 +84,7 @@ export function collectModels(cfg) {
66
84
  const models = Array.isArray(ds.models) ? ds.models : [];
67
85
  for (const m of models) {
68
86
  rows.push({
87
+ key: `deepseek/${m.id || ''}`,
69
88
  provider: 'deepseek',
70
89
  displayName: 'DeepSeek',
71
90
  modelId: m.id || '',
@@ -106,6 +125,32 @@ function renderMarkdownTable(rows) {
106
125
  `_来源:${join(dshHome(), 'settings.yaml')}_`,
107
126
  ].join('\n');
108
127
  }
128
+ /** 统一 JSON 响应:设状态码 + Content-Type + 序列化输出。 */
129
+ function sendJson(res, code, obj) {
130
+ res.statusCode = code;
131
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
132
+ res.end(JSON.stringify(obj));
133
+ }
134
+ /** 读 JSON body(限 64KB),且必须是 application/json(兼作 CSRF 门卫)。 */
135
+ async function readJsonBody(req) {
136
+ const ct = String(req.headers['content-type'] ?? '');
137
+ if (!ct.includes('application/json')) {
138
+ throw new Error('Content-Type 必须是 application/json');
139
+ }
140
+ let body = '';
141
+ for await (const chunk of req) {
142
+ body += chunk;
143
+ if (body.length > 65536)
144
+ throw new Error('请求体过大');
145
+ }
146
+ if (body === '')
147
+ return {};
148
+ const parsed = JSON.parse(body);
149
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
150
+ throw new Error('请求体必须是 JSON 对象');
151
+ }
152
+ return parsed;
153
+ }
109
154
  export function apply(ctx) {
110
155
  // ── 1. Tool 插件(对话中调用,返回 Markdown 表格)──────────────────
111
156
  ctx.tools.register(defineTool({
@@ -118,7 +163,7 @@ export function apply(ctx) {
118
163
  render: (_args, value) => [{ type: 'text', text: value }],
119
164
  },
120
165
  async execute() {
121
- const cfg = readSettings();
166
+ const cfg = readSettingsCached();
122
167
  return renderMarkdownTable(collectModels(cfg));
123
168
  },
124
169
  }));
@@ -130,80 +175,61 @@ export function apply(ctx) {
130
175
  path: '/api/model-health/json',
131
176
  handler: (_req, res) => {
132
177
  try {
133
- const cfg = readSettings();
178
+ const cfg = readSettingsCached();
134
179
  const rows = collectModels(cfg);
135
- const payload = JSON.stringify({
180
+ sendJson(res, 200, {
136
181
  ok: true,
137
182
  count: rows.length,
138
183
  source: join(dshHome(), 'settings.yaml'),
139
184
  updatedAt: new Date().toISOString(),
140
185
  models: rows.map(toPublicRow),
141
186
  });
142
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
143
- res.end(payload);
144
187
  }
145
188
  catch (e) {
146
- const payload = JSON.stringify({
147
- ok: false,
148
- error: `读取模型列表失败:${e.message}`,
149
- });
150
- res.statusCode = 500;
151
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
152
- res.end(payload);
189
+ sendJson(res, 500, { ok: false, error: `读取模型列表失败:${e.message}` });
153
190
  }
154
191
  },
155
192
  });
156
193
  // ── 3. HTTP 路由(测试单个模型可用性)──────────────────────────────
157
- // POST /api/model-health/test body: { "index": <number> }
158
- // 对该模型发起一次最小 chat completions 请求,返回 { ok, index, status, latency, error? }
194
+ // POST /api/model-health/test body: { "key": "<provider>/<modelId>" }
195
+ // 按 key 定位模型(settings 增删/排序后不错位),
196
+ // 发起一次最小 chat completions 请求,返回 { ok, key, status, latency, error? }
159
197
  ctx.webServer.register({
160
198
  kind: 'exact',
161
199
  path: '/api/model-health/test',
162
200
  handler: async (req, res) => {
163
- // 读取请求体
164
- let body = '';
165
- for await (const chunk of req)
166
- body += chunk;
167
- let index;
201
+ let key;
168
202
  try {
169
- index = JSON.parse(body || '{}').index;
170
- if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
171
- throw new Error('index 必须是非负整数');
172
- }
203
+ key = (await readJsonBody(req)).key;
204
+ if (typeof key !== 'string' || key === '')
205
+ throw new Error('key 必须是非空字符串');
173
206
  }
174
207
  catch (e) {
175
- res.statusCode = 400;
176
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
177
- res.end(JSON.stringify({ ok: false, error: `参数错误:${e.message}` }));
208
+ sendJson(res, 400, { ok: false, error: `参数错误:${e.message}` });
178
209
  return;
179
210
  }
180
211
  // 查找模型配置
181
212
  let row;
182
213
  try {
183
- const cfg = readSettings();
214
+ const cfg = readSettingsCached();
184
215
  const rows = collectModels(cfg);
185
- const found = rows[index];
216
+ const found = rows.find((r) => r.key === key);
186
217
  if (!found) {
187
- res.statusCode = 404;
188
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
189
- res.end(JSON.stringify({ ok: false, error: `未找到索引为 ${index} 的模型` }));
218
+ sendJson(res, 404, { ok: false, error: `未找到 key 为 ${key} 的模型(配置可能已变化,请刷新列表)` });
190
219
  return;
191
220
  }
192
221
  row = found;
193
222
  }
194
223
  catch (e) {
195
- res.statusCode = 500;
196
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
197
- res.end(JSON.stringify({ ok: false, error: e.message }));
224
+ sendJson(res, 500, { ok: false, error: e.message });
198
225
  return;
199
226
  }
200
227
  // 仅支持 openai-completions 协议
201
228
  if (row.api !== 'openai-completions' && row.api !== 'deepseek') {
202
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
203
- res.end(JSON.stringify({
204
- ok: true, index, status: 'skip',
229
+ sendJson(res, 200, {
230
+ ok: true, key, status: 'skip',
205
231
  error: `不支持 ${row.api} 协议的测试`,
206
- }));
232
+ });
207
233
  return;
208
234
  }
209
235
  // 获取 API Key
@@ -220,11 +246,10 @@ export function apply(ctx) {
220
246
  }
221
247
  }
222
248
  if (!apiKey) {
223
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
224
- res.end(JSON.stringify({
225
- ok: true, index, status: 'fail',
249
+ sendJson(res, 200, {
250
+ ok: true, key, status: 'fail',
226
251
  error: `未配置 ${row.apiKeyEnv} 的 API Key(请在 设置 → 模型 中添加)`,
227
- }));
252
+ });
228
253
  return;
229
254
  }
230
255
  // 发起最小测试请求(max_tokens=1,超时 10s)
@@ -250,24 +275,21 @@ export function apply(ctx) {
250
275
  clearTimeout(timer);
251
276
  const latency = Date.now() - start;
252
277
  if (resp.ok) {
253
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
254
- res.end(JSON.stringify({ ok: true, index, status: 'ok', latency }));
278
+ sendJson(res, 200, { ok: true, key, status: 'ok', latency });
255
279
  }
256
280
  else {
257
281
  const text = await resp.text().catch(() => '');
258
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
259
- res.end(JSON.stringify({
260
- ok: true, index, status: 'fail', latency,
282
+ sendJson(res, 200, {
283
+ ok: true, key, status: 'fail', latency,
261
284
  error: `HTTP ${resp.status}: ${text.slice(0, 300)}`,
262
- }));
285
+ });
263
286
  }
264
287
  }
265
288
  catch (e) {
266
289
  clearTimeout(timer);
267
290
  const latency = Date.now() - start;
268
291
  const msg = e?.name === 'AbortError' ? '超时(10s)' : (e?.message || String(e));
269
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
270
- res.end(JSON.stringify({ ok: true, index, status: 'fail', latency, error: msg }));
292
+ sendJson(res, 200, { ok: true, key, status: 'fail', latency, error: msg });
271
293
  }
272
294
  },
273
295
  });
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "dsh-model-health",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/oxlyn/dsh-model-health.git"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "exports": {