[English](README.md) | **简体中文** # dsh-balance-hud **DSH(DeepSeek Harness)** Web GUI 右下角的常驻 HUD:一眼看到 DeepSeek **账户余额**和**本会话花费**。 [English](README.md) · [设计说明](docs/DESIGN.zh-CN.md) · [Design notes](docs/DESIGN.md) · [LICENSE](LICENSE)  > **关于这些图片**:它们**不是运行中 DSH 窗口的真实截图**,而是按插件自身的布局与配色 > 渲染出来的图(用的是真实样式取值与真实数字)—— 制作时没有可用的实时界面截图工具。 > 请把它们当作如实的**布局参考**,而不是"已装好并正在运行"的证据。 > 欢迎用真实截图替换。 ```bash node tools/install.mjs # 安装到 ~/.dsh/profiles/desktop,然后重启 DSH node tools/smoke.mjs # 离线冒烟套件(83 条断言),不需要装 DSH ``` - **账户余额** —— 充值余额 + 赠金余额,走的是「设置 → 账号与余额」同一个服务 (`ctx.deepseekAccount.getBalance()`)。 - **两条花费口径** —— 按官方价格表算的 token **预估**,以及由真实余额减少量累计出的**实际**值。 - **付充值无感** —— 充值只抬高余额,**不会打断花费计数**。 - **按会话独立** —— 基线与计数按会话 id 分槽存储;刷新页面或切换会话都不会覆盖别的会话。 - **不碰凭据,默认不外联** —— 插件从头到尾不接触你的 API key;只有你开启价格同步后才会联网 (见 [价格与同步](#价格与同步)),在此之前不产生任何外部请求。 - **前后各半、一条中继** —— 浏览器半边负责渲染,Host 半边开只读回环路由(余额、赠金信号、 价格同步、模型列表),浏览器本身只跟回环地址说话。 - **可配置、有预算** —— 「设置 → 通用」里有一行偏好,可改模型、轮询间隔、预算与提醒; 花费接近预算时卡片会亮起彩色描边,面板还会倒计时到下一次峰谷切换。 ## 界面 ```text ┌──────────────────────────────┐ │ 账户余额 ¥43.42 │ │ 本会话预估 ¥0.59 │ └──────────────────────────────┘ ``` 把鼠标移到卡片上会展开明细面板:余额分项(充值 / 赠金 / 合计)、预估花费的三个计价分项、 实际值、token 用量,以及并行会话时的分摊行(`本会话占比`、`其它会话`、`跟踪会话`、 `账户级合计`)。展开面板用 4 位小数,小卡片用 2 位。 | 字段 | 含义 | | --- | --- | | `账户余额` | 账户合计(充值 + 赠金),2 位小数 | | `本会话预估` | 本会话的预估花费;卡片 2 位、面板 4 位;会话还没有 token 用量时显示 `—` | | `实际扣费` | 本会话累计的真实花费(单会话时;并行时改叫 `本会话分摊`) | 金额小于可显示的最小单位时,卡片显示 `<¥0.01`、面板显示 `<¥0.0001` —— 花了钱就**绝不会**被抹成 0。 ## 交互 - **悬停卡片**展开明细面板。 - 卡片与面板是两个独立元素,中间留了 **8 px** 的视觉间隔。悬停判定挂在**卡片和面板自己**身上 (各自有 enter / leave),不挂在外层定位容器上;并用一个 **140 ms** 的待关闭定时器, 让指针落进那段间隔时面板的 `mouseenter` 能取消卡片的 `mouseleave`。 - 从**卡片左边**或**面板左边**滑出即收起。这是刻意的:容器在四周预留了空白, 把收起判定挂在容器盒子上会让面板停在"其实已经离开"的空白处不消失。 - **点击卡片**折叠成角标(2 格:标签 + 余额),再点恢复。 - **键盘**:卡片是 `role="button"` + `tabIndex=0`,`Enter` / `空格` 切换折叠;面板是 `role="dialog"`。 - 面板里有 `刷新` 按钮(点击立刻显示忙碌文案,完成后给一个 `更新于 HH:MM:SS` 时间戳), 以及手动兜底的 `我刚充值了` 按钮(处理中显示查询状态)。 - **HUD 里刻意没有任何 `title` 属性。** 原生浏览器 tooltip 会在悬停约一秒后弹出黑色方块盖住面板, 所以摘要写在 `aria-label` 里。冒烟测试把这条当回归守卫断言。 ## 设置 插件往 DSH 的 `settings.general.item` 槽位注册**一行偏好** —— `余额与花费 HUD`。DSH 对这个槽位的 定义是「General 区里的一行偏好 —— 单个设置专用的增量座位,不需要独占一整页」(要整页请用 `settings.section`;DSH 自己的 Language、link-opening、performance-usage 几行也注册在这里)。 位置:**设置 → 通用 → 余额与花费 HUD**。它**不会**占用整个设置页。 | 设置项 | 取值 | 默认 | 作用 | | --- | --- | --- | --- | | `计价模型` | `deepseek-flash`、`deepseek-v4-pro` | `deepseek-flash` | 预估与分摊权重所用的价格表 | | `自动同步价格` | 不自动(只手动) / 每 6 小时 / 每 1 天 / 每 3 天 / 每 7 天 | 不自动(只手动) | 官方价格表的自动同步节奏;不选就永远不会自动跑(见 [价格与同步](#价格与同步)) | | `前台刷新间隔` | 30 / 60 / 120 / 300 秒 | 60 秒 | 页面可见时的余额轮询节奏 | | `后台(页面隐藏)` | 30 / 60 / 120 / 300 秒 | 300 秒 | 标签页隐藏时的轮询节奏 | | `峰前提醒` | 5 / 10 / 15 / 30 分钟 | 10 分钟 | 峰谷切换提醒提前多久出现 | | `本会话预算(元)` | 元,`0` = 不设 | 0 | 本会话预估花费的预算 | | `每日预算(元)` | 元,`0` = 不设 | 0 | 北京自然日确认花费的预算 | | `显示账户级合计` | 开 / 关 | 开 | 并行会话时面板里的 `账户级合计` 行 | | `峰时提醒` | 开 / 关 | 开 | 面板里的峰谷提醒行 | | `预算超限发系统通知` | 开 / 关 | 关 | 首次达到某个预算档位时的可选系统通知 | **持久化。** 这一行写的是 `localStorage` 的 `dsh-balance-hud:settings:v1`,与记账键 (`dsh-balance-hud:tracker:v4`)**刻意分开**,两者不可能互相干扰。 **两个注册点,一个容器。** 设置行(`settings.general.item`)与浮层(`shell.overlay`)是 **两个独立的槽位注册,没有共同的 React 祖先**,因此无法共享 React state。它们改用一个小的订阅 容器 —— `createSettingsStore` → `get` / `set` / `subscribe`:`set` 合并一份部分补丁、落盘、广播, 所以在任一处改动,另一处立刻同步 —— 不需要刷新,也不需要共享父组件。`useSettings` 是架在同一个 容器上的 React 绑定。 **配置永不被信任。** `normalizeSettings` **逐字段对白名单校验**:未知模型、不在选项内的间隔、 负数预算、非布尔开关,一律回落默认值,而不是传进界面;`localStorage` 里的 JSON 损坏时同样回落 默认、不抛错。配置是用户可写、且随版本变化的,所以每次读取都重新校验。 ## 预算 在设置行里可以设**本会话预算**和/或**每日预算**(单位元;`0` 表示不设)。 `evaluateBudget(spent, budget)` 返回 `{ level, ratio, remaining }`,`level` 为 `none | warn | critical | over`: | 使用率 | 档位 | | --- | --- | | < 75 % | `none` | | ≥ 75 % | `warn` | | ≥ 90 % | `critical` | | ≥ 100 % | `over` | `remaining` 夹在 0 以上,"超限"不会报出负数余额。`worstBudgetLevel` 再取"本会话"与"每日"两条预算 里更严重的一条,所以「今天已经用爆、但本会话还很便宜」同样会告警。 **视觉。** 预算达到或超过阈值时,卡片左侧出现 **3 px 彩色描边** —— `warn` / `critical` 用主题的 警告色、`over` 用错误色。它**刻意不是**网格里多出来的一格:卡片网格必须保持 **2 列 × 2 行**, 冒烟测试用断言钉住了这一点。展开面板会多一行预算,显示「已用 / 预算」以及剩余金额或 `已超限`。 **可选系统通知。** 打开 `预算超限发系统通知`(默认关)后,**每个档位在本次挂载内最多只发一次**, 所以不会变成骚扰。它走浏览器 `Notification` API,只在权限已授权时才发;没有授权时静默降级为 只有描边变色,不影响其它功能。 **每日预算用按天台账。** `localStorage` 键 `dsh-balance-hud:daily:v1`,内容是 `{ date, total }`, 其中 `date` 是**北京**自然日(`beijingDateKey`)—— 官方计费与峰谷窗口都按北京时间,日界跟着同一个 口径才不会错位。台账只累加**确认花费**(某次余额读数确认的那部分),不用递推显示值,因此同一笔钱 不会被重复计入。读到新的一天时台账归零。 ## 峰时提醒 峰时 = 北京时间**周一至周五 09:00–12:00 与 14:00–18:00**;峰时价恰好是闲时价的 **2 倍**。 `minutesUntilTierChange(now)` 返回 `{ minutes, to }`,`to` 为 `'peak'`(即将进入贵的那档) 或 `'off'`(即将回到便宜的那档)。 当"下一次切换"落在配置的提前量以内时,面板显示一行提醒: - `约 N 分钟后进入峰时,价格翻倍` - `约 N 分钟后回到闲时价` 一个 **60 秒**的滴答同时驱动倒计时与跨天重置,所以数字是活的。午休、跨夜、跨周末都算得对 —— 例如周五 18:30 → 周一 09:00 是 **870 分钟** —— 冒烟测试断言了这些用例。 ## 两条花费口径 | 口径 | 算法 | 性质 | | --- | --- | --- | | **预估** | 会话 `tokenUsage` 投影 × 官方价格表 | 精确可算,但只覆盖模型 token | | **实际** | 观测到的**余额减少量**累计求和,按会话存在 `localStorage` | 平台真实扣费,但有结算延迟 | 预估把用量拆成四类 token —— 未命中输入、缓存命中、缓存写、输出 —— 各自按峰谷两档价计价。 **缓存写按未命中输入价计**,与平台扣费口径一致。实际值是一个**计数器,不是做减法**: 每一次观测到的余额下降都加进当前会话的计数器,所以充值无法干扰它。 ## 充值识别 **原则:外部变动改变余额,但绝不能改变花费计数。** 判据用的是 **token 估算上界**,而且它**双向都成立**:平台扣掉的钱不可能超过这段时间 消耗的 token 所值的钱;反过来,余额**减少**的量如果超过这个上界,多出来的部分也不可能来自消费。 ```text 余额升幅 > 全部会话 token 估算之和 + 0.02 → 外部加钱:充值 / 赠金到账 / 账单修正 余额降幅 > 全部会话 token 估算之和 + 0.02 → 外部减钱:赠金过期回收 / 退款 / 账单修正 其余 → 普通消费,照常累计 ``` 这样既不会因为"充值和消费同时发生"漏判,也不会因为正常消费波动误判。 ### 为什么必须处理"减钱"那个方向:赠金会过期 赠金有有效期。到期未用完的部分会被平台**回收**,表现为余额凭空下降、却没有任何消费。 早期版本把**任何**余额下降都算成消费(`delta < 0 → spend`),于是 5 元赠金过期会被记成 5 元"本会话花费";而计数器只增不减,这个错误会一直留在记录里直到 30 天后被淘汰。 现在两个方向走**同一条路径**:只移动账户级字段(`baseline` / `lastBalance`), **`spent` 原封不动**,并在 `topups[]` 里记下是哪一类变动。 多会话时,`applyExternalChange` 会把所有会话的账户级字段一起移动,但**刻意不动 `lastRead`** (会话私有的"上次真正观测到的余额")。于是每个会话都会自愈:还没观测到这次变动的会话, 下次读数时仍能看到完整落差,从而自己判出这是外部变动,而不会把它摊到自己的消费上。 ### 三层,按权威度排序 1. **token 上界判据** —— 纯客户端、始终生效,是**权威**判据(两个方向都是它说了算)。 2. **官方赠金印证** —— `GET /api/balance-hud/bonuses` 读 `ctx.deepseekAccount.getUnnotifiedBonuses()`。它是**只读且刻意不回执**的 (不调 `ackBonusNotified`),因此**不会消费掉 DSH 自己的赠金通知**;数据保持可读, 客户端按 `orderId` 去重。即使这条路由不通,变动仍会被识别 —— 这一层只是印证。 注意:**它只能印证"发放",官方没有"过期"的推送**,所以减钱那个方向完全靠第 1 层。 顺带把官方给的 `expiresAt` 透出来,提示里会带上"最早到期"日期。 3. **手动兜底** —— `我刚充值了` 按钮走同一条重锚路径,处理期间显示"查询中…"。 面板会显示约 12 秒的提示,两类都有: - 加钱:`已识别充值 ¥50.00,花费继续累计(官方赠金 ¥5.00 印证),最早到期 2025-12-31。` - 减钱:`检测到 ¥5.00 非消费性扣减(赠金过期回收等),未计入花费。` ## 并行会话与分摊 账户余额是**账户级**的:它不携带"这笔钱是哪个会话花的",所以单个会话的真实花费 **在本地不可观测**。插件用比例分摊: ```text 本会话分摊 = 本会话累计花费 × (本会话 token 成本 / 全部会话 token 成本) ``` 所有会话的 token 成本都用同一套四类计价 + 峰谷价,权重才可比。只有一个会话时权重恒为 1, 数值就退化成精确值。 **这是估算。** 两个会话的 token 消耗与平台结算并不同步,比例会漂移;有新会话加入分母时, 数字会下跳一次。面板里有 `本会话占比`、`其它会话`、`跟踪会话` 几行,能看到当时的权重是多少。 ## 持久化 记录在 `localStorage` 的 `dsh-balance-hud:tracker:v4` 下,按会话 id 分槽: ```json { "sessions": { "<会话id>": { "baseline": 43.42, "lastBalance": 43.12, "spent": 0.3, "ts": 0 } } } ``` | 情况 | 行为 | | --- | --- | | 刷新页面 / 重新挂载 | 从 `localStorage` 读回,计数器继续累计 | | 切换会话 | 各会话有自己的记录,互不覆盖、互不清零 | | 识别到充值 | 只移动基线,`spent` 不动 | | 记录超过 30 天 | 写回时淘汰 | | 超过 30 个会话 | 保留最近 30 个 | | `localStorage` 不可用 | 降级为仅内存,HUD 照常工作 | 页面存活期间,内存里的 store 还带着一个 `topups[]` 历史和一个 `lastObservedBalance`; 但**当前写盘只持久化 `sessions`**。老数据会自动迁移 —— `v3` 是 `{ sessions: { … } }`、 `v2` 是扁平的 `{ [会话id]: { … } }` 字典、`v1` 是单条 `{ sessionId, … }` —— 迁完会删掉旧键。 余额默认每 **60 秒**拉一次(页面隐藏时 **5 分钟**),页面回到前台立刻补读, 失败按 1s → 2s → 5s → 10s → 20s 退避重试。未登录不算错误,也不会走快速重试。两个间隔都可以在 设置行里改(见 [设置](#设置))。 除了记账键,还有三个**互相独立**的键:`dsh-balance-hud:settings:v1`(设置行,见 [设置](#设置))、`dsh-balance-hud:daily:v1`(北京自然日台账,见 [预算](#预算))和 `dsh-balance-hud:prices:v1`(同步来的价格表与它的 HTTP 校验器,见 [价格与同步](#价格与同步))。 清理或写坏其中一个,都不会影响其它几个。 ## 架构 ```text 浏览器(客户端半边 lib/client.js) Host(Host 半边 lib/index.js) 注册 shell.overlay 槽位 inject: [deepseekAccount, webServer] 每 60 秒轮询一次 │ fetch ──────────────────────────────────────► GET /api/balance-hud/balance (只读) GET /api/balance-hud/bonuses (只读) GET /api/balance-hud/pricing (价格同步,需主动开启) GET /api/balance-hud/models (模型列表,仅回环) └─► ctx.deepseekAccount.getBalance() ctx.deepseekAccount.getUnnotifiedBonuses() └─► 官方 account platform fetch(PRICING_DOC_URL) ← 仅主动开启同步时 └─► 官方价格文档页 ``` **为什么需要这条中继。** 浏览器半边无法直接调 `ctx.remote.account.getBalance`: 不声明就访问会抛 `cannot get property "remote.account" without inject`; 而在客户端半边声明 `inject = ["remote"]` 又无法满足 —— fiber 永久挂起,插件根本不激活。 两条都在真实 `@deepseek-ai/cordis` 4.0.4 上复现过。所以由 Host 半边读余额, 再经**仅回环、只读**的路由交给浏览器。所有路由都**只接受回环来源** (`127.0.0.1`、`::1`、IPv4 映射回环形式),其它一律 `403`;只支持 `GET`, 不改任何状态,不返回凭据,并带 `cache-control: no-store`。其中一条在用户主动开启后 才会产生对外请求 —— 价格同步。 | 返回 | 含义 | HUD 显示 | | --- | --- | --- | | `{ kind: 'ready', currency, rechargeTotal, bonusTotal, total }` | 已登录且有钱包 | `¥xx.xx` | | `{ kind: 'unavailable', reason }` | 未登录 / 凭据在查询期间变更 | 未登录 | | `{ kind: 'error', reason }` | 查询失败、路由不可达、响应不合法 | 读取失败(原因在面板里) | ## 价格口径 单位 **元 / 百万 tokens**,取自官方 [模型 & 价格](https://api-docs.deepseek.com/zh-cn/quick_start/pricing);闲时价恰好是峰时价的一半。 | 模型 | 缓存命中输入 | 缓存未命中输入 | 输出 | | --- | --- | --- | --- | | `deepseek-flash` | 闲 0.02 / 峰 0.04 | 闲 1 / 峰 2 | 闲 4 / 峰 8 | | `deepseek-v4-pro` | 闲 0.15 / 峰 0.30 | 闲 4.5 / 峰 9 | 闲 13.5 / 峰 27 | - **峰时** = 北京时间周一至周五 09:00–12:00 与 14:00–18:00。其余时段(含周末与法定节假日) 按闲时计;只取本地时间的「星期 + 小时」换算,不引入时区库。 - **缓存写**按未命中输入价计费。 - 价格表里两个模型都有;**模型是一个设置项**(默认 `deepseek-flash`),预估与分摊权重都跟着它走。 未知模型回落到 flash 价格(而不是归零),面板会说明这一点。 - 官方调价后,可以同步价格表(见 [价格与同步](#价格与同步)),或者改 `PRICES` 再刷新页面。 - 上表是**内置**快照,日期为 **2026-09-29**(`BUILTIN_PRICES_DATE`);同步成功后就改用官方的新表。 ## 价格与同步 预估所用的价格表**内置在插件里**,是一份有日期的快照(内置表日期 **2026-09-29**);除非你主动 要求,插件一直用它。官方随时可能调价,所以插件也可以**同步官方价格表** —— 来源是 [官方价格页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing),这也是它唯一会访问的外部主机。 **两种触发方式,都必须由你主动选择:** - 设置行里的 **`同步价格`** 按钮(英文名 `Sync prices`):点一次,立刻同步一次; - **`自动同步价格`** 下拉:选项是 `不自动(只手动)` / `每 6 小时` / `每 1 天` / `每 3 天` / `每 7 天` (即 `off` / 6 小时 / 24 小时 / 3 天 / 7 天)。不选间隔就不会自动跑,出厂状态是「不自动」 (`syncHours: 0`)—— 装上插件后不碰这些控件,插件**一个外部请求都不会发**。 开启自动同步后,**默认节奏是 24 小时**(`DEFAULT_SYNC_HOURS`),理由是:官方调价会先在 changelog 里预告,价格页随后才改,所以这是「几个月一次」的事件,不是每天的事。价格页是 CDN 资源, 会回 `ETag` 与 `Last-Modified`,同步时带上 `If-None-Match`,页面没变就回 **`304 Not Modified` 且没有响应体** —— 每次轮询只有几百字节。24 小时把「过期窗口」压到一天, 代价几乎为零。 **抓取由 Host 半边执行,不是浏览器。** 浏览器从不直接访问价格页,它调两条只读回环路由: `GET /api/balance-hud/pricing`(同步价格)与 `GET /api/balance-hud/models`(DSH 已注册的模型)。 只有前者可能产生对外请求;后者完全不需要网络 —— 它读 DSH 自己的 `llm` 服务 (`listProviders()` / `listModels()`),设置行用它填充「计价模型」下拉,拿不到 `llm` 时回落内置选项。 **算错价,比用旧价更糟。** 页面由一个纯函数解析,它的每个判断都**与页面编码无关** (抓取链路可能把 UTF-8 字节按 Latin-1 解开,中文标签会变成乱码),并且做**语义校验**: 官方规则是「闲时价 = 峰时价的一半」,所以峰时价必须**严格大于**闲时价。任何一项校验不过, 整次同步**整体作废**,插件继续用上一份好表 —— 内置表或上次同步成功的表。 **半解析出来的表永远不会被用来算钱。** **存在哪里、行里显示什么。** 同步结果缓存在 `localStorage` 的 `dsh-balance-hud:prices:v1`, 一起存的还有 `ETag`、`Last-Modified` 与抓取时间戳;收到 `304` 时只刷新检查时间,不动价格表。 设置行会显示**当前用的是哪一份、是哪天的** —— `当前价格:内置表(2026-09-29)`, 同步成功后变成 `当前价格:已同步(YYYY-MM-DD)`。 ## 安装 这是一个本地 profile 插件,**不发布到 npm**。 ```powershell node tools/install.mjs # 安装到 ~/.dsh/profiles/desktop node tools/install.mjs --dry-run # 预览,不写任何文件 node tools/install.mjs --uninstall # 卸载 node tools/install.mjs --profile