@tzyc/dsh-model-visibility 1.10.3 → 1.12.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/README.md +64 -15
- package/client-src/catalog-store.mjs +90 -16
- package/client-src/checker-view.mjs +158 -65
- package/client-src/css.mjs +153 -221
- package/client-src/dict.mjs +119 -81
- package/client-src/format.mjs +9 -2
- package/client-src/pure.mjs +24 -7
- package/client-src/settings-section.mjs +242 -137
- package/client-src/usage-view.mjs +69 -65
- package/client.js +864 -594
- package/index.js +4 -2
- package/lib/check-catalog.js +868 -108
- package/lib/export-csv.js +48 -14
- package/lib/learned-catalog.js +10 -2
- package/lib/model-info.js +4 -2
- package/lib/model-overrides.js +9 -4
- package/lib/provider-probe.js +138 -22
- package/lib/provider-write.js +31 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](package.json)
|
|
11
|
-
[](test)
|
|
12
|
+
[](https://github.com/TZYCeng/dsh-model-visibility/releases)
|
|
13
13
|
[](https://awesome-dsh-plugin.com/p/TZYCeng/dsh-model-visibility/)
|
|
14
14
|
|
|
15
15
|
</div>
|
|
@@ -64,7 +64,7 @@ yujianwudi:
|
|
|
64
64
|
| **官方校正** | 厂商文档能证明的错误行(如 `glm-5.3` 被目录标成多模态)显示 `官方校正` + 文档链接,并压过目录与中转站列表 |
|
|
65
65
|
| **图像放行** | 默认开启:判定可输入图像的中转站对话模型,在请求侧补上 `image`,不用改任何配置文件;关闭即回到适配器声明 |
|
|
66
66
|
| **token 用量** | 页面顶部常驻:今天 / 本周 / 本月 / 总计的输入与输出 token(缓存读写合计进输入);一键导出用量 CSV 与模型清单 CSV |
|
|
67
|
-
| **联网校验** |
|
|
67
|
+
| **联网校验** | 选一个「校验员」模型:先拉公开目录(models.dev 两种端点 / OpenRouter / Hugging Face,含国内镜像回退)做基准,再分批核对其余模型的类型 / 上下文 / 模态 / 推理档位;**公开目录拿到的事实会真正并入结果**,校验员答不上时照样落库。结果写进本页与本地快照 `learned.json`(同名模型跨路由复用缓存),开关打开时同时写入提供商配置;并发线程可按次调整 |
|
|
68
68
|
| **重新获取能力** | 工具栏「重新获取能力」作废旧缓存与进行中的扫描,重新询问适配器与所有中转站(比「刷新」更硬,只绕过 TTL 的「刷新」仍会合并进行中的读取) |
|
|
69
69
|
| **ID 归一化** | `openai/gpt-4o`、`~deepseek-v3`、`glm-5.3:free`、`:free:batch` 这类前缀 / 叠加后缀先归一化再匹配目录,避免「同一个模型查不到」 |
|
|
70
70
|
| **持久化** | 隐藏列表写进 profile 的 `cordis.patch.yml`,并镜像到 `$DSH_HOME/dsh-model-visibility/hidden.json`,插件重装 / profile 自愈后自动恢复;手动覆盖单独一份 `overrides.json` |
|
|
@@ -94,12 +94,18 @@ npm install
|
|
|
94
94
|
|
|
95
95
|
## 界面速览
|
|
96
96
|
|
|
97
|
-
-
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
-
|
|
102
|
-
|
|
97
|
+
- **标题行**:`已显示 36 / 271 · 已关闭 235`,右侧是 `说明`、`刷新`、`重新获取能力`。
|
|
98
|
+
`说明` 折叠着标签含义、来源分层与类型配色——默认收起,把首屏留给模型。
|
|
99
|
+
- **用量条**:`今天 / 本周 / 本月 / 总计` 四个数字(合计 token,悬停看输入/输出/缓存读写拆分),
|
|
100
|
+
右侧是 `用量 CSV` 与 `模型 CSV`。首次扫描期间数字来自已折叠的账本,扫描中显示「统计中…」并加快轮询。
|
|
101
|
+
- **筛选栏**:搜索框、类型按钮(带数量)、`多模态` / `支持推理`、上下文下限,以及右端的
|
|
102
|
+
`全部显示` 与 `折叠全部`。
|
|
103
|
+
- **图像放行**:一行开关加一句说明,不再是整块说明卡。
|
|
104
|
+
- **分组**:整行标题可点(折叠/展开),显示 `已显示/总数`、类型计数,以及中转站诊断徽标
|
|
105
|
+
(只有真出问题时才是告警色)。全组开关是右侧的轻量按钮。
|
|
106
|
+
- **模型行**:名称与 ID 同一行,下方是能力标签;行右侧是 `编辑` 与开关。
|
|
107
|
+
- **页脚**:`探测:N 个中转站可读` 与 `学习快照 N 条`。
|
|
108
|
+
- **联网校验**:底部一个可折叠面板,展开才有校验员选择与进度;失败的批次直接列出真实原因。
|
|
103
109
|
|
|
104
110
|
## 能力标注从哪里来
|
|
105
111
|
|
|
@@ -189,14 +195,31 @@ Host 端通过 `settings.describe()` 读取配置了 `baseURL` 与 `apiKeyEnv`
|
|
|
189
195
|
3. 同源尝试 `GET {origin}/api/pricing`(New API 的公开价格接口),用 `supported_endpoint_types` 判断生图 / 生视频 / 嵌入;
|
|
190
196
|
4. 只保留能力事实(模态、上下文、最大输出、推理、类型),不保留响应正文;失败只记录诊断。
|
|
191
197
|
|
|
192
|
-
|
|
198
|
+
**降级与诊断**:`/models` 401/403/超时/DNS 失败时,若 `/api/pricing` 还能列出模型,就退化为 `partial`——
|
|
199
|
+
分组会显示「价格接口 N 个」,模型总数与类型仍然正确;两者都失败时状态为 `failed`,分组徽标显示
|
|
200
|
+
「读取失败」,悬停可看到人类可读的原因(如「域名解析失败(DNS 查不到该主机)」「连接超时」),
|
|
201
|
+
而不是裸的 `ENOTFOUND`。
|
|
202
|
+
|
|
203
|
+
安全约束:**只向 `https://` 的 base URL 发请求(`http://` 仅放行 localhost / 127.0.0.1 / ::1)**,
|
|
204
|
+
避免明文链路带走 API key;不跟随重定向(避免把 `Authorization` 带到别的域)、4 MB 响应上限、
|
|
205
|
+
每请求 3.5 s 超时、Provider 并发探测、结果缓存 10 分钟。关闭探测:设置行写 `probe: false`,
|
|
206
|
+
或环境变量 `MODEL_VISIBILITY_PROBE=0`。
|
|
193
207
|
|
|
194
208
|
## 学习快照(联网校验)
|
|
195
209
|
|
|
196
210
|
静态内置目录(models.dev 2026-10-07 快照)已删除:多数中转站不公布上下文 / 模态 / 推理档位,静态表会过时。替代品是**学习快照** `~/.dsh/dsh-model-visibility/learned.json`,数据来自两层:
|
|
197
211
|
|
|
198
|
-
1.
|
|
199
|
-
|
|
212
|
+
1. **公开目录基准**(无需密钥,按归一化 id 匹配,四源合并,**先命中者为准**):
|
|
213
|
+
- `https://models.dev/models.json`——与 provider 无关的模型元数据(`modalities` / `limit` / `reasoning` / `attachment`),最贴合中转站的模型 id;
|
|
214
|
+
- `https://openrouter.ai/api/v1/models`——含 `architecture.input_modalities` 与 `context_length`;
|
|
215
|
+
- `https://huggingface.co/api/models`——开源权重与自建部署(vLLM / LM Studio / Ollama)几乎只出现在这里;
|
|
216
|
+
该域名在国内常不可达,会依次回退 `hf-mirror.com`、`hugging-face.cn`;
|
|
217
|
+
- `https://models.dev/api.json`——provider 限定目录,只用来补前三个源的空缺。
|
|
218
|
+
2. **校验员模型**:在插件设置页选定一个提供商的模型作为「校验员」,其余模型按批(默认 6 个/批)经 `llm.prepareCall` 发给它,要求输出结构化 JSON 事实;`unknown` 一律允许,不猜。并发线程数可在面板里按次调整(1–8,默认 2)。
|
|
219
|
+
|
|
220
|
+
**关键行为**:公开目录的事实会真正并入结果,而不只是给个来源标签。校验员答「不知道」、或整批失败时,
|
|
221
|
+
只要公开目录里有这个模型,能力仍然会落库(来源标 `已校验`,虚线)。面板会逐条列出每个公开目录
|
|
222
|
+
「读到了 / 为什么没读到 / 多少个模型」,并注明是否发生了镜像回退。
|
|
200
223
|
|
|
201
224
|
结果落地:
|
|
202
225
|
|
|
@@ -207,6 +230,30 @@ Host 端通过 `settings.describe()` 读取配置了 `baseURL` 与 `apiKeyEnv`
|
|
|
207
230
|
|
|
208
231
|
进度、取消与最终统计由 `GET/POST /api/dsh-model-visibility/verify` 提供,面板内轮询展示。
|
|
209
232
|
|
|
233
|
+
### 校验批次的预算、格式与失败处理
|
|
234
|
+
|
|
235
|
+
校验是「查表」不是「推理」,但有四个容易踩的坑,现在都在代码里处理了:
|
|
236
|
+
|
|
237
|
+
- **输出预算**:每批请求 8192 输出 token(`CHECKER_MAX_TOKENS`)。旧版写死 2048,思考型校验员
|
|
238
|
+
(如带 `reasoningEfforts` 的 DeepSeek / Claude)会把额度全花在思考通道上,正文要么为空、要么被
|
|
239
|
+
截断,于是**每一批**都报「回复里没有 JSON」——实测一次 191 个模型、32 个批次全失败、烧掉 63K token。
|
|
240
|
+
- **思考档位**:请求里固定 `reasoningEffort: 'low'`(`CHECKER_REASONING_EFFORT`),把思考通道压到最低,
|
|
241
|
+
避免它挤掉正文。两项都可通过 `createCapabilityVerifier({ checkerMaxTokens, checkerReasoningEffort })` 覆盖,
|
|
242
|
+
传 `checkerReasoningEffort: null` 可关闭锁定。
|
|
243
|
+
- **截断即失败**:`finish.reason.kind === 'max-tokens'` 现在会被当作错误,并明确说明「回复在 N 个输出
|
|
244
|
+
token 处被截断(模型把预算花在思考上了)」,而不是笼统的「没有 JSON」。
|
|
245
|
+
- **提示词 + 格式检测**:系统提示词直接给出**精确的 JSON 数组 schema**(每个键、每个取值、`null` 表示未知、
|
|
246
|
+
禁止 markdown 围栏与解释),回复再经过 `validateReplyShape` 严格校验:必须是数组、每条要有 `id`、
|
|
247
|
+
不能漏掉被问的 id、不能多出没问的 id。不合格会**定向重试一次**;仍然不合格但 JSON 可用时,保留已答部分
|
|
248
|
+
并记下违规原因,而不是整批丢弃。`{models:[…]}` / `{results:[…]}` 这种包裹形式视为合格,不浪费重试。
|
|
249
|
+
- **失败切分重试**:一批解析失败会二分重试,直到定位到那一个回答不了的模型;其余模型的事实照样落库。
|
|
250
|
+
- **取消与重启互斥**:取消只 abort,不等旧 worker 结束就不再接受新 sweep;重复 `start()` 会 join 旧 run,
|
|
251
|
+
不再出现两轮并发写同一个状态(曾出现 `done=36 / total=23` 和 6 路并发调用)。
|
|
252
|
+
- **并发线程**:每轮可单独设置 1–8 路并发(默认 2,上限 `MAX_CONCURRENCY`);中转站限流时调低。
|
|
253
|
+
|
|
254
|
+
`learned.json` 也改成**每批一次**批量写入(旧实现每个模型读一次、写一次整份文件:200 个模型 =
|
|
255
|
+
199 次全量读 + 199 次全量写)。
|
|
256
|
+
|
|
210
257
|
## 路由
|
|
211
258
|
|
|
212
259
|
```
|
|
@@ -261,7 +308,7 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
261
308
|
| `lib/model-overrides.js` | 手动覆盖的读写与校验(`overrides.json`,按 mtime 重读) |
|
|
262
309
|
| `lib/provider-write.js` | 把可对应字段写回 `llm-pi-ai` Provider 配置(`settings.mutate` 路径操作) |
|
|
263
310
|
| `lib/learned-catalog.js` | 学习快照 `learned.json` 的读写与归一化 id 匹配 |
|
|
264
|
-
| `lib/check-catalog.js` |
|
|
311
|
+
| `lib/check-catalog.js` | 联网校验:公开目录四源解析(models.dev 三种端点 / OpenRouter / Hugging Face 含镜像回退)、强格式提示词与校验、校验员分批、并发线程、写回与进度状态 |
|
|
265
312
|
| `lib/usage-ledger.js` | token 用量账本:会话日志 zstd 帧解码、按天 / 按模型折叠与增量扫描 |
|
|
266
313
|
| `lib/export-csv.js` | 一键导出的两个 CSV 生成器(用量 / 模型清单) |
|
|
267
314
|
| `lib/provider-probe.js` | 读取 Provider 配置、解析凭据、探测中转站、10 min 缓存与诊断 |
|
|
@@ -271,7 +318,7 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
271
318
|
| `client-src/*.mjs` | 浏览器端源码(纯函数模块可被 `node --test` 直测),`scripts/build-client.mjs` 机械拼装成单文件 bundle |
|
|
272
319
|
| `client.js` | 构建产物(勿手改):设置页、筛选、折叠、覆盖编辑器、能力标签、用量条、校验面板、模型目录 store 过滤 |
|
|
273
320
|
| `locale/*.json` | 插件列表里的标题与描述 |
|
|
274
|
-
| `test/*.test.mjs` |
|
|
321
|
+
| `test/*.test.mjs` | 180 个单元 / 集成 / 渲染冒烟测试(`test/setup.mjs` 把 `DSH_HOME` 指向临时目录,测试不读本机真实快照) |
|
|
275
322
|
| `icon.svg` · `docs/` | 插件图标与截图 |
|
|
276
323
|
|
|
277
324
|
## 实现要点
|
|
@@ -348,7 +395,9 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
348
395
|
npm test
|
|
349
396
|
```
|
|
350
397
|
|
|
351
|
-
|
|
398
|
+
180 个用例。覆盖:证据优先级、默认文本不被当成事实、三种中转站 dialect 的解析、凭据缺失与 401、`/v1/models` 回退、pricing 回退与传输错误可读化、缓存 TTL / `fresh` / `reload`、模型类型与家族模态、目录别名匹配、推理档位清洗与 Provider 档位白名单、官方校正、手动覆盖的校验与持久化(含损坏文件与外部改动)、Provider 回写(档位映射、按模型下标写入、各种拒绝原因、写后字段剥离)、请求侧图像准入与适配器闸门的可逆性、CSV 导出(真实账本形状、转义、公式注入)、校验批次的输出预算与截断诊断 / 失败切分重试 / 取消重启互斥,以及 Host 接线(能力路由 + 校正 + `applyInput` 开关 + 硬重载提升 `reload` 能力位 + 覆盖读写路由 + 回写后网关立即按新配置放行)。
|
|
399
|
+
|
|
400
|
+
`test/client-render.test.mjs` 额外把真实 `client.js` 渲染一遍并点击每个控件:单测曾 115/115 全绿,而设置页每个模型开关都在抛 `ReferenceError: FIELD is not defined`(1.10.0 拆模块时丢的 import),因为旧测试只验证「模块能被 require」,从没渲染过组件。
|
|
352
401
|
|
|
353
402
|
GUI 冒烟(改完代码后):
|
|
354
403
|
|
|
@@ -286,6 +286,7 @@ export function createUsageApi() {
|
|
|
286
286
|
let state = { status: 'idle', snapshot: null, error: null }
|
|
287
287
|
let inflight = null
|
|
288
288
|
let timer = null
|
|
289
|
+
let timerMs = null
|
|
289
290
|
|
|
290
291
|
const publish = (next) => {
|
|
291
292
|
state = next
|
|
@@ -317,16 +318,33 @@ export function createUsageApi() {
|
|
|
317
318
|
return run
|
|
318
319
|
}
|
|
319
320
|
|
|
320
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* Poll cadence follows the ledger's own progress: a cold scan of the session
|
|
323
|
+
* tree takes seconds, so the strip asks again quickly until it settles
|
|
324
|
+
* instead of showing em dashes for half a minute.
|
|
325
|
+
*/
|
|
326
|
+
const LIVE_POLL_MS = 3_000
|
|
321
327
|
const POLL_MS = 30_000
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
328
|
+
const rearm = () => {
|
|
329
|
+
// Same rule as the verify store: no observer, no armed timer.
|
|
330
|
+
if (listeners.size === 0) {
|
|
331
|
+
stopTimer()
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
const wantMs = state.snapshot?.computing === true ? LIVE_POLL_MS : POLL_MS
|
|
335
|
+
if (timer !== null && timerMs === wantMs) return
|
|
336
|
+
if (timer !== null) clearInterval(timer)
|
|
337
|
+
timerMs = wantMs
|
|
338
|
+
timer = setInterval(() => {
|
|
339
|
+
void read().then(rearm, rearm)
|
|
340
|
+
}, wantMs)
|
|
341
|
+
if (typeof timer === 'object' && timer !== null && typeof timer.unref === 'function') timer.unref()
|
|
325
342
|
}
|
|
326
343
|
const stopTimer = () => {
|
|
327
344
|
if (timer !== null) {
|
|
328
345
|
clearInterval(timer)
|
|
329
346
|
timer = null
|
|
347
|
+
timerMs = null
|
|
330
348
|
}
|
|
331
349
|
}
|
|
332
350
|
|
|
@@ -334,7 +352,11 @@ export function createUsageApi() {
|
|
|
334
352
|
subscribe(fn) {
|
|
335
353
|
const first = listeners.size === 0
|
|
336
354
|
listeners.add(fn)
|
|
337
|
-
|
|
355
|
+
// Read once immediately: waiting a full poll interval is why the strip
|
|
356
|
+
// looked empty on every first visit.
|
|
357
|
+
if (first) {
|
|
358
|
+
void read().then(rearm, rearm)
|
|
359
|
+
}
|
|
338
360
|
return () => {
|
|
339
361
|
listeners.delete(fn)
|
|
340
362
|
if (listeners.size === 0) stopTimer()
|
|
@@ -344,12 +366,21 @@ export function createUsageApi() {
|
|
|
344
366
|
return state
|
|
345
367
|
},
|
|
346
368
|
load() {
|
|
347
|
-
return read()
|
|
369
|
+
return read().then(
|
|
370
|
+
(value) => {
|
|
371
|
+
rearm()
|
|
372
|
+
return value
|
|
373
|
+
},
|
|
374
|
+
(error) => {
|
|
375
|
+
rearm()
|
|
376
|
+
throw error
|
|
377
|
+
},
|
|
378
|
+
)
|
|
348
379
|
},
|
|
349
380
|
refresh() {
|
|
350
381
|
// A POST rescan is never needed: the Host rescan throttle refreshes on
|
|
351
382
|
// reads anyway. A plain read is enough here.
|
|
352
|
-
return
|
|
383
|
+
return this.load()
|
|
353
384
|
},
|
|
354
385
|
}
|
|
355
386
|
}
|
|
@@ -364,6 +395,8 @@ export function createVerifyApi() {
|
|
|
364
395
|
let state = { status: 'idle', snapshot: null, error: null }
|
|
365
396
|
let inflight = null
|
|
366
397
|
let timer = null
|
|
398
|
+
/** Cadence the armed timer currently runs at, so rearm can compare. */
|
|
399
|
+
let timerMs = null
|
|
367
400
|
|
|
368
401
|
const publish = (next) => {
|
|
369
402
|
state = next
|
|
@@ -425,16 +458,30 @@ export function createVerifyApi() {
|
|
|
425
458
|
|
|
426
459
|
const SWEEP_POLL_MS = 2_000
|
|
427
460
|
const IDLE_POLL_MS = 60_000
|
|
428
|
-
|
|
429
|
-
|
|
461
|
+
/** Re-arm the timer at the cadence the *current* snapshot needs. */
|
|
462
|
+
const rearm = () => {
|
|
463
|
+
// Never arm a timer nobody will observe: a bare `load()` (a test, an
|
|
464
|
+
// explicit refresh) must not leave an interval holding the page alive.
|
|
465
|
+
if (listeners.size === 0) {
|
|
466
|
+
stopTimer()
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
const wantFast = state.snapshot?.state?.running === true
|
|
470
|
+
const wantMs = wantFast ? SWEEP_POLL_MS : IDLE_POLL_MS
|
|
471
|
+
if (timer !== null && timerMs === wantMs) return
|
|
472
|
+
if (timer !== null) clearInterval(timer)
|
|
473
|
+
timerMs = wantMs
|
|
430
474
|
timer = setInterval(() => {
|
|
431
|
-
void read()
|
|
432
|
-
},
|
|
475
|
+
void read().then(rearm, rearm)
|
|
476
|
+
}, wantMs)
|
|
477
|
+
// A polling strip must not keep a non-browser process alive.
|
|
478
|
+
if (typeof timer === 'object' && timer !== null && typeof timer.unref === 'function') timer.unref()
|
|
433
479
|
}
|
|
434
480
|
const stopTimer = () => {
|
|
435
481
|
if (timer !== null) {
|
|
436
482
|
clearInterval(timer)
|
|
437
483
|
timer = null
|
|
484
|
+
timerMs = null
|
|
438
485
|
}
|
|
439
486
|
}
|
|
440
487
|
|
|
@@ -443,8 +490,8 @@ export function createVerifyApi() {
|
|
|
443
490
|
const first = listeners.size === 0
|
|
444
491
|
listeners.add(fn)
|
|
445
492
|
if (first) {
|
|
446
|
-
void read()
|
|
447
|
-
|
|
493
|
+
void read().then(rearm, rearm)
|
|
494
|
+
rearm()
|
|
448
495
|
}
|
|
449
496
|
return () => {
|
|
450
497
|
listeners.delete(fn)
|
|
@@ -455,13 +502,40 @@ export function createVerifyApi() {
|
|
|
455
502
|
return state
|
|
456
503
|
},
|
|
457
504
|
load() {
|
|
458
|
-
return read()
|
|
505
|
+
return read().then(
|
|
506
|
+
(value) => {
|
|
507
|
+
rearm()
|
|
508
|
+
return value
|
|
509
|
+
},
|
|
510
|
+
(error) => {
|
|
511
|
+
rearm()
|
|
512
|
+
throw error
|
|
513
|
+
},
|
|
514
|
+
)
|
|
459
515
|
},
|
|
460
516
|
start(payload) {
|
|
461
|
-
return post(payload)
|
|
517
|
+
return post(payload).then(
|
|
518
|
+
(value) => {
|
|
519
|
+
rearm()
|
|
520
|
+
return value
|
|
521
|
+
},
|
|
522
|
+
(error) => {
|
|
523
|
+
rearm()
|
|
524
|
+
throw error
|
|
525
|
+
},
|
|
526
|
+
)
|
|
462
527
|
},
|
|
463
528
|
cancel() {
|
|
464
|
-
return post({ cancel: true })
|
|
529
|
+
return post({ cancel: true }).then(
|
|
530
|
+
(value) => {
|
|
531
|
+
rearm()
|
|
532
|
+
return value
|
|
533
|
+
},
|
|
534
|
+
(error) => {
|
|
535
|
+
rearm()
|
|
536
|
+
throw error
|
|
537
|
+
},
|
|
538
|
+
)
|
|
465
539
|
},
|
|
466
540
|
}
|
|
467
541
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The online capability verification panel.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Collapsed to a single summary line: this is a maintenance action, not the
|
|
5
|
+
* point of the page. Expanding it reveals the checker picker, the write-back
|
|
6
|
+
* switch and — the part that matters when a sweep fails — the per-batch errors
|
|
7
|
+
* with their real messages instead of a bare count.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* The last route choice is kept in browser storage only: the Host persists run
|
|
10
|
+
* facts, not UI selections.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
const React = require('react')
|
|
@@ -16,6 +17,10 @@ import { formatTokens, checkerRoutesOf } from './pure.mjs'
|
|
|
16
17
|
import { useSource } from './hooks.mjs'
|
|
17
18
|
|
|
18
19
|
const PICK_KEY = 'dsh-model-visibility:checker-pick:v1'
|
|
20
|
+
/** How many failure lines to show before summarising the rest. */
|
|
21
|
+
const MAX_ERROR_LINES = 6
|
|
22
|
+
/** Concurrency choices offered in the panel; mirrors lib/check-catalog MAX_CONCURRENCY. */
|
|
23
|
+
const CONCURRENCY_CHOICES = [1, 2, 3, 4, 6, 8]
|
|
19
24
|
|
|
20
25
|
function readPick() {
|
|
21
26
|
try {
|
|
@@ -25,10 +30,14 @@ function readPick() {
|
|
|
25
30
|
provider: typeof parsed.provider === 'string' ? parsed.provider : '',
|
|
26
31
|
model: typeof parsed.model === 'string' ? parsed.model : '',
|
|
27
32
|
writeBack: parsed.writeBack !== false,
|
|
33
|
+
concurrency:
|
|
34
|
+
Number.isFinite(parsed.concurrency) && parsed.concurrency > 0
|
|
35
|
+
? Math.min(8, Math.max(1, Math.trunc(parsed.concurrency)))
|
|
36
|
+
: 2,
|
|
28
37
|
}
|
|
29
|
-
: { provider: '', model: '', writeBack: true }
|
|
38
|
+
: { provider: '', model: '', writeBack: true, concurrency: 2 }
|
|
30
39
|
} catch (_error) {
|
|
31
|
-
return { provider: '', model: '', writeBack: true }
|
|
40
|
+
return { provider: '', model: '', writeBack: true, concurrency: 2 }
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -59,10 +68,12 @@ export function VerifyPanel({ verify, catalog, t, canUse }) {
|
|
|
59
68
|
.find((group) => group.id === pick.provider)?.models ?? []
|
|
60
69
|
|
|
61
70
|
if (!canUse) {
|
|
62
|
-
return h('div', { className: '
|
|
71
|
+
return h('div', { className: 'mvp_banner' }, t('checker.hostStale'))
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
const tokensSpent = progress
|
|
74
|
+
const tokensSpent = progress
|
|
75
|
+
? formatTokens((progress.tokenUsage?.[0] ?? 0) + (progress.tokenUsage?.[1] ?? 0))
|
|
76
|
+
: null
|
|
66
77
|
|
|
67
78
|
const commit = (next) => {
|
|
68
79
|
setPick(next)
|
|
@@ -72,11 +83,11 @@ export function VerifyPanel({ verify, catalog, t, canUse }) {
|
|
|
72
83
|
const run = () => {
|
|
73
84
|
setMessage(null)
|
|
74
85
|
verify
|
|
75
|
-
.start({
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
.start({
|
|
87
|
+
provider: pick.provider,
|
|
88
|
+
model: pick.model,
|
|
89
|
+
writeBack: pick.writeBack,
|
|
90
|
+
concurrency: pick.concurrency,
|
|
80
91
|
})
|
|
81
92
|
.catch((error) => setMessage(String(error?.message ?? error)))
|
|
82
93
|
}
|
|
@@ -85,6 +96,24 @@ export function VerifyPanel({ verify, catalog, t, canUse }) {
|
|
|
85
96
|
verify.cancel().catch((error) => setMessage(String(error?.message ?? error)))
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
const summary = running
|
|
100
|
+
? t('checker.running', {
|
|
101
|
+
phase: t('checker.phase.' + (progress?.phase ?? 'verifying')),
|
|
102
|
+
done: progress?.done ?? 0,
|
|
103
|
+
total: progress?.total ?? 0,
|
|
104
|
+
tokens: tokensSpent,
|
|
105
|
+
})
|
|
106
|
+
: finished
|
|
107
|
+
? t('checker.finished', {
|
|
108
|
+
done: progress?.done ?? 0,
|
|
109
|
+
total: progress?.total ?? 0,
|
|
110
|
+
tokens: tokensSpent,
|
|
111
|
+
})
|
|
112
|
+
: t('checker.idle')
|
|
113
|
+
|
|
114
|
+
const errors = Array.isArray(progress?.errors) ? progress.errors : []
|
|
115
|
+
const visibleErrors = errors.slice(0, MAX_ERROR_LINES)
|
|
116
|
+
|
|
88
117
|
return h(
|
|
89
118
|
'div',
|
|
90
119
|
{ className: 'mvp_checker' + (open ? '' : ' mvp_checker--closed') },
|
|
@@ -96,26 +125,16 @@ export function VerifyPanel({ verify, catalog, t, canUse }) {
|
|
|
96
125
|
'aria-expanded': open,
|
|
97
126
|
onClick: () => setOpen(!open),
|
|
98
127
|
},
|
|
128
|
+
h('span', { className: 'mvp_chevron' }, open ? '▾' : '▸'),
|
|
99
129
|
h('span', { className: 'mvp_checkerTitle' }, t('checker.title')),
|
|
100
|
-
h(
|
|
101
|
-
|
|
102
|
-
{ className: 'mvp_checkerSummary' },
|
|
103
|
-
running
|
|
104
|
-
? t('checker.running', {
|
|
105
|
-
done: progress?.done ?? 0,
|
|
106
|
-
total: progress?.total ?? 0,
|
|
107
|
-
phase: t(`checker.phase.${progress?.phase ?? 'checker'}`),
|
|
108
|
-
})
|
|
109
|
-
: finished
|
|
110
|
-
? t('checker.finished', { done: progress?.done ?? 0, total: progress?.total ?? 0, tokens: tokensSpent })
|
|
111
|
-
: t('checker.idle'),
|
|
112
|
-
),
|
|
130
|
+
h('span', { className: 'mvp_checkerSummary' }, summary),
|
|
131
|
+
errors.length > 0 ? h('span', { className: 'mvp_badge' }, t('checker.errorBadge', { count: errors.length })) : null,
|
|
113
132
|
),
|
|
114
133
|
open
|
|
115
134
|
? h(
|
|
116
135
|
'div',
|
|
117
136
|
{ className: 'mvp_checkerBody' },
|
|
118
|
-
h('
|
|
137
|
+
h('p', { className: 'mvp_checkerHint' }, t('checker.description')),
|
|
119
138
|
h(
|
|
120
139
|
'div',
|
|
121
140
|
{ className: 'mvp_checkerForm' },
|
|
@@ -154,67 +173,141 @@ export function VerifyPanel({ verify, catalog, t, canUse }) {
|
|
|
154
173
|
),
|
|
155
174
|
),
|
|
156
175
|
),
|
|
157
|
-
),
|
|
158
|
-
h(
|
|
159
|
-
'div',
|
|
160
|
-
{ className: 'mvp_checkerActions' },
|
|
161
176
|
h(
|
|
162
|
-
'
|
|
163
|
-
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
177
|
+
'label',
|
|
178
|
+
{ className: 'mvp_field', title: t('checker.concurrencyTitle') },
|
|
179
|
+
h('span', { className: 'mvp_fieldLabel' }, t('checker.concurrency')),
|
|
180
|
+
h(
|
|
181
|
+
'select',
|
|
182
|
+
{
|
|
183
|
+
className: 'mvp_select',
|
|
184
|
+
value: String(pick.concurrency),
|
|
185
|
+
'aria-label': t('checker.concurrency'),
|
|
186
|
+
disabled: running,
|
|
187
|
+
onChange: (event) =>
|
|
188
|
+
commit({ ...pick, concurrency: Number(event.target.value) }),
|
|
189
|
+
},
|
|
190
|
+
CONCURRENCY_CHOICES.map((value) =>
|
|
191
|
+
h('option', { key: value, value: String(value) }, t('checker.concurrencyValue', { count: value })),
|
|
192
|
+
),
|
|
193
|
+
),
|
|
173
194
|
),
|
|
174
195
|
h(
|
|
175
|
-
'
|
|
176
|
-
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
196
|
+
'label',
|
|
197
|
+
{ className: 'mvp_check', title: t('checker.writeBackTitle') },
|
|
198
|
+
h('input', {
|
|
199
|
+
type: 'checkbox',
|
|
200
|
+
checked: pick.writeBack,
|
|
201
|
+
disabled: running,
|
|
202
|
+
onChange: (event) => commit({ ...pick, writeBack: event.target.checked }),
|
|
203
|
+
}),
|
|
204
|
+
h('span', null, t('checker.writeBack')),
|
|
183
205
|
),
|
|
184
206
|
h(
|
|
185
207
|
'button',
|
|
186
208
|
{
|
|
187
209
|
type: 'button',
|
|
188
210
|
className: running ? 'mvp_button' : 'mvp_button mvp_button--accent',
|
|
189
|
-
disabled: running
|
|
211
|
+
disabled: !running && (pick.provider === '' || pick.model === ''),
|
|
190
212
|
title: running ? t('checker.cancelTitle') : t('checker.startTitle'),
|
|
191
213
|
onClick: running ? cancel : run,
|
|
192
214
|
},
|
|
193
215
|
running ? t('checker.cancel') : t('checker.start'),
|
|
194
216
|
),
|
|
195
217
|
),
|
|
196
|
-
|
|
218
|
+
running || finished
|
|
197
219
|
? h(
|
|
198
220
|
'div',
|
|
199
221
|
{ className: 'mvp_checkerProgress' },
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
222
|
+
h('div', {
|
|
223
|
+
className: 'mvp_progressBar',
|
|
224
|
+
role: 'progressbar',
|
|
225
|
+
'aria-valuemin': 0,
|
|
226
|
+
'aria-valuemax': progress?.total ?? 0,
|
|
227
|
+
'aria-valuenow': progress?.done ?? 0,
|
|
228
|
+
}, h('span', {
|
|
229
|
+
className: 'mvp_progressFill',
|
|
230
|
+
style: {
|
|
231
|
+
width:
|
|
232
|
+
(progress?.total ?? 0) > 0
|
|
233
|
+
? Math.min(100, Math.round(((progress?.done ?? 0) / progress.total) * 100)) + '%'
|
|
234
|
+
: '0%',
|
|
235
|
+
},
|
|
236
|
+
})),
|
|
237
|
+
h(
|
|
238
|
+
'span',
|
|
239
|
+
{ className: 'mvp_checkerProgressText' },
|
|
240
|
+
t('checker.progress', {
|
|
241
|
+
done: progress?.done ?? 0,
|
|
242
|
+
total: progress?.total ?? 0,
|
|
243
|
+
tokens: tokensSpent,
|
|
244
|
+
}),
|
|
245
|
+
),
|
|
246
|
+
h(
|
|
247
|
+
'span',
|
|
248
|
+
{ className: 'mvp_checkerProgressText' },
|
|
249
|
+
t('checker.threads', { count: progress?.concurrency ?? pick.concurrency }),
|
|
250
|
+
),
|
|
205
251
|
)
|
|
206
252
|
: null,
|
|
207
|
-
|
|
253
|
+
// Which public catalogs were read, and which were not. Without this
|
|
254
|
+
// "the catalogs returned nothing" and "the catalogs errored" look
|
|
255
|
+
// identical, which is exactly how a broken fetch stayed invisible.
|
|
256
|
+
(progress?.sources?.length ?? 0) > 0
|
|
208
257
|
? h(
|
|
209
258
|
'div',
|
|
210
|
-
{
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
259
|
+
{ className: 'mvp_sources' },
|
|
260
|
+
h('div', { className: 'mvp_sourcesTitle' }, t('checker.sourcesTitle')),
|
|
261
|
+
h(
|
|
262
|
+
'ul',
|
|
263
|
+
{ className: 'mvp_sourceList' },
|
|
264
|
+
progress.sources.map((source, index) =>
|
|
265
|
+
h(
|
|
266
|
+
'li',
|
|
267
|
+
{ key: index, className: source.status === 'ok' ? 'mvp_source--ok' : 'mvp_source--bad' },
|
|
268
|
+
h('span', { className: 'mvp_sourceName' }, String(source.name)),
|
|
269
|
+
h('span', { className: 'mvp_sourceState' }, t('checker.source.' + String(source.status))),
|
|
270
|
+
source.models > 0
|
|
271
|
+
? h('span', { className: 'mvp_sourceCount' }, t('checker.sourceModels', { count: source.models }))
|
|
272
|
+
: null,
|
|
273
|
+
source.message
|
|
274
|
+
? h('span', { className: 'mvp_sourceMsg' }, String(source.message))
|
|
275
|
+
: null,
|
|
276
|
+
),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
: null,
|
|
281
|
+
(progress?.publicOnly ?? 0) > 0
|
|
282
|
+
? h(
|
|
283
|
+
'div',
|
|
284
|
+
{ className: 'mvp_usageHint' },
|
|
285
|
+
t('checker.publicOnly', { count: progress.publicOnly }),
|
|
286
|
+
)
|
|
287
|
+
: null,
|
|
288
|
+
errors.length > 0
|
|
289
|
+
? h(
|
|
290
|
+
'div',
|
|
291
|
+
{ className: 'mvp_checkerErrors' },
|
|
292
|
+
h('div', { className: 'mvp_checkerErrorsTitle' }, t('checker.errorTitle')),
|
|
293
|
+
h(
|
|
294
|
+
'ul',
|
|
295
|
+
{ className: 'mvp_errorList' },
|
|
296
|
+
visibleErrors.map((entry, index) =>
|
|
297
|
+
h('li', { key: index }, typeof entry === 'string' ? entry : String(entry)),
|
|
298
|
+
),
|
|
299
|
+
),
|
|
300
|
+
errors.length > visibleErrors.length
|
|
301
|
+
? h(
|
|
302
|
+
'div',
|
|
303
|
+
{ className: 'mvp_usageHint' },
|
|
304
|
+
t('checker.errorMore', { count: errors.length - visibleErrors.length }),
|
|
305
|
+
)
|
|
306
|
+
: null,
|
|
215
307
|
)
|
|
216
308
|
: null,
|
|
217
|
-
message !== null ? h('div', { className: '
|
|
309
|
+
message !== null ? h('div', { className: 'mvp_banner mvp_banner--error' }, message) : null,
|
|
310
|
+
state.error !== null ? h('div', { className: 'mvp_banner mvp_banner--error' }, state.error) : null,
|
|
218
311
|
)
|
|
219
312
|
: null,
|
|
220
313
|
)
|