# 控制台 把「WorkBuddy 模型 → DeepSeek Harness」这条链路的**状态、启停、诊断、模型注册** 集中到一屏的本地管理界面。 仅监听 `127.0.0.1:8792`,不对外暴露。 ## 启动 ```cmd 启动.cmd ``` 从项目根启动并自动打开浏览器(macOS / Linux 用 `scripts/start.sh`)。也可以直接运行: ```cmd node dashboard\server.mjs ``` 桥**不需要单独启动** —— 页面上的「启动桥服务」按钮会按 [`config.mjs`](../config.mjs) 的配置把它拉起来(含环境变量注入),因此与控制台共享同一套配置。 ## 界面 | 区域 | 作用 | |---|---| | 顶部提示条 | 只显示**需要你动手**的事,按严重度排序:桥不可用 > 令牌临期 > 新失败 > 未签到。桥不可用**不可关闭**(恢复后自动消失),其余可「知道了」静默 | | 顶部徽章 | 桥是否正常(绿 = 运行中 / 黄 = 有响应但异常 / 红 = 未运行);标签页标题同步反映(含「新失败 N」,**不含** PID 这类会闪的数字) | | 操作条 | 启动桥、重启桥、停止、重新诊断、复制桥地址、**复制诊断报告**(危险动作都有二次确认) | | 状态卡 | 账号、令牌剩余有效期、凭据加密方式、上游端点、桥进程(PID / 运行时长 / 目录模型数)、可用模型数、积分余额、dsh 就绪度 | | 账号 | 列出登录目录下**全部**账号快照及其可用性、剩余有效期,可切换桥使用哪一个(切换会清空上一个账号的体检结论) | | 用量统计 | 最近 1 / 7 / 30 天的调用次数、token、平均耗时、消耗积分、失败数;**趋势图**可切指标(调用次数 / tokens / 积分)、形态(形态自动 / 柱状 / 折线 / 按模型堆叠)与粒度(按天 / 按周 / 最近 24 小时),**积分排行**按模型给出占比与「未回报」分区;按模型明细(含积分/次);可导出 CSV,也可**清空账本**(二次确认) | | 最近请求 | 逐条请求明细,成功与失败都在,失败标红并显示上游错误码(**点击失败标签可复制错误详情**);支持**模型筛选** + **仅看失败** + 40 / 100 / 200 条叠加,可**暂停自动刷新**、**导出当前筛选的 CSV**;只含元数据 | | 每日签到 | 今日签到状态、连续天数、一键领取;**每日自动签到**开关(默认开)与「上次自动尝试」留痕 | | 环境诊断 | 8 项检查,**按 fail > warn > ok 排序**——红色项不解决,模型不会出现在 dsh 里 | | 可用模型 | 实际可用的全部模型;**体检范围可选**(勾选的 / 已注册的 / 全部)、可**一键取消勾选不可用的**、**同步可用模型到 dsh**、**手动刷新目录**;可勾选写入 dsh 配置,也可测试每个模型能否调通(**并发 3、单项超时 20 秒、带进度条与预计剩余**,结果跨刷新保留)、导出 CSV;点 id 旁的 **ⓘ** 展开详情(描述 / 厂商 / 标签 / 精确上下文与输出 / 实测成本 / 体检结论 / 注册状态) | | 对话测试 | **多轮对话**(带上下文)、按轮次分段显示、显示本轮 token / 扣分 / 耗时、助手回答可复制;流式输出可**随时停止**;模型下拉可按关键字搜索;附带桥日志查看(可**按关键字过滤** / 只看错误 / 只看本次启动 / 清空) | | 客户端接入 | 桥的两套协议地址与本地令牌,**每一块都有独立的复制按钮**(值本身也可点);「自定义提供商」图形表单的**逐字段取值表**(每格单独可复制);opencode / Claude Code 的完整配置片段;各客户端的**如实兼容性分档**;「复制全部」导出纯文本速查表 | | 客户端接入 | 两套协议的 Base URL 与本地令牌(**点一下即复制**);opencode / Claude Code 的完整配置片段;兼容性清单与**如实的兼容性分档**;RAG 不可用的原因说明 | 每个数据面板的表头都显示「更新于 HH:MM:SS」;模型面板另显示「目录 HH:MM」(上游目录的抓取时刻)。 顶部「刷新」会**一次性覆盖全部面板**(用量、账号、签到、模型表、诊断、最近请求),刷新期间按钮禁用并显示「刷新中…」。 ### 「客户端接入」:为什么不写成「支持」 这个面板给出的每个兼容性结论都有**可复核的证据等级**,只有三档: | 档位 | 含义 | |---|---| | **已实测** | 桥侧跑通过,证据写在该条目的说明里(例如 opencode 是用它底层的 `@ai-sdk/openai-compatible` 实跑的) | | **协议兼容** | 客户端只用 `/v1/models` 与 `/v1/chat/completions`,桥侧已验证;但**没有真的装一遍跑通** | | **不支持** | 上游或协议层面就缺能力(如 RAG —— 上游没有 embedding 模型) | **把「协议兼容」写成「支持」是欺骗**:用户照着装完发现不对,会连带怀疑整个 面板甚至整个项目。所以宁可用保守措辞。 同理,`/v1/embeddings` 返回 **501** 而不是「尽力而为」的空向量——错误的成功 比明确的失败代价大得多(见 [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md))。 面板里的令牌**从 `/api/clients` 取真实值**,不在前端写死:用户改过 `WORKBUDDY_LOCAL_TOKEN` 后,写死的值会让面板显示一个错的 key,照着填必然 401。 ### 「客户端接入」:为什么给的是逐格复制,而不是一段配置 这个面板服务两类完全不同的客户端,必须分开对待: - **配置文件型**(如 opencode):给一份可直接粘贴的 `opencode.json`。 - **图形表单型**(GUI 客户端弹「自定义提供商」):给**逐字段的取值表**。 只给 JSON 片段对第二类**等于没教** —— 用户对着「提供商 ID / 基础 URL / API 密钥」 这几格不知道该填什么。所以字段表是**一格一行、每行一个复制按钮**,而不是一大块 文本:GUI 表单本来就是一格一格填的。 **每个模型都带上「上下文长度 / 输出上限」**,因为这两格填 0 或留空,客户端会把 上下文显示成 **0**,用户会以为桥坏了。桥从上游目录拿得到真实值 (`context_window` / `max_output_tokens`),没理由让用户自己猜。 > 但**不是每个表单都有这两格** —— opencode 的表单就没有。这种情况上下文只能靠 > 配置文件声明:`opencode.json` 里每个模型的 `limit: { context, output }`。 > 面板生成的片段已经按上游真实值填好。**「上下文 0」不是桥的问题,也不是填错了**, > 这两件事必须在界面上讲清楚,否则用户会去改桥。 同理,地址与令牌这些单值也都配了**看得见的**复制按钮。只做「点一下值试试」这种 隐含交互,用户根本不知道那里能复制 —— 功能存在但发现不了,等于没有。 **排版:四列写死,全表对齐。** 这个面板是「照着抄」用的,用户的动作是 「看一眼 → 点复制 → 粘进客户端表单」。所以字段名 / 值 / 复制按钮 / 说明四列 **共用一套写死的列宽**,眼睛能竖着扫下来。 两个踩过的坑: 1. **列宽不能写成 `minmax(0, Npx)`**。字段表按「提供商级 / 模型 N」分成多个 `.fggroup`,而每个组是**独立的 grid 容器** —— 内容自适应会让各组的第 2 列宽度 互不相同,第 4 列(说明)随之错位。实测 `minmax(0, 248px)` 下说明列出现 2 种 左边缘,写死 `240px` 后归 1。 2. **没有值的行必须补一个占位格**。「请求头 → 留空」那行若只放一个占位符,后面的 说明就会滑进按钮列,整张表的说明列跟着错位。所以「值 + 复制按钮」始终占满两格。 分组只能靠**组标题**体现,不能给 `.fggroup` 加 padding 或左边框 —— 同样是各组 独立 grid 的缘故,任何改变内容盒的样式都会破坏跨组对齐。 `tools/dev/test-clients-panel.mjs` 里有几何断言守着这几条(字段列宽、值列左边缘、 按钮左边缘、说明列左边缘必须各自唯一),改样式跑一次就知道有没有破坏对齐。 ### 「客户端接入」:先选后展开,且勾什么就生成什么 这一块经历过一次返工,两次都是**实测数据逼出来的**: **① 原先把每个客户端的完整配置同时铺开 —— 实测 3220px,占整页 6918px 的 47%。** 其中「逐字段表单指引」1135px、「opencode」1061px,26 个复制按钮同时可见。 而用户的真实任务是「接上我自己那一个」,不是「读完所有的」。 现在顶部一排选择器(opencode / Claude Code / 图形表单 / 其它客户端), **只渲染选中的那一块**;三个接入值与 RAG 说明常驻(都是短文本、与选谁无关)。 面板 3220px → 646px,整页 6918px → 4344px。 **② 一度把长配置块默认折叠起来 —— 这是个错误,已改回默认展开。** 折叠后的样子是:一行「opencode.json」+ 一个「复制」按钮,**内容完全看不见**。 让用户复制一个看不到的东西是自相矛盾的。真正该解决的不是「太长」,而是 **为什么长**:配置里塞了所有精选模型,而用户通常只要其中一两个。 **所以现在有模型选择器**(就在配置片段上方): - 列的是**完整目录**(`/api/models`)里的所有可用模型,不限桥精选的那几个 - 勾什么,下面的 `opencode.json` 与逐字段表就**立刻按选择重算** - 默认勾选桥精选集(保持「开箱即用」),可「全选 / 清空」 - 新加入的模型从目录取**真实的上下文长度 / 输出上限** —— 这是「上下文 0」的根治点 > **配置块默认展开**,内容一眼可见。`clientCodeBlock` 仍保留折叠能力,但**没有 > 任何地方默认折叠** —— 想收起长块是用户的动作,不该是默认状态。 > 踩过的坑:`lastModels` 本身就是模型数组(`loadModels` 里是 `lastModels = d.models`), > 不是 `{models:[...]}`。写成 `lastModels.models` 会永远拿到空目录, > 表现为「选择器只列得出精选集、用户加不了别的模型」—— 而页面看起来一切正常。 ### 可访问性:四个必须守住的点 面板里有二十多个复制按钮,可访问性不是「加分项」而是「能不能用」的问题。 四个点各有实测数字,`tools/dev/audit-clients-panel.mjs` 可随时复测: | 点 | 现状 | 为什么 | |---|---|---| | **对比度** | 全部 ≥ 4.5:1(原先最低 **2.45:1**) | 说明文字写的是「必须带 `/v1` 结尾」这类**不看就会填错**的提示,不能当装饰处理。为此新增了 `--hint` 令牌:`--faint` 只有 2.45:1,而 `--muted` 在 `--panel-muted` 上又掉到 4.34:1,两者都不够 | | **可访问名** | 21 个按钮,21 个互不重复的 `aria-label` | 原先全叫「复制」,屏幕阅读器用户听到的是一串无法区分的「复制」,不知道哪个对应哪一格 | | **就地反馈** | 点击后按钮自己变「已复制」,1.6 秒恢复 | 实测滚到长面板底部点复制时,顶部提示条 `top = -1873px`,完全在视口外 —— 只靠它等于没有反馈 | | **命中区** | 视觉 24px,实际 32px | 24×24 **已满足 WCAG 2.2 AA 的 Target Size**,所以这不是合规问题而是手感问题:一屏二十几个按钮时 24px 偏小。用 `::after` 扩命中区,零布局影响 | 另外两条刻意的取舍: - **值本身可点,但故意不进 Tab 顺序**。每行旁边已经有带可访问名的复制按钮,键盘用户 走那条路就够了;20 行 × 2 个焦点停靠点会让 Tab 键变成折磨。功能没丢,只是不重复 提供第二个入口。 - **`aria-live` 区域用 `.sr-only` 而不是 `display:none`**。后者会把元素从无障碍树里摘掉, live region 就不再播报了 —— 加了等于没加。同理不用 `visibility:hidden`。 **提示条不再塞全文。** `copyText` 原先把整段文本拼进提示条,复制一份配置或一轮对话时 提示条会被撑成几百字,既挤掉旁边的按钮、也让「复制成功」这个信号本身被淹没。现在只说 「已复制:<字段名>」,全文放进 tooltip(`setMsg` 的第三个参数),信息一点没丢。 ### 多设备:先分清「多设备」是什么 控制台**只绑 `127.0.0.1`**,手机根本连不上 —— 所以这里的「多设备」实际指 **不同窗口宽度与浏览器缩放**,不是手机触屏。据此定的取舍: - 320 / 390 / 768 / 1024 / 1440 五档都无横向溢出(`test-clients-panel.mjs` 守着) - **窄屏下配置片段改为换行而不是横向滚动**(≤640px)。原先 320–414px 下有 2 个 `
`
  需要横滚 —— 窄窗口下用户会以为内容被截断,而这些片段本来就是「整段复制走」、
  不是逐字阅读的,换行不影响用途
- 触控目标按**桌面**标准(32px 命中区)而非 iOS 的 44px:这台机器上的控制台
  不会从触屏设备打开,为了 44px 去改全局按钮尺寸会破坏其它面板的既有布局

**兼容性分三档,且如实标注:**

| 档位 | 含义 |
|---|---|
| ✅ 已实测 | 桥侧真的跑通过,证据写在卡片说明里(可复核) |
| ⚪ 协议兼容 | 客户端只用 `/v1/models` + `/v1/chat/completions`,桥侧端点已验证;但**没有真的装一遍跑通** |
| ❌ 不支持 | 上游或协议层面就缺能力(如 RAG 需要的 `/v1/embeddings`) |

把「协议兼容」写成「支持」是欺骗:用户装完发现不对,会连带怀疑整个项目。

「复制全部」导出的是**纯文本速查表**,方便贴进聊天或笔记。

截图:[浅色](../docs/clients-panel-light.png) · [深色](../docs/clients-panel-dark.png) · [窄屏](../docs/clients-panel-narrow.png)

### 「最近请求」:失败也要看得见

用量统计只统计**成功**的调用(失败没有 token 也没有扣分),所以「某个模型调不通」
在用量里完全不可见。桥因此把失败也写进同一个账本,靠 `ok` 字段区分:

```json
{"t":1791038059257,"model":"x","stream":false,"ms":418,"ok":false,"status":400,"code":11102,"error":"model [x] service info not found"}
```

`/v1/usage` 把失败单列成 `total.failed` 与 `failures[]`(不计入 token / 积分);
`/v1/requests` 给出逐条明细。**两者都只回元数据白名单字段,不回请求体与消息内容**;
错误原文截断到 160 字符并压成单行(上游的 401 会返回一整段 HTML)。

### 体检结果跨刷新保留(按模型合并)

「测试可用性」要打几十个请求,刷新一次就全丢太浪费。结果存进 `.state.json` 的
`probe` 字段,页面重开还在,表头显示「上次体检 N 分钟前 · 可用 M / N」。

写入是**按模型合并**,不是整体替换:本轮没测到的模型保留历史条目(连同它自己的
时间戳),因此「测两个就取消」不会把其余模型的结论抹成「未测」。想彻底清空请点
「清除体检」(服务端会删掉整个 `probe` 字段)。

**切换账号会清空体检存档**——体检结论只对被测时的那个账号成立,而两个账号的模型
id 往往重合,混着看会直接误导。

写入前**逐条清洗**:`ok` 必须是布尔、`ms`/`at` 归一化成数字、错误截断到 200 字符、
最多 300 条(超出丢最旧的)。状态文件是程序可写的,POST 进来的内容不能原样落盘。

### 「消耗积分」为什么常常是 0

上游在 `usage.credit` 里回报**本次实际扣减的积分**,桥原样记进本地账本。两点必须知道:

1. **上游按两位小数回报。** 一个几十 token 的小请求真实扣分远小于 0.01,回报就是
   `0`。所以单次经常是 0,**合计才有意义**——页面的「积分/次」列就是为此加的。
2. **「回报了 0」和「没有回报」是两回事。** 桥分别记录 `credit` 与 `creditCalls`
   (有几次调用带了扣分数据):

   | 情况 | 显示 |
   |---|---|
   | 上游回报了 0 | `0` |
   | 上游回报了 0.01 | `0.01` |
   | 上游没回报(含升级前写入的旧记录) | `—`,鼠标悬停提示「上游未回报该模型的扣分」 |

   卡片下方的「已回报 N/M 次」就是这个比例。不区分这两者的话,页面上全是同一个
   `—`,看起来像统计坏了。

实测参考(`glm-5.3`,目录倍率 0.79):335 tokens → 0.17 积分,935 tokens → 0.51 积分,
线性放大。

### 「桥进程」卡片与健康探测
`/health` 由桥自己汇报 `pid` / `startedAt` / `uptimeMs` / `catalogSize`,页面据此显示
运行时长与进程号,便于确认「现在跑的是不是我刚启动的那个」。

**`/health` 不会等待上游。** 模型目录(要串行打两个上游端点)只在缓存过期时
**后台**刷新;页面与 `tools/doctor.mjs` 的探测超时是 4 秒,一旦让健康检查去等目录,
冷缓存时就会把「桥在跑」误判成「桥没起」。

### 已注册模型的失效提醒

「可用模型」上方会在必要时出现黄色提示条:列出了**已写进 dsh 但已不在上游目录中**
的模型 id。这类条目会留在 dsh 的模型选择器里,但调用必然失败;点提示条里的
**「清理并保存」**一步即可清掉(仍会自动备份),也可以手动取消勾选后重新保存。

### 账本重置

「用量统计」表头的「清空账本」会调 `DELETE /api/usage`(二次确认),由**桥**执行
文件与内存副本的双重置。桥在内存里持有一份账本副本,只删文件是不够的——它累计到
2000 行会做一次整块重写,被删掉的历史会被写回来。桥现在每次写入前都比对文件大小,
发现被外部清空/删除就以文件为准,因此手工删除也不会「复活」。

### 账号切换

切换会**停止桥、写入选择、用新账号重启桥**(约 10 秒)。选择记在项目根的
`.state.json`(已 gitignore),下次启动直接沿用。

不可用的账号(例如由另一个客户端 build 写入、本机密钥解不开)会标红,切换按钮置灰。

### 模型注册

勾选 → 点「保存到 dsh 设置」→ **同时写入两处**(各自都会自动备份):

| 位置 | 原因 |
|---|---|
| `~/.dsh/settings.yaml` | 官方「用户设置文档」,DSH Desktop 会把它导入 patch 层 |
| `~/.dsh/profiles/desktop/cordis.patch.yml` | DSH **实际生效**的位置,立即生效、不必等重启 |

只写前者会留下「patch 里还是旧列表」的不一致——界面读到的已注册模型与实际生效的不符。
读取时**以 `settings.yaml` 为准**(那是用户最新意图),避免把已取消的旧模型算回来。

### 能力元数据(视觉 / 思考强度):从上游一路传到 dsh

模型选择器里的「多模态」与「思考强度」不是界面猜的,链条是:

```
上游目录的 supportsImages / supportsReasoning
  └─▶ 桥 /v1/models 的 supports_images / supports_reasoning / reasoning_efforts
        └─▶ 插件 toAdapterModel:inputModalities / reasoning{efforts, defaultEffort}
              └─▶ dsh:模态标记 + 推理等级下拉
```

**上游目录并不完整**:国际版能调 `deepseek-v4.1-flash`,但它不出现在任何目录端点里
(`/health.upstreamShape.supplemented` 能看到补了谁),桥靠 `FEATURED` 兜底补全。
**补全项必须把能力标记一起带上** —— 2026-10-12 修过一次真实故障:补全时漏带标记,
dsh 把它显示成「纯文本、不支持推理」,用户点思考强度直接被拒
(`does not support reasoning effort "high"`)。由此定下几条约定:

- `FEATURED` 里写的能力标记(`images` / `supportsReasoning`)**必须是实测过的**,
  注释里要给出探针(`tools/dev/probe-capability.mjs` 验视觉、`probe-effort.mjs` 验推理档);
- `regions`:只在**确实提供**该模型的区域补全 —— `deepseek-v4-pro` 在国际版实测
  400(`service info not found`),所以只在国内版补;区域按**登录域名**判;
- 上游目录里**有**的模型一律以上游为准:补全只填空缺,不覆盖上游的能力字段。

**思考强度的档位同样是逐模型实测的**(`tools/dev/probe-efforts-all.mjs`,
可续跑、429 退避):目录里没有任何字段说"接受哪几档 `reasoning_effort`",
所以桥按实测表给出 `reasoning_efforts`(默认 `minimal/low/medium/high/max`,
实测连 `xhigh` 也收的模型另有覆盖表)。两条硬规矩:

- `off` / `none` **不声明** —— 一部分模型对它 400、另一部分模型收了但仍然思考
  (实测 `reasoning_tokens > 0`),暴露出来只会误导或报错;
- 默认档位里**不放 `xhigh`**:它是 opencode 系的扩展档,实测 `gemini-3.5-flash`
  对它 400 —— 只对实测过的模型给,未测模型宁可少一档。

### 模型工具栏

| 控件 | 作用 |
|---|---|
| 搜索框 | 按 id / 显示名实时过滤 |
| 筛选下拉 | 全部 / 仅免费 / 仅已注册 / 仅未注册 / **仅可用 / 仅不可用 / 未测** |
| 显示计数 | 过滤生效时显示「显示 N / M」,清空筛选后自动消失 |
| 按倍率排序 | 升序,倍率未知的排最后 |
| 可用性表头 | 点击循环:可用优先 → 耗时↑ → 耗时↓ → 默认(按 id);同时显示体检时间 |
| 全选 | **只作用于当前可见行**,并**跳过体检结论为「不可用」的模型**(提示里给出跳过数量) |
| 加选免费 | 把目录里标了免费 / 促销的模型**追加**到当前勾选(同样跳过已知不可用的) |
| 清空勾选 | 取消全部勾选;未点保存前不影响 dsh 配置 |
| 取消勾选不可用的 | 只在「有不可用结论**且它还在勾选里**」时出现;只改本地勾选,**未保存前不影响 dsh** |
| 保存到 dsh 设置 | 写入 `settings.yaml` 与 `cordis.patch.yml`,各自自动备份;保存后会如实说明生效时机(`cordis.patch.yml` = 立即生效;否则要等下次启动导入) |
| 同步可用模型到 dsh | 把「**体检可用 ∩ 当前目录**」一次写入 dsh(会替换当前勾选,原文件自动备份)。没有可用结论时禁用并提示「先做一次体检」 |
| 范围 | 体检范围:**勾选的(K)/ 已注册的(R)/ 全部(M)**。默认「有勾选就测勾选的,一个都没勾就测全部」 |
| 测试可用性 | 只测所选范围,按钮文案带数量「测试可用性(K 个)」;并发 3 探测能否调通,单项 20 秒超时;进度区显示进度条、完成数/总数、当前模型、已用与预计剩余 |
| 取消测试 | 只在体检进行中出现;≤1 秒内停止发起新请求并中断在飞的请求,已完成结果保留 |
| 清除体检 | 只在有存档时出现(彻底删除存档) |
| 导出 CSV | 导出当前目录,含**体检结论 / 体检耗时 / 支持图片 / 实测成本(含样本数)** |
| 刷新目录 | 立即强制重取上游目录;期间按钮禁用。失败时**保留旧表格**并提示「仍显示 HH:MM 的缓存」 |

点击表格里的**模型 id 列**可直接复制(带复制图标);上下文、最大输出这类数字列不触发复制。
数字列用**紧凑形式**(`1M` / `960K` / `131K`),精确值放在 tooltip 与详情弹层里。
点 id 旁的 **ⓘ** 在行下方展开详情(同屏只开一个,Esc / 点空白 / 再点 ⓘ 均可关闭);
**缺数据的字段整行不显示**,不写「无」。

### 三种「跳过 / 清理」的分工(别混)

| 动作 | 清什么 |
|---|---|
| 全选 / 加选免费 | 跳过**已知不可用**的模型(**不碰未测的**——未测 ≠ 不可用) |
| 取消勾选不可用的 | 把**已测出不可用**的从当前勾选里去掉(纯本地,不写盘) |
| 清理并保存 | 清掉**已不在上游目录**的注册(写入 dsh) |

### 「实测成本」列的口径

模型表有一列「实测成本」,形如 `0.12 / 千 token(3 次)`。

- 口径:账本**最近 N 天**(跟随「用量统计」的天数选择)里,该模型的
  `扣分合计 ÷ token 合计 × 1000`,**只含成功且回报了扣分的调用**。
- 数据不足一律显示「—」并给出**原因**(没有成功调用 / 上游一次都没回报扣分 /
  没有可计入的 token)。**绝不**用目录倍率(那是官方标价)或别的模型的值顶替。
- 免费且上游回报 0 → 显示 `0`(「回报 0」和「没回报」是两回事)。
- 每个折算值都带**样本数**:样本量常常很小,不带样本数的单价会被误读成官方单价。
- 本轮**不参与排序**:样本量小时排序会把噪声放大成结论。

### 图表口径(零依赖内联 SVG)

用量区的图都是自己画的 SVG(**没有引入任何图表库**),口径先于视觉:

- 默认形态**跟指标走**:调用次数 = 柱状;tokens = 输入/输出堆叠柱;积分 = 折线 + **累计虚线**。
  另有「形态」下拉可显式选 柱状 / 折线 / **按模型堆叠**(`dashed` 的「累计」系列**任何形态下都画成线** ——
  累计值画成柱子会被读成"当天消耗",那是语义差异不是画法偏好)。
- **粒度**:按天 / **按周** / 最近 24 小时,与「最近 1 / 7 / 30 天」选择联动:
  - 小时视图由桥按**本地时区**分桶并**补齐 24 个桶**(空桶记 0,否则柱状图会跳变),取数窗口固定 1 天;
  - **周视图取数固定 84 天(12 周)**,按**自然周(周一为一周之首)**在前端聚合,
    中间空周补 0、标签为「MM-DD 周」(`days[].byModel` 在前端逐周合并,「按模型堆叠」照常可用)。
    桶的起点锚在**数据**上(最早一天所在的那一周画到本周)而不是固定画 12 个桶 ——
    取数窗口是滚动 84 天、与周一对不齐,固定 12 桶会把窗口最早几天的数据漏在桶外,
    而它们仍计入面板 total(违反"图上的数字必须能在面板里对上")。
    守这组口径的是 `tools/dev/test-trend-week.mjs`。
- 失败调用**不计入**积分 / token 图(与用量面板口径一致);「未回报扣分」的次数
  写在图的 `aria-label` 里,**不假装成 0 消耗**。
- 积分排行:top 8 + 「其它」,`creditCalls = 0` 的模型**单列**在「未回报」区,
  不混进占比——否则会把「没数据」画成「没有消耗」。点条形可联动筛选「最近请求」。
  (排行与「按模型堆叠」**共用同一份模型配色**,且按 `usage.models` 预分配 ——
  见 `seedModelColors`;按渲染顺序分配会让同一个模型在两处显示成两个颜色。)
- 空态与异常态各有文案,并**清掉上一次的图**;窗口内全 0 时**不画空轴**,
  只有一个时间点时给「换个粒度能看到更多」的提示(不静默留白)。
- 图上的每个数字都能在面板 / 账本里对上(合计、累计末端都做了一致性断言)。

### 每日自动签到

签到是确定性的每日动作,忘了就是白丢积分。默认**开启**:

- **两条触发路径,都是「直接签、不预检」**:
  ① **桥自己的定时器**(启动 20 秒后一次、之后每小时一次)——覆盖「不开控制台
  也不调模型」,这是最常见的状态;② 有人调模型时顺带补签(fire-and-forget,
  绝不拖慢调用)。

  > **为什么不做「先查状态再签」**:那个 POST 本身是幂等的 —— 上游对重复签到返回
  > 「已签到」,桥按**成功**处理。多一次 GET 只是白打一次上游。
  > 控制台原先正是这么做的(每小时 GET 一次状态),**一天 24 次纯浪费**,
  > 而且签完之后照打不误。已删除:签到由桥独占,控制台只负责显示。
  >
  > **签完就停**:两条路径都有「当天成功过就不再试」的闸门(`autoCheckinDoneDay`)。
  > 没有它的话,定时器每小时都会问一次「签了没」,而答案永远是「签了」。
  > 国际版「没有签到活动」同样当天收工 —— 那个账号永远不会有成功。

  > **为什么由桥负责**:桥才是常驻后台的那个进程,控制台是用户平时会关掉的。
  > 实测撞上过:桥从 13:03 一直跑着、一整天**零模型请求**,于是**一次签到都没发生**,
  > 用户晚上打开控制台才发现还得手动签 —— 而「自动签到」的承诺就是不让人记着这件事。

- **面板显示的「上次尝试」来自桥**:`/health.autoCheckin`(`{at, result, credit}`)
  经 `checkinViewWithBridge` 投影成面板认识的形状,取**较新的那个** ——
  用户手动签过之后,`.state.json` 里的 `manual` 记录比桥的更近,不该被旧记录盖掉。

- **开关**:签到面板的「每日自动签到」,写入 `.state.json` 的 `checkin.auto`。
  控制台侧**立即生效**;**桥侧需重启桥生效**(页面会如实提示,不会偷偷重启)。
- **失败策略**(写死,避免对上游打无效请求):失败后冷却 1 小时再试,当天最多 3 次。
  上游「已签到」按**成功**处理,绝不记成失败。
  另外**当天签成功后就不再打上游**(`autoCheckinDoneDay`)—— 没有这条的话,
  定时器每小时都会问一次「签了没」,而答案永远是「签了」。
  国际版「没有签到活动」同样当天收工:那个账号永远不会有成功。
- **状态可见**:面板显示 `今日已签到 · 自动/手动(HH:MM)`、失败时红字给出原因与
  「可手动重试」、国际版则如实写「当前账号没有签到活动」。另外有「上次尝试」留痕。
- **不进账本**:账本只统计模型调用,签到结果只进 `.state.json` 与桥的 `/health` 内存态。
- 手动「立即签到」保留,与自动**共用同一接口**与幂等语义,成功后标注为「手动」。

### 目录过滤:不能对话的模型不进列表

桥在**目录层**就排掉了明显不能对话的条目(与既有的 `codewise` / `hunyuan-image`
判定同层,前端不做隐藏):

| 规则 | 依据 |
|---|---|
| id 以 `codewise` / `hunyuan-image` 开头 | 内部工具模型 |
| 未声明上下文窗口**且**输出上限存在但低于 1024 | 实测样本 `nes-gf`:无 `context_window`、输出上限 256、无倍率,选中必然失败 |

判定完全基于上游目录字段,**判断不了的一律保留**;被排掉的 id 记在 `/health` 的
`upstreamShape.droppedNonChat` 里,可追溯。

### 轮询不会打断操作

页面每 20 秒刷新一次数据,但**表格数据没变化时不会重建 DOM**——
否则会把正在输入的搜索词、筛选、勾选和体检结果全部冲掉。
必须重建时会先捕获再恢复这些状态,**包括滚动位置与「全选」勾选态**。

账号列表每 60 秒刷新(读账号要启动客户端取密钥,约 0.3s,故放慢)。
桥日志面板**展开时**才每 3 秒刷新;收起时不会读日志文件。

所有面板加载器都走**单飞**(in-flight 去重):轮询与手动刷新共用同一把锁,
响应变慢时既不会叠出并发请求,刷新期间也不会被轮询插队再打一遍。

积分余额缓存 60 秒(失败缓存 15 秒),签到成功或切换账号后立即失效——
否则每 20 秒的轮询就等于每 20 秒打一次计费网关。

## 视觉规范:Claude / Anthropic 那套语言

这一页的视觉基调是 **Claude / Anthropic 的品牌语言**:暖纸底 + 土色强调。
落到代码上就是四句话 —— **暖纸底不用纯白、1px 细边框不用投影、主按钮是深色不是彩色、不用渐变**。

### 基准色直接取自官方色板

不做主观改动,值就是这些(**用途列已按现状校正** —— 早期版本把 Windstorm 写成"语义 info"、
把陶土写成"强调",那是默认强调色还是陶土的年代的说法):

| 角色 | 色值 | 用途 |
|---|---|---|
| Lotion | `#faf9f5` | 页面底(暖纸) |
| Salt | `#f0eee6` | 卡片 / 面板(**比页面深**一档) |
| Last Straw | `#e3dacc` | 边框 / 次级块 |
| Sooty | `#141414` | 正文、主按钮实底、深色主题的底 |
| Noir | `#2c2b26` | 次级文字、软边框 |
| Windstorm | `#6a9bcc` | **默认强调色的基准**(压暗为 `--primary: #5982ab`) |
| Peachy Feeling | `#d97757` | 配色方案「陶土」的基准色 |
| Moss Gardens | `#788c5d` | 配色方案「苔绿」的基准色 |
| Magic Carpet | `#827dbd` | 配色方案「雾紫」的基准色 |
| Emerald Oasis | `#629887` | 配色方案「青绿」的基准色 |

五套强调色方案(右上角色点菜单切换,选择记在 `localStorage`)覆盖同一批 `--primary*`
令牌;语义色(ok / warn / fail / info / promo)则是在土色系里**按对比度另推**的一批值
(每个都两档通吃),并不直接等于上表某个基准色。完整令牌表见 `docs/DESIGN_SYSTEM.md` §2。

三条与常见做法**相反**、但正是这套风格所在的规矩:

**① 暖纸底,卡片比页面深。** 不是"白卡浮在灰底上",而是"纸叠在纸上"。
所以 `--panel`(#f0eee6) 比 `--bg`(#faf9f5) **深**,内嵌面(`--panel-subtle`)再深一档。

> ⚠️ 阶梯必须**单调**、方向是"越靠内越深"。第一版照搬了浅冷色主题里"内层更白"的
> 习惯,把 `--panel-subtle` 写得比 `--panel` 亮 —— 结果嵌在面板里的行
> (诊断行、对话轮次、折叠开关)看起来是**凸起来**的,在暖纸底上那读作"被高亮了",
> 而它们的真实语义是"嵌进去的一块"。

**② 1px 细边框分隔,不用投影。** 整个浅色主题**取消了静态卡片的投影**
(`--shadow-card` / `--shadow-panel` = `none`)。暖米色底上的冷灰投影会显脏
(交界处一圈发青的脏边),而且"纸"本来就不该有浮起来的物理暗示。
投影只留给**真正浮在内容之上**的层(下拉 / 抽屉 / 气泡)—— 它们下面压着的是内容,
确实需要"离开纸面"的暗示。

**③ 主按钮是深色,不是彩色。** 实心按钮用 Sooty `#141414`(配浅字 18.4:1),
强调色**只当强调色**(边框 / 图标 / 选中态 / 焦点环)。
彩色填充按钮配白字很难过 AA —— 实测陶土 `#d97757` 配白字只有 **3.12:1**。

### 强调色不能直接当文字

每套方案的 `--primary` 都是**边框 / 图标档**,直接当文字基本必挂:
实测默认蓝的压暗值(`#5982ab`)压 Salt 只有 3.47:1、陶土基准色(`#d97757`)压 Salt 只有 **2.69:1**。
所以每套方案另推一个**保持色相**的压暗变体 `--primary-text`(默认蓝是 `#48698b`,
压 `--panel` 实测 4.93:1)。

> 推导时曾算出来 4.57:1,**实测只有 4.49:1**(差 0.01 不及格)—— 那一版最终取了留余量的
> 值(实测 5.11:1)。**以实测为准,不以算的为准** —— 浏览器算有效背景色时会带上
> 边框等层的合成。后来配色重做时,五套方案的 `--primary-text` 都按这条纪律重新卡过。

**写控件时用错分档,是这个约定最容易破的地方。** `--primary` 与 `--primary-text`
是两个**角色**,不是"同一色的明暗两档随便挑":

| 令牌 | 角色 | 保底要求 |
|---|---|---|
| `--primary` | 边框、图标、1px 描边、焦点环 | 压面板 ≥ 3.4:1 |
| `--primary-text` | **承载文字的控件**(标签、芯片、选中态) | 压自己的底 ≥ 4.9:1 |

实测撞过:模型选择芯片 `.modelpick-chip.on` 写的是 `color: var(--primary)` ——
字面上完全合理,读代码看不出来。但芯片是 11.5px / weight 600(属正文档,要 4.5:1),
于是**五套配色在浅色主题下全部不达标**(3.39–3.56:1,肉眼就是"字发虚")。
换成 `--primary-text` 后十种组合全过(浅色 4.88–5.05、深色 7.78–8.92)。

> 抓住它的是 `tools/dev/test-accent-schemes.mjs` —— 它不点名元素,而是把
> **所有"有文字 + 有不透明底色"的控件**捞出来逐个体检(十种配色组合共 240 个)。
> 新增控件自动纳入覆盖,不需要有人记得来加断言。
> 该用例与 `test-solid-contrast.mjs` 共用 `tools/dev/contrast-scan.mjs` 的扫描源码,
> **不要复制第二份** —— 两边判据会漂移。

语义色(ok / warn / fail / info / promo)换成 Claude 的土色系,每个值都**两档通吃**:
既当 1px 边框 / 16px 图标,也当 12–13px 正文(推导时就按"压在自己那层 soft 底上
≥4.5:1"卡的,所以不需要再分文字档)。

### 三层字体:衬线负责展示、无衬线负责界面、等宽负责数据

这是 Claude 的排版骨架。**不引 webfont** —— 控制台是本地工具(127.0.0.1),
断网要照常打开,为了排版去请求第三方字体服务不划算。用的是系统里必然存在的、
观感最接近的替代:

| 角色 | Claude 原字体 | 这里的替代 |
|---|---|---|
| 展示 / 阅读 | Anthropic Serif(Tiempos / Copernicus 血脉) | **Georgia + 中文宋体** |
| 界面 | Anthropic Sans | 系统 UI 无衬线(Windows 上是 Segoe UI) |
| 数据 / 代码 | Anthropic Mono | Consolas |

官方文档给的对外替代正是 `Lora → Georgia` 这条链,所以 Georgia 不是将就。

衬线栈里**必须带上中文字体**(宋体):Georgia 没有中文字形,不写的话中文会掉回
默认无衬线,一行里两种笔法就打架了。

**衬线的用法和 Claude 一致:只用于展示性文字**(品牌名、面板标题),
**不用于界面控件**(按钮、标签、表头、表格)—— 混用会让"这是标题还是按钮"变含糊。

### 调色板在 OKLCH 里推导,按十六进制落地

主色阶是在 **OKLCH** 里按明度等步长推导的(感知均匀),但**样式里写十六进制**。
这条是**实测**定的:

```
写入 oklch(0.55 0.2 275)      → Chrome 的 computed value 原样回 "oklch(0.55 0.2 275)"
写入 color-mix(in oklch, …)   → 回 "oklch(0.755 0.115 277)"
写入 #5854ed                  → 回 "rgb(88, 84, 237)"
```

而 WCAG 门禁(`tools/dev/test-clients-panel.mjs` 的 `parseRgb`)用 `/rgba?\(/` 解析颜色
—— **样式里直接写 `oklch()` 会让那个门禁整个量不到颜色**,一个本来在守对比度的工具
会突然全部报「找不到元素」。OKLCH 的价值在"感知均匀地推导",不在输出语法。

### 不用渐变

品牌标、页签、主按钮原先各叠了一条渐变。**全部移除。** 理由不只是审美:
渐变让「这个按钮是什么颜色」变成一个**没法回答**的问题(顶部一个值、底部另一个值),
而按钮底色是要和状态色、对比度一起被校验的。

全文件只剩**一处**渐变:骨架屏的 shimmer —— 那是加载指示器,渐变**就是**它的动画本身。

### 圆角从外到内递减

外层容器 `--radius-xl`(20) → 面板 / 抽屉 `--radius`(12) → 小元件 `--radius-sm`(8)。
外层和内层一样圆时,两者之间那道边看起来会"不平行"(内外圆弧不同心)。

### 没有左侧色条

告警条、诊断行、对话轮次、写入区、验证结论 —— 这些**原先都有一条 3px 左侧竖条**
表示状态。现在全部去掉了:

- 它在**圆角矩形上是对不齐的** —— 直边色条贴到圆角处会露出一小截错位的直线;
- 状态本来就有更好的载体:**整块的暖色底 + 语义色的 1px 边框 + 文案本身的颜色**。

替代方案分别是:告警条整块染色、诊断行整行染色、对话轮次整边圆角卡片、
写入区用更实的边框 + 更深的底、验证结论用**虚线边框**(虚线天然读作"辅助/参考",
不抢"结果"的注意力)。

### 入场:一次编排好的依次浮现

八张状态卡一张比一张晚 45ms 升起(`nth-child` 给延迟),整排约 0.3s。
比"每张自己淡入"多出来的信息是**顺序** —— 人眼会顺着它把八张卡读成"一屏数据在铺开"。

⚠️ 动画必须用 `animation-fill-mode: backwards`,**不能用 `both`**:`both` 会把动画
结束帧(`transform: none`)永久留在元素上,而动画优先级高于普通声明 ——
`.card:hover` 的 `translateY(-2px)` 会被**静默压死**,鼠标移上去什么都不发生,
代码读起来完全正常。`tools/dev/test-card-motion.mjs` 用真实鼠标事件守住这条。


## 自绘控件:下拉与提示气泡

这两处原本都交给**操作系统**绘制(原生 `` **留在常规流**里,只 `opacity:0` + `pointer-events:none` | ① 宽度仍由它决定(它按最长 option 撑宽,改成让触发器算宽度会明显变窄);② `.value` / `.options` / `change` / 盒模型照旧 —— 自动化用例直接写 `sel.value` 再派 `change`,一条都不用改 |
| 提示气泡 | `title` 属性照旧存在 | 自动化用例断言 `.title`、i18n 按 `title` 翻译。**浏览器没有禁用 `title` 提示的开关**,所以只能**悬停时把 `title` 摘下来存进内存、离开时装回** —— 原生框永远不弹,而任何非悬停时刻读到的都还是原值 |

两个容易踩的点:

- **只在悬停时摘 `title`**:原生提示框本来也不在聚焦时弹,而 `title` 是屏幕阅读器读的
  可访问描述 —— 键盘聚焦时把它摘掉等于把描述弄丢了。
- 下拉菜单**每次打开时重建**(所以动态填 options 的 `#reqModelFilter` / `#modelKind` /
  `#probeScope` 不会显示过期项);触发器文案另有一个 400ms 的**本地**兜底同步 ——
  页面里有几处直接写 `sel.value` 而不派 `change`,只靠事件会漏。它是纯字符串比较,
  不发请求,页面隐藏时跳过。

`tools/dev/test-ui-kit.mjs` 同时钉住「自绘层在」与「原生控件仍是真值来源」这两件事
(含动态渲染的 select、点条目写回原生值、悬停期间 `title` 被摘下且移开后还原)。

## 语言:中 / 英(i18n)

右上角有一个 `EN` / `中` 开关。选择写进 `localStorage`(键 `wb-lang`),刷新后保持;
`` 与标签页标题一起跟着变。

### 为什么是「中文原文当 key 的词条表 + 一次 DOM 翻译遍」

这个控制台是**单文件、无构建**的,全部 UI 文案散在 4000 行内联脚本里,
而且大量文案是拼出来的(`'完成 ' + done + ' / ' + total`)。逐点改成 `t('key')`
意味着要动几百处渲染代码——**收益只是英文更整齐,代价是把一个已经稳定的页面
重新引入几百个改动点,而这些点全都没有测试覆盖**。

所以选了 gettext 风格:**中文原文即词条 key**。

- 加一条文案 = 加一行 `'中文': 'English',`,**不必改渲染代码**;
- 静态 HTML 与 JS 渲染出来的 DOM 走**同一条**翻译路径,不会两套口径;
- 拼接串(计数、时长、状态组合)交给正则规则表 `I18N_RULES_EN` 兜。

代价(写在这里,别当它不存在):整句被 `` / `` 拆开的段落,
需要给每个**文本片段**各写一条词条,并保证拼起来读得通。

### 三个必须知道的实现细节

1. **可逆靠记原文**:翻译前把节点原文记在 `node.__i18nSrc`(文本)/ `el.__i18nAttr`(属性)上。
   切语言 = 拿原文重算,所以**不需要**「英文 → 中文」的反向表,规则生成的动态串
   也能精确还原,不会留半句英文。
2. **中文模式不挂观察器**:JS 本来写出的就是中文。只有英文模式才挂
   `MutationObserver` 增量翻译新渲染的节点(20 秒一轮的表格重绘靠它兜住),
   自己写回时用 `takeRecords()` 丢掉自己造成的记录,不会自激。
3. **原生弹窗必须手动翻**:`confirm()` 的文本不在 DOM 里,DOM 翻译遍够不着,
   所以 `confirmDangerous()` 逐段 `t()` 后再拼;复制到剪贴板的文本同理,
   走 `translateMultiline()` **逐行**翻译(整段翻会把换行吃掉)。

### 哪些东西**不**翻

| 区域 | 为什么 |
|---|---|
| 对话正文(`.turn-body`) | 是**数据**:用户输入 + 模型输出。标 `data-i18n-skip` |
| 桥日志(`#logOut`) | 上游原文,翻了就没法拿去搜 |
| `value` 属性 | 承载用户输入,重跑翻译遍会把用户打的字冲掉 |
| `