@linxin666/dsh-usage 0.4.8 → 0.4.10

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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 09251404a6aa753795f71a9b378fa4df13cd0595
6
- README.zh.md: 1e71587e55dbb3285423af2ec69bad7b091e91c0
5
+ README.md: 56545c0c2e7799499caa38f098d327663081e5bc
6
+ README.zh.md: 7cf471e394a185355c98bb7f67b43fb3a4455e5c
package/README.md CHANGED
@@ -5,7 +5,7 @@ English | [中文](README.zh.md)
5
5
  <p align="center">
6
6
  <img src="https://img.shields.io/npm/v/@linxin666/dsh-usage?style=flat-square" alt="Version">
7
7
  &nbsp;
8
- <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.1--rc.2-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
8
+ <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.1--rc.3-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
9
9
  &nbsp;
10
10
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square" alt="License">
11
11
  </p>
@@ -23,7 +23,7 @@ The plugin runs a host-side service and a first-level settings section (使用
23
23
 
24
24
  - **Usage tab (用量)**: today's token totals per bucket (input / output / cache read / cache write, disjoint as the provider reports them) with per-provider and per-model breakdown, the last 30 days as a horizontal provider/model bar chart, and the balance of every configured provider (one row per adapter family: a dormant catalog alias shadowed by the family's live route no longer renders its own balance row). A **day picker** switches that card to any retained day that recorded usage (today first, then the other days newest first) and the card then shows that day's own totals, provider rows, and estimated spend; the peak/off-peak line stays on today because it describes the pricing window now. The ledger folds live `session/event` streams (`request/header` route attribution plus `assistant/message` usage) into `$DSH_HOME/dsh-usage/usage-ledger.json` with day-based retention, so past days survive restarts; counting starts when the plugin is first enabled. For the official DeepSeek family the tab also shows the current peak/off-peak pricing period (Beijing weekdays 09:00-12:00 and 14:00-18:00 are peak, billed at double) and today's estimated spend in CNY; that line is toned by the current clock — red while peak, green while off-peak — and always carries the word plus a dot, so colour is never the only signal.
25
25
  - **Plans tab (个人套餐)**: coding-plan quota windows for every configured provider that exposes one — used percent and reset time per window (Kimi For Coding 5h/week, GLM Coding Plan 5h/week/month, OpenCode Go rolling/weekly/monthly, MiniMax 5h/week, Codex / ChatGPT subscription 5h/week). Providers without a real plan or subscription (DeepSeek, ZenMux, Moonshot, OpenRouter, SiliconFlow) never appear on this tab; their balance shows on the usage tab instead.
26
- - **Token Bank tab (Token 银行)**: the whale-yuan voucher (鲸元券) minted from the official DeepSeek family's ledger at an anti-inflation exchange rate of 1,000,000 tokens per whale yuan. The tab sums the family's retained-ledger tokens (the `deepseek` catalog alias and the live `deepseek-official` route combined), stamps the minted face value onto the banknote artwork with a serial line carrying the minting window, and offers a save-image button plus a native share button when the browser supports sharing files. The spend line shows the real CNY total observed from the official balance once available — balance decreases accrue as spend, top-ups never count — and falls back to the fold-time estimate before the first observation. The minted total prefers the host's whole-ledger aggregate and falls back to the last 30 days on an older host; with no official DeepSeek usage the tab shows its empty state. A day picker mints the note from one picked day instead of the whole retention window (the observed-spend line, which accrues from the first balance reading, is whole-window only and falls back to that day's fold-time estimate on a single day). The on-note text is locale-neutral (digits, latin captions, ISO dates) and the export is rendered locally in the browser.
26
+ - **Token Bank tab (Token 银行)**: the whale-yuan voucher (鲸元券) minted from every provider's ledger usage at an anti-inflation exchange rate of 1,000,000 tokens per whale yuan: the DeepSeek official family (the `deepseek` catalog alias, the live `deepseek-official` route and the signed-in `deepseek-account` route) counts its tokens and calls beside every subscription and relay route. The tab sums those retained-ledger totals, stamps the minted face value onto the banknote artwork with a serial line carrying the minting window, and offers a save-image button plus a native share button when the browser supports sharing files. The spend line stays official-family only: it shows the real CNY total observed from the official balance once available — balance decreases accrue as spend, top-ups never count — and falls back to the fold-time estimate before the first observation, because subscription and relay routes ship no price book. The minted total prefers the host's whole-ledger aggregate and falls back to the last 30 days on an older host; with no usage at all the tab shows its empty state. A day picker mints the note from one picked day instead of the whole retention window (the observed-spend line, which accrues from the first balance reading, is whole-window only and falls back to that day's fold-time estimate on a single day). The on-note text is locale-neutral (digits, latin captions, ISO dates) and the export is rendered locally in the browser.
27
27
  - **Sidebar foot card**: a compact glance seated above the sidebar's Settings row — today's estimated spend as the headline (today's tokens when nothing priced is recorded yet), the tokens/calls line, and up to two configured-provider balances with a +N overflow. The whole card is one button that opens the usage settings section, so detail stays in one place, and the corner chevron folds it into a one-line strip (the spending provider's name, the peak marker, the label, and the headline value), a choice persisted across reloads. When today's spend runs on the official DeepSeek route the card marks the pricing clock for the current date and hour (weekends and Chinese public holidays are off-peak for the whole day, so the red state only ever appears inside a weekday peak window): a red dot with 高峰 (peak) or a green dot with 空闲 (off-peak), plus a line naming when the current period ends or when peak returns in the expanded state; a card for any other provider never carries the marker. It polls at a relaxed 30 s cadence while the tab is visible, hides in the collapsed 56 px rail, and disappears while the plugin is disabled.
28
28
  - Probes run entirely host-side on a poll cycle (default 60 s, manual refresh button); API keys are resolved through the harness credential seam (`llm-pi-ai` records, `apiKeyEnv` references) and never reach the browser.
29
29
 
@@ -39,7 +39,7 @@ The published rule bills a Chinese public holiday entirely off-peak even on a Mo
39
39
 
40
40
  ## Install
41
41
 
42
- Requires DSH 0.2.1-rc.2 or later: the plugin is developed against the 0.2.1-rc.2 DSH cohort (its settings page is the Host-generated page of the plugin's own Config schema) and its `@deepseek-ai/*` runtime imports are provided by the host itself.
42
+ Requires DSH 0.2.1-rc.3 or later: the plugin is developed against the 0.2.1-rc.3 DSH cohort (its settings page is the Host-generated page of the plugin's own Config schema) and its `@deepseek-ai/*` runtime imports are provided by the host itself.
43
43
 
44
44
  In your profile (e.g. `~/.dsh/profiles/web`):
45
45
 
package/README.zh.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <p align="center">
6
6
  <img src="https://img.shields.io/npm/v/@linxin666/dsh-usage?style=flat-square" alt="Version">
7
7
  &nbsp;
8
- <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.1--rc.2-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
8
+ <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.1--rc.3-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
9
9
  &nbsp;
10
10
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square" alt="License">
11
11
  </p>
@@ -23,7 +23,7 @@
23
23
 
24
24
  - **用量页签**:今日 token 分桶合计(输入 / 输出 / 缓存读 / 缓存写,按 provider 上报口径互不相加),分 provider 与模型细分,近 30 天以「提供方-模型」水平条形图展示,以及所有已配置 provider 的余额(同一适配器家族只渲染一行:被该家族运行时路由遮蔽的休眠目录别名不再单独出余额行)。卡片顶部的**日期选择器**可切换到保留期内任何有记录的一天(今日在最前,其余按新到旧排列),此时卡片展示该日自己的合计、分 provider 行与消费估算;峰谷计价时段行只跟随今日,因为它描述的是当前时段的计价。对 DeepSeek 官方路由,页签还展示当前峰谷计价时段(北京时间工作日 09:00-12:00、14:00-18:00 为高峰,按双倍计费)与今日消费估算(CNY);该时段行按当时钟点着色——高峰为红、空闲为绿,并始终带「高峰 / 空闲」文字与圆点,颜色不是唯一信号。台账从 `session/event` 实时流折叠(`request/header` 归因路由 + `assistant/message` 用量),持久化到 `$DSH_HOME/dsh-usage/usage-ledger.json`,按本地日保留,因此历史日重启后仍在;统计自插件首次启用起计。
25
25
  - **个人套餐页签**:每个已配置且暴露套餐端点的 provider 的配额窗口——已用百分比与重置时间(Kimi For Coding 5 小时/每周、GLM 编程计划 5 小时/每周/每月、OpenCode Go 滚动/每周/每月、MiniMax 5 小时/每周、Codex / ChatGPT 订阅 5 小时/每周)。没有真实套餐/订阅体系的厂商(DeepSeek、ZenMux、Moonshot、OpenRouter、SiliconFlow)不出现在此页签,其余额显示在用量页签。
26
- - **Token 银行页签**:以 DeepSeek 官方家族的台账用量铸造「鲸元券」,汇率按 100 万 tokens 兑 1 鲸元(防膨胀)。页签把该家族保留台账内的 token 总量(`deepseek` 目录别名与运行时路由 `deepseek-official` 合并计算)折算成票面面额印在钞票图上,附带走票窗口的序列号行,并提供保存图片按钮与(浏览器支持文件分享时的)系统分享按钮。消费行优先展示从官方余额观测到的真实累计花费——余额下降即计为消费,充值上涨不计——首次观测到下降之前回落为折叠时刻估算。铸造总量优先取宿主的全台账聚合,旧宿主回落为近 30 天;无官方用量时展示空状态。页签的日期选择器可把票券改为按某一天铸造(真实花费观测行是自首次余额观测起的累计值,只在整段窗口下展示,单日票券回落为该日的折叠时刻估算)。票面文字与语言无关(数字、拉丁小字、ISO 日期),导出图在浏览器本地渲染。
26
+ - **Token 银行页签**:以台账里全部 provider 的用量铸造「鲸元券」,汇率按 100 万 tokens 兑 1 鲸元(防膨胀):DeepSeek 官方家族(`deepseek` 目录别名、运行时路由 `deepseek-official`、登录账号使用的 `deepseek-account`)与订阅、中转路由一起计入 token 与调用次数。页签把这些保留台账内的 token 总量折算成票面面额印在钞票图上,附带走票窗口的序列号行,并提供保存图片按钮与(浏览器支持文件分享时的)系统分享按钮。消费行只统计官方家族:优先展示从官方余额观测到的真实累计花费——余额下降即计为消费,充值上涨不计——首次观测到下降之前回落为折叠时刻估算,订阅与中转路由没有价目表,永不计入消费。铸造总量优先取宿主的全台账聚合,旧宿主回落为近 30 天;台账没有任何用量时展示空状态。页签的日期选择器可把票券改为按某一天铸造(真实花费观测行是自首次余额观测起的累计值,只在整段窗口下展示,单日票券回落为该日的折叠时刻估算)。票面文字与语言无关(数字、拉丁小字、ISO 日期),导出图在浏览器本地渲染。
27
27
  - **侧栏底部速览卡**:位于侧栏「设置」行上方、与更新/远程触发器同组的紧凑卡片——头条为今日消费估算(今日尚无可计价用量时回落为 tokens 总量),下方是 tokens/调用次数行,以及最多两个已配置 provider 的余额(超出折叠为 +N)。整张卡片主体是一个按钮,点击打开使用统计设置分区,细节只保留在一处;右上角箭头可把卡片收成一行摘要条(消费提供方名称 + 峰谷标记 + 标签 + 头条值),选择跨刷新保持。今日消费走 DeepSeek 官方路由时,卡片按当前钟点与日期标出峰谷(周末与中国法定节假日全天算空闲,红色只出现在工作日的高峰窗口):红色圆点加「高峰」、绿色圆点加「空闲」,展开态另有一行注明当前时段何时结束(高峰)或何时进入高峰(空闲);其他 provider 的卡片不出现该标记。标签页可见时以 30 秒的宽松周期轮询,侧栏折叠为 56px 栏轨时隐藏,插件停用时消失。
28
28
  - 探测完全在宿主侧按轮询周期执行(默认 60 秒,可手动刷新);API key 经宿主凭据缝解析(`llm-pi-ai` 记录、`apiKeyEnv` 引用),永不进入浏览器。
29
29
 
@@ -39,7 +39,7 @@
39
39
 
40
40
  ## 安装
41
41
 
42
- 要求 DSH 0.2.1-rc.2 或更高:插件基于 0.2.1-rc.2 DSH cohort 开发(其设置页即宿主根据插件自身 Config schema 生成的页面),`@deepseek-ai/*` 运行时导入由宿主本体提供。
42
+ 要求 DSH 0.2.1-rc.3 或更高:插件基于 0.2.1-rc.3 DSH cohort 开发(其设置页即宿主根据插件自身 Config schema 生成的页面),`@deepseek-ai/*` 运行时导入由宿主本体提供。
43
43
 
44
44
  在 profile(如 `~/.dsh/profiles/web`)中:
45
45
 
package/lib/client.js CHANGED
@@ -97,13 +97,15 @@ window.__ModuleLoader__.load({
97
97
  "usage.config.title": "设置",
98
98
  "usage.config.enabled": "启用插件",
99
99
  "usage.config.pollIntervalSec": "轮询间隔(秒)",
100
+ "usage.config.spendCurrency": "消费显示货币",
101
+ "usage.config.cnyPerUsd": "汇率(1 美元兑人民币)",
100
102
  "usage.config.saveFailed": "保存失败:设置未被接受。",
101
103
  "usage.bank.title": "鲸元券",
102
- "usage.bank.hint": "DeepSeek 官方 API 与 MiMo 每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。",
103
- "usage.bank.noUsage": "暂无 DeepSeek 官方 / MiMo 用量数据(统计自插件启用起)",
104
+ "usage.bank.hint": "所有模型每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。",
105
+ "usage.bank.noUsage": "暂无用量数据",
104
106
  "usage.bank.minted": "累计铸造 {minted} 鲸元({tokens} tokens)",
105
- "usage.bank.spend.observed": "官方余额实测花费 ¥{cost}(自 {since} 起)",
106
- "usage.bank.spend.estimated": "消费估算:约 ¥{cost}",
107
+ "usage.bank.spend.observed": "官方余额实测花费 {cost}(自 {since} 起)",
108
+ "usage.bank.spend.estimated": "消费估算:约 {cost}",
107
109
  "usage.bank.window": "统计窗口 {from} ~ {to}",
108
110
  "usage.bank.windowAll": "全部保留期",
109
111
  "usage.bank.save": "保存图片",
@@ -167,13 +169,15 @@ window.__ModuleLoader__.load({
167
169
  "usage.config.title": "Settings",
168
170
  "usage.config.enabled": "Enable plugin",
169
171
  "usage.config.pollIntervalSec": "Poll interval (seconds)",
172
+ "usage.config.spendCurrency": "Spend display currency",
173
+ "usage.config.cnyPerUsd": "Exchange rate (CNY per USD)",
170
174
  "usage.config.saveFailed": "Save failed: the deployment did not accept the setting.",
171
175
  "usage.bank.title": "Whale-yuan voucher",
172
- "usage.bank.hint": "Every 1,000,000 tokens spent on the DeepSeek official API or on MiMo mint one whale yuan; save or share the note.",
173
- "usage.bank.noUsage": "No DeepSeek official or MiMo usage yet (counting starts when the plugin is enabled)",
176
+ "usage.bank.hint": "Every 1,000,000 tokens spent across all models mint one whale yuan; save or share the note.",
177
+ "usage.bank.noUsage": "No usage data yet",
174
178
  "usage.bank.minted": "Minted {minted} whale yuan ({tokens} tokens)",
175
- "usage.bank.spend.observed": "Spent ¥{cost} observed on the official balance (watching since {since})",
176
- "usage.bank.spend.estimated": "Estimated spend: about ¥{cost}",
179
+ "usage.bank.spend.observed": "Spent {cost} observed on the official balance (watching since {since})",
180
+ "usage.bank.spend.estimated": "Estimated spend: about {cost}",
177
181
  "usage.bank.window": "Window {from} - {to}",
178
182
  "usage.bank.windowAll": "All retained days",
179
183
  "usage.bank.save": "Save image",
@@ -1096,7 +1100,7 @@ window.__ModuleLoader__.load({
1096
1100
  //#endregion
1097
1101
  //#region src/client/voucher.ts
1098
1102
  /**
1099
- * The Whale-yuan voucher (鲸元券): the mint family's retained ledger totals
1103
+ * The Whale-yuan voucher (鲸元券): the profile's retained ledger totals
1100
1104
  * stamped onto the banknote artwork as a denomination, ready
1101
1105
  * to save or share. The canvas copy is deliberately locale-neutral (digits,
1102
1106
  * latin captions, ISO dates) so the exported image needs no dictionary;
@@ -1119,30 +1123,23 @@ window.__ModuleLoader__.load({
1119
1123
  return `${date.getFullYear()}-${month}-${day}`;
1120
1124
  }
1121
1125
  /**
1122
- * Provider route ids minted beside the DeepSeek official family: the MiMo
1123
- * gateway, which a profile may run as its default route and which the ledger
1124
- * records under its own route id. The bank counts the tokens a profile spends,
1125
- * not only the ones DeepSeek bills. Route ids rather than adapter families
1126
- * because minting is an accounting decision that must never widen the
1127
- * credential or probing paths — the same split `isDeepSeekProviderRoute()`
1128
- * keeps against `adapterFor()` (issue #1772). An empty list mints exactly the
1129
- * official family, which is how a profile that never runs MiMo behaves.
1130
- */
1131
- const EXTRA_MINT_ROUTE_IDS = ["mimo"];
1132
- /** Whether one provider route's ledger rows mint whale yuan. */
1133
- function mintsWhaleYuan(provider) {
1134
- return isDeepSeekProviderRoute(provider) || EXTRA_MINT_ROUTE_IDS.includes(provider);
1135
- }
1136
- /**
1137
- * Sum the mint family's rows out of a usage window: the whole DeepSeek
1138
- * official family (the `deepseek` catalog alias, the live `deepseek-official`
1139
- * route and the signed-in `deepseek-account` route all fold into one voucher)
1140
- * plus {@link EXTRA_MINT_ROUTE_IDS}. Undefined when the window is absent
1141
- * (older host) or the family has no usage — a zero-token note mints nothing.
1126
+ * Sum every provider's rows out of a usage window: the note is a
1127
+ * proof-of-spend for what the profile actually used, so the tokens and calls
1128
+ * of every route in the ledger mint — the DeepSeek official family (the
1129
+ * `deepseek` catalog alias, the live `deepseek-official` route and the
1130
+ * signed-in `deepseek-account` route) beside every subscription and relay
1131
+ * route. Undefined when the window is absent (older host) or nothing was used
1132
+ * — a zero-token note mints nothing.
1133
+ *
1134
+ * Minting is an accounting decision over ledger rows, so it reads no adapter:
1135
+ * widening it must never widen the credential or balance-probe paths, which
1136
+ * keep reading `adapterFor()` (the split `isDeepSeekProviderRoute()` also
1137
+ * keeps, issue #1772).
1142
1138
  *
1143
- * Only the priced family contributes `cost`: MiMo ships no price book, so it
1144
- * mints tokens and calls while the spend line keeps reporting the official
1145
- * family's fold-time estimate (and the observed-balance watch above it).
1139
+ * Only the priced family contributes `cost`: subscription and relay routes
1140
+ * ship no price book, so they mint tokens and calls while the spend line keeps
1141
+ * reporting the official family's fold-time estimate (and the observed-balance
1142
+ * watch above it).
1146
1143
  */
1147
1144
  function voucherData(window) {
1148
1145
  if (window === void 0) return void 0;
@@ -1150,7 +1147,6 @@ window.__ModuleLoader__.load({
1150
1147
  let calls = 0;
1151
1148
  let cost = 0;
1152
1149
  for (const row of window.providers) {
1153
- if (!mintsWhaleYuan(row.provider)) continue;
1154
1150
  tokens += totalTokens(row.totals);
1155
1151
  calls += row.totals.calls;
1156
1152
  if (isDeepSeekProviderRoute(row.provider)) cost += row.totals.cost;
@@ -1229,11 +1225,38 @@ window.__ModuleLoader__.load({
1229
1225
  ctx.fillText(`NO.${voucherSerial(face)} ${data.from} - ${data.to}`, centerX, Math.round(art.height * .845));
1230
1226
  }
1231
1227
  //#endregion
1228
+ //#region src/core/spend.ts
1229
+ /**
1230
+ * The rate used when the row configures none: a plain default, so an
1231
+ * unconfigured display still has a defined美元 figure instead of a blank card.
1232
+ * The plugin never fetches a live rate (its host routes are loopback-fenced and
1233
+ * its browser half has no network), so there is no fetch that could fail and
1234
+ * leave the card empty.
1235
+ */
1236
+ const DEFAULT_CNY_PER_USD = 7.2;
1237
+ /** The display used when the row carries no setting: the historical CNY rendering. */
1238
+ const DEFAULT_SPEND_DISPLAY = {
1239
+ currency: "CNY",
1240
+ cnyPerUsd: DEFAULT_CNY_PER_USD
1241
+ };
1242
+ /**
1243
+ * Render one stored CNY cost in the display currency.
1244
+ * A non-finite or negative cost renders as zero rather than as `NaN`/`¥-0.10`.
1245
+ * @param costCny - the stored cost in CNY.
1246
+ * @param display - the display to render in; defaults to CNY.
1247
+ * @returns the formatted amount with its currency symbol.
1248
+ */
1249
+ function formatSpend(costCny, display = DEFAULT_SPEND_DISPLAY) {
1250
+ const safe = Number.isFinite(costCny) && costCny > 0 ? costCny : 0;
1251
+ if (display.currency === "USD") return "$" + (safe / (Number.isFinite(display.cnyPerUsd) && display.cnyPerUsd > 0 ? display.cnyPerUsd : DEFAULT_CNY_PER_USD)).toFixed(2);
1252
+ return "¥" + safe.toFixed(2);
1253
+ }
1254
+ //#endregion
1232
1255
  //#region src/client/UsageSectionCard.tsx
1233
1256
  /**
1234
1257
  * The usage statistics settings section: three tabs (用量: today's usage,
1235
1258
  * balances, trend; 个人套餐: per-provider plan quota windows; Token 银行:
1236
- * the whale-yuan voucher minted from the DeepSeek official family's usage)
1259
+ * the whale-yuan voucher minted from every provider's ledger usage)
1237
1260
  * plus a compact settings row. Data comes from the host's loopback-fenced
1238
1261
  * /api/dsh-usage/overview document; polling runs only while the section is
1239
1262
  * mounted and the tab is visible.
@@ -1264,10 +1287,6 @@ window.__ModuleLoader__.load({
1264
1287
  return "";
1265
1288
  }
1266
1289
  }
1267
- /** Formatted CNY spend estimate (the only priced currency today). */
1268
- function formatCost(cost) {
1269
- return "¥" + cost.toFixed(2);
1270
- }
1271
1290
  function toneClass(percent) {
1272
1291
  if (percent >= 90) return usage_module_css_default.barLow;
1273
1292
  if (percent >= 70) return usage_module_css_default.barWarn;
@@ -1636,7 +1655,7 @@ window.__ModuleLoader__.load({
1636
1655
  children: isToday ? t("usage.today.cost") : t("usage.day.cost")
1637
1656
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1638
1657
  className: usage_module_css_default.providerTokens,
1639
- children: formatCost(day?.totals.cost ?? 0)
1658
+ children: formatSpend(day?.totals.cost ?? 0, snapshot.display)
1640
1659
  })]
1641
1660
  }),
1642
1661
  (day?.providers.length ?? 0) > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1655,7 +1674,7 @@ window.__ModuleLoader__.load({
1655
1674
  formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens),
1656
1675
  " · ",
1657
1676
  t("usage.calls", { n: row.totals.calls }),
1658
- row.totals.cost > 0 ? ` · ${formatCost(row.totals.cost)}` : ""
1677
+ row.totals.cost > 0 ? ` · ${formatSpend(row.totals.cost, snapshot.display)}` : ""
1659
1678
  ]
1660
1679
  })]
1661
1680
  }, row.provider))
@@ -1725,6 +1744,7 @@ window.__ModuleLoader__.load({
1725
1744
  window: bankWindow,
1726
1745
  observedSpend: bankSelection === void 0 ? snapshot.usage.observedSpend : void 0,
1727
1746
  loading: bankSelection !== void 0 && bankDayLoad?.status === "loading",
1747
+ display: snapshot.display,
1728
1748
  ...bankDayLoad?.status === "error" ? { error: bankDayLoad.error ?? "" } : {}
1729
1749
  })] })
1730
1750
  ]
@@ -1830,17 +1850,17 @@ window.__ModuleLoader__.load({
1830
1850
  return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens;
1831
1851
  }
1832
1852
  /**
1833
- * The Token 银行 card: the mint family's (DeepSeek official + MiMo)
1834
- * retained-ledger usage minted onto the whale-yuan note at 1,000,000 tokens
1835
- * per whale yuan. The window is the whole retained ledger by default (the
1836
- * host's aggregate, falling back to the 30-day trend when an older host serves
1837
- * no `all`) or the single day the user picked; the spend line prefers the
1838
- * official balance watch (whole window only) and falls back to the fold-time
1839
- * estimate, which only the priced family feeds; the artwork draw failure
1840
- * degrades to an error line and never takes the section down.
1853
+ * The Token 银行 card: every provider's retained-ledger usage minted onto the
1854
+ * whale-yuan note at 1,000,000 tokens per whale yuan. The window is the whole
1855
+ * retained ledger by default (the host's aggregate, falling back to the 30-day
1856
+ * trend when an older host serves no `all`) or the single day the user picked;
1857
+ * the spend line prefers the official balance watch (whole window only) and
1858
+ * falls back to the fold-time estimate, which only the priced family feeds;
1859
+ * the artwork draw failure degrades to an error line and never takes the
1860
+ * section down.
1841
1861
  */
1842
1862
  function VoucherCard(props) {
1843
- const { window: ledger, observedSpend, loading, error } = props;
1863
+ const { window: ledger, observedSpend, loading, error, display } = props;
1844
1864
  const data = voucherData(ledger);
1845
1865
  const canvasRef = (0, react.useRef)(null);
1846
1866
  const [drawError, setDrawError] = (0, react.useState)(void 0);
@@ -1940,9 +1960,9 @@ window.__ModuleLoader__.load({
1940
1960
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1941
1961
  className: usage_module_css_default.muted,
1942
1962
  children: observedSpend !== void 0 ? t("usage.bank.spend.observed", {
1943
- cost: observedSpend.cny.toFixed(2),
1963
+ cost: formatSpend(observedSpend.cny, display),
1944
1964
  since: formatDay(observedSpend.since)
1945
- }) : t("usage.bank.spend.estimated", { cost: data.cost.toFixed(2) })
1965
+ }) : t("usage.bank.spend.estimated", { cost: formatSpend(data.cost, display) })
1946
1966
  }),
1947
1967
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1948
1968
  className: usage_module_css_default.muted,
@@ -2060,30 +2080,65 @@ window.__ModuleLoader__.load({
2060
2080
  }),
2061
2081
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2062
2082
  className: usage_module_css_default.settingsGrid,
2063
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2064
- className: usage_module_css_default.settingItem,
2065
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2066
- type: "checkbox",
2067
- checked: value.enabled ?? true,
2068
- disabled,
2069
- onChange: (event) => {
2070
- write("enabled", event.target.checked);
2071
- }
2072
- }), t("usage.config.enabled")]
2073
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2074
- className: usage_module_css_default.settingItem,
2075
- children: [t("usage.config.pollIntervalSec"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2076
- type: "number",
2077
- min: 30,
2078
- max: 3600,
2079
- value: typeof value.pollIntervalSec === "number" ? value.pollIntervalSec : 60,
2080
- disabled,
2081
- onChange: (event) => {
2082
- const parsed = Number(event.target.value);
2083
- if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) write("pollIntervalSec", Math.round(parsed));
2084
- }
2085
- })]
2086
- })]
2083
+ children: [
2084
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2085
+ className: usage_module_css_default.settingItem,
2086
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2087
+ type: "checkbox",
2088
+ checked: value.enabled ?? true,
2089
+ disabled,
2090
+ onChange: (event) => {
2091
+ write("enabled", event.target.checked);
2092
+ }
2093
+ }), t("usage.config.enabled")]
2094
+ }),
2095
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2096
+ className: usage_module_css_default.settingItem,
2097
+ children: [t("usage.config.pollIntervalSec"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2098
+ type: "number",
2099
+ min: 30,
2100
+ max: 3600,
2101
+ value: typeof value.pollIntervalSec === "number" ? value.pollIntervalSec : 60,
2102
+ disabled,
2103
+ onChange: (event) => {
2104
+ const parsed = Number(event.target.value);
2105
+ if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) write("pollIntervalSec", Math.round(parsed));
2106
+ }
2107
+ })]
2108
+ }),
2109
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2110
+ className: usage_module_css_default.settingItem,
2111
+ children: [t("usage.config.spendCurrency"), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2112
+ value: value.spendCurrency === "USD" ? "USD" : "CNY",
2113
+ disabled,
2114
+ onChange: (event) => {
2115
+ write("spendCurrency", event.target.value === "USD" ? "USD" : "CNY");
2116
+ },
2117
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2118
+ value: "CNY",
2119
+ children: "CNY"
2120
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2121
+ value: "USD",
2122
+ children: "USD"
2123
+ })]
2124
+ })]
2125
+ }),
2126
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2127
+ className: usage_module_css_default.settingItem,
2128
+ children: [t("usage.config.cnyPerUsd"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2129
+ type: "number",
2130
+ min: .01,
2131
+ max: 1e3,
2132
+ step: .01,
2133
+ value: typeof value.cnyPerUsd === "number" ? value.cnyPerUsd : 7.2,
2134
+ disabled,
2135
+ onChange: (event) => {
2136
+ const parsed = Number(event.target.value);
2137
+ if (Number.isFinite(parsed) && parsed >= .01 && parsed <= 1e3) write("cnyPerUsd", Math.round(parsed * 100) / 100);
2138
+ }
2139
+ })]
2140
+ })
2141
+ ]
2087
2142
  }),
2088
2143
  failure !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2089
2144
  className: usage_module_css_default.errorLine,
@@ -2317,7 +2372,7 @@ window.__ModuleLoader__.load({
2317
2372
  const totals = snapshot.usage.today.totals;
2318
2373
  if (totals.cost > 0) return {
2319
2374
  label: t("usage.foot.cost"),
2320
- value: "¥" + totals.cost.toFixed(2),
2375
+ value: formatSpend(totals.cost, snapshot.display),
2321
2376
  tokenFallback: false,
2322
2377
  provider: spendProvider(snapshot)
2323
2378
  };
@@ -2328,7 +2383,7 @@ window.__ModuleLoader__.load({
2328
2383
  };
2329
2384
  return {
2330
2385
  label: t("usage.foot.cost"),
2331
- value: "¥0.00",
2386
+ value: formatSpend(0, snapshot.display),
2332
2387
  tokenFallback: false
2333
2388
  };
2334
2389
  }
@@ -2528,45 +2583,130 @@ window.__ModuleLoader__.load({
2528
2583
  }), toggle]
2529
2584
  });
2530
2585
  }
2586
+ /** The shell slot whose occupant is the settings shell (and carries its store). */
2587
+ const SETTINGS_SEAT_SLOT = "sidebar.settings";
2588
+ /** The shell sidebar column, which also scopes the settings seat. */
2589
+ const SIDEBAR_SELECTOR = "[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]";
2590
+ /** The shell's settings row: the launcher seat plus the Settings trigger fallback. */
2591
+ const SETTINGS_AREA_SELECTOR = "[class*=\"settingsArea\"]";
2592
+ /** A launcher that opens the settings dialog by itself (the shell's own gear). */
2593
+ const DIALOG_TRIGGER_SELECTOR = "button[aria-haspopup=\"dialog\"]";
2594
+ /** A launcher that opens a menu whose settings item opens the dialog (the account row). */
2595
+ const MENU_ITEM_SELECTOR = "[role=\"menu\"] button[role=\"menuitem\"]";
2596
+ /** One shell frame of the replay poll: menus and the panel mount a frame later. */
2597
+ const REPLAY_STEP_MS = 50;
2598
+ /** Replay poll budget in steps (about one second of shell frames). */
2599
+ const REPLAY_STEPS = 20;
2600
+ /** The shell sidebar column, when the shell has mounted one. */
2601
+ function sidebarColumn() {
2602
+ return document.querySelector(SIDEBAR_SELECTOR) ?? void 0;
2603
+ }
2531
2604
  /** The shell sidebar's foot area (footer actions + the Settings row), when mounted. */
2532
2605
  function footArea() {
2533
- return document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]")?.querySelector("[class*=\"footArea\"]") ?? void 0;
2606
+ return sidebarColumn()?.querySelector("[class*=\"footArea\"]") ?? void 0;
2534
2607
  }
2535
2608
  /**
2536
- * Open the settings panel on the usage section. The shell owns the panel's
2537
- * open state and exposes no service for it, so the card replays the user's
2538
- * own path: activate the sidebar Settings trigger, then pick the nav row
2539
- * carrying the section's (localized) label. Every step degrades silently —
2540
- * with the panel already open only the nav pick runs, and a missing row
2541
- * leaves the panel on its default section.
2542
- * @param label - the usage section's nav label in the active locale.
2609
+ * Resolve the settings shell's store handle from the `sidebar.settings` seat.
2610
+ *
2611
+ * The seat is a single-occupant slot and the shell's own registration carries
2612
+ * the store that owns the panel's open state and active section, so reading it
2613
+ * is the one step that opens the panel without touching the DOM. A shell that
2614
+ * exposes no seat, a seat without a store, or a registry answering mid-teardown
2615
+ * all yield undefined, and the caller replays the DOM path instead.
2616
+ * @param slots - the renderer-owned slot registry.
2617
+ * @returns the store handle, or undefined when the seat carries none.
2543
2618
  */
2544
- function openUsageSettings(label) {
2545
- /** Click the nav row carrying the usage label inside the open panel. */
2546
- const pick = (panel) => {
2547
- const wanted = label();
2548
- for (const row of panel.querySelectorAll("nav button")) if (row.textContent !== null && row.textContent.includes(wanted)) {
2549
- row.click();
2550
- return;
2551
- }
2552
- };
2619
+ function settingsShellStore(slots) {
2620
+ let seat;
2621
+ try {
2622
+ seat = slots?.entries(SETTINGS_SEAT_SLOT)[0]?.store;
2623
+ } catch {
2624
+ return;
2625
+ }
2626
+ if (seat === void 0 || seat === null) return void 0;
2627
+ return typeof seat.create === "function" ? seat : void 0;
2628
+ }
2629
+ /**
2630
+ * Drive the shell's settings store onto the usage section.
2631
+ * @param store - the seat's store handle.
2632
+ * @param id - the section's slot registration id.
2633
+ * @returns true when the store accepted the call, false when it threw.
2634
+ */
2635
+ function openThroughStore(store, id) {
2636
+ try {
2637
+ store.create().actions.openSection(id);
2638
+ return true;
2639
+ } catch {
2640
+ return false;
2641
+ }
2642
+ }
2643
+ /**
2644
+ * Click the nav row carrying the section's label inside the open panel.
2645
+ * @param panel - the open settings dialog.
2646
+ * @param label - the section's nav label in the active locale.
2647
+ */
2648
+ function pickSectionRow(panel, label) {
2649
+ const wanted = label();
2650
+ for (const row of panel.querySelectorAll("nav button")) if (row.textContent !== null && row.textContent.includes(wanted)) {
2651
+ row.click();
2652
+ return;
2653
+ }
2654
+ }
2655
+ /**
2656
+ * Poll the page over the shell's next frames until a lookup yields a value.
2657
+ * @param find - the lookup, run once per step.
2658
+ * @param steps - remaining frame budget.
2659
+ * @returns the first value found, or undefined once the budget is spent.
2660
+ */
2661
+ function until(find, steps = REPLAY_STEPS) {
2662
+ const found = find();
2663
+ if (found !== void 0 && found !== null) return Promise.resolve(found);
2664
+ if (steps <= 0) return Promise.resolve(void 0);
2665
+ return new Promise((resolve) => {
2666
+ window.setTimeout(resolve, REPLAY_STEP_MS);
2667
+ }).then(() => until(find, steps - 1));
2668
+ }
2669
+ /**
2670
+ * Replay the user's own settings path, one shell frame at a time.
2671
+ * @param launcher - the settings seat's trigger.
2672
+ * @param label - the section's nav label in the active locale.
2673
+ */
2674
+ async function replay(launcher, label) {
2675
+ launcher.click();
2676
+ if (launcher.getAttribute("aria-haspopup") === "menu") {
2677
+ const item = await until(() => document.querySelector(MENU_ITEM_SELECTOR));
2678
+ if (item === void 0) return;
2679
+ item.click();
2680
+ }
2681
+ const panel = await until(() => document.querySelector("[role=\"dialog\"]"));
2682
+ if (panel !== void 0) pickSectionRow(panel, label);
2683
+ }
2684
+ /**
2685
+ * Open the settings panel on the usage section.
2686
+ *
2687
+ * The seat's store is the primary path: `openSection` opens the dialog on the
2688
+ * usage row in one call, with no dependence on which plugin occupies the
2689
+ * `settings.launcher` seat — an account row there turns a trigger click into a
2690
+ * menu, which is how this click used to land on the account menu instead of the
2691
+ * panel. Without such a store the card replays the user's own path: activate
2692
+ * the launcher (preferring the dialog trigger over a menu trigger), follow a
2693
+ * menu launcher's first settings item, then pick the nav row carrying the
2694
+ * section's label. Every step degrades silently — a missing surface leaves the
2695
+ * page untouched, and with the panel already open only the nav pick runs.
2696
+ * @param target - the section id, its localized label, and the seat's store.
2697
+ */
2698
+ function openUsageSettings(target) {
2699
+ const { id, label, store } = target;
2700
+ if (store !== void 0 && openThroughStore(store, id)) return;
2553
2701
  const open = document.querySelector("[role=\"dialog\"]");
2554
2702
  if (open !== null) {
2555
- pick(open);
2703
+ pickSectionRow(open, label);
2556
2704
  return;
2557
2705
  }
2558
- (document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]")?.querySelector("[class*=\"settingsArea\"] button"))?.click();
2559
- let tries = 0;
2560
- const attempt = () => {
2561
- const panel = document.querySelector("[role=\"dialog\"]");
2562
- if (panel !== null) {
2563
- pick(panel);
2564
- return;
2565
- }
2566
- tries += 1;
2567
- if (tries <= 20) window.setTimeout(attempt, 50);
2568
- };
2569
- window.setTimeout(attempt, 0);
2706
+ const area = sidebarColumn()?.querySelector(SETTINGS_AREA_SELECTOR);
2707
+ const launcher = area?.querySelector(DIALOG_TRIGGER_SELECTOR) ?? area?.querySelector("button");
2708
+ if (launcher === void 0 || launcher === null) return;
2709
+ replay(launcher, label);
2570
2710
  }
2571
2711
  /**
2572
2712
  * Mount the sidebar foot card directly above the Settings row.
@@ -2583,7 +2723,7 @@ window.__ModuleLoader__.load({
2583
2723
  const place = () => {
2584
2724
  const foot = footArea();
2585
2725
  if (foot === void 0) return;
2586
- const settings = foot.querySelector("[class*=\"settingsArea\"]");
2726
+ const settings = foot.querySelector(SETTINGS_AREA_SELECTOR);
2587
2727
  if (settings !== null) {
2588
2728
  if (container.nextElementSibling !== settings) foot.insertBefore(container, settings);
2589
2729
  return;
@@ -2717,6 +2857,8 @@ window.__ModuleLoader__.load({
2717
2857
  ];
2718
2858
  /** First-level nav position: directly below the Workshop section (order 150). */
2719
2859
  const SECTION_ORDER = 151;
2860
+ /** The section's nav row id — what the shell's store selects when the card opens. */
2861
+ const USAGE_SECTION_ID = "dsh-usage";
2720
2862
  /** Required services. */
2721
2863
  const inject = [
2722
2864
  "slots",
@@ -2784,7 +2926,11 @@ window.__ModuleLoader__.load({
2784
2926
  poll,
2785
2927
  settings: settingsForm,
2786
2928
  onOpen: () => {
2787
- openUsageSettings(() => t("usage.title"));
2929
+ openUsageSettings({
2930
+ id: USAGE_SECTION_ID,
2931
+ label: () => t("usage.title"),
2932
+ store: settingsShellStore(ctx.slots)
2933
+ });
2788
2934
  },
2789
2935
  locale: ctx.locale
2790
2936
  });
@@ -2797,7 +2943,7 @@ window.__ModuleLoader__.load({
2797
2943
  try {
2798
2944
  const unregister = ctx.slots.register({
2799
2945
  name: "settings.section",
2800
- id: "dsh-usage",
2946
+ id: USAGE_SECTION_ID,
2801
2947
  order: SECTION_ORDER,
2802
2948
  label: () => ctx.locale.bind(NS)("usage.title"),
2803
2949
  locale: NS,