@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 CHANGED
@@ -8,8 +8,8 @@
8
8
 
9
9
  [![license](https://img.shields.io/badge/license-MIT-3da639?style=flat-square)](LICENSE)
10
10
  [![dsh bundle](https://img.shields.io/badge/DeepSeek%20Harness-bundle-4d6bfe?style=flat-square)](package.json)
11
- [![tests](https://img.shields.io/badge/tests-114%20passing-3da639?style=flat-square)](test)
12
- [![version](https://img.shields.io/badge/version-1.10.3-4d6bfe?style=flat-square)](https://github.com/TZYCeng/dsh-model-visibility/releases)
11
+ [![tests](https://img.shields.io/badge/tests-180%20passing-3da639?style=flat-square)](test)
12
+ [![version](https://img.shields.io/badge/version-1.12.1-4d6bfe?style=flat-square)](https://github.com/TZYCeng/dsh-model-visibility/releases)
13
13
  [![marketplace](https://img.shields.io/badge/awesome--dsh--plugin-listed-8a2be2?style=flat-square)](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
- | **联网校验** | 选一个「校验员」模型:先拉 models.dev / OpenRouter 公开目录做基准,再分批核对其余模型的类型 / 上下文 / 模态 / 推理档位;结果写进本页与本地快照 `learned.json`(同名模型跨路由复用缓存),开关打开时同时写入提供商配置 |
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
- - **顶部统计**:`已显示 86 / 301 个模型 · 已关闭 215 个`,后面跟着 `全部显示`、`折叠全部分组`、`刷新`、`重新获取能力`。
98
- - **筛选栏**:搜索框(名称或 ID)、类型按钮(带数量)、能力开关、最小上下文下拉。
99
- - **图像放行开关**:`让输入框按能力标注放行图像`,说明写在旁边,实时生效。
100
- - **图例**:解释标签含义(推理 / 上下文 / 多模态)、类型配色,以及「手动」标签的优先级。
101
- - **分组**:标题显示 `已显示/总数`、`对话 n · 生图 n · 生视频 n · 其它 n`、以及该中转站读到了多少模型 / 多少能力字段;右侧是整组开关。
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
- 安全约束:不跟随重定向(避免把 `Authorization` 带到别的域)、4 MB 响应上限、每请求 3.5 s 超时、Provider 并发探测、结果缓存 10 分钟。关闭探测:设置行写 `probe: false`,或环境变量 `MODEL_VISIBILITY_PROBE=0`。
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. **公开目录基准**:`https://models.dev/api.json` 与 `https://openrouter.ai/api/v1/models`(无需密钥),按归一化 id 匹配;
199
- 2. **校验员模型**:在插件设置页选定一个提供商的模型作为「校验员」,其余模型按批(默认 6 个/批、并发 2)经 `llm.prepareCall` 发给它,要求输出结构化 JSON 事实;`unknown` 一律允许,不猜。
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` | 72 个单元 / 集成测试 |
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
- 112 个用例。覆盖:证据优先级、默认文本不被当成事实、三种中转站 dialect 的解析、凭据缺失与 401、`/v1/models` 回退、缓存 TTL / `fresh` / `reload`、模型类型与家族模态、目录别名匹配、推理档位清洗、官方校正、手动覆盖的校验与持久化(含损坏文件与外部改动)、Provider 回写(档位映射、按模型下标写入、各种拒绝原因、写后字段剥离)、请求侧图像准入与适配器闸门的可逆性,以及 Host 接线(能力路由 + 校正 + `applyInput` 开关 + 硬重载提升 `reload` 能力位 + 覆盖读写路由 + 回写后网关立即按新配置放行)。
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
- /** One poll now and another every POLL_MS while someone subscribes. */
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 startTimer = () => {
323
- if (timer !== null) return
324
- timer = setInterval(() => read(), POLL_MS)
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
- if (first) startTimer()
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 read()
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
- const startTimer = () => {
429
- if (timer !== null) return
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
- }, state.snapshot?.state?.running === true ? SWEEP_POLL_MS : IDLE_POLL_MS)
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
- startTimer()
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
- * The user picks a checker route (provider + model) and a write-back switch,
5
- * runs the sweep, and the panel polls the Host for progress and the checker's
6
- * token spend. The last route choice is kept in browser storage only: the
7
- * Host persists run facts, not UI selections.
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
- * @module client-src/checker-view
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: 'mvp_fallback' }, t('checker.hostStale'))
71
+ return h('div', { className: 'mvp_banner' }, t('checker.hostStale'))
63
72
  }
64
73
 
65
- const tokensSpent = progress ? formatTokens((progress.tokenUsage?.[0] ?? 0) + (progress.tokenUsage?.[1] ?? 0)) : null
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({ provider: pick.provider, model: pick.model, writeBack: pick.writeBack })
76
- .then((result) => {
77
- if (result !== undefined && result.ok === false) {
78
- setMessage(result.message ?? t('checker.failed'))
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
- 'span',
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('div', { className: 'mvp_checkerHint' }, t('checker.description')),
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
- 'button',
163
- {
164
- type: 'button',
165
- role: 'switch',
166
- className: 'mvp_switch',
167
- 'aria-checked': pick.writeBack,
168
- title: t('checker.writeBackTitle'),
169
- disabled: running,
170
- onClick: () => commit({ ...pick, writeBack: !pick.writeBack }),
171
- },
172
- h('span', { className: 'mvp_thumb' }),
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
- 'button',
176
- {
177
- type: 'button',
178
- className: 'mvp_button',
179
- title: t('checker.writeBackTitle'),
180
- onClick: () => commit({ ...pick, writeBack: !pick.writeBack }),
181
- },
182
- t('checker.writeBack'),
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 ? false : pick.provider === '' || pick.model === '',
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
- progress !== null && (running || finished)
218
+ running || finished
197
219
  ? h(
198
220
  'div',
199
221
  { className: 'mvp_checkerProgress' },
200
- t('checker.progress', {
201
- done: progress?.done ?? 0,
202
- total: progress?.total ?? 0,
203
- tokens: tokensSpent,
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
- (progress?.errors?.length ?? 0) > 0
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
- className: 'mvp_usageHint mvp_usageHint--warn',
212
- title: progress.errors.join('\n'),
213
- },
214
- t('checker.errors', { count: progress.errors.length }),
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: 'mvp_usageHint mvp_usageHint--warn' }, message) : null,
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
  )