dsh-model-health 0.1.0

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 ADDED
@@ -0,0 +1,81 @@
1
+ # dsh-model-health
2
+
3
+ > DeepSeek Harness (DSH) 插件:在设置页展示「模型健康」面板——列出所有已配置模型,并支持一键批量测试可用性与延迟。
4
+
5
+ ## 功能 / Features
6
+
7
+ 读取 `$DSH_HOME/settings.yaml`(默认 `~/.dsh/settings.yaml`),提供三种使用方式:
8
+
9
+ | # | 形式 | 入口 | 说明 |
10
+ |---|------|------|------|
11
+ | 1 | 设置页面板「模型健康」 | Web UI → 设置 → 模型健康 | React 表格:Provider / 模型 ID / 名称 / 上下文窗口 / 最大输出 / 输入模态 / API 协议 / BaseURL 等列(可选列可切换显示) |
12
+ | 2 | 一键测试全部 | 面板内「测试全部」按钮 | 并发(上限 6)对每个模型发一次 `max_tokens=1` 的最小 chat completions 请求,10s 超时;逐行显示 可用 / 不可用 / 跳过 / 延迟,失败原因悬停查看,结果持久化到 localStorage |
13
+ | 3 | Tool 插件 `list_models` | 对话中调用 | 返回 Markdown 表格,供模型在对话中查看模型清单 |
14
+
15
+ 支持 `llm-pi-ai`(多协议自定义提供商)与 `llm-deepseek`(官方)两类配置;测试支持 `openai-completions` 与 `deepseek` 协议,其余标记为「跳过」。API Key 通过 DSH credential service 解析,不会输出到浏览器。
16
+
17
+ ## 安装 / Install
18
+
19
+ 一句话安装(npm 包,发布后可用):
20
+
21
+ ```sh
22
+ dsh plugin --profile my-profile add dsh-model-health
23
+ ```
24
+
25
+ ## 源码安装 / Install from source
26
+
27
+ ```sh
28
+ git clone https://github.com/<你的用户名>/dsh-model-health.git
29
+ cd dsh-model-health
30
+ pnpm install
31
+ pnpm run build # tsc → dist/index.js (pure ESM) + dist/client.js
32
+
33
+ # 在插件的父目录执行(dsh plugin add 的相对路径锚定调用目录):
34
+ cd ..
35
+ dsh plugin --profile my-profile add ./dsh-model-health
36
+ dsh --profile my-profile
37
+ # 启动日志应出现:[dsh-model-health] ready — tool "list_models" + routes GET /api/model-health/json, POST /api/model-health/test
38
+ ```
39
+
40
+ 无 API key 也可验证加载:
41
+
42
+ ```sh
43
+ dsh --profile my-profile --dump-config | grep dsh-model-health # 配置层含本行
44
+ ```
45
+
46
+ ## Dependencies pinned / 依赖锁定
47
+
48
+ - `@deepseek-ai/dsh-tools`: `0.1.0-rc.8` (exact — the **`next`**-tag line; npm `latest` is stale).
49
+ - `@deepseek-ai/cordis`: `^4.0.1` (peerDependency — host provides it; types-only in code).
50
+
51
+ ## Pitfalls / 坑(从真实 spike 提炼,防呆)
52
+
53
+ 1. Node version: DSH requires Node ^22.19.0 || >=24.0.0. Older Node (e.g. v22.17) only warns EBADENGINE but may hit runtime issues — upgrade if you can.
54
+ - Node 版本:DSH 要求 ^22.19.0 || >=24.0.0。旧版本(如 v22.17)只告警 EBADENGINE,不阻断,但建议升级。
55
+
56
+ 2. npm dist-tag trap (the big one): `@deepseek-ai/dsh-tools` `latest` is a STALE 0.0.1-rc.1; the real line is under the `next` tag (0.1.0-rc.x). This scaffold pins the next-tag version for you — never `npm i @deepseek-ai/dsh-tools` over it.
57
+ - npm dist-tag 坑(最大):`@deepseek-ai/dsh-tools` 的 latest 是过期的 0.0.1-rc.1,正确版本在 next tag。本脚手架已锁 next 版本,勿再手动 npm i 覆盖。
58
+
59
+ 3. Version-line alignment: keep every `@deepseek-ai/dsh-*` package on the same `0.1.0-rc.x` line so pnpm does not install two module copies.
60
+ - 版本线对齐:所有 @deepseek-ai/dsh-* 包统一用同一 0.1.0-rc.x 线,避免 pnpm 装两份模块。
61
+
62
+ 4. `@deepseek-ai/cordis` is a peerDependency: import only `type { Context }` (erased at compile). At runtime the host hands you `ctx` — never import cordis values at runtime.
63
+ - @deepseek-ai/cordis 是 peerDep:只 import type(编译期擦除),运行时 ctx 由宿主传入。
64
+
65
+ 5. Pure ESM: package.json must set `"type": "module"`; build with `module: esnext` + `moduleResolution: bundler` to keep bare specifiers.
66
+ - 纯 ESM:package.json 必须 "type": "module";tsc 用 module:esnext + moduleResolution:bundler 保留 bare specifier。
67
+
68
+ 6. `dsh plugin add <dir>` anchors relative paths to the INVOKING directory — run it from the parent directory, not from inside the plugin.
69
+ - dsh plugin add <dir> 的相对路径锚定调用目录——要在插件的父目录执行。
70
+
71
+ 7. In the bundle `cordis.patch.yml`, `name` is a package name (resolved via node_modules / `$DSH_HOME/profiles/node_modules`), not a relative path.
72
+ - bundle 的 cordis.patch.yml 里 name 用包名(走 node_modules 解析),不要用相对路径。
73
+
74
+ 8. Registrations are effects: `ctx.tools.register()` / `ctx.on()` auto-dispose on unload. Wrap your OWN resources (timers/connections) in `ctx.effect(() => { acquire; return cleanup })`.
75
+ - 注册是 effect:ctx.tools.register()/ctx.on() 卸载自动清理;自己的资源(timer/连接)要包 ctx.effect(() => {…; return cleanup})。
76
+
77
+ 9. Load order = service dependencies, never file order: `export const inject = ['tools']` makes the plugin wait until `ctx.tools` is ready.
78
+ - 加载顺序靠服务依赖(inject),不靠文件顺序。
79
+
80
+ 10. Full end-to-end (model actually calls your tool) needs `DEEPSEEK_API_KEY`; without it `--verify` proves load/list/event, and the model call fails with MISSING_CREDENTIAL.
81
+ - 端到端(模型真正调工具)需 DEEPSEEK_API_KEY;无 key 时 --verify 只能证明加载/列出/事件,模型调用会 MISSING_CREDENTIAL。
@@ -0,0 +1,9 @@
1
+ # dsh-model-health bundle layer — applied when a profile lists this bundle
2
+ # (package.json declares `dsh.bundle.patch` pointing at this file).
3
+ #
4
+ # PITFALL: `name` is a PACKAGE NAME (resolved through the profile's node_modules
5
+ # or the installation fallback `$DSH_HOME/profiles/node_modules`), NOT a
6
+ # relative path. `dsh-model-health` is this package's main entry (→ dist/index.js).
7
+ - insert:
8
+ - id: dsh-model-health
9
+ name: dsh-model-health
package/dist/client.js ADDED
@@ -0,0 +1,593 @@
1
+ // dsh-model-health — client 模块:在设置页注入"模型列表"section。
2
+ //
3
+ // 格式:window.__ModuleLoader__.load({ id, factory: (require) => {...} })
4
+ // 这是 DSH 浏览器侧的模块加载器格式,factory 内用 require() 获取依赖。
5
+ //
6
+ // 注册一个 settings.section slot,组件通过 fetch /api/model-health/json 获取
7
+ // 模型列表 JSON,然后用 React 直接渲染表格。
8
+ // 支持"测试全部":并发 POST /api/model-health/test,逐个更新测试状态。
9
+ //
10
+ window.__ModuleLoader__.load({
11
+ id: "dsh-model-health",
12
+ factory: (require) => {
13
+ var module = { exports: {} };
14
+ var exports = module.exports;
15
+
16
+ const React = require("react");
17
+ const { useState, useEffect, useCallback } = React;
18
+
19
+ // 内联样式(适配 DSH 明暗主题的 CSS 变量)
20
+ const STYLES = {
21
+ wrap: {
22
+ maxWidth: 1100,
23
+ color: "var(--dsw-alias-label-primary, #333)",
24
+ display: "flex",
25
+ flexDirection: "column",
26
+ gap: 12,
27
+ },
28
+ head: {
29
+ display: "flex",
30
+ alignItems: "baseline",
31
+ gap: 8,
32
+ flexWrap: "wrap",
33
+ },
34
+ title: {
35
+ margin: 0,
36
+ fontSize: 16,
37
+ fontWeight: 500,
38
+ lineHeight: "24px",
39
+ },
40
+ count: {
41
+ display: "inline-block",
42
+ background: "var(--dsw-alias-button-primary-fill, #4f46e5)",
43
+ color: "var(--dsw-alias-label-primary-foreground, #fff)",
44
+ padding: "2px 10px",
45
+ borderRadius: 12,
46
+ fontSize: 12,
47
+ },
48
+ toolbar: {
49
+ display: "flex",
50
+ alignItems: "center",
51
+ gap: 8,
52
+ flexWrap: "wrap",
53
+ },
54
+ meta: {
55
+ color: "var(--dsw-alias-label-tertiary, #888)",
56
+ fontSize: 13,
57
+ marginRight: "auto",
58
+ },
59
+ btn: {
60
+ boxSizing: "border-box",
61
+ height: 28,
62
+ padding: "0 14px",
63
+ fontSize: 12,
64
+ lineHeight: "18px",
65
+ cursor: "pointer",
66
+ border: "1px solid var(--dsw-alias-border-l2, #ddd)",
67
+ borderRadius: 14,
68
+ background: "transparent",
69
+ color: "var(--dsw-alias-label-primary, #333)",
70
+ },
71
+ btnPrimary: {
72
+ background: "var(--dsw-alias-button-primary-fill, #4f46e5)",
73
+ color: "var(--dsw-alias-label-primary-foreground, #fff)",
74
+ border: "none",
75
+ },
76
+ btnDisabled: {
77
+ opacity: 0.5,
78
+ cursor: "not-allowed",
79
+ },
80
+ progress: {
81
+ fontSize: 12,
82
+ color: "var(--dsw-alias-label-tertiary, #888)",
83
+ },
84
+ tableWrap: {
85
+ // 圆角/阴影放在外层容器:<table> 上的 overflow/border-radius 不生效,
86
+ // 且 collapse 模式下圆角无法裁剪单元格背景
87
+ background: "var(--dsw-alias-bg-module-platform, #fff)",
88
+ borderRadius: 8,
89
+ overflow: "hidden",
90
+ boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
91
+ },
92
+ table: {
93
+ width: "100%",
94
+ borderCollapse: "collapse",
95
+ fontSize: 14,
96
+ },
97
+ th: {
98
+ padding: "10px 12px",
99
+ textAlign: "left",
100
+ // 四边全边框:collapse 模式下相邻边自动合并,
101
+ // 整张表(含外框)呈现统一闭合的网格线
102
+ border: "1px solid var(--dsw-alias-border-l2, #eee)",
103
+ background: "var(--dsw-alias-bg-layer-2, #fafafa)",
104
+ fontWeight: 600,
105
+ whiteSpace: "nowrap",
106
+ },
107
+ td: {
108
+ padding: "10px 12px",
109
+ textAlign: "left",
110
+ border: "1px solid var(--dsw-alias-border-l2, #eee)",
111
+ color: "var(--dsw-alias-label-primary, #333)",
112
+ },
113
+ code: {
114
+ background: "var(--dsw-alias-bg-layer-2, #f0f0f0)",
115
+ padding: "2px 6px",
116
+ borderRadius: 3,
117
+ fontSize: 12,
118
+ fontFamily: "var(--ds-font-family-code, monospace)",
119
+ },
120
+ badge: {
121
+ display: "inline-block",
122
+ padding: "2px 8px",
123
+ borderRadius: 10,
124
+ fontSize: 12,
125
+ fontWeight: 500,
126
+ whiteSpace: "nowrap",
127
+ },
128
+ badgeOk: {
129
+ background: "var(--dsw-alias-state-success-bg, #e6f4ea)",
130
+ color: "var(--dsw-alias-state-success-primary, #1e8e3e)",
131
+ },
132
+ badgeFail: {
133
+ background: "var(--dsw-alias-state-error-bg, #fce8e6)",
134
+ color: "var(--dsw-alias-state-error-primary, #d32f2f)",
135
+ },
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)",
142
+ color: "var(--dsw-alias-label-tertiary, #888)",
143
+ },
144
+ badgeIdle: {
145
+ background: "var(--dsw-alias-bg-layer-2, #f0f0f0)",
146
+ color: "var(--dsw-alias-label-tertiary, #999)",
147
+ },
148
+ center: {
149
+ textAlign: "center",
150
+ color: "var(--dsw-alias-label-tertiary, #999)",
151
+ padding: "40px 0",
152
+ },
153
+ error: {
154
+ color: "var(--dsw-alias-state-error-primary, #d32f2f)",
155
+ padding: "16px",
156
+ },
157
+ loading: {
158
+ color: "var(--dsw-alias-label-tertiary, #999)",
159
+ padding: "24px 0",
160
+ textAlign: "center",
161
+ },
162
+ errorMsg: {
163
+ color: "var(--dsw-alias-state-error-primary, #d32f2f)",
164
+ fontSize: 12,
165
+ maxWidth: 200,
166
+ overflow: "hidden",
167
+ textOverflow: "ellipsis",
168
+ whiteSpace: "nowrap",
169
+ },
170
+ colToggleLabel: {
171
+ display: "inline-flex",
172
+ alignItems: "center",
173
+ gap: 4,
174
+ fontSize: 12,
175
+ cursor: "pointer",
176
+ color: "var(--dsw-alias-label-secondary, #555)",
177
+ userSelect: "none",
178
+ },
179
+ colToggleCheckbox: {
180
+ cursor: "pointer",
181
+ },
182
+ };
183
+
184
+ // 列定义:optional=true 的列默认不显示,由多选框控制
185
+ 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: "延迟" },
196
+ ];
197
+
198
+ // 状态标签映射
199
+ const STATUS_LABEL = {
200
+ idle: "未测试",
201
+ testing: "测试中",
202
+ ok: "可用",
203
+ fail: "不可用",
204
+ skip: "跳过",
205
+ };
206
+ const STATUS_STYLE = {
207
+ idle: STYLES.badgeIdle,
208
+ testing: STYLES.badgeTesting,
209
+ ok: STYLES.badgeOk,
210
+ fail: STYLES.badgeFail,
211
+ skip: STYLES.badgeSkip,
212
+ };
213
+
214
+ /**
215
+ * 模型列表 section 组件。
216
+ * fetch /api/model-health/json 获取数据 → React 渲染表格。
217
+ * "测试全部" → 并发 POST /api/model-health/test,逐个更新状态。
218
+ */
219
+ // localStorage 持久化测试结果的 key
220
+ const STORAGE_KEY = "dsh-model-health:test-results";
221
+
222
+ function loadStoredResults() {
223
+ try {
224
+ const raw = localStorage.getItem(STORAGE_KEY);
225
+ return raw ? JSON.parse(raw) : {};
226
+ } catch (e) {
227
+ return {};
228
+ }
229
+ }
230
+
231
+ function saveStoredResults(results) {
232
+ try {
233
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(results));
234
+ } catch (e) {
235
+ // ignore quota errors
236
+ }
237
+ }
238
+
239
+ function ModelListSection() {
240
+ const [state, setState] = useState({
241
+ loading: true,
242
+ error: null,
243
+ data: null,
244
+ });
245
+ // testResults: { [index]: { status, latency?, error? } }
246
+ // 初始值从 localStorage 读取,实现跨刷新持久化
247
+ const [testResults, setTestResults] = useState(loadStoredResults);
248
+ const [testing, setTesting] = useState(false);
249
+ const [progress, setProgress] = useState({ done: 0, total: 0 });
250
+ // 可选列显示状态:{ [colKey]: boolean },默认 false
251
+ const [optionalCols, setOptionalCols] = useState({});
252
+ // hover tooltip 状态:{ index, error } | null
253
+ const [tooltip, setTooltip] = useState(null);
254
+
255
+ const loadData = useCallback(async () => {
256
+ setState((s) => ({ ...s, loading: true, error: null }));
257
+ try {
258
+ const resp = await fetch("/api/model-health/json", { cache: "no-store" });
259
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
260
+ const json = await resp.json();
261
+ if (!json.ok) throw new Error(json.error || "未知错误");
262
+ setState({ loading: false, error: null, data: json });
263
+ // 不清空 testResults,保留上次测试结果
264
+ } catch (e) {
265
+ setState({ loading: false, error: e.message, data: null });
266
+ }
267
+ }, []);
268
+
269
+ useEffect(() => {
270
+ loadData();
271
+ }, [loadData]);
272
+
273
+ // 测试全部:并发发起所有测试请求,每个完成即更新对应行状态
274
+ const testAll = useCallback(async () => {
275
+ const data = state.data;
276
+ if (!data || !data.models || data.models.length === 0) return;
277
+ const total = data.models.length;
278
+ setTesting(true);
279
+ setProgress({ done: 0, total });
280
+ // 初始化所有为 testing
281
+ const init = {};
282
+ for (let i = 0; i < total; i++) init[i] = { status: "testing" };
283
+ setTestResults(init);
284
+ saveStoredResults(init);
285
+
286
+ let done = 0;
287
+ // 并发测试,但限制并发数避免压垮 host/网络
288
+ const CONCURRENCY = 6;
289
+ const indices = Array.from({ length: total }, (_, i) => i);
290
+ const queue = [...indices];
291
+
292
+ // 更新单个结果并同步到 localStorage
293
+ function updateResult(idx, result) {
294
+ setTestResults((s) => {
295
+ const next = { ...s, [idx]: result };
296
+ saveStoredResults(next);
297
+ return next;
298
+ });
299
+ }
300
+
301
+ async function runOne(idx) {
302
+ try {
303
+ const resp = await fetch("/api/model-health/test", {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json" },
306
+ body: JSON.stringify({ index: idx }),
307
+ cache: "no-store",
308
+ });
309
+ const json = await resp.json();
310
+ if (!json.ok) {
311
+ updateResult(idx, { status: "fail", error: json.error });
312
+ } else {
313
+ updateResult(idx, {
314
+ status: json.status || "fail",
315
+ latency: json.latency,
316
+ error: json.error,
317
+ });
318
+ }
319
+ } catch (e) {
320
+ updateResult(idx, { status: "fail", error: e.message });
321
+ } finally {
322
+ done++;
323
+ setProgress({ done, total });
324
+ if (done >= total) setTesting(false);
325
+ }
326
+ }
327
+
328
+ // 限制并发的 worker pool
329
+ async function worker() {
330
+ while (queue.length > 0) {
331
+ const idx = queue.shift();
332
+ if (idx === undefined) break;
333
+ await runOne(idx);
334
+ }
335
+ }
336
+ const workers = Array.from(
337
+ { length: Math.min(CONCURRENCY, total) },
338
+ () => worker()
339
+ );
340
+ await Promise.all(workers);
341
+ }, [state.data]);
342
+
343
+ const h = React.createElement;
344
+
345
+ if (state.loading) {
346
+ return h("div", { style: STYLES.wrap },
347
+ h("div", { style: STYLES.loading }, "加载中...")
348
+ );
349
+ }
350
+
351
+ if (state.error) {
352
+ return h("div", { style: STYLES.wrap },
353
+ h("div", { style: STYLES.error },
354
+ "读取模型列表失败:", state.error
355
+ ),
356
+ h("button", {
357
+ style: STYLES.btn,
358
+ onClick: loadData,
359
+ }, "重试")
360
+ );
361
+ }
362
+
363
+ const data = state.data || { models: [], count: 0, source: "", updatedAt: "" };
364
+ const models = data.models || [];
365
+ const updatedAt = new Date(data.updatedAt || Date.now()).toLocaleString("zh-CN");
366
+
367
+ // 统计测试结果
368
+ const stats = { ok: 0, fail: 0, skip: 0, testing: 0 };
369
+ for (const k in testResults) {
370
+ const s = testResults[k].status;
371
+ if (s === "ok") stats.ok++;
372
+ else if (s === "fail") stats.fail++;
373
+ else if (s === "skip") stats.skip++;
374
+ else if (s === "testing") stats.testing++;
375
+ }
376
+
377
+ // 可见列:非可选列始终显示;可选列根据 optionalCols 状态
378
+ const visibleCols = COLUMNS.filter(
379
+ (col) => !col.optional || optionalCols[col.key]
380
+ );
381
+ const optionalColumns = COLUMNS.filter((col) => col.optional);
382
+
383
+ const toggleCol = (key) => {
384
+ setOptionalCols((s) => ({ ...s, [key]: !s[key] }));
385
+ };
386
+
387
+ return h("div", { style: STYLES.wrap },
388
+ h("div", { style: STYLES.head },
389
+ h("h2", { style: STYLES.title }, "已配置模型"),
390
+ h("span", { style: STYLES.count }, data.count || models.length)
391
+ ),
392
+ h("div", { style: STYLES.toolbar },
393
+ h("span", { style: STYLES.meta },
394
+ "更新时间:", updatedAt
395
+ ),
396
+ // 测试统计
397
+ (stats.ok + stats.fail + stats.skip > 0) && h("span", { style: STYLES.progress },
398
+ `✓ ${stats.ok} ✗ ${stats.fail}`,
399
+ stats.skip > 0 ? ` ⊘ ${stats.skip}` : ""
400
+ ),
401
+ testing && h("span", { style: STYLES.progress },
402
+ `${progress.done}/${progress.total}`
403
+ ),
404
+ // 测试全部按钮
405
+ h("button", {
406
+ style: {
407
+ ...STYLES.btn,
408
+ ...STYLES.btnPrimary,
409
+ ...(testing ? STYLES.btnDisabled : {}),
410
+ },
411
+ disabled: testing || models.length === 0,
412
+ onClick: testAll,
413
+ }, testing ? "测试中..." : "测试全部")
414
+ ),
415
+ // 可选列 toggle 按钮组
416
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
417
+ h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-tertiary, #888)" } },
418
+ "显示列:"
419
+ ),
420
+ optionalColumns.map((col) => {
421
+ const active = !!optionalCols[col.key];
422
+ return h("button", {
423
+ key: col.key,
424
+ onClick: () => toggleCol(col.key),
425
+ style: {
426
+ boxSizing: "border-box",
427
+ height: 24,
428
+ padding: "0 10px",
429
+ fontSize: 12,
430
+ cursor: "pointer",
431
+ border: active
432
+ ? "1px solid var(--dsw-alias-button-primary-fill, #4f46e5)"
433
+ : "1px solid var(--dsw-alias-border-l2, #ddd)",
434
+ borderRadius: 12,
435
+ background: active
436
+ ? "var(--dsw-alias-button-primary-fill, #4f46e5)"
437
+ : "transparent",
438
+ color: active
439
+ ? "var(--dsw-alias-label-primary-foreground, #fff)"
440
+ : "var(--dsw-alias-label-secondary, #555)",
441
+ },
442
+ }, col.label);
443
+ })
444
+ ),
445
+ models.length === 0
446
+ ? h("div", { style: STYLES.center },
447
+ "未配置任何模型,请在 设置 → 模型 中添加。"
448
+ )
449
+ : h("div", { style: STYLES.tableWrap },
450
+ h("table", { style: STYLES.table },
451
+ h("thead", null,
452
+ h("tr", null,
453
+ visibleCols.map((col) =>
454
+ h("th", {
455
+ key: col.key,
456
+ style: STYLES.th,
457
+ }, col.label)
458
+ )
459
+ )
460
+ ),
461
+ h("tbody", null,
462
+ // 预计算 provider 连续段的 rowspan:{ [startIdx]: spanLen }
463
+ // 相同 provider 的连续行合并为一个单元格
464
+ (() => {
465
+ const providerSpan = {};
466
+ let k = 0;
467
+ while (k < models.length) {
468
+ const start = k;
469
+ const p = models[k].provider;
470
+ while (k < models.length && models[k].provider === p) k++;
471
+ providerSpan[start] = k - start;
472
+ }
473
+ 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;
477
+ const latencyText = tr.latency != null ? `${tr.latency}ms` : "-";
478
+ return h("tr", { key: (row.modelId || "") + i },
479
+ visibleCols.map((col) => {
480
+ if (col.key === "provider") {
481
+ const span = providerSpan[i];
482
+ if (span === undefined) return null; // 被合并,跳过
483
+ return h("td", {
484
+ key: col.key,
485
+ rowSpan: span,
486
+ style: { ...STYLES.td, verticalAlign: "middle", fontWeight: 600 },
487
+ }, row.provider);
488
+ }
489
+ if (col.key === "_status") {
490
+ const hasError = tr.error && tr.status === "fail";
491
+ return h("td", {
492
+ key: col.key,
493
+ style: { ...STYLES.td },
494
+ },
495
+ h("span", {
496
+ style: {
497
+ ...STYLES.badge,
498
+ ...statusStyle,
499
+ cursor: hasError ? "help" : "default",
500
+ },
501
+ onMouseEnter: hasError
502
+ ? (e) => {
503
+ const rowEl = e.currentTarget.closest('tr');
504
+ const tableEl = e.currentTarget.closest('table');
505
+ if (rowEl && tableEl) {
506
+ const rowRect = rowEl.getBoundingClientRect();
507
+ const tableRect = tableEl.getBoundingClientRect();
508
+ setTooltip({
509
+ index: i,
510
+ error: tr.error,
511
+ left: tableRect.left,
512
+ width: tableRect.width,
513
+ rowTop: rowRect.top,
514
+ });
515
+ }
516
+ }
517
+ : undefined,
518
+ onMouseLeave: hasError
519
+ ? () => setTooltip(null)
520
+ : undefined,
521
+ }, statusLabel)
522
+ );
523
+ }
524
+ if (col.key === "_latency") {
525
+ return h("td", { key: col.key, style: STYLES.td }, latencyText);
526
+ }
527
+ const value = row[col.key] ?? "-";
528
+ return h("td", {
529
+ key: col.key,
530
+ style: STYLES.td,
531
+ },
532
+ col.code
533
+ ? h("code", { style: STYLES.code }, String(value))
534
+ : String(value)
535
+ );
536
+ })
537
+ );
538
+ });
539
+ })()
540
+ )
541
+ )
542
+ ),
543
+ // 全宽 hover tooltip:横向占满表格,显示在所悬停行的正上方
544
+ tooltip ? h("div", {
545
+ style: {
546
+ position: "fixed",
547
+ left: tooltip.left + "px",
548
+ width: tooltip.width + "px",
549
+ top: tooltip.rowTop + "px",
550
+ transform: "translateY(-100%)",
551
+ marginTop: -4,
552
+ padding: "8px 12px",
553
+ background: "var(--dsw-alias-state-error-bg, #2a2a2a)",
554
+ color: "var(--dsw-alias-state-error-primary, #ff6b6b)",
555
+ fontSize: 12,
556
+ lineHeight: "16px",
557
+ borderRadius: 6,
558
+ whiteSpace: "pre-wrap",
559
+ wordBreak: "break-all",
560
+ zIndex: 1000,
561
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
562
+ border: "1px solid var(--dsw-alias-border-l2, #444)",
563
+ pointerEvents: "none",
564
+ },
565
+ }, tooltip.error) : null
566
+ );
567
+ }
568
+
569
+ // client 侧依赖:slots(注册 UI slot 的服务)
570
+ const inject = ["slots"];
571
+
572
+ /**
573
+ * @param {import('@deepseek-ai/dsh-client-runtime/client').ClientContext} ctx
574
+ */
575
+ function apply(ctx) {
576
+ ctx.slots.inject("settings.section", () =>
577
+ ctx.slots.register(
578
+ {
579
+ name: "settings.section",
580
+ id: "model-health",
581
+ order: 50,
582
+ label: "模型健康",
583
+ },
584
+ ModelListSection
585
+ )
586
+ );
587
+ }
588
+
589
+ exports.apply = apply;
590
+ exports.inject = inject;
591
+ return module.exports;
592
+ }
593
+ });
package/dist/index.js ADDED
@@ -0,0 +1,275 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { load as parseYaml } from 'js-yaml';
6
+ export const name = 'dsh-model-health';
7
+ // 声明所有用到的服务:
8
+ // - tools:Tool 插件
9
+ // - webServer:HTTP 路由
10
+ // - credentials:通过 apiKeyEnv 名解析实际 API Key(DSH 的 credential seam)
11
+ export const inject = ['tools', 'webServer', 'credentials'];
12
+ /** JSON 输出时去掉 apiKeyEnv(不暴露到浏览器) */
13
+ function toPublicRow(r) {
14
+ const { apiKeyEnv: _omit, ...public_ } = r;
15
+ return public_;
16
+ }
17
+ /** 解析 DSH 配置目录:优先 $DSH_HOME,回退到 ~/.dsh。 */
18
+ function dshHome() {
19
+ return process.env.DSH_HOME || join(homedir(), '.dsh');
20
+ }
21
+ /** 读取并解析 settings.yaml。 */
22
+ function readSettings() {
23
+ const settingsPath = join(dshHome(), 'settings.yaml');
24
+ let raw;
25
+ try {
26
+ raw = readFileSync(settingsPath, 'utf8');
27
+ }
28
+ catch (e) {
29
+ throw new Error(`无法读取 settings.yaml (${settingsPath}):${e.message}。` +
30
+ `请在 设置 → 模型 中配置后重试。`);
31
+ }
32
+ const cfg = parseYaml(raw);
33
+ return cfg && typeof cfg === 'object' ? cfg : {};
34
+ }
35
+ /** 从 settings 配置对象中收集所有已配置的模型。 */
36
+ export function collectModels(cfg) {
37
+ const rows = [];
38
+ // 1. 通用多协议提供商 llm-pi-ai
39
+ const piAi = cfg['llm-pi-ai'];
40
+ if (piAi && typeof piAi === 'object') {
41
+ const providers = piAi.providers || {};
42
+ for (const [route, p] of Object.entries(providers)) {
43
+ if (!p || typeof p !== 'object')
44
+ continue;
45
+ const models = Array.isArray(p.models) ? p.models : [];
46
+ for (const m of models) {
47
+ rows.push({
48
+ provider: route,
49
+ displayName: p.displayName || route,
50
+ modelId: m.id || '',
51
+ modelName: m.name || m.id || '',
52
+ contextWindow: m.contextWindow ?? p.defaultContextWindow ?? '-',
53
+ maxTokens: m.maxTokens ?? p.defaultMaxTokens ?? '-',
54
+ input: Array.isArray(m.input) ? m.input.join('/')
55
+ : (p.defaultInput || ['text']).join('/'),
56
+ api: p.api || '-',
57
+ baseURL: p.baseURL || '-',
58
+ apiKeyEnv: p.apiKeyEnv || '',
59
+ });
60
+ }
61
+ }
62
+ }
63
+ // 2. 官方 DeepSeek 提供商 llm-deepseek
64
+ const ds = cfg['llm-deepseek'];
65
+ if (ds && typeof ds === 'object') {
66
+ const models = Array.isArray(ds.models) ? ds.models : [];
67
+ for (const m of models) {
68
+ rows.push({
69
+ provider: 'deepseek',
70
+ displayName: 'DeepSeek',
71
+ modelId: m.id || '',
72
+ modelName: m.name || m.id || '',
73
+ contextWindow: m.contextWindow ?? '-',
74
+ maxTokens: m.maxTokens ?? '-',
75
+ input: Array.isArray(m.input) ? m.input.join('/') : 'text',
76
+ api: 'deepseek',
77
+ baseURL: 'https://api.deepseek.com',
78
+ apiKeyEnv: ds.apiKeyEnv || 'DEEPSEEK_API_KEY',
79
+ });
80
+ }
81
+ }
82
+ return rows;
83
+ }
84
+ /** 渲染为 Markdown 表格。 */
85
+ function renderMarkdownTable(rows) {
86
+ if (rows.length === 0) {
87
+ return [
88
+ '当前 settings.yaml 中未配置任何模型。',
89
+ '',
90
+ '请在 Web UI 的 设置 → 模型 中添加 DeepSeek API Key 或自定义提供商。',
91
+ `配置文件位置:${join(dshHome(), 'settings.yaml')}`,
92
+ ].join('\n');
93
+ }
94
+ const header = '| # | Provider | 模型 ID | 名称 | 上下文窗口 | 最大输出 | 输入模态 | API 协议 | BaseURL |';
95
+ const sep = '|---|----------|---------|------|-----------|---------|---------|---------|---------|';
96
+ const lines = rows.map((r, i) => {
97
+ return `| ${i + 1} | ${r.provider} | ${r.modelId} | ${r.modelName} | ${r.contextWindow} | ${r.maxTokens} | ${r.input} | ${r.api} | ${r.baseURL} |`;
98
+ });
99
+ return [
100
+ `已配置 **${rows.length}** 个模型:`,
101
+ '',
102
+ header,
103
+ sep,
104
+ ...lines,
105
+ '',
106
+ `_来源:${join(dshHome(), 'settings.yaml')}_`,
107
+ ].join('\n');
108
+ }
109
+ export function apply(ctx) {
110
+ // ── 1. Tool 插件(对话中调用,返回 Markdown 表格)──────────────────
111
+ ctx.tools.register(defineTool({
112
+ name: 'list_models',
113
+ description: '列出当前 DSH 配置($DSH_HOME/settings.yaml)中已配置的所有模型,' +
114
+ '以 Markdown 表格展示。用于在对话中查看当前可用模型清单。',
115
+ parameters: {},
116
+ output: {
117
+ schema: { type: 'string' },
118
+ render: (_args, value) => [{ type: 'text', text: value }],
119
+ },
120
+ async execute() {
121
+ const cfg = readSettings();
122
+ return renderMarkdownTable(collectModels(cfg));
123
+ },
124
+ }));
125
+ // ── 2. HTTP 路由(返回 JSON,供 client 侧 React 组件 fetch)─────────
126
+ // API: ctx.webServer.register({ kind, path, handler })
127
+ // handler 签名: (req: IncomingMessage, res: ServerResponse) => void
128
+ ctx.webServer.register({
129
+ kind: 'exact',
130
+ path: '/api/model-health/json',
131
+ handler: (_req, res) => {
132
+ try {
133
+ const cfg = readSettings();
134
+ const rows = collectModels(cfg);
135
+ const payload = JSON.stringify({
136
+ ok: true,
137
+ count: rows.length,
138
+ source: join(dshHome(), 'settings.yaml'),
139
+ updatedAt: new Date().toISOString(),
140
+ models: rows.map(toPublicRow),
141
+ });
142
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
143
+ res.end(payload);
144
+ }
145
+ 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);
153
+ }
154
+ },
155
+ });
156
+ // ── 3. HTTP 路由(测试单个模型可用性)──────────────────────────────
157
+ // POST /api/model-health/test body: { "index": <number> }
158
+ // 对该模型发起一次最小 chat completions 请求,返回 { ok, index, status, latency, error? }
159
+ ctx.webServer.register({
160
+ kind: 'exact',
161
+ path: '/api/model-health/test',
162
+ handler: async (req, res) => {
163
+ // 读取请求体
164
+ let body = '';
165
+ for await (const chunk of req)
166
+ body += chunk;
167
+ let index;
168
+ try {
169
+ index = JSON.parse(body || '{}').index;
170
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
171
+ throw new Error('index 必须是非负整数');
172
+ }
173
+ }
174
+ 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}` }));
178
+ return;
179
+ }
180
+ // 查找模型配置
181
+ let row;
182
+ try {
183
+ const cfg = readSettings();
184
+ const rows = collectModels(cfg);
185
+ const found = rows[index];
186
+ 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} 的模型` }));
190
+ return;
191
+ }
192
+ row = found;
193
+ }
194
+ 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 }));
198
+ return;
199
+ }
200
+ // 仅支持 openai-completions 协议
201
+ 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',
205
+ error: `不支持 ${row.api} 协议的测试`,
206
+ }));
207
+ return;
208
+ }
209
+ // 获取 API Key
210
+ // 通过 DSH credential service 解析 API Key
211
+ // credentials.resolve(ref) 从 DSH 的 secret store 读取,无需进程环境变量
212
+ let apiKey = '';
213
+ if (row.apiKeyEnv) {
214
+ try {
215
+ const hit = await ctx.credentials.resolve(row.apiKeyEnv);
216
+ apiKey = hit?.value || '';
217
+ }
218
+ catch (e) {
219
+ // resolve 可能抛错(如 ref 未配置),按未设置处理
220
+ }
221
+ }
222
+ if (!apiKey) {
223
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
224
+ res.end(JSON.stringify({
225
+ ok: true, index, status: 'fail',
226
+ error: `未配置 ${row.apiKeyEnv} 的 API Key(请在 设置 → 模型 中添加)`,
227
+ }));
228
+ return;
229
+ }
230
+ // 发起最小测试请求(max_tokens=1,超时 10s)
231
+ const url = row.baseURL.replace(/\/$/, '') + '/chat/completions';
232
+ const controller = new AbortController();
233
+ const timer = setTimeout(() => controller.abort(), 10000);
234
+ const start = Date.now();
235
+ try {
236
+ const resp = await fetch(url, {
237
+ method: 'POST',
238
+ headers: {
239
+ 'Content-Type': 'application/json',
240
+ 'Authorization': `Bearer ${apiKey}`,
241
+ },
242
+ body: JSON.stringify({
243
+ model: row.modelId,
244
+ messages: [{ role: 'user', content: 'hi' }],
245
+ max_tokens: 1,
246
+ stream: false,
247
+ }),
248
+ signal: controller.signal,
249
+ });
250
+ clearTimeout(timer);
251
+ const latency = Date.now() - start;
252
+ if (resp.ok) {
253
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
254
+ res.end(JSON.stringify({ ok: true, index, status: 'ok', latency }));
255
+ }
256
+ else {
257
+ 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,
261
+ error: `HTTP ${resp.status}: ${text.slice(0, 300)}`,
262
+ }));
263
+ }
264
+ }
265
+ catch (e) {
266
+ clearTimeout(timer);
267
+ const latency = Date.now() - start;
268
+ 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 }));
271
+ }
272
+ },
273
+ });
274
+ console.log(`[dsh-model-health] ready — tool "list_models" + routes GET /api/model-health/json, POST /api/model-health/test`);
275
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "dsh-model-health",
3
+ "version": "0.1.0",
4
+ "description": "DSH plugin that lists all configured models with availability/latency testing, by reading $DSH_HOME/settings.yaml.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "exports": {
9
+ ".": "./dist/index.js",
10
+ "./client": "./dist/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "cordis.patch.yml"
16
+ ],
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ },
21
+ "client": {
22
+ "inject": [
23
+ "@deepseek-ai/dsh-client-runtime",
24
+ "@deepseek-ai/dsh-client-ui-settings"
25
+ ],
26
+ "platform": "web"
27
+ }
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json && node -e \"fs.cpSync('src/client.js', 'dist/client.js')\"",
31
+ "prepare": "pnpm run build",
32
+ "typecheck": "tsc -p tsconfig.json --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.8",
36
+ "js-yaml": "^4.1.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/cordis": "^4.0.1"
40
+ },
41
+ "devDependencies": {
42
+ "@deepseek-ai/cordis": "^4.0.1",
43
+ "@types/js-yaml": "^4.0.9",
44
+ "@types/node": "^22.10.0",
45
+ "typescript": "^5.6.0"
46
+ }
47
+ }