dsh-api-probe 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +288 -2
- package/cordis.patch.yml +6 -0
- package/icon.svg +5 -0
- package/lib/client.js +1240 -0
- package/lib/index.js +1612 -0
- package/package.json +75 -4
- package/screenshots.json +6 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirrors the host's non-chat filter: suggesting an embedding or rerank model
|
|
3
|
+
* here would hand the user a name that cannot answer a chat request.
|
|
4
|
+
*
|
|
5
|
+
* Declared at module top level, outside the factory, so the contract tests can
|
|
6
|
+
* exercise it directly instead of grepping the source.
|
|
7
|
+
*/
|
|
8
|
+
const NON_CHAT_MODEL = /embed|rerank|whisper|tts|moderation|clip|dall-?e|bge-|m3e|gte-|audio|vision|speech|transcri/i;
|
|
9
|
+
|
|
10
|
+
/** Pick the most useful model names to offer as one-click chips. */
|
|
11
|
+
function chatModelNames(names, limit) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const chat = [];
|
|
14
|
+
const rest = [];
|
|
15
|
+
for (const raw of Array.isArray(names) ? names : []) {
|
|
16
|
+
const name = typeof raw === "string" ? raw.trim() : "";
|
|
17
|
+
if (name === "" || seen.has(name)) continue;
|
|
18
|
+
seen.add(name);
|
|
19
|
+
(NON_CHAT_MODEL.test(name) ? rest : chat).push(name);
|
|
20
|
+
}
|
|
21
|
+
chat.sort();
|
|
22
|
+
// Only fall back to embedding/rerank names when the provider offers nothing
|
|
23
|
+
// else: an empty chip row would silently hide why no model could be picked.
|
|
24
|
+
return (chat.length > 0 ? chat : rest).slice(0, limit);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Settings worth remembering between runs.
|
|
29
|
+
*
|
|
30
|
+
* `base`, `model` and `preset` are deliberately excluded. This tool's job is
|
|
31
|
+
* "I just got a NEW address, go check it" — restoring yesterday's address into
|
|
32
|
+
* the box is exactly how you end up testing the wrong API without noticing,
|
|
33
|
+
* because a pre-filled field looks like something the app filled in for you.
|
|
34
|
+
* `preset` goes with them: leaving it restored made every fresh open look like
|
|
35
|
+
* the tool had decided which provider the user belongs to.
|
|
36
|
+
*/
|
|
37
|
+
const PERSISTED_KEYS = [
|
|
38
|
+
"api", "timeout", "rounds", "concurrency", "prompt", "proxy", "insecure",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Model IDs are not portable, and not even stable per vendor: the same model
|
|
43
|
+
* is deepseek-ai/deepseek-v4.1-flash on NVIDIA, @cf/deepseek/… on Cloudflare,
|
|
44
|
+
* deepseek-chat on DeepSeek's own API. There is therefore no example we could
|
|
45
|
+
* put in the model box that would not be wrong for someone. The placeholder is
|
|
46
|
+
* plain text pointing at where the real names are.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
window.__ModuleLoader__.load({
|
|
50
|
+
id: "dsh-api-probe",
|
|
51
|
+
factory: (require) => {
|
|
52
|
+
/* Everything below runs inside this guard. The browser module loader has
|
|
53
|
+
* no per-plugin try/catch: an exception escaping this factory takes the
|
|
54
|
+
* whole Web shell down, and the failure names no plugin. That is exactly
|
|
55
|
+
* how the first released version broke the app. A degraded no-op module
|
|
56
|
+
* costs one missing panel; an uncaught throw costs every panel. */
|
|
57
|
+
try {
|
|
58
|
+
const react = require("react");
|
|
59
|
+
|
|
60
|
+
/* ------------------------------------------------------------------ *
|
|
61
|
+
* 手写 createElement:本文件由浏览器直接执行,没有编译步骤,
|
|
62
|
+
* 所以不能用 JSX 语法。
|
|
63
|
+
* ------------------------------------------------------------------ */
|
|
64
|
+
const el = (type, props, ...kids) => react.createElement(type, props, ...kids);
|
|
65
|
+
const { useState, useEffect, useRef, useMemo, useCallback } = react;
|
|
66
|
+
|
|
67
|
+
const PANEL_ID = "api-probe";
|
|
68
|
+
const PANEL_ORDER = 35;
|
|
69
|
+
const NS = "dsh-api-probe";
|
|
70
|
+
const PREFS_KEY = "dsh-api-probe:prefs:v1";
|
|
71
|
+
|
|
72
|
+
/* Route paths are DOCUMENT-RELATIVE on purpose (no leading slash): the
|
|
73
|
+
* harness serves the GUI with `<base href="./">`, so a root-absolute
|
|
74
|
+
* `/api/...` escapes that prefix and never reaches the host route. */
|
|
75
|
+
const API = {
|
|
76
|
+
probe: "api/dsh-api-probe/probe",
|
|
77
|
+
health: "api/dsh-api-probe/health",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Entries carry an ASCII `sort` key and the list is sorted by it below.
|
|
81
|
+
// Two rules decide that key, and both exist so the dropdown cannot
|
|
82
|
+
// quietly reorder itself between releases:
|
|
83
|
+
//
|
|
84
|
+
// * ASCII, never a collation call. `localeCompare("zh")` resolves through
|
|
85
|
+
// the host's ICU build, so the same plugin can order the same list
|
|
86
|
+
// differently on two machines. An order that depends on the host is
|
|
87
|
+
// worse than plain A-Z.
|
|
88
|
+
// * Chinese-named and English-named vendors stay in separate blocks.
|
|
89
|
+
// Interleaving them by one key buried 百川 between Baseten and Cerebras
|
|
90
|
+
// and pushed 火山引擎 behind Hyperbolic, which just reads as arbitrary.
|
|
91
|
+
// Two blocks keep each section internally consistent, so someone
|
|
92
|
+
// scanning for either group only ever moves in one direction.
|
|
93
|
+
//
|
|
94
|
+
// So `sort` is `cn-<pinyin initial>` or `en-<leading letter>`, and 自定义
|
|
95
|
+
// takes `zzz` to pin itself LAST as the escape hatch — a vendor missing
|
|
96
|
+
// from this list is still reachable that way.
|
|
97
|
+
//
|
|
98
|
+
// Presets carry a BASE URL only — never a model ID, and `hints` is always
|
|
99
|
+
// empty. They used to hold hand-written model names ("gpt-4o-mini",
|
|
100
|
+
// "qwen-plus", "meta/llama-3.3-70b-instruct", …), which was actively
|
|
101
|
+
// misleading: the same model is deepseek-ai/deepseek-v4.1-flash on NVIDIA,
|
|
102
|
+
// @cf/deepseek/… on Cloudflare and deepseek-chat on DeepSeek's own API, and
|
|
103
|
+
// relays rename on top of that. A stale chip looks authoritative and walks
|
|
104
|
+
// the user into a 400. The provider's own /models response is the only
|
|
105
|
+
// source that cannot rot, so chips come from the probe result (see
|
|
106
|
+
// `modelChips`) and a blank `model` is what triggers that lookup.
|
|
107
|
+
//
|
|
108
|
+
// A base URL here is a starting point, not a verified promise: vendors
|
|
109
|
+
// move hosts without announcement. tests/check_providers.mjs re-checks the
|
|
110
|
+
// whole list against the live endpoints when you want to trust it again.
|
|
111
|
+
const PRESETS = [
|
|
112
|
+
// ============ 第一梯队:中文服务商,按拼音首字母 A-Z ============
|
|
113
|
+
// A — a-li-yun
|
|
114
|
+
{ id: "dashscope", label: "阿里云百炼 DashScope(通义千问)", api: "openai", base: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "", hints: [], sort: "cn-a" },
|
|
115
|
+
// B — bai-chuan
|
|
116
|
+
{ id: "baichuan", label: "百川智能 Baichuan", api: "openai", base: "https://api.baichuan-ai.com/v1", model: "", hints: [], sort: "cn-b" },
|
|
117
|
+
// G — gui-ji-liu-dong
|
|
118
|
+
{ id: "siliconflow", label: "硅基流动 SiliconFlow", api: "openai", base: "https://api.siliconflow.cn/v1", model: "", hints: [], sort: "cn-g" },
|
|
119
|
+
// H — huo-shan
|
|
120
|
+
{ id: "volcengine", label: "火山引擎(豆包)", api: "openai", base: "https://ark.cn-beijing.volces.com/api/v3", model: "", hints: [], sort: "cn-h" },
|
|
121
|
+
// J — jie-tiao-xing-chen
|
|
122
|
+
{ id: "jietiao", label: "阶跃星辰 StepFun", api: "openai", base: "https://api.stepfun.com/v1", model: "", hints: [], sort: "cn-j" },
|
|
123
|
+
// M — mo-zha
|
|
124
|
+
{ id: "modelscope", label: "魔搭 ModelScope", api: "openai", base: "https://api-inference.modelscope.cn/v1", model: "", hints: [], sort: "cn-m1" },
|
|
125
|
+
// M — mi-ni-max
|
|
126
|
+
{ id: "minimax", label: "MiniMax 海螺", api: "openai", base: "https://api.minimax.chat/v1", model: "", hints: [], sort: "cn-m2" },
|
|
127
|
+
// S — shang-tang
|
|
128
|
+
{ id: "sensenova", label: "商汤 SenseNova", api: "openai", base: "https://token.sensenova.cn/v1", model: "", hints: [], sort: "cn-s" },
|
|
129
|
+
// X — xun-fei
|
|
130
|
+
{ id: "xfeng", label: "讯飞星火 Spark", api: "openai", base: "https://spark-api-open.xf-yun.com/v1", model: "", hints: [], sort: "cn-x" },
|
|
131
|
+
// Y — yue-zhi-an-mian
|
|
132
|
+
{ id: "moonshot", label: "月之暗面 Moonshot(Kimi)", api: "openai", base: "https://api.moonshot.cn/v1", model: "", hints: [], sort: "cn-y" },
|
|
133
|
+
// Z — zhi-pu
|
|
134
|
+
{ id: "zhipu", label: "智谱 AI(GLM)", api: "openai", base: "https://open.bigmodel.cn/api/paas/v4", model: "", hints: [], sort: "cn-z" },
|
|
135
|
+
|
|
136
|
+
// ============ 第二梯队:英文服务商,按字母 A-Z ============
|
|
137
|
+
{ id: "anthropic", label: "Anthropic 官方", api: "anthropic", base: "https://api.anthropic.com/v1", model: "", hints: [], sort: "en-a" },
|
|
138
|
+
{ id: "baseten", label: "Baseten", api: "openai", base: "https://inference.baseten.ai/v1", model: "", hints: [], sort: "en-b" },
|
|
139
|
+
{ id: "cerebras", label: "Cerebras", api: "openai", base: "https://api.cerebras.ai/v1", model: "", hints: [], sort: "en-c" },
|
|
140
|
+
{ id: "chutes", label: "Chutes AI", api: "openai", base: "https://llm.chutes.ai/v1", model: "", hints: [], sort: "en-ch" },
|
|
141
|
+
// The path segment must be the account id, so this one cannot be a working
|
|
142
|
+
// URL out of the box — it is a labelled starting point.
|
|
143
|
+
{ id: "cloudflare", label: "Cloudflare Workers AI", api: "openai", base: "https://api.cloudflare.com/client/v4/accounts/你的账户ID/ai/v1", model: "", hints: [], sort: "en-cl" },
|
|
144
|
+
{ id: "deepinfra", label: "DeepInfra", api: "openai", base: "https://api.deepinfra.com/v1/openai", model: "", hints: [], sort: "en-d1" },
|
|
145
|
+
{ id: "deepseek", label: "DeepSeek 深度求索", api: "openai", base: "https://api.deepseek.com/v1", model: "", hints: [], sort: "en-d2" },
|
|
146
|
+
{ id: "deepseek-anthropic", label: "DeepSeek(Anthropic 原生接口)", api: "anthropic", base: "https://api.deepseek.com/anthropic", model: "", hints: [], sort: "en-d3" },
|
|
147
|
+
{ id: "featherless", label: "Featherless", api: "openai", base: "https://api.featherless.ai/v1", model: "", hints: [], sort: "en-f1" },
|
|
148
|
+
{ id: "fireworks", label: "Fireworks AI", api: "openai", base: "https://api.fireworks.ai/inference/v1", model: "", hints: [], sort: "en-f2" },
|
|
149
|
+
{ id: "gemini", label: "Google Gemini(原生接口)", api: "gemini", base: "https://generativelanguage.googleapis.com/v1beta", model: "", hints: [], sort: "en-g1" },
|
|
150
|
+
{ id: "groq", label: "Groq", api: "openai", base: "https://api.groq.com/openai/v1", model: "", hints: [], sort: "en-g2" },
|
|
151
|
+
{ id: "hyperbolic", label: "Hyperbolic", api: "openai", base: "https://api.hyperbolic.xyz/v1", model: "", hints: [], sort: "en-h" },
|
|
152
|
+
{ id: "llamacpp", label: "llama.cpp / one-api(本地)", api: "openai", base: "http://localhost:8080/v1", model: "", hints: [], sort: "en-l1" },
|
|
153
|
+
{ id: "lmstudio", label: "LM Studio(本地)", api: "openai", base: "http://localhost:1234/v1", model: "", hints: [], sort: "en-l2" },
|
|
154
|
+
{ id: "mistral", label: "Mistral AI", api: "openai", base: "https://api.mistral.ai/v1", model: "", hints: [], sort: "en-m" },
|
|
155
|
+
{ id: "nebius", label: "Nebius AI Studio", api: "openai", base: "https://api.studio.nebius.ai/v1", model: "", hints: [], sort: "en-n1" },
|
|
156
|
+
{ id: "nvidia", label: "NVIDIA NIM(英伟达)", api: "openai", base: "https://integrate.api.nvidia.com/v1", model: "", hints: [], sort: "en-n2" },
|
|
157
|
+
{ id: "novita", label: "Novita AI", api: "openai", base: "https://api.novita.ai/v3/openai", model: "", hints: [], sort: "en-n3" },
|
|
158
|
+
{ id: "ollama", label: "Ollama(本地)", api: "openai", base: "http://localhost:11434/v1", model: "", hints: [], sort: "en-o1" },
|
|
159
|
+
{ id: "openai", label: "OpenAI", api: "openai", base: "https://api.openai.com/v1", model: "", hints: [], sort: "en-o2" },
|
|
160
|
+
{ id: "openrouter", label: "OpenRouter", api: "openai", base: "https://openrouter.ai/api/v1", model: "", hints: [], sort: "en-o3" },
|
|
161
|
+
{ id: "perplexity", label: "Perplexity", api: "openai", base: "https://api.perplexity.ai", model: "", hints: [], sort: "en-p" },
|
|
162
|
+
{ id: "sambanova", label: "SambaNova", api: "openai", base: "https://api.sambanova.ai/v1", model: "", hints: [], sort: "en-s" },
|
|
163
|
+
{ id: "together", label: "Together AI", api: "openai", base: "https://api.together.xyz/v1", model: "", hints: [], sort: "en-t" },
|
|
164
|
+
{ id: "xai", label: "xAI Grok", api: "openai", base: "https://api.x.ai/v1", model: "", hints: [], sort: "en-x" },
|
|
165
|
+
|
|
166
|
+
// ============ 兜底:固定在最后 ============
|
|
167
|
+
{ id: "custom", label: "自定义 / 其他(OpenAI 兼容)", api: "openai", base: "", model: "", hints: [], sort: "zzz" },
|
|
168
|
+
].sort((a, b) => (a.sort < b.sort ? -1 : a.sort > b.sort ? 1 : 0));
|
|
169
|
+
|
|
170
|
+
const DEFAULT_PROMPT = "用一句话介绍你自己。";
|
|
171
|
+
|
|
172
|
+
/* ============================ 国际化 ============================ */
|
|
173
|
+
|
|
174
|
+
const zh = {
|
|
175
|
+
"entry.label": "API 体检",
|
|
176
|
+
"page.title": "API 体检",
|
|
177
|
+
"page.subtitle": "填好地址和 Key,点一下按钮,告诉你这个 API 到底能不能用、快不快。",
|
|
178
|
+
"form.provider": "服务商",
|
|
179
|
+
"form.provider.help": "选错没关系,可自行修改地址和模型。",
|
|
180
|
+
"form.base": "API 地址",
|
|
181
|
+
"form.base.help": "只填域名就行,我自动补成 /v1;已带路径的按原样使用。",
|
|
182
|
+
"form.key": "API Key",
|
|
183
|
+
"form.key.help": "仅用于本次请求,不保存。",
|
|
184
|
+
"form.key.show": "显示",
|
|
185
|
+
"form.key.hide": "隐藏",
|
|
186
|
+
"form.model": "模型名",
|
|
187
|
+
"form.model.help": "留空即可——我会从服务商的模型列表里自动挑一个能对话的。",
|
|
188
|
+
"form.api": "接口协议",
|
|
189
|
+
"form.api.help": "OpenAI 兼容=绝大多数服务商(DeepSeek、Kimi、智谱…)都用这套格式,选它就行;只有 Anthropic、Gemini 官方接口才选后面两项。",
|
|
190
|
+
"adv.summary": "高级设置(一般不用动)",
|
|
191
|
+
"adv.timeout": "超时(毫秒)",
|
|
192
|
+
"adv.rounds": "测速次数",
|
|
193
|
+
"adv.rounds.help": "越多越准但越慢;建议 3 次。",
|
|
194
|
+
"adv.concurrency": "并发数",
|
|
195
|
+
"adv.concurrency.help": "并发请求数;默认 1 更真实。",
|
|
196
|
+
"adv.prompt": "测试问题",
|
|
197
|
+
"adv.proxy": "代理地址",
|
|
198
|
+
"adv.proxy.help": "仅在需要代理时填写;留空为直连。",
|
|
199
|
+
"adv.insecure": "跳过证书校验(不建议)",
|
|
200
|
+
"run": "开始检测",
|
|
201
|
+
"running": "正在检测…",
|
|
202
|
+
"cancel": "停止",
|
|
203
|
+
"reset": "清空结果",
|
|
204
|
+
"copy": "复制报告",
|
|
205
|
+
"copied": "已复制",
|
|
206
|
+
"foot.key": "Key 和地址、模型名都不记——每次都是空白的,免得忘了清。",
|
|
207
|
+
"foot.needInput": "先把上面的「API 地址」和「API Key」填上,按钮就能点了。",
|
|
208
|
+
"prog.running": "检测中",
|
|
209
|
+
"prog.done": "检测完成",
|
|
210
|
+
"prog.failed": "检测结束(有失败项)",
|
|
211
|
+
"stage.network": "① 网络通不通",
|
|
212
|
+
"stage.auth": "② Key 和模型对不对",
|
|
213
|
+
"stage.probe": "③ 能不能真的说话",
|
|
214
|
+
"stage.speed": "④ 快不快、稳不稳",
|
|
215
|
+
"model.title": "这次测的是哪个模型",
|
|
216
|
+
"model.yours": "这是你自己填的模型名,测的就是它。",
|
|
217
|
+
"model.auto": "这是替你从服务商列表里挑的一个,不是你指定的。如果这不是你打算用的模型,回到上面「模型名」里改成你要的那个。",
|
|
218
|
+
"form.model.chiplabel": "该地址实际提供的模型(点一下填入):",
|
|
219
|
+
"form.model.placeholder": "查该服务商的文档,或留空自动挑",
|
|
220
|
+
"form.model.warn": "",
|
|
221
|
+
"form.model.chip.title": "点一下把这个模型名填进上面的输入框",
|
|
222
|
+
"models.more": "点击填入模型名;共 {count} 个,此处显示前 14 个。",
|
|
223
|
+
"metric.ttft": "首字延迟",
|
|
224
|
+
"metric.tps": "输出速度",
|
|
225
|
+
"metric.rate": "成功率",
|
|
226
|
+
"metric.ttft.help": "从回车到首字的等待时间,影响等待感。",
|
|
227
|
+
"metric.tps.help": "模型每秒输出字符数,影响阅读流畅度。",
|
|
228
|
+
"metric.tps.unmeasurable": "这次整块返回,测不出逐字输出速度",
|
|
229
|
+
"metric.rate.help": "成功率越高越可靠。",
|
|
230
|
+
"sample.title": "模型实际说的话",
|
|
231
|
+
"sample.none": "这次没有拿到正文。",
|
|
232
|
+
"fail.title": "失败记录",
|
|
233
|
+
"fail.none": "全部成功,没有失败。",
|
|
234
|
+
"models.title": "服务商提供的模型",
|
|
235
|
+
"checks.title": "检测过程",
|
|
236
|
+
"idle.title": "还没开始",
|
|
237
|
+
"idle.body": "填完上面的地址和 Key,点「开始检测」。",
|
|
238
|
+
"t.good": "很好",
|
|
239
|
+
"t.fair": "还行",
|
|
240
|
+
"t.slow": "偏慢",
|
|
241
|
+
"t.bad": "很差",
|
|
242
|
+
"t.none": "—",
|
|
243
|
+
"s.fast": "几乎不用等",
|
|
244
|
+
"s.wait": "有一点等待",
|
|
245
|
+
"s.slow": "明显在等回复",
|
|
246
|
+
"s.terrible": "等得太久了",
|
|
247
|
+
"p.read": "读起来像看现成文字",
|
|
248
|
+
"p.ok": "读起来挺顺",
|
|
249
|
+
"p.slow": "有点一个字一个字蹦",
|
|
250
|
+
"p.bad": "慢到影响阅读",
|
|
251
|
+
"r.steady": "非常稳",
|
|
252
|
+
"r.stable": "基本稳",
|
|
253
|
+
"r.flaky": "偶尔会抽风",
|
|
254
|
+
"r.bad": "经常失败",
|
|
255
|
+
"tag.auto": "自动挑的",
|
|
256
|
+
"tag.you": "你填的",
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const en = {
|
|
260
|
+
"entry.label": "API Doctor",
|
|
261
|
+
"page.title": "API Doctor",
|
|
262
|
+
"page.subtitle": "Fill in the endpoint and key, press the button, and find out whether this API works and how fast it is.",
|
|
263
|
+
"form.provider": "Provider",
|
|
264
|
+
"form.provider.help": "Provider choice doesn't matter; you can edit the fields below.",
|
|
265
|
+
"form.base": "API address",
|
|
266
|
+
"form.base.help": "Just the host is enough (/v1 is appended); a full path is used as-is.",
|
|
267
|
+
"form.key": "API key",
|
|
268
|
+
"form.key.help": "Used only for this request; not saved.",
|
|
269
|
+
"form.key.show": "Show",
|
|
270
|
+
"form.key.hide": "Hide",
|
|
271
|
+
"form.model": "Model name",
|
|
272
|
+
"form.model.help": "Leave blank — I'll pick a working chat model from the provider's own list.",
|
|
273
|
+
"form.api": "Protocol",
|
|
274
|
+
"form.api.help": "OpenAI-compatible covers most providers (DeepSeek, Kimi, Zhipu…). Only Anthropic and Gemini official APIs need the other two.",
|
|
275
|
+
"adv.summary": "Advanced (usually not needed)",
|
|
276
|
+
"adv.timeout": "Timeout (ms)",
|
|
277
|
+
"adv.rounds": "Speed-test rounds",
|
|
278
|
+
"adv.rounds.help": "More rounds = more accurate but slower; 3 rounds sufficient.",
|
|
279
|
+
"adv.concurrency": "Concurrency",
|
|
280
|
+
"adv.concurrency.help": "Concurrent requests; default 1 for real-world.",
|
|
281
|
+
"adv.prompt": "Test prompt",
|
|
282
|
+
"adv.proxy": "Proxy",
|
|
283
|
+
"adv.proxy.help": "Set only if using proxy; blank for direct.",
|
|
284
|
+
"adv.insecure": "Skip certificate validation (not recommended)",
|
|
285
|
+
"run": "Run check",
|
|
286
|
+
"running": "Checking…",
|
|
287
|
+
"cancel": "Stop",
|
|
288
|
+
"reset": "Clear result",
|
|
289
|
+
"copy": "Copy report",
|
|
290
|
+
"copied": "Copied",
|
|
291
|
+
"foot.key": "Your key, address and model name are never stored — every run starts blank so you cannot test a stale one by accident.",
|
|
292
|
+
"foot.needInput": "Fill in the API address and API key above to enable the button.",
|
|
293
|
+
"prog.running": "Checking",
|
|
294
|
+
"prog.done": "Check complete",
|
|
295
|
+
"prog.failed": "Finished with failures",
|
|
296
|
+
"stage.network": "1. Is the network reachable?",
|
|
297
|
+
"stage.auth": "2. Are the key and model right?",
|
|
298
|
+
"stage.probe": "3. Can it actually talk?",
|
|
299
|
+
"stage.speed": "4. How fast and stable?",
|
|
300
|
+
"model.title": "Which model this test used",
|
|
301
|
+
"model.yours": "This is the model name you entered — that is what got tested.",
|
|
302
|
+
"model.auto": "Auto-picked from the provider's list, not chosen by you. If it isn't the model you plan to use, change it in the model field above.",
|
|
303
|
+
"form.model.warn": "",
|
|
304
|
+
"form.model.chiplabel": "Models this address actually offers (click to fill):",
|
|
305
|
+
"form.model.placeholder": "check your provider's docs, or leave blank",
|
|
306
|
+
"form.model.chip.title": "Click to fill this model name into the field above",
|
|
307
|
+
"models.more": "Click to fill model field; {count} total, showing first 14.",
|
|
308
|
+
"metric.ttft": "Time to first token",
|
|
309
|
+
"metric.tps": "Output speed",
|
|
310
|
+
"metric.rate": "Success rate",
|
|
311
|
+
"metric.ttft.help": "Time to first character; affects waiting perception.",
|
|
312
|
+
"metric.tps.help": "Output speed; affects reading smoothness.",
|
|
313
|
+
"metric.tps.unmeasurable": "Arrived in one piece this time, so output speed can't be measured",
|
|
314
|
+
"metric.rate.help": "Success rate; higher means more reliable.",
|
|
315
|
+
"sample.title": "What the model actually said",
|
|
316
|
+
"sample.none": "No body text came back this time.",
|
|
317
|
+
"fail.title": "Failures",
|
|
318
|
+
"fail.none": "All rounds succeeded.",
|
|
319
|
+
"models.title": "Models offered by the provider",
|
|
320
|
+
"checks.title": "Check trace",
|
|
321
|
+
"idle.title": "Nothing run yet",
|
|
322
|
+
"idle.body": "Fill in the address and key above, then press Run check.",
|
|
323
|
+
"t.good": "Great",
|
|
324
|
+
"t.fair": "OK",
|
|
325
|
+
"t.slow": "Slow",
|
|
326
|
+
"t.bad": "Bad",
|
|
327
|
+
"t.none": "—",
|
|
328
|
+
"s.fast": "Almost no waiting",
|
|
329
|
+
"s.wait": "A little waiting",
|
|
330
|
+
"s.slow": "Noticeably waiting",
|
|
331
|
+
"s.terrible": "Waiting far too long",
|
|
332
|
+
"p.read": "Reads like finished text",
|
|
333
|
+
"p.ok": "Reads smoothly",
|
|
334
|
+
"p.slow": "A bit letter-by-letter",
|
|
335
|
+
"p.bad": "Slow enough to hurt",
|
|
336
|
+
"r.steady": "Rock solid",
|
|
337
|
+
"r.stable": "Mostly solid",
|
|
338
|
+
"r.flaky": "Flaky at times",
|
|
339
|
+
"r.bad": "Fails often",
|
|
340
|
+
"tag.auto": "auto-picked",
|
|
341
|
+
"tag.you": "yours",
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
let runtimeT;
|
|
345
|
+
function setRuntimeTranslate(t) {
|
|
346
|
+
runtimeT = t;
|
|
347
|
+
}
|
|
348
|
+
function tt(key, values) {
|
|
349
|
+
if (runtimeT !== void 0) {
|
|
350
|
+
const out = runtimeT(key, values);
|
|
351
|
+
if (typeof out === "string" && out !== key) return out;
|
|
352
|
+
}
|
|
353
|
+
let text = zh[key] ?? key;
|
|
354
|
+
if (values !== void 0) {
|
|
355
|
+
for (const [name, value] of Object.entries(values)) text = text.replaceAll(`{${name}}`, String(value));
|
|
356
|
+
}
|
|
357
|
+
return text;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* ============================== 样式 ============================== */
|
|
361
|
+
|
|
362
|
+
const CSS = `
|
|
363
|
+
/* The scroll fix needs a BOUNDED height, not just overflow-y:auto. Host mounts
|
|
364
|
+
this panel inside a plain block, so flex:1 1 auto resolves to nothing and the
|
|
365
|
+
root simply grows to fit its content — overflow then never triggers and the
|
|
366
|
+
wheel does nothing. max-height:100vh pins the root to the viewport so it
|
|
367
|
+
becomes the scroll container itself; the report's tail is always reachable. */
|
|
368
|
+
.dshap-root{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:13px;line-height:1.6;display:flex;flex-direction:column;gap:16px;min-height:0;max-height:100vh;overflow-y:auto;overflow-x:hidden;padding-right:4px}
|
|
369
|
+
.dshap-root *{box-sizing:border-box}
|
|
370
|
+
.dshap-head{display:flex;flex-direction:column;gap:4px}
|
|
371
|
+
.dshap-title{margin:0;font-size:20px;font-weight:650;letter-spacing:.01em}
|
|
372
|
+
.dshap-sub{margin:0;color:var(--dsw-alias-label-tertiary,#9a9a9a);font-size:12.5px;max-width:62ch}
|
|
373
|
+
.dshap-card{background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.08));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:14px}
|
|
374
|
+
.dshap-field{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
375
|
+
.dshap-lab{display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#c2c2c2)}
|
|
376
|
+
.dshap-help{font-weight:400;font-size:11.5px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}
|
|
377
|
+
.dshap-grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}
|
|
378
|
+
.dshap-input,.dshap-select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;outline:none}
|
|
379
|
+
.dshap-input:focus,.dshap-select:focus{border-color:var(--dsw-alias-brand-primary,#4f8cff)}
|
|
380
|
+
.dshap-select{font-family:inherit}
|
|
381
|
+
.dshap-keywrap{display:flex;gap:8px;align-items:center}
|
|
382
|
+
.dshap-keywrap .dshap-input{flex:1;min-width:0}
|
|
383
|
+
.dshap-mini{flex:none;padding:6px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-secondary,#c2c2c2);font-size:11.5px;cursor:pointer}
|
|
384
|
+
.dshap-mini:hover{color:var(--dsw-alias-label-primary,#e6e6e6);border-color:var(--dsw-alias-border-l1,rgba(127,127,127,.4))}
|
|
385
|
+
.dshap-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
386
|
+
/* Explicit colors, not --brand-primary. That token resolves to near-white in
|
|
387
|
+
some themes, and #fff text on a white fill renders the label invisible —
|
|
388
|
+
which is exactly the blank white button this replaced. */
|
|
389
|
+
.dshap-btn{padding:9px 18px;border-radius:9px;border:1px solid transparent;font-size:13px;font-weight:600;cursor:pointer;background:linear-gradient(180deg,#3f8cff,#2f6fe0);color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3)}
|
|
390
|
+
.dshap-btn:hover:not(:disabled){background:linear-gradient(180deg,#4f97ff,#3a7bec)}
|
|
391
|
+
/* A dimmed fill on a dark theme turns the button into a washed-out grey lump
|
|
392
|
+
with unreadable text. An empty outline reads as "not available yet", which is
|
|
393
|
+
what it actually means. */
|
|
394
|
+
.dshap-btn:disabled{opacity:1;cursor:default;background:transparent;border-color:var(--dsw-alias-border-l1,rgba(127,127,127,.22));color:var(--dsw-alias-label-tertiary,#8a8a8a);font-weight:500}
|
|
395
|
+
.dshap-btn-ghost{background:transparent;border-color:var(--dsw-alias-border-l2,rgba(127,127,127,.3));color:var(--dsw-alias-label-secondary,#c2c2c2)}
|
|
396
|
+
.dshap-btn-ghost:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
397
|
+
/* Progress: the track is ALWAYS mounted (no conditional render) so the fill can
|
|
398
|
+
grow continuously instead of popping in at its final width. dshap-prog-fill
|
|
399
|
+
carries its own right-edge glow, so the bar reads as "filling" rather than as
|
|
400
|
+
a white slab that suddenly changes colour.
|
|
401
|
+
|
|
402
|
+
It must NOT be a flex sibling of the buttons: on that flex line it claimed a
|
|
403
|
+
share of the free space and inflated into a fat full-width block. */
|
|
404
|
+
.dshap-prog{height:3px;border-radius:999px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.18));overflow:hidden;width:100%;position:relative}
|
|
405
|
+
.dshap-prog-fill{height:100%;border-radius:999px;width:0;background:linear-gradient(90deg,#3f8cff,#5aa9ff);transition:width .55s cubic-bezier(.4,0,.2,1),background .55s ease;position:relative;min-width:4px}
|
|
406
|
+
/* A leading highlight implies ongoing motion even while a stage is slow. */
|
|
407
|
+
.dshap-prog-fill::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent);animation:dshap-sheen 1.6s ease-in-out infinite}
|
|
408
|
+
@keyframes dshap-sheen{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
|
|
409
|
+
.dshap-prog-done .dshap-prog-fill{background:linear-gradient(90deg,#3ecf8e,#2fae75)}
|
|
410
|
+
.dshap-prog-done .dshap-prog-fill::after{animation:none;opacity:0}
|
|
411
|
+
/* The status word is the readable half of the feedback — a 3px bar alone is
|
|
412
|
+
too thin to notice, and this is the only text that says what is happening.
|
|
413
|
+
Weight and size match the check-row labels; it turns green with the bar. */
|
|
414
|
+
.dshap-prog-row{display:flex;align-items:center;gap:10px;margin-top:-4px;opacity:0;transition:opacity .3s ease}
|
|
415
|
+
.dshap-prog-row.dshap-prog-show{opacity:1}
|
|
416
|
+
.dshap-prog-label{flex:none;font-size:12.5px;font-weight:650;color:var(--dsw-alias-label-secondary,#c2c2c2);font-variant-numeric:tabular-nums;opacity:0;transition:opacity .3s ease,color .45s ease}
|
|
417
|
+
.dshap-prog-label-show{opacity:1}
|
|
418
|
+
.dshap-prog-done .dshap-prog-label{color:#3ecf8e}
|
|
419
|
+
.dshap-foot{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}
|
|
420
|
+
.dshap-adv{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.2));border-radius:10px;padding:0 14px;background:transparent}
|
|
421
|
+
.dshap-adv>summary{cursor:pointer;padding:10px 0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#c2c2c2);list-style:none}
|
|
422
|
+
.dshap-adv>summary::-webkit-details-marker{display:none}
|
|
423
|
+
.dshap-adv>summary::before{content:"▸ ";display:inline-block;transition:transform .15s}
|
|
424
|
+
.dshap-adv[open]>summary::before{transform:rotate(90deg)}
|
|
425
|
+
.dshap-advbody{display:flex;flex-direction:column;gap:12px;padding:4px 0 14px}
|
|
426
|
+
.dshap-checkrow{display:flex;align-items:baseline;gap:9px;padding:3px 0;font-size:12.5px}
|
|
427
|
+
.dshap-dot{flex:none;width:8px;height:8px;border-radius:50%;transform:translateY(-1px)}
|
|
428
|
+
.dshap-s-ok .dshap-dot{background:#3ecf8e}
|
|
429
|
+
.dshap-s-warn .dshap-dot{background:#e5a13a}
|
|
430
|
+
.dshap-s-fail .dshap-dot{background:#e5484d}
|
|
431
|
+
.dshap-s-skip .dshap-dot{background:#6b7280}
|
|
432
|
+
.dshap-s-run .dshap-dot{background:#4f8cff;animation:dshap-pulse 1s ease-in-out infinite}
|
|
433
|
+
@keyframes dshap-pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
434
|
+
.dshap-cl{flex:none;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6);min-width:5.5em}
|
|
435
|
+
.dshap-skip .dshap-cl{color:var(--dsw-alias-label-tertiary,#9a9a9a);font-weight:500}
|
|
436
|
+
.dshap-cd{color:var(--dsw-alias-label-tertiary,#9a9a9a);word-break:break-word}
|
|
437
|
+
.dshap-ms{flex:none;margin-left:auto;padding-left:10px;font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a);font-variant-numeric:tabular-nums}
|
|
438
|
+
.dshap-stage{margin-top:10px}
|
|
439
|
+
.dshap-stage-h{font-size:12px;font-weight:650;color:var(--dsw-alias-label-secondary,#c2c2c2);display:flex;align-items:center;gap:8px}
|
|
440
|
+
.dshap-verdict{border-radius:12px;padding:16px 18px;border:1px solid;display:flex;flex-direction:column;gap:8px}
|
|
441
|
+
.dshap-v-good{background:rgba(62,207,142,.1);border-color:rgba(62,207,142,.45)}
|
|
442
|
+
.dshap-v-ok{background:rgba(79,140,255,.1);border-color:rgba(79,140,255,.4)}
|
|
443
|
+
.dshap-v-slow{background:rgba(229,161,58,.1);border-color:rgba(229,161,58,.42)}
|
|
444
|
+
.dshap-v-bad{background:rgba(229,72,77,.1);border-color:rgba(229,72,77,.45)}
|
|
445
|
+
.dshap-v-idle{background:rgba(127,127,127,.1);border-color:rgba(127,127,127,.22)}
|
|
446
|
+
.dshap-v-head{font-size:16px;font-weight:650;display:flex;align-items:center;gap:8px}
|
|
447
|
+
.dshap-v-list{margin:0;padding-left:18px;color:var(--dsw-alias-label-secondary,#c2c2c2);font-size:12.5px;display:flex;flex-direction:column;gap:3px}
|
|
448
|
+
.dshap-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
|
|
449
|
+
.dshap-metric{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:11px;padding:13px 15px;display:flex;flex-direction:column;gap:5px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.06))}
|
|
450
|
+
.dshap-m-top{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
|
|
451
|
+
.dshap-m-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#c2c2c2)}
|
|
452
|
+
.dshap-m-val{font-size:25px;font-weight:680;letter-spacing:-.01em;font-variant-numeric:tabular-nums;line-height:1.15}
|
|
453
|
+
.dshap-m-note{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}
|
|
454
|
+
.dshap-g-good{color:#3ecf8e}.dshap-g-fair{color:#4f8cff}.dshap-g-slow{color:#e5a13a}.dshap-g-bad{color:#e5484d}.dshap-g-none{color:var(--dsw-alias-label-tertiary,#9a9a9a)}
|
|
455
|
+
.dshap-tag{display:inline-block;padding:1px 7px;border-radius:999px;font-size:10.5px;font-weight:600;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));color:var(--dsw-alias-label-tertiary,#9a9a9a)}
|
|
456
|
+
.dshap-sample{margin:0;padding:11px 13px;border-radius:9px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.18));font-size:12.5px;white-space:pre-wrap;word-break:break-word;max-height:220px;overflow:auto;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
457
|
+
.dshap-where{margin:9px 0 0;padding:9px 12px;border-radius:9px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.18));background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary,rgba(230,230,230,.78))}
|
|
458
|
+
.dshap-raw{margin:8px 0 0;padding:9px 12px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.26));background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));font-size:11.5px;line-height:1.55;white-space:pre-wrap;word-break:break-all;max-height:180px;overflow:auto;color:var(--dsw-alias-label-tertiary,rgba(230,230,230,.62))}
|
|
459
|
+
.dshap-chips{display:flex;flex-wrap:wrap;gap:6px}
|
|
460
|
+
.dshap-chip{padding:2px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.12));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.18));font-size:11.5px;color:var(--dsw-alias-label-secondary,#c2c2c2)}
|
|
461
|
+
.dshap-chip-btn{cursor:pointer;font-family:inherit;line-height:1.5;transition:border-color .12s,color .12s}
|
|
462
|
+
.dshap-chip-btn:hover{border-color:var(--dsw-alias-border-l2,rgba(127,127,127,.34));color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
463
|
+
.dshap-chip-on{border-color:var(--dsw-alias-brand-primary,#4f8cff);color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
464
|
+
.dshap-hintrow{margin-top:7px}
|
|
465
|
+
.dshap-hintlabel{margin-top:6px;font-size:11.5px;color:var(--dsw-alias-label-tertiary,#8a8a8a)}
|
|
466
|
+
.dshap-modelwarn{margin:6px 0 0;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#9a9a9a)}
|
|
467
|
+
.dshap-warn{margin-top:8px;padding:9px 12px;border-radius:9px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.2));background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
468
|
+
.dshap-empty{color:var(--dsw-alias-label-tertiary,#9a9a9a);font-size:12.5px;font-style:italic}
|
|
469
|
+
.dshap-idle{display:flex;flex-direction:column;align-items:center;gap:6px;padding:34px 16px;text-align:center}
|
|
470
|
+
.dshap-idle-t{font-size:14px;font-weight:600;color:var(--dsw-alias-label-secondary,#c2c2c2)}
|
|
471
|
+
.dshap-idle-b{font-size:12.5px;color:var(--dsw-alias-label-tertiary,#9a9a9a);max-width:44ch}
|
|
472
|
+
.dshap-toggle{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#c2c2c2);cursor:pointer;user-select:none}
|
|
473
|
+
.dshap-hrow{font-size:12px;font-weight:650;margin:2px 0 0;color:var(--dsw-alias-label-secondary,#c2c2c2)}
|
|
474
|
+
`;
|
|
475
|
+
|
|
476
|
+
let styleInjected = false;
|
|
477
|
+
function ensureStyle() {
|
|
478
|
+
if (styleInjected || typeof document === "undefined") return;
|
|
479
|
+
if (document.getElementById("dshap-style")) {
|
|
480
|
+
styleInjected = true;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const node = document.createElement("style");
|
|
484
|
+
node.id = "dshap-style";
|
|
485
|
+
node.textContent = CSS;
|
|
486
|
+
document.head.appendChild(node);
|
|
487
|
+
styleInjected = true;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* ============================ 小工具 ============================ */
|
|
491
|
+
|
|
492
|
+
function fmtMs(ms) {
|
|
493
|
+
if (ms === null || ms === void 0) return "—";
|
|
494
|
+
if (ms < 1000) return `${Math.round(ms)} ms`;
|
|
495
|
+
return `${(ms / 1000).toFixed(2)} s`;
|
|
496
|
+
}
|
|
497
|
+
function fmtTps(tps) {
|
|
498
|
+
if (tps === null || tps === void 0) return "—";
|
|
499
|
+
return `${tps.toFixed(1)} 字/秒`;
|
|
500
|
+
}
|
|
501
|
+
function fmtPct(rate) {
|
|
502
|
+
if (rate === null || rate === void 0) return "—";
|
|
503
|
+
return `${Math.round(rate * 100)}%`;
|
|
504
|
+
}
|
|
505
|
+
function gradeText(grade) {
|
|
506
|
+
const map = { good: "t.good", fair: "t.fair", slow: "t.slow", bad: "t.bad", none: "t.none" };
|
|
507
|
+
return tt(map[grade] ?? "t.none");
|
|
508
|
+
}
|
|
509
|
+
function ttftNote(ms) {
|
|
510
|
+
if (ms === null || ms === void 0) return "—";
|
|
511
|
+
if (ms < 500) return tt("s.fast");
|
|
512
|
+
if (ms < 1200) return tt("s.wait");
|
|
513
|
+
if (ms < 2500) return tt("s.slow");
|
|
514
|
+
return tt("s.terrible");
|
|
515
|
+
}
|
|
516
|
+
function tpsNote(tps) {
|
|
517
|
+
if (tps === null || tps === void 0) return "—";
|
|
518
|
+
if (tps >= 45) return tt("p.read");
|
|
519
|
+
if (tps >= 20) return tt("p.ok");
|
|
520
|
+
if (tps >= 8) return tt("p.slow");
|
|
521
|
+
return tt("p.bad");
|
|
522
|
+
}
|
|
523
|
+
// A missing output speed is not a fast one. When the vendor answered in a
|
|
524
|
+
// single piece there was no generation to time, so say that instead of
|
|
525
|
+
// leaving a bare dash that reads like "nothing to report here".
|
|
526
|
+
function tpsNoteWithReason(tps, streamed) {
|
|
527
|
+
if (tps === null || tps === void 0) return streamed === false ? tt("metric.tps.unmeasurable") : "—";
|
|
528
|
+
return tpsNote(tps);
|
|
529
|
+
}
|
|
530
|
+
function rateNote(rate) {
|
|
531
|
+
if (rate === null || rate === void 0) return "—";
|
|
532
|
+
if (rate >= 1) return tt("r.steady");
|
|
533
|
+
if (rate >= 0.95) return tt("r.stable");
|
|
534
|
+
if (rate >= 0.8) return tt("r.flaky");
|
|
535
|
+
return tt("r.bad");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function loadPrefs() {
|
|
539
|
+
const base = {
|
|
540
|
+
preset: "custom", api: "openai", base: "", model: "",
|
|
541
|
+
timeout: 20000, rounds: 3, concurrency: 1, prompt: DEFAULT_PROMPT,
|
|
542
|
+
proxy: "", insecure: false,
|
|
543
|
+
};
|
|
544
|
+
try {
|
|
545
|
+
const raw = localStorage.getItem(PREFS_KEY);
|
|
546
|
+
if (!raw) return base;
|
|
547
|
+
const saved = JSON.parse(raw);
|
|
548
|
+
if (saved && typeof saved === "object") {
|
|
549
|
+
for (const key of PERSISTED_KEYS) {
|
|
550
|
+
if (saved[key] !== void 0 && typeof saved[key] === typeof base[key]) base[key] = saved[key];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch {
|
|
554
|
+
/* storage unavailable — fall back to defaults */
|
|
555
|
+
}
|
|
556
|
+
return base;
|
|
557
|
+
}
|
|
558
|
+
/** Persist only the whitelisted settings. The API key, the API address and
|
|
559
|
+
* the model name are deliberately NOT part of what gets written. */
|
|
560
|
+
function savePrefs(form) {
|
|
561
|
+
const kept = {};
|
|
562
|
+
for (const key of PERSISTED_KEYS) kept[key] = form[key];
|
|
563
|
+
try {
|
|
564
|
+
localStorage.setItem(PREFS_KEY, JSON.stringify(kept));
|
|
565
|
+
} catch {
|
|
566
|
+
/* ignore quota / private mode */
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Read an NDJSON stream and hand each parsed event to onEvent. */
|
|
571
|
+
async function readNdjson(response, onEvent) {
|
|
572
|
+
if (!response.body) {
|
|
573
|
+
const text = await response.text();
|
|
574
|
+
for (const line of text.split("\n")) {
|
|
575
|
+
if (line.trim()) onEvent(JSON.parse(line));
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const reader = response.body.getReader();
|
|
580
|
+
const decoder = new TextDecoder();
|
|
581
|
+
let buffer = "";
|
|
582
|
+
for (;;) {
|
|
583
|
+
const { done, value } = await reader.read();
|
|
584
|
+
if (done) break;
|
|
585
|
+
buffer += decoder.decode(value, { stream: true });
|
|
586
|
+
let at;
|
|
587
|
+
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
588
|
+
const line = buffer.slice(0, at).trim();
|
|
589
|
+
buffer = buffer.slice(at + 1);
|
|
590
|
+
if (!line) continue;
|
|
591
|
+
try {
|
|
592
|
+
onEvent(JSON.parse(line));
|
|
593
|
+
} catch {
|
|
594
|
+
/* ignore a malformed line rather than killing the run */
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function Icon() {
|
|
601
|
+
return el(
|
|
602
|
+
"svg",
|
|
603
|
+
{ width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
604
|
+
el("path", { key: "a", d: "M3.5 13a8.5 8.5 0 0 1 17 0" }),
|
|
605
|
+
el("path", { key: "b", d: "M12 13l4-3" }),
|
|
606
|
+
el("circle", { key: "c", cx: 12, cy: 13, r: 1.5 }),
|
|
607
|
+
el("path", { key: "d", d: "M3.5 17.5h3M10 17.5h3M16.5 17.5h4" }),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/* ============================ 各组件 ============================ */
|
|
612
|
+
|
|
613
|
+
function Field(props) {
|
|
614
|
+
return el(
|
|
615
|
+
"label",
|
|
616
|
+
{ className: "dshap-field" },
|
|
617
|
+
el("span", { className: "dshap-lab" }, props.label, props.help ? el("span", { className: "dshap-help" }, props.help) : null),
|
|
618
|
+
props.children,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function CheckRow(props) {
|
|
623
|
+
const c = props.check;
|
|
624
|
+
// The resolved IP addresses are only useful when someone needs to
|
|
625
|
+
// verify the network path — beginners read "success" and move on.
|
|
626
|
+
// Keep the IP out of the main line and tuck it into a collapsed
|
|
627
|
+
// "technical details" row instead.
|
|
628
|
+
return el(
|
|
629
|
+
"div",
|
|
630
|
+
{ className: `dshap-checkrow dshap-s-${c.status}` },
|
|
631
|
+
el("span", { className: "dshap-dot" }),
|
|
632
|
+
el("span", { className: "dshap-cl" }, c.label),
|
|
633
|
+
el("span", { className: "dshap-cd" }, c.detail),
|
|
634
|
+
c.techDetail ? el(
|
|
635
|
+
"details",
|
|
636
|
+
{ className: "dshap-tech" },
|
|
637
|
+
el("summary", null, "技术详情"),
|
|
638
|
+
el("span", { className: "dshap-cd" }, c.techDetail),
|
|
639
|
+
) : null,
|
|
640
|
+
c.ms !== null && c.ms !== void 0 ? el("span", { className: "dshap-ms" }, fmtMs(c.ms)) : null,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function Metric(props) {
|
|
645
|
+
return el(
|
|
646
|
+
"div",
|
|
647
|
+
{ className: "dshap-metric" },
|
|
648
|
+
el("div", { className: "dshap-m-top" }, el("span", { className: "dshap-m-name" }, props.name), el("span", { className: "dshap-tag" }, gradeText(props.grade))),
|
|
649
|
+
el("div", { className: `dshap-m-val dshap-g-${props.grade ?? "none"}` }, props.value),
|
|
650
|
+
el("div", { className: "dshap-m-note" }, props.note),
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function VerdictBanner(props) {
|
|
655
|
+
const v = props.verdict;
|
|
656
|
+
const level = v && v.level ? v.level : "bad";
|
|
657
|
+
return el(
|
|
658
|
+
"div",
|
|
659
|
+
{ className: `dshap-verdict dshap-v-${level}` },
|
|
660
|
+
el("div", { className: "dshap-v-head" }, props.error ? tt("idle.title") : v.headline),
|
|
661
|
+
props.error
|
|
662
|
+
? el("p", { className: "dshap-m-note", style: { margin: 0 } }, props.error)
|
|
663
|
+
: el(
|
|
664
|
+
"ul",
|
|
665
|
+
{ className: "dshap-v-list" },
|
|
666
|
+
...(v.reasons ?? []).map((reason, index) => el("li", { key: index }, reason)),
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function Panel() {
|
|
672
|
+
const [form, setForm] = useState(loadPrefs);
|
|
673
|
+
const [key, setKey] = useState("");
|
|
674
|
+
const [showKey, setShowKey] = useState(false);
|
|
675
|
+
const [events, setEvents] = useState([]);
|
|
676
|
+
const [result, setResult] = useState(null);
|
|
677
|
+
const [running, setRunning] = useState(false);
|
|
678
|
+
const [fatal, setFatal] = useState(null);
|
|
679
|
+
const [copied, setCopied] = useState(false);
|
|
680
|
+
const abortRef = useRef(null);
|
|
681
|
+
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
ensureStyle();
|
|
684
|
+
}, []);
|
|
685
|
+
|
|
686
|
+
const set = useCallback((field, value) => {
|
|
687
|
+
setForm((prev) => {
|
|
688
|
+
const next = { ...prev, [field]: value };
|
|
689
|
+
if (field !== "preset") next.preset = matchPreset(next);
|
|
690
|
+
savePrefs(next);
|
|
691
|
+
return next;
|
|
692
|
+
});
|
|
693
|
+
}, []);
|
|
694
|
+
|
|
695
|
+
function matchPreset(f) {
|
|
696
|
+
const hit = PRESETS.find((p) => p.base === f.base && p.api === f.api && p.model === f.model);
|
|
697
|
+
return hit ? hit.id : "custom";
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function onPreset(id) {
|
|
701
|
+
const preset = PRESETS.find((p) => p.id === id) ?? PRESETS[0];
|
|
702
|
+
const next = { ...form, preset: id, api: preset.api, base: preset.base, model: preset.model };
|
|
703
|
+
savePrefs(next);
|
|
704
|
+
setForm(next);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const onRun = useCallback(async () => {
|
|
708
|
+
if (running) return;
|
|
709
|
+
setRunning(true);
|
|
710
|
+
setFatal(null);
|
|
711
|
+
setResult(null);
|
|
712
|
+
setCopied(false);
|
|
713
|
+
setEvents([]);
|
|
714
|
+
const controller = new AbortController();
|
|
715
|
+
abortRef.current = controller;
|
|
716
|
+
try {
|
|
717
|
+
const response = await fetch(API.probe, {
|
|
718
|
+
method: "POST",
|
|
719
|
+
headers: { "content-type": "application/json" },
|
|
720
|
+
body: JSON.stringify({
|
|
721
|
+
base: form.base.trim(),
|
|
722
|
+
key: key.trim(),
|
|
723
|
+
model: form.model.trim(),
|
|
724
|
+
api: form.api,
|
|
725
|
+
timeout: Number(form.timeout) || 20000,
|
|
726
|
+
rounds: Number(form.rounds) || 3,
|
|
727
|
+
concurrency: Number(form.concurrency) || 1,
|
|
728
|
+
prompt: form.prompt || DEFAULT_PROMPT,
|
|
729
|
+
proxy: form.proxy.trim(),
|
|
730
|
+
insecure: form.insecure === true,
|
|
731
|
+
}),
|
|
732
|
+
signal: controller.signal,
|
|
733
|
+
});
|
|
734
|
+
if (!response.ok) {
|
|
735
|
+
// The host answers with { error } for 403/405/400. Swallowing it
|
|
736
|
+
// would hide "loopback-only" behind a bare status code.
|
|
737
|
+
let detail = "";
|
|
738
|
+
try {
|
|
739
|
+
const body = await response.json();
|
|
740
|
+
if (body && typeof body.error === "string") detail = `(${body.error})`;
|
|
741
|
+
} catch {
|
|
742
|
+
/* keep the bare status */
|
|
743
|
+
}
|
|
744
|
+
setFatal(`请求失败(HTTP ${response.status})${detail}。宿主插件可能没装好,刷新页面再试。`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
await readNdjson(response, (event) => {
|
|
748
|
+
if (event.type === "result") setResult(event.result);
|
|
749
|
+
else setEvents((prev) => prev.concat(event));
|
|
750
|
+
});
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error && error.name === "AbortError") setFatal(tt("running") + " — " + tt("cancel"));
|
|
753
|
+
else setFatal(`连不上体检服务:${error && error.message ? error.message : String(error)}`);
|
|
754
|
+
} finally {
|
|
755
|
+
abortRef.current = null;
|
|
756
|
+
setRunning(false);
|
|
757
|
+
}
|
|
758
|
+
}, [running, form, key]);
|
|
759
|
+
|
|
760
|
+
function onCancel() {
|
|
761
|
+
if (abortRef.current) abortRef.current.abort();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const groups = useMemo(() => {
|
|
765
|
+
const out = [];
|
|
766
|
+
let current = null;
|
|
767
|
+
for (const event of events) {
|
|
768
|
+
if (event.type === "stage") {
|
|
769
|
+
current = { stage: event.stage, title: event.title, checks: [] };
|
|
770
|
+
out.push(current);
|
|
771
|
+
} else if (event.type === "check" && current) {
|
|
772
|
+
current.checks.push(event);
|
|
773
|
+
} else if (event.type === "metrics") {
|
|
774
|
+
out.push({ stage: "metrics", title: event, checks: [] });
|
|
775
|
+
} else if (event.type === "sample") {
|
|
776
|
+
out.push({ stage: "sample", title: event, checks: [] });
|
|
777
|
+
} else if (event.type === "models") {
|
|
778
|
+
out.push({ stage: "models", title: event, checks: [] });
|
|
779
|
+
} else if (event.type === "model") {
|
|
780
|
+
out.push({ stage: "model", title: event, checks: [] });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return out;
|
|
784
|
+
}, [events]);
|
|
785
|
+
|
|
786
|
+
const liveMetrics = useMemo(() => {
|
|
787
|
+
for (let i = events.length - 1; i >= 0; i -= 1) if (events[i].type === "metrics") return events[i];
|
|
788
|
+
return null;
|
|
789
|
+
}, [events]);
|
|
790
|
+
const sampleEvent = useMemo(() => {
|
|
791
|
+
for (let i = events.length - 1; i >= 0; i -= 1) if (events[i].type === "sample") return events[i];
|
|
792
|
+
return null;
|
|
793
|
+
}, [events]);
|
|
794
|
+
const modelsEvent = useMemo(() => {
|
|
795
|
+
for (let i = events.length - 1; i >= 0; i -= 1) if (events[i].type === "models") return events[i];
|
|
796
|
+
return null;
|
|
797
|
+
}, [events]);
|
|
798
|
+
const modelEvent = useMemo(() => {
|
|
799
|
+
for (let i = events.length - 1; i >= 0; i -= 1) if (events[i].type === "model") return events[i];
|
|
800
|
+
return null;
|
|
801
|
+
}, [events]);
|
|
802
|
+
|
|
803
|
+
// Chips come ONLY from the provider's own /models response. There is no
|
|
804
|
+
// static fallback: the same model carries a different ID at every vendor
|
|
805
|
+
// (deepseek-ai/deepseek-v4.1-flash on NVIDIA, @cf/deepseek/… on
|
|
806
|
+
// Cloudflare, deepseek-chat on DeepSeek's own API), and a relay may be
|
|
807
|
+
// hosting upstream names, its own aliases, or both. Guessing one is
|
|
808
|
+
// guessing something only the user knows, and a wrong guess 404s.
|
|
809
|
+
const modelChips = useMemo(() => chatModelNames(modelsEvent?.models, 10), [modelsEvent]);
|
|
810
|
+
|
|
811
|
+
/* No static example: any name we could print would be wrong for some
|
|
812
|
+
* provider. The placeholder points at where the real names live. */
|
|
813
|
+
const modelPlaceholder = tt("form.model.placeholder");
|
|
814
|
+
|
|
815
|
+
const metrics = result ? result.metrics : liveMetrics;
|
|
816
|
+
const speed = result ? result.stages?.speed : null;
|
|
817
|
+
|
|
818
|
+
// Four pipeline stages; the bar advances one notch per stage that has
|
|
819
|
+
// reported anything, and jumps to 100 once a result exists. Deriving it
|
|
820
|
+
// from emitted events (not a timer) keeps it honest when a stage fails
|
|
821
|
+
// fast or the speed test takes ten times as long as the rest.
|
|
822
|
+
//
|
|
823
|
+
// Stages contribute a geometric share of the bar rather than a flat 25%
|
|
824
|
+
// each, so the fill keeps creeping forward during a slow stage instead of
|
|
825
|
+
// parking on a hard 24% / 48% step that looks like a frozen bar.
|
|
826
|
+
const progress = useMemo(() => {
|
|
827
|
+
if (result) return 100;
|
|
828
|
+
if (events.length === 0) return 2;
|
|
829
|
+
const stages = new Set();
|
|
830
|
+
let latest = 0;
|
|
831
|
+
for (const event of events) {
|
|
832
|
+
if (event.type === "stage") stages.add(event.stage);
|
|
833
|
+
// The last check's timing is the freshest progress signal we have.
|
|
834
|
+
if (typeof event.ms === "number" && event.ms > latest) latest = event.ms;
|
|
835
|
+
}
|
|
836
|
+
// 6 / 15 / 30 / 52 % — each stage unlocks a bigger slice of the bar.
|
|
837
|
+
const stops = [6, 15, 30, 52];
|
|
838
|
+
const done = Math.min(stages.size, 4);
|
|
839
|
+
const base = stops[done - 1] ?? 2;
|
|
840
|
+
// Creep toward the next stop using the slowest observed check, so a
|
|
841
|
+
// request that is still open shows movement. Capped so the bar can
|
|
842
|
+
// never reach the next stop (and never imply false completion).
|
|
843
|
+
const creep = Math.min(10, latest / 180);
|
|
844
|
+
return Math.min(96, base + creep);
|
|
845
|
+
}, [events, result]);
|
|
846
|
+
|
|
847
|
+
const onCopy = useCallback(async () => {
|
|
848
|
+
if (!result) return;
|
|
849
|
+
const v = result.verdict;
|
|
850
|
+
const sampleStage = result.stages?.sample ?? null;
|
|
851
|
+
const authStage = result.stages?.auth ?? null;
|
|
852
|
+
const lines = [
|
|
853
|
+
`# ${tt("page.title")} — ${result.config?.base ?? ""}`,
|
|
854
|
+
``,
|
|
855
|
+
`**${v?.headline ?? ""}**`,
|
|
856
|
+
...(v?.reasons ?? []).map((r) => `- ${r}`),
|
|
857
|
+
``,
|
|
858
|
+
// The exact observation matters when the verdict is disputed — the
|
|
859
|
+
// phase says whether bytes ever left this machine.
|
|
860
|
+
`卡在哪一步: ${sampleStage?.phase ?? "未进入对话测试"}`,
|
|
861
|
+
`测试用的模型: ${result.stages?.model ?? "(未确定)"}${result.stages?.modelAuto ? "(自动挑选,非你指定)" : ""}`,
|
|
862
|
+
result.stages?.modelNotice ? `模型名提示: ${result.stages.modelNotice}` : ``,
|
|
863
|
+
`请求阶段诊断: ${authStage?.diagnosis?.title ?? sampleStage?.diagnosis?.title ?? "无"}`,
|
|
864
|
+
sampleStage?.diagnosis?.detail ? `细节: ${sampleStage.diagnosis.detail}` : ``,
|
|
865
|
+
sampleStage?.error ? `服务商原文: ${String(sampleStage.error).slice(0, 500)}` : ``,
|
|
866
|
+
``,
|
|
867
|
+
`| ${tt("metric.ttft")} | ${fmtMs(metrics?.ttftMedian)} |`,
|
|
868
|
+
`| ${tt("metric.tps")} | ${fmtTps(metrics?.tpsMedian)} |`,
|
|
869
|
+
`| ${tt("metric.rate")} | ${fmtPct(metrics?.successRate)} (${metrics?.successes ?? 0}/${metrics?.attempts ?? 0}) |`,
|
|
870
|
+
``,
|
|
871
|
+
`Key: \`${result.config?.keyMasked ?? ""}\``,
|
|
872
|
+
];
|
|
873
|
+
try {
|
|
874
|
+
await navigator.clipboard.writeText(lines.join("\n"));
|
|
875
|
+
setCopied(true);
|
|
876
|
+
setTimeout(() => setCopied(false), 1800);
|
|
877
|
+
} catch {
|
|
878
|
+
setCopied(false);
|
|
879
|
+
}
|
|
880
|
+
}, [result, metrics]);
|
|
881
|
+
|
|
882
|
+
return el(
|
|
883
|
+
"div",
|
|
884
|
+
{ className: "dshap-root" },
|
|
885
|
+
/* ---- 标题 ---- */
|
|
886
|
+
el(
|
|
887
|
+
"div",
|
|
888
|
+
{ className: "dshap-head" },
|
|
889
|
+
el("h2", { className: "dshap-title" }, tt("page.title")),
|
|
890
|
+
el("p", { className: "dshap-sub" }, tt("page.subtitle")),
|
|
891
|
+
),
|
|
892
|
+
|
|
893
|
+
/* ---- 表单 ---- */
|
|
894
|
+
el(
|
|
895
|
+
"div",
|
|
896
|
+
{ className: "dshap-card" },
|
|
897
|
+
el(
|
|
898
|
+
"div",
|
|
899
|
+
{ className: "dshap-grid2" },
|
|
900
|
+
el(Field, { label: tt("form.provider"), help: tt("form.provider.help") }, el(
|
|
901
|
+
"select",
|
|
902
|
+
{ className: "dshap-select", value: form.preset, onChange: (e) => onPreset(e.target.value) },
|
|
903
|
+
...PRESETS.map((p) => el("option", { key: p.id, value: p.id }, p.label)),
|
|
904
|
+
)),
|
|
905
|
+
el(Field, { label: tt("form.base"), help: tt("form.base.help") }, el("input", {
|
|
906
|
+
className: "dshap-input", value: form.base, spellCheck: false, autoComplete: "off",
|
|
907
|
+
// Follows the selected provider so the example is the one the
|
|
908
|
+
// user just chose, rather than a hardcoded vendor that may be
|
|
909
|
+
// rare where they are. Falls back to a mainstream default.
|
|
910
|
+
placeholder: (PRESETS.find((p) => p.id === form.preset)?.base) || "https://api.deepseek.com/v1",
|
|
911
|
+
onChange: (e) => set("base", e.target.value),
|
|
912
|
+
})),
|
|
913
|
+
),
|
|
914
|
+
el(
|
|
915
|
+
"div",
|
|
916
|
+
{ className: "dshap-grid2" },
|
|
917
|
+
el(Field, { label: tt("form.key"), help: tt("form.key.help") }, el(
|
|
918
|
+
"div",
|
|
919
|
+
{ className: "dshap-keywrap" },
|
|
920
|
+
el("input", {
|
|
921
|
+
className: "dshap-input", type: showKey ? "text" : "password", value: key,
|
|
922
|
+
spellCheck: false, autoComplete: "off", placeholder: "sk-…",
|
|
923
|
+
onChange: (e) => setKey(e.target.value),
|
|
924
|
+
}),
|
|
925
|
+
el("button", { className: "dshap-mini", type: "button", onClick: () => setShowKey((v) => !v) }, showKey ? tt("form.key.hide") : tt("form.key.show")),
|
|
926
|
+
)),
|
|
927
|
+
el(
|
|
928
|
+
"div",
|
|
929
|
+
null,
|
|
930
|
+
el(Field, { label: tt("form.model"), help: tt("form.model.help") }, el("input", {
|
|
931
|
+
className: "dshap-input", value: form.model, spellCheck: false, autoComplete: "off",
|
|
932
|
+
placeholder: modelPlaceholder,
|
|
933
|
+
onChange: (e) => set("model", e.target.value),
|
|
934
|
+
})),
|
|
935
|
+
// Model IDs are provider-specific and the examples beside this box
|
|
936
|
+
// are easy to copy by reflex, so say so before they cause a 400.
|
|
937
|
+
// Nothing is printed under the model box on purpose. An explanatory note here
|
|
938
|
+
// reads as a warning about the field the user is looking at, and the
|
|
939
|
+
// honest advice ("look up your provider's ID") is not actionable
|
|
940
|
+
// without knowing the provider anyway. Real guidance appears where
|
|
941
|
+
// it applies: the provider list after a run, and the 404 hint.
|
|
942
|
+
tt("form.model.warn")
|
|
943
|
+
? el("p", { className: "dshap-modelwarn" }, tt("form.model.warn"))
|
|
944
|
+
: null,
|
|
945
|
+
// Typing a model name from memory is where beginners go wrong, so
|
|
946
|
+
// offer the names we know for this provider as one-click chips.
|
|
947
|
+
modelChips.length > 0
|
|
948
|
+
? el(
|
|
949
|
+
"div",
|
|
950
|
+
{ className: "dshap-hintrow" },
|
|
951
|
+
el("span", { className: "dshap-hintlabel" }, tt("form.model.chiplabel")),
|
|
952
|
+
el(
|
|
953
|
+
"div",
|
|
954
|
+
{ className: "dshap-chips" },
|
|
955
|
+
...modelChips.map((name) =>
|
|
956
|
+
el("button", {
|
|
957
|
+
className: `dshap-chip dshap-chip-btn${form.model === name ? " dshap-chip-on" : ""}`,
|
|
958
|
+
type: "button", key: name, title: tt("form.model.chip.title"),
|
|
959
|
+
onClick: () => set("model", name),
|
|
960
|
+
}, name),
|
|
961
|
+
),
|
|
962
|
+
),
|
|
963
|
+
)
|
|
964
|
+
: null,
|
|
965
|
+
),
|
|
966
|
+
),
|
|
967
|
+
el(
|
|
968
|
+
"details",
|
|
969
|
+
{ className: "dshap-adv" },
|
|
970
|
+
el("summary", null, tt("adv.summary")),
|
|
971
|
+
el(
|
|
972
|
+
"div",
|
|
973
|
+
{ className: "dshap-advbody" },
|
|
974
|
+
el(
|
|
975
|
+
"div",
|
|
976
|
+
{ className: "dshap-grid2" },
|
|
977
|
+
el(Field, { label: tt("adv.timeout") }, el("input", {
|
|
978
|
+
className: "dshap-input", type: "number", min: 1000, max: 120000, value: form.timeout,
|
|
979
|
+
onChange: (e) => set("timeout", e.target.value),
|
|
980
|
+
})),
|
|
981
|
+
el(Field, { label: tt("adv.rounds"), help: tt("adv.rounds.help") }, el("input", {
|
|
982
|
+
className: "dshap-input", type: "number", min: 1, max: 10, value: form.rounds,
|
|
983
|
+
onChange: (e) => set("rounds", e.target.value),
|
|
984
|
+
})),
|
|
985
|
+
),
|
|
986
|
+
el(
|
|
987
|
+
"div",
|
|
988
|
+
{ className: "dshap-grid2" },
|
|
989
|
+
el(Field, { label: tt("adv.concurrency"), help: tt("adv.concurrency.help") }, el("input", {
|
|
990
|
+
className: "dshap-input", type: "number", min: 1, max: 5, value: form.concurrency,
|
|
991
|
+
onChange: (e) => set("concurrency", e.target.value),
|
|
992
|
+
})),
|
|
993
|
+
el(Field, { label: tt("form.api"), help: tt("form.api.help") }, el(
|
|
994
|
+
"select",
|
|
995
|
+
{ className: "dshap-select", value: form.api, onChange: (e) => set("api", e.target.value) },
|
|
996
|
+
el("option", { value: "openai" }, "OpenAI 兼容(绝大多数服务商)"),
|
|
997
|
+
el("option", { value: "anthropic" }, "Anthropic 原生"),
|
|
998
|
+
el("option", { value: "gemini" }, "Google Gemini 原生"),
|
|
999
|
+
)),
|
|
1000
|
+
),
|
|
1001
|
+
el(Field, { label: tt("adv.prompt") }, el("input", {
|
|
1002
|
+
className: "dshap-input", value: form.prompt,
|
|
1003
|
+
onChange: (e) => set("prompt", e.target.value),
|
|
1004
|
+
})),
|
|
1005
|
+
el(Field, { label: tt("adv.proxy"), help: tt("adv.proxy.help") }, el("input", {
|
|
1006
|
+
className: "dshap-input", value: form.proxy, spellCheck: false, autoComplete: "off",
|
|
1007
|
+
placeholder: "http://127.0.0.1:7890",
|
|
1008
|
+
onChange: (e) => set("proxy", e.target.value),
|
|
1009
|
+
})),
|
|
1010
|
+
el("label", { className: "dshap-toggle" }, el("input", {
|
|
1011
|
+
type: "checkbox", checked: form.insecure,
|
|
1012
|
+
onChange: (e) => set("insecure", e.target.checked),
|
|
1013
|
+
}), el("span", null, tt("adv.insecure"))),
|
|
1014
|
+
),
|
|
1015
|
+
),
|
|
1016
|
+
el(
|
|
1017
|
+
"div",
|
|
1018
|
+
{ className: "dshap-actions" },
|
|
1019
|
+
running
|
|
1020
|
+
? el("button", { className: "dshap-btn", type: "button", onClick: onCancel }, tt("cancel"))
|
|
1021
|
+
: el(
|
|
1022
|
+
"button",
|
|
1023
|
+
{
|
|
1024
|
+
className: "dshap-btn",
|
|
1025
|
+
type: "button",
|
|
1026
|
+
disabled: form.base.trim() === "" || key.trim() === "",
|
|
1027
|
+
// A dead button with no explanation reads as broken; name the
|
|
1028
|
+
// missing field in the tooltip instead.
|
|
1029
|
+
title: form.base.trim() === "" || key.trim() === "" ? tt("foot.needInput") : "",
|
|
1030
|
+
onClick: onRun,
|
|
1031
|
+
},
|
|
1032
|
+
tt("run"),
|
|
1033
|
+
),
|
|
1034
|
+
result
|
|
1035
|
+
? el("button", { className: "dshap-btn dshap-btn-ghost", type: "button", onClick: onCopy }, copied ? tt("copied") : tt("copy"))
|
|
1036
|
+
: null,
|
|
1037
|
+
result || events.length
|
|
1038
|
+
? el("button", {
|
|
1039
|
+
className: "dshap-btn dshap-btn-ghost", type: "button",
|
|
1040
|
+
onClick: () => { setResult(null); setEvents([]); setFatal(null); },
|
|
1041
|
+
}, tt("reset"))
|
|
1042
|
+
: null,
|
|
1043
|
+
el("span", { className: "dshap-foot" }, form.base.trim() === "" || key.trim() === "" ? tt("foot.needInput") : tt("foot.key")),
|
|
1044
|
+
),
|
|
1045
|
+
/* Track and label stay mounted; only their opacity toggles.
|
|
1046
|
+
Conditional rendering would remount the fill at its final
|
|
1047
|
+
width, which is what produced the "sudden white bar" jump.
|
|
1048
|
+
They live in their own row so the bar cannot stretch
|
|
1049
|
+
against the buttons. */
|
|
1050
|
+
el(
|
|
1051
|
+
"div",
|
|
1052
|
+
{ className: `dshap-prog-row${running || result ? " dshap-prog-show" : ""}${running ? "" : " dshap-prog-done"}` },
|
|
1053
|
+
el(
|
|
1054
|
+
"div",
|
|
1055
|
+
{ className: "dshap-prog" },
|
|
1056
|
+
el("div", { className: "dshap-prog-fill", style: { width: `${progress}%` } }),
|
|
1057
|
+
),
|
|
1058
|
+
el(
|
|
1059
|
+
"span",
|
|
1060
|
+
{ className: `dshap-prog-label${running || result ? " dshap-prog-label-show" : ""}` },
|
|
1061
|
+
running ? tt("prog.running") : result ? tt("prog.done") : "",
|
|
1062
|
+
),
|
|
1063
|
+
),
|
|
1064
|
+
),
|
|
1065
|
+
|
|
1066
|
+
/* ---- 进行中 / 结果 ---- */
|
|
1067
|
+
fatal ? el(VerdictBanner, { error: fatal }) : result ? el(VerdictBanner, { verdict: result.verdict, error: result.error }) : el(VerdictBanner, { verdict: { level: "idle", headline: tt("idle.title") } }),
|
|
1068
|
+
|
|
1069
|
+
metrics
|
|
1070
|
+
? el(
|
|
1071
|
+
"div",
|
|
1072
|
+
{ className: "dshap-metrics" },
|
|
1073
|
+
el(Metric, {
|
|
1074
|
+
name: tt("metric.ttft"), grade: metrics.ttftGrade,
|
|
1075
|
+
value: fmtMs(metrics.ttftMedian), note: ttftNote(metrics.ttftMedian),
|
|
1076
|
+
}),
|
|
1077
|
+
el(Metric, {
|
|
1078
|
+
name: tt("metric.tps"), grade: metrics.tpsGrade,
|
|
1079
|
+
value: fmtTps(metrics.tpsMedian), note: tpsNoteWithReason(metrics.tpsMedian, sampleEvent?.streamed),
|
|
1080
|
+
}),
|
|
1081
|
+
el(Metric, {
|
|
1082
|
+
name: tt("metric.rate"), grade: metrics.rateGrade,
|
|
1083
|
+
value: fmtPct(metrics.successRate),
|
|
1084
|
+
note: `${rateNote(metrics.successRate)} · ${metrics.successes ?? 0}/${metrics.attempts ?? 0}`,
|
|
1085
|
+
}),
|
|
1086
|
+
)
|
|
1087
|
+
: null,
|
|
1088
|
+
|
|
1089
|
+
sampleEvent
|
|
1090
|
+
? el(
|
|
1091
|
+
"div",
|
|
1092
|
+
{ className: "dshap-card" },
|
|
1093
|
+
el("div", { className: "dshap-hrow" }, tt("sample.title")),
|
|
1094
|
+
sampleEvent.text
|
|
1095
|
+
? el("pre", { className: "dshap-sample" }, sampleEvent.text)
|
|
1096
|
+
: el("p", { className: "dshap-empty" }, tt("sample.none")),
|
|
1097
|
+
// "no text came back" is ambiguous on its own: the request may
|
|
1098
|
+
// never have left the machine. Always say where it stopped.
|
|
1099
|
+
sampleEvent.where && !sampleEvent.ok
|
|
1100
|
+
? el("div", { className: "dshap-where" }, sampleEvent.where)
|
|
1101
|
+
: null,
|
|
1102
|
+
sampleEvent.error && !sampleEvent.ok
|
|
1103
|
+
? el("pre", { className: "dshap-raw" }, String(sampleEvent.error).slice(0, 600))
|
|
1104
|
+
: null,
|
|
1105
|
+
)
|
|
1106
|
+
: null,
|
|
1107
|
+
|
|
1108
|
+
speed && speed.failures && speed.failures.length
|
|
1109
|
+
? el(
|
|
1110
|
+
"div",
|
|
1111
|
+
{ className: "dshap-card" },
|
|
1112
|
+
el("div", { className: "dshap-hrow" }, `${tt("fail.title")} (${speed.failures.length})`),
|
|
1113
|
+
el("pre", { className: "dshap-sample" }, speed.failures.map((f, index) => `#${index + 1} ${f.where ? f.where + "\n" : ""}${f.error ?? ""}`).join("\n\n")),
|
|
1114
|
+
)
|
|
1115
|
+
: null,
|
|
1116
|
+
|
|
1117
|
+
modelEvent
|
|
1118
|
+
? el(
|
|
1119
|
+
"div",
|
|
1120
|
+
{ className: "dshap-card" },
|
|
1121
|
+
el("div", { className: "dshap-hrow" }, tt("model.title")),
|
|
1122
|
+
el("div", { className: "dshap-where" }, modelEvent.auto ? tt("model.auto") : tt("model.yours")),
|
|
1123
|
+
modelEvent.notice ? el("div", { className: "dshap-warn" }, modelEvent.notice) : null,
|
|
1124
|
+
)
|
|
1125
|
+
: null,
|
|
1126
|
+
|
|
1127
|
+
modelsEvent && modelsEvent.models && modelsEvent.models.length
|
|
1128
|
+
? el(
|
|
1129
|
+
"div",
|
|
1130
|
+
{ className: "dshap-card" },
|
|
1131
|
+
el("div", { className: "dshap-hrow" }, tt("models.title")),
|
|
1132
|
+
el(
|
|
1133
|
+
"div",
|
|
1134
|
+
{ className: "dshap-chips" },
|
|
1135
|
+
...chatModelNames(modelsEvent.models, 14).map((n) =>
|
|
1136
|
+
el("button", {
|
|
1137
|
+
className: `dshap-chip dshap-chip-btn${form.model === n ? " dshap-chip-on" : ""}`,
|
|
1138
|
+
type: "button", key: n, onClick: () => set("model", n),
|
|
1139
|
+
}, n),
|
|
1140
|
+
),
|
|
1141
|
+
),
|
|
1142
|
+
el("div", { className: "dshap-hintlabel" }, tt("models.more", { count: modelsEvent.models.length })),
|
|
1143
|
+
)
|
|
1144
|
+
: null,
|
|
1145
|
+
|
|
1146
|
+
groups.some((g) => g.stage !== "metrics" && g.stage !== "sample" && g.stage !== "models" && g.stage !== "model")
|
|
1147
|
+
? el(
|
|
1148
|
+
"div",
|
|
1149
|
+
{ className: "dshap-card" },
|
|
1150
|
+
el("div", { className: "dshap-hrow" }, tt("checks.title")),
|
|
1151
|
+
...groups
|
|
1152
|
+
.filter((g) => ["network", "auth", "probe", "speed"].includes(g.stage))
|
|
1153
|
+
.map((g) =>
|
|
1154
|
+
el(
|
|
1155
|
+
"div",
|
|
1156
|
+
{ className: "dshap-stage", key: g.stage },
|
|
1157
|
+
el("div", { className: "dshap-stage-h" }, tt(`stage.${g.stage}`)),
|
|
1158
|
+
...g.checks.map((c, index) => el(CheckRow, { key: `${g.stage}-${index}`, check: c })),
|
|
1159
|
+
),
|
|
1160
|
+
),
|
|
1161
|
+
)
|
|
1162
|
+
: null,
|
|
1163
|
+
|
|
1164
|
+
!result && !fatal && events.length === 0 && !running
|
|
1165
|
+
? el(
|
|
1166
|
+
"div",
|
|
1167
|
+
{ className: "dshap-card" },
|
|
1168
|
+
el(
|
|
1169
|
+
"div",
|
|
1170
|
+
{ className: "dshap-idle" },
|
|
1171
|
+
el(Icon, null),
|
|
1172
|
+
el("div", { className: "dshap-idle-t" }, tt("idle.title")),
|
|
1173
|
+
el("div", { className: "dshap-idle-b" }, tt("idle.body")),
|
|
1174
|
+
),
|
|
1175
|
+
)
|
|
1176
|
+
: null,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/* ============================ 注册 ============================ */
|
|
1181
|
+
|
|
1182
|
+
function registerPanel(ctx) {
|
|
1183
|
+
const slots = ctx.slots;
|
|
1184
|
+
const disposers = [];
|
|
1185
|
+
// Both seats are declared by shell plugins this package does not depend on,
|
|
1186
|
+
// so each registration is wrapped in slots.inject: a shell that never declares
|
|
1187
|
+
// the seat leaves the panel absent instead of failing boot.
|
|
1188
|
+
disposers.push(slots.inject("sidebar.panellist", () => slots.register({
|
|
1189
|
+
name: "sidebar.panellist",
|
|
1190
|
+
id: PANEL_ID,
|
|
1191
|
+
order: PANEL_ORDER,
|
|
1192
|
+
label: () => tt("entry.label"),
|
|
1193
|
+
}, Icon)));
|
|
1194
|
+
disposers.push(slots.inject("main", () => slots.register({
|
|
1195
|
+
name: "main",
|
|
1196
|
+
key: PANEL_ID,
|
|
1197
|
+
inject: () => ({}),
|
|
1198
|
+
}, Panel)));
|
|
1199
|
+
return () => {
|
|
1200
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const inject = ["slots", "locale", "layout"];
|
|
1205
|
+
|
|
1206
|
+
function apply(ctx) {
|
|
1207
|
+
ctx.effect(() => {
|
|
1208
|
+
try {
|
|
1209
|
+
return ctx.locale.register(NS, { zh, en });
|
|
1210
|
+
} catch {
|
|
1211
|
+
return () => {};
|
|
1212
|
+
}
|
|
1213
|
+
}, "api-probe: dictionaries");
|
|
1214
|
+
try {
|
|
1215
|
+
setRuntimeTranslate(ctx.locale.bind(NS));
|
|
1216
|
+
} catch {
|
|
1217
|
+
/* keep the built-in zh dictionary */
|
|
1218
|
+
}
|
|
1219
|
+
const disposers = [];
|
|
1220
|
+
try {
|
|
1221
|
+
disposers.push(registerPanel(ctx));
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
console.warn("[api-probe] panel registration failed:", error);
|
|
1224
|
+
}
|
|
1225
|
+
ctx.effect(() => () => {
|
|
1226
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
1227
|
+
}, "api-probe: panel");
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/* The module loader calls this factory and uses its RETURN VALUE as the
|
|
1231
|
+
* plugin module. Omitting the return hands it `undefined`, and the web
|
|
1232
|
+
* shell fails to boot with no plugin-specific hint — that is exactly
|
|
1233
|
+
* what the first shipped version did. */
|
|
1234
|
+
return { inject, apply };
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
console.error("[api-probe] client factory failed; panel disabled:", error);
|
|
1237
|
+
return { inject: [], apply() {} };
|
|
1238
|
+
}
|
|
1239
|
+
},
|
|
1240
|
+
});
|