dsh-model-health 0.2.8 → 0.2.9
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 +3 -2
- package/dist/client.js +83 -26
- package/dist/index.js +89 -20
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -53,11 +53,11 @@ dsh --profile web --dump-config | grep dsh-model-health # 配置层含本行
|
|
|
53
53
|
**特性一览:**
|
|
54
54
|
|
|
55
55
|
- 支持 `llm-pi-ai`(多协议自定义提供商)与 `llm-deepseek`(官方)两类配置来源
|
|
56
|
-
- 可用性测试支持 `openai-completions` 与 `deepseek`
|
|
56
|
+
- 可用性测试支持 `openai-completions` 与 `deepseek` 协议,其余协议自动标记「跳过」;对 200 响应校验 body 确实是成功应答(部分网关 200 但 body 是错误对象)
|
|
57
57
|
- API Key 通过 DSH credential service(`ctx.credentials.resolve`)解析,密钥不会输出到浏览器
|
|
58
58
|
- 测试结果(状态/延迟/错误)持久化到 localStorage,刷新页面后仍可见
|
|
59
59
|
- 状态列的 可用 / 不可用 / 跳过 徽章随时可点击,对单个模型重新测试,原地刷新状态与延迟
|
|
60
|
-
- 设置导航使用脉搏线(Activity)图标——宿主图标表没有「健康」类目,参考 dsh-better-sidebar 的方案,JS 精准打 marker
|
|
60
|
+
- 设置导航使用脉搏线(Activity)图标——宿主图标表没有「健康」类目,参考 dsh-better-sidebar 的方案,JS 精准打 marker(rAF 合帧防抖),CSS 用 `::before` + `mask` 把齿轮 svg 原地替换;颜色随主题走
|
|
61
61
|
|
|
62
62
|
## 实现方式 / How it works
|
|
63
63
|
|
|
@@ -69,6 +69,7 @@ dsh --profile web --dump-config | grep dsh-model-health # 配置层含本行
|
|
|
69
69
|
│ - ctx.webServer.register: │
|
|
70
70
|
│ GET /api/model-health/json 读取并解析 settings.yaml → JSON │
|
|
71
71
|
│ POST /api/model-health/test 对单模型发最小请求测可用性/延迟 │
|
|
72
|
+
│ (Origin 限本机;200 响应校验 body 为成功应答) │
|
|
72
73
|
│ - 解析 DSH credential service 获取 API Key │
|
|
73
74
|
└──────────────────────────────────────────────────────────────────┘
|
|
74
75
|
│ fetch
|
package/dist/client.js
CHANGED
|
@@ -394,18 +394,39 @@
|
|
|
394
394
|
//#endregion
|
|
395
395
|
//#region src/client/storage.ts
|
|
396
396
|
const STORAGE_KEY = "dsh-model-health:test-results";
|
|
397
|
+
const VALID_STATUSES = /* @__PURE__ */ new Set([
|
|
398
|
+
"idle",
|
|
399
|
+
"testing",
|
|
400
|
+
"ok",
|
|
401
|
+
"fail",
|
|
402
|
+
"skip"
|
|
403
|
+
]);
|
|
404
|
+
/**
|
|
405
|
+
* 清洗任意来源(localStorage / 旧版本数据)的结果对象:
|
|
406
|
+
* - 残留的 testing(测试中刷新页面)归一为 idle
|
|
407
|
+
* - 旧版按数组下标存的纯数字 key 直接丢弃
|
|
408
|
+
* 纯函数,可单测。
|
|
409
|
+
*/
|
|
410
|
+
function sanitizeStoredResults(parsed) {
|
|
411
|
+
const cleaned = {};
|
|
412
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return cleaned;
|
|
413
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
414
|
+
if (/^\d+$/.test(k)) continue;
|
|
415
|
+
if (!v || typeof v !== "object") continue;
|
|
416
|
+
const raw = v;
|
|
417
|
+
const status = raw.status;
|
|
418
|
+
if (!VALID_STATUSES.has(status)) continue;
|
|
419
|
+
const result = { status: status === "testing" ? "idle" : status };
|
|
420
|
+
if (typeof raw.latency === "number" && raw.latency >= 0) result.latency = raw.latency;
|
|
421
|
+
if (typeof raw.error === "string") result.error = raw.error;
|
|
422
|
+
cleaned[k] = result;
|
|
423
|
+
}
|
|
424
|
+
return cleaned;
|
|
425
|
+
}
|
|
397
426
|
function loadStoredResults() {
|
|
398
427
|
try {
|
|
399
428
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
400
|
-
|
|
401
|
-
const cleaned = {};
|
|
402
|
-
for (const k in parsed) {
|
|
403
|
-
if (/^\d+$/.test(k)) continue;
|
|
404
|
-
const v = parsed[k];
|
|
405
|
-
if (!v || typeof v !== "object") continue;
|
|
406
|
-
cleaned[k] = v.status === "testing" ? { status: "idle" } : v;
|
|
407
|
-
}
|
|
408
|
-
return cleaned;
|
|
429
|
+
return raw ? sanitizeStoredResults(JSON.parse(raw)) : {};
|
|
409
430
|
} catch {
|
|
410
431
|
return {};
|
|
411
432
|
}
|
|
@@ -441,12 +462,15 @@
|
|
|
441
462
|
});
|
|
442
463
|
}, []);
|
|
443
464
|
const testOne = useCallback(async (key) => {
|
|
465
|
+
setResults((s) => {
|
|
466
|
+
if (s[key]?.status === "testing") return s;
|
|
467
|
+
return {
|
|
468
|
+
...s,
|
|
469
|
+
[key]: { status: "testing" }
|
|
470
|
+
};
|
|
471
|
+
});
|
|
444
472
|
const seq = (seqRef.current[key] || 0) + 1;
|
|
445
473
|
seqRef.current[key] = seq;
|
|
446
|
-
setResults((s) => ({
|
|
447
|
-
...s,
|
|
448
|
-
[key]: { status: "testing" }
|
|
449
|
-
}));
|
|
450
474
|
const result = await requestModelTest(key);
|
|
451
475
|
writeResult(key, seq, result);
|
|
452
476
|
}, [writeResult]);
|
|
@@ -465,7 +489,7 @@
|
|
|
465
489
|
});
|
|
466
490
|
let done = 0;
|
|
467
491
|
const queue = models.map((m) => m.key);
|
|
468
|
-
async
|
|
492
|
+
const worker = async () => {
|
|
469
493
|
while (queue.length > 0) {
|
|
470
494
|
const key = queue.shift();
|
|
471
495
|
if (key === void 0) break;
|
|
@@ -477,9 +501,20 @@
|
|
|
477
501
|
});
|
|
478
502
|
if (done >= total) setTesting(false);
|
|
479
503
|
}
|
|
480
|
-
}
|
|
504
|
+
};
|
|
481
505
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, total) }, () => worker()));
|
|
482
|
-
}, [testOne])
|
|
506
|
+
}, [testOne]),
|
|
507
|
+
prune: useCallback((validKeys) => {
|
|
508
|
+
setResults((s) => {
|
|
509
|
+
const next = {};
|
|
510
|
+
let changed = false;
|
|
511
|
+
for (const k in s) if (validKeys.has(k)) next[k] = s[k];
|
|
512
|
+
else changed = true;
|
|
513
|
+
if (!changed) return s;
|
|
514
|
+
saveStoredResults(next);
|
|
515
|
+
return next;
|
|
516
|
+
});
|
|
517
|
+
}, [])
|
|
483
518
|
};
|
|
484
519
|
}
|
|
485
520
|
|
|
@@ -552,7 +587,7 @@
|
|
|
552
587
|
//#region src/client/components/ModelListSection.tsx
|
|
553
588
|
function ModelListSection({ t, locale }) {
|
|
554
589
|
const React = getReact();
|
|
555
|
-
const { useState, useEffect, useCallback } = React;
|
|
590
|
+
const { useState, useEffect, useCallback, useRef } = React;
|
|
556
591
|
const run = useTestResults();
|
|
557
592
|
const [state, setState] = useState({
|
|
558
593
|
loading: true,
|
|
@@ -562,6 +597,8 @@
|
|
|
562
597
|
const [optionalCols, setOptionalCols] = useState({});
|
|
563
598
|
const [tooltip, setTooltip] = useState(null);
|
|
564
599
|
const [localeTick, setLocaleTick] = useState(0);
|
|
600
|
+
const tRef = useRef(t);
|
|
601
|
+
tRef.current = t;
|
|
565
602
|
useEffect(() => {
|
|
566
603
|
if (!locale || typeof locale.subscribe !== "function") return;
|
|
567
604
|
return locale.subscribe(() => setLocaleTick((n) => n + 1));
|
|
@@ -574,7 +611,7 @@
|
|
|
574
611
|
}));
|
|
575
612
|
try {
|
|
576
613
|
const json = await fetchModelList();
|
|
577
|
-
if (!json.ok) throw new Error(json.error ||
|
|
614
|
+
if (!json.ok) throw new Error(json.error || tRef.current("unknownError"));
|
|
578
615
|
setState({
|
|
579
616
|
loading: false,
|
|
580
617
|
error: null,
|
|
@@ -587,10 +624,23 @@
|
|
|
587
624
|
data: null
|
|
588
625
|
});
|
|
589
626
|
}
|
|
590
|
-
}, [
|
|
627
|
+
}, []);
|
|
591
628
|
useEffect(() => {
|
|
592
629
|
loadData();
|
|
593
630
|
}, [loadData]);
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
if (state.data?.ok && state.data.models) run.prune(new Set(state.data.models.map((m) => m.key)));
|
|
633
|
+
}, [state.data, run.prune]);
|
|
634
|
+
useEffect(() => {
|
|
635
|
+
if (!tooltip) return;
|
|
636
|
+
const close = () => setTooltip(null);
|
|
637
|
+
window.addEventListener("scroll", close, true);
|
|
638
|
+
window.addEventListener("resize", close);
|
|
639
|
+
return () => {
|
|
640
|
+
window.removeEventListener("scroll", close, true);
|
|
641
|
+
window.removeEventListener("resize", close);
|
|
642
|
+
};
|
|
643
|
+
}, [tooltip]);
|
|
594
644
|
const toggleCol = (key) => {
|
|
595
645
|
setOptionalCols((s) => ({
|
|
596
646
|
...s,
|
|
@@ -613,15 +663,13 @@
|
|
|
613
663
|
const models = data.models || [];
|
|
614
664
|
const activeLang = locale && typeof locale.getLocale === "function" ? locale.getLocale().active : "zh";
|
|
615
665
|
const updatedAt = new Date(data.updatedAt || Date.now()).toLocaleString(activeLang === "en" ? "en-US" : "zh-CN");
|
|
616
|
-
const validKeys = new Set(models.map((m) => m.key));
|
|
617
666
|
const stats = {
|
|
618
667
|
ok: 0,
|
|
619
668
|
fail: 0,
|
|
620
669
|
skip: 0
|
|
621
670
|
};
|
|
622
|
-
for (const
|
|
623
|
-
|
|
624
|
-
const s = run.results[k].status;
|
|
671
|
+
for (const m of models) {
|
|
672
|
+
const s = run.results[m.key]?.status;
|
|
625
673
|
if (s === "ok") stats.ok++;
|
|
626
674
|
else if (s === "fail") stats.fail++;
|
|
627
675
|
else if (s === "skip") stats.skip++;
|
|
@@ -665,7 +713,7 @@
|
|
|
665
713
|
style: STYLES.th
|
|
666
714
|
}, t(col.label))))), /* @__PURE__ */ React.createElement("tbody", null, models.map((row, i) => {
|
|
667
715
|
const tr = run.results[row.key] || { status: "idle" };
|
|
668
|
-
return /* @__PURE__ */ React.createElement("tr", { key:
|
|
716
|
+
return /* @__PURE__ */ React.createElement("tr", { key: row.key }, visibleCols.map((col) => {
|
|
669
717
|
if (col.key === "provider") {
|
|
670
718
|
const span = providerSpan[i];
|
|
671
719
|
if (span === void 0) return null;
|
|
@@ -714,7 +762,8 @@
|
|
|
714
762
|
*/
|
|
715
763
|
function registerSettingsNavIcon(label) {
|
|
716
764
|
let disposed = false;
|
|
717
|
-
|
|
765
|
+
let scheduled = false;
|
|
766
|
+
const run = () => {
|
|
718
767
|
if (disposed) return;
|
|
719
768
|
const currentLabel = label().trim();
|
|
720
769
|
const buttons = document.querySelectorAll("[role=\"dialog\"] nav button");
|
|
@@ -724,7 +773,15 @@
|
|
|
724
773
|
else button.removeAttribute(SETTINGS_NAV_MARKER);
|
|
725
774
|
}
|
|
726
775
|
};
|
|
727
|
-
sync()
|
|
776
|
+
const sync = () => {
|
|
777
|
+
if (scheduled || disposed) return;
|
|
778
|
+
scheduled = true;
|
|
779
|
+
requestAnimationFrame(() => {
|
|
780
|
+
scheduled = false;
|
|
781
|
+
run();
|
|
782
|
+
});
|
|
783
|
+
};
|
|
784
|
+
run();
|
|
728
785
|
const observer = new MutationObserver(sync);
|
|
729
786
|
observer.observe(document.body, {
|
|
730
787
|
childList: true,
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,7 @@ function toPublicRow(r) {
|
|
|
47
47
|
}
|
|
48
48
|
function collectModels(cfg) {
|
|
49
49
|
const rows = [];
|
|
50
|
+
if (!cfg || typeof cfg !== "object") return rows;
|
|
50
51
|
const piAi = cfg["llm-pi-ai"];
|
|
51
52
|
if (piAi && typeof piAi === "object") {
|
|
52
53
|
const providers = piAi.providers || {};
|
|
@@ -96,25 +97,27 @@ function collectModels(cfg) {
|
|
|
96
97
|
//#endregion
|
|
97
98
|
//#region src/host/markdown.ts
|
|
98
99
|
function renderMarkdownTable(rows) {
|
|
100
|
+
const settingsPath = join(dshHome(), "settings.yaml");
|
|
99
101
|
if (rows.length === 0) return [
|
|
100
|
-
"当前 settings.yaml
|
|
102
|
+
"当前 settings.yaml 中未配置任何模型。/ No models are configured in settings.yaml.",
|
|
101
103
|
"",
|
|
102
104
|
"请在 Web UI 的 设置 → 模型 中添加 DeepSeek API Key 或自定义提供商。",
|
|
103
|
-
|
|
105
|
+
"Please add a DeepSeek API key or a custom provider in Web UI → Settings → Models.",
|
|
106
|
+
`配置文件位置 / Config file: ${settingsPath}`
|
|
104
107
|
].join("\n");
|
|
105
|
-
const header = "| # | Provider |
|
|
106
|
-
const sep = "
|
|
108
|
+
const header = "| # | Provider | Model ID | Name | Context Window | Max Output | Input | API | BaseURL |";
|
|
109
|
+
const sep = "|---|----------|----------|------|----------------|------------|-------|-----|---------|";
|
|
107
110
|
const lines = rows.map((r, i) => {
|
|
108
111
|
return `| ${i + 1} | ${r.provider} | ${r.modelId} | ${r.modelName} | ${r.contextWindow} | ${r.maxTokens} | ${r.input} | ${r.api} | ${r.baseURL} |`;
|
|
109
112
|
});
|
|
110
113
|
return [
|
|
111
|
-
`已配置 **${rows.length}**
|
|
114
|
+
`已配置 **${rows.length}** 个模型 / **${rows.length}** model(s) configured:`,
|
|
112
115
|
"",
|
|
113
116
|
header,
|
|
114
117
|
sep,
|
|
115
118
|
...lines,
|
|
116
119
|
"",
|
|
117
|
-
`_
|
|
120
|
+
`_来源 / Source: ${settingsPath}_`
|
|
118
121
|
].join("\n");
|
|
119
122
|
}
|
|
120
123
|
//#endregion
|
|
@@ -125,6 +128,27 @@ function sendJson(res, code, obj) {
|
|
|
125
128
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
126
129
|
res.end(JSON.stringify(obj));
|
|
127
130
|
}
|
|
131
|
+
const LOCAL_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
132
|
+
"localhost",
|
|
133
|
+
"127.0.0.1",
|
|
134
|
+
"[::1]",
|
|
135
|
+
"::1",
|
|
136
|
+
"0.0.0.0"
|
|
137
|
+
]);
|
|
138
|
+
/**
|
|
139
|
+
* 浏览器跨站请求会携带 Origin 头:要求其指向本机(无 Origin 的请求——
|
|
140
|
+
* curl / 服务间调用——放行)。与 readJsonBody 的 Content-Type 门卫叠加,
|
|
141
|
+
* 构成写路由(POST /test)的双层 CSRF/SSRF 缓解。
|
|
142
|
+
*/
|
|
143
|
+
function isLocalOrigin(req) {
|
|
144
|
+
const origin = String(req.headers.origin ?? "");
|
|
145
|
+
if (!origin) return true;
|
|
146
|
+
try {
|
|
147
|
+
return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
128
152
|
/** 读 JSON body(限 64KB),且必须是 application/json(兼作 CSRF 门卫)。 */
|
|
129
153
|
async function readJsonBody(req) {
|
|
130
154
|
if (!String(req.headers["content-type"] ?? "").includes("application/json")) throw new Error("Content-Type 必须是 application/json");
|
|
@@ -140,11 +164,47 @@ async function readJsonBody(req) {
|
|
|
140
164
|
}
|
|
141
165
|
//#endregion
|
|
142
166
|
//#region src/host/model-test.ts
|
|
167
|
+
/** 错误体最多读这么多字节就提前取消(展示时也只取前 300 字符) */
|
|
168
|
+
const MAX_ERROR_BODY_BYTES = 1024;
|
|
169
|
+
/** 成功响应体较大(含 usage 等),最多读 4KB 用于成功校验 */
|
|
170
|
+
const SUCCESS_BODY_MAX_CHARS = 4096;
|
|
171
|
+
const PROBE_TIMEOUT_MS = 1e4;
|
|
172
|
+
/** 读取响应体前若干字符:超大响应(如 HTML 错误页)不整体载入内存。 */
|
|
173
|
+
async function readBodySnippet(resp, maxChars) {
|
|
174
|
+
if (Number(resp.headers.get("content-length") || 0) > MAX_ERROR_BODY_BYTES) return "";
|
|
175
|
+
const reader = resp.body?.getReader();
|
|
176
|
+
if (!reader) return "";
|
|
177
|
+
const decoder = new TextDecoder();
|
|
178
|
+
let text = "";
|
|
179
|
+
try {
|
|
180
|
+
while (text.length < maxChars) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done) break;
|
|
183
|
+
text += decoder.decode(value, { stream: true });
|
|
184
|
+
}
|
|
185
|
+
text += decoder.decode();
|
|
186
|
+
} finally {
|
|
187
|
+
await reader.cancel().catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
return text.slice(0, maxChars);
|
|
190
|
+
}
|
|
191
|
+
/** 校验 200 响应体确实是该协议的成功应答(部分网关 200 但 body 是错误对象)。 */
|
|
192
|
+
function validateSuccessBody(_api, text) {
|
|
193
|
+
if (!text.trim()) return "响应体为空";
|
|
194
|
+
let json;
|
|
195
|
+
try {
|
|
196
|
+
json = JSON.parse(text);
|
|
197
|
+
} catch {
|
|
198
|
+
return "响应体不是合法 JSON";
|
|
199
|
+
}
|
|
200
|
+
if (json?.error) return `响应体含错误:${(typeof json.error === "string" ? json.error : JSON.stringify(json.error)).slice(0, 200)}`;
|
|
201
|
+
if (!Array.isArray(json?.choices)) return "响应缺少 choices 字段";
|
|
202
|
+
}
|
|
143
203
|
/** 发起最小测试请求(max_tokens=1,超时 10s),返回结果对象。 */
|
|
144
204
|
async function probeModel(row, apiKey) {
|
|
145
205
|
const url = row.baseURL.replace(/\/$/, "") + "/chat/completions";
|
|
146
206
|
const controller = new AbortController();
|
|
147
|
-
const timer = setTimeout(() => controller.abort(),
|
|
207
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
148
208
|
const start = Date.now();
|
|
149
209
|
try {
|
|
150
210
|
const resp = await fetch(url, {
|
|
@@ -165,31 +225,40 @@ async function probeModel(row, apiKey) {
|
|
|
165
225
|
signal: controller.signal
|
|
166
226
|
});
|
|
167
227
|
const latency = Date.now() - start;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
clearTimeout(timer);
|
|
177
|
-
return {
|
|
228
|
+
const snippet = await readBodySnippet(resp, resp.ok ? SUCCESS_BODY_MAX_CHARS : 300).catch(() => "");
|
|
229
|
+
if (!resp.ok) return {
|
|
230
|
+
status: "fail",
|
|
231
|
+
latency,
|
|
232
|
+
error: `HTTP ${resp.status}: ${snippet.slice(0, 300)}`
|
|
233
|
+
};
|
|
234
|
+
const problem = validateSuccessBody(row.api, snippet);
|
|
235
|
+
return problem ? {
|
|
178
236
|
status: "fail",
|
|
179
237
|
latency,
|
|
180
|
-
error:
|
|
238
|
+
error: problem
|
|
239
|
+
} : {
|
|
240
|
+
status: "ok",
|
|
241
|
+
latency
|
|
181
242
|
};
|
|
182
243
|
} catch (e) {
|
|
183
|
-
clearTimeout(timer);
|
|
184
244
|
return {
|
|
185
245
|
status: "fail",
|
|
186
246
|
latency: Date.now() - start,
|
|
187
|
-
error: e?.name === "AbortError" ?
|
|
247
|
+
error: e?.name === "AbortError" ? `超时(${PROBE_TIMEOUT_MS / 1e3}s)` : e?.message || String(e)
|
|
188
248
|
};
|
|
249
|
+
} finally {
|
|
250
|
+
clearTimeout(timer);
|
|
189
251
|
}
|
|
190
252
|
}
|
|
191
253
|
function createTestRouteHandler(resolveApiKey) {
|
|
192
254
|
return async function handler(req, res) {
|
|
255
|
+
if (!isLocalOrigin(req)) {
|
|
256
|
+
sendJson(res, 403, {
|
|
257
|
+
ok: false,
|
|
258
|
+
error: "拒绝非本地来源的请求"
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
193
262
|
let key;
|
|
194
263
|
try {
|
|
195
264
|
key = (await readJsonBody(req)).key;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-health",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
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": {
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsdown",
|
|
35
35
|
"prepare": "pnpm run build",
|
|
36
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
|
+
"test": "vitest run"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"js-yaml": "^4.1.0"
|
|
@@ -49,6 +50,12 @@
|
|
|
49
50
|
"@types/node": "^22.10.0",
|
|
50
51
|
"@types/react": "^19.2.18",
|
|
51
52
|
"tsdown": "^0.22.14",
|
|
52
|
-
"typescript": "^5.6.0"
|
|
53
|
+
"typescript": "^5.6.0",
|
|
54
|
+
"vitest": "^3.2.4"
|
|
55
|
+
},
|
|
56
|
+
"pnpm": {
|
|
57
|
+
"onlyBuiltDependencies": [
|
|
58
|
+
"esbuild"
|
|
59
|
+
]
|
|
53
60
|
}
|
|
54
61
|
}
|