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 +213 -80
- package/dist/index.js +73 -51
- package/package.json +5 -1
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
|
-
|
|
121
|
-
display: "inline-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
background: "var(--dsw-alias-state-error-bg, #fce8e6)",
|
|
198
|
+
statusTextFail: {
|
|
134
199
|
color: "var(--dsw-alias-state-error-primary, #d32f2f)",
|
|
135
200
|
},
|
|
136
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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: "
|
|
187
|
-
{ key: "modelId", label: "
|
|
188
|
-
{ key: "modelName", label: "
|
|
189
|
-
{ key: "contextWindow", label: "
|
|
190
|
-
{ key: "maxTokens", label: "
|
|
191
|
-
{ key: "input", label: "
|
|
192
|
-
{ key: "api", label: "
|
|
193
|
-
{ key: "baseURL", label: "
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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: { [
|
|
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 (
|
|
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
|
|
290
|
-
const queue = [...indices];
|
|
405
|
+
const queue = data.models.map((m) => m.key);
|
|
291
406
|
|
|
292
407
|
// 更新单个结果并同步到 localStorage
|
|
293
|
-
function updateResult(
|
|
408
|
+
function updateResult(key, result) {
|
|
294
409
|
setTestResults((s) => {
|
|
295
|
-
const next = { ...s, [
|
|
410
|
+
const next = { ...s, [key]: result };
|
|
296
411
|
saveStoredResults(next);
|
|
297
412
|
return next;
|
|
298
413
|
});
|
|
299
414
|
}
|
|
300
415
|
|
|
301
|
-
async function runOne(
|
|
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({
|
|
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(
|
|
426
|
+
updateResult(key, { status: "fail", error: json.error });
|
|
312
427
|
} else {
|
|
313
|
-
updateResult(
|
|
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(
|
|
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
|
|
332
|
-
if (
|
|
333
|
-
await runOne(
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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[
|
|
475
|
-
const
|
|
476
|
-
const
|
|
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.
|
|
498
|
-
...
|
|
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
|
-
},
|
|
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 =
|
|
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 =
|
|
178
|
+
const cfg = readSettingsCached();
|
|
134
179
|
const rows = collectModels(cfg);
|
|
135
|
-
|
|
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
|
-
|
|
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: { "
|
|
158
|
-
//
|
|
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
|
-
|
|
170
|
-
if (typeof
|
|
171
|
-
throw new Error('
|
|
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.
|
|
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 =
|
|
214
|
+
const cfg = readSettingsCached();
|
|
184
215
|
const rows = collectModels(cfg);
|
|
185
|
-
const found = rows
|
|
216
|
+
const found = rows.find((r) => r.key === key);
|
|
186
217
|
if (!found) {
|
|
187
|
-
res
|
|
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.
|
|
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
|
|
203
|
-
|
|
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
|
|
224
|
-
|
|
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
|
|
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
|
|
259
|
-
|
|
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
|
|
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
|
+
"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": {
|