dsh-consumption-badge 1.2.2 → 1.3.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
@@ -2,15 +2,28 @@
2
2
 
3
3
  A **persistent** DSH (DeepSeek Harness) Web UI plugin that puts a live consumption badge
4
4
  next to every assistant message, and opens a detail panel with the session's spend curve.
5
- The UI is **bilingual**: it follows the shell language (Chinese or English) and switches live.
5
+ The UI is **bilingual** (follows the shell language, switches live) and amounts can be shown in
6
+ **any of 17 major currencies** — pick one in Settings → Usage.
6
7
 
7
8
  ```
8
- ⛽ 25.6k | ≈¥0.020 | Off-peak
9
+ ⛽ 3.2k | ≈¥0.004 | Off-peak | Balance = ¥42.50
9
10
  ```
10
11
 
11
- - **Collapsed pill** – session-cumulative tokens, estimated cost (always prefixed `≈`), and the
12
- current peak/off-peak tier (red `Peak`, green `Off-peak`), injected into
12
+ - **Collapsed pill** – **that assistant message's own** token count, its **own** estimated cost
13
+ (always prefixed `≈`), its pricing tier (red `Peak`, green `Off-peak`, attributed to the turn's
14
+ request start), and the DeepSeek **account balance**; injected into
13
15
  `conversation.chat.assistant-actions` right after the shipped copy/branch controls.
16
+ The two numbers are **this turn's, not the session's**: the slot renders once per turn
17
+ (`TurnTailNodeView`) and hands the badge that turn's final `messageId`; the host answers with a
18
+ `messageId → message` map (which carries the turn number) next to the per-turn fold, and the badge
19
+ renders that turn's folded total. Mapping is resolved in two steps so the pill is right even
20
+ before the host half is restarted: the host map first, then the `data-turn-tail` attribute the
21
+ shipped conversation view puts on the turn wrapper. If neither resolves (or the turn has no record
22
+ yet) the pill falls back to the session-wide figures instead of showing a false zero, and its
23
+ tooltip names the scope being shown. The balance segment renders `Balance = ¥42.50` when the
24
+ account API answers, `…` while loading and `—` on failure (the reason rides the tooltip); balance
25
+ uses the same 60 s cache as the modal, refreshed once a minute. The modal itself still shows the
26
+ whole session, unchanged.
14
27
  - **Detail modal** (centered, blurred backdrop, `Esc` or backdrop-click to close):
15
28
  cumulative tokens with hit/miss/output split, estimated cost, current pricing tier,
16
29
  DeepSeek account balance, and a **per-turn dual-axis line chart**
@@ -71,6 +84,111 @@ The UI is **bilingual**: it follows the shell language (Chinese or English) and
71
84
 
72
85
  Everything is an **estimate** for reference only — this plugin is not a billing system of record.
73
86
 
87
+ ## Currency
88
+
89
+ The price table is always **CNY per 1M tokens** (that is what DeepSeek bills in); the currency
90
+ setting only decides how amounts are *displayed*. Pick it in **Settings → Usage → Currency**:
91
+
92
+ | Aspect | Behavior |
93
+ | --- | --- |
94
+ | Currencies | CNY (default) plus USD, EUR, GBP, JPY, HKD, TWD, KRW, SGD, AUD, CAD, CHF, INR, RUB, BRL, THB, MXN |
95
+ | What converts | every amount in the plugin: pill (`≈# dsh-consumption-badge
96
+
97
+ A **persistent** DSH (DeepSeek Harness) Web UI plugin that puts a live consumption badge
98
+ next to every assistant message, and opens a detail panel with the session's spend curve.
99
+ The UI is **bilingual** (follows the shell language, switches live) and amounts can be shown in
100
+ **any of 17 major currencies** — pick one in Settings → Usage.
101
+
102
+ ```
103
+ ⛽ 3.2k | ≈¥0.004 | Off-peak | Balance = ¥42.50
104
+ ```
105
+
106
+ - **Collapsed pill** – **that assistant message's own** token count, its **own** estimated cost
107
+ (always prefixed `≈`), its pricing tier (red `Peak`, green `Off-peak`, attributed to the turn's
108
+ request start), and the DeepSeek **account balance**; injected into
109
+ `conversation.chat.assistant-actions` right after the shipped copy/branch controls.
110
+ The two numbers are **this turn's, not the session's**: the slot renders once per turn
111
+ (`TurnTailNodeView`) and hands the badge that turn's final `messageId`; the host answers with a
112
+ `messageId → message` map (which carries the turn number) next to the per-turn fold, and the badge
113
+ renders that turn's folded total. Mapping is resolved in two steps so the pill is right even
114
+ before the host half is restarted: the host map first, then the `data-turn-tail` attribute the
115
+ shipped conversation view puts on the turn wrapper. If neither resolves (or the turn has no record
116
+ yet) the pill falls back to the session-wide figures instead of showing a false zero, and its
117
+ tooltip names the scope being shown. The balance segment renders `Balance = ¥42.50` when the
118
+ account API answers, `…` while loading and `—` on failure (the reason rides the tooltip); balance
119
+ uses the same 60 s cache as the modal, refreshed once a minute. The modal itself still shows the
120
+ whole session, unchanged.
121
+ - **Detail modal** (centered, blurred backdrop, `Esc` or backdrop-click to close):
122
+ cumulative tokens with hit/miss/output split, estimated cost, current pricing tier,
123
+ DeepSeek account balance, and a **per-turn dual-axis line chart**
124
+ (left axis: tokens, right axis: ¥) with a hover tooltip showing the model,
125
+ input-hit / input-miss / output tokens, and the turn's peak/off-peak flag.
126
+ - **Settings page · Usage** (`settings.section`, id `balance-dsh-consumption`, order 26):
127
+ a top row of four cards — **Total tokens** (with hit/miss/output split, all-history totals),
128
+ **Estimated cost**, **Current window** (peak/off-peak) and **Account balance** (reusing the
129
+ badge's cached balance) — then **all-session history** as one line chart, filterable by time range
130
+ (Today / Last 7 days / Last 30 days / Last 12 months), by workspace
131
+ (All workspaces + every registered workspace + Unassigned), and by an explicit
132
+ calendar range that overrides the dropdown. Bucket size follows the range:
133
+ **hourly for today and any single-day calendar range**, daily for multi-day ranges, and
134
+ monthly for the last 12 months. Left axis (tokens) is hidden until you
135
+ toggle it in the legend; the right axis (¥) is always on. Hovering a point shows its
136
+ total, its peak/off-peak split and the models involved; the footer sums the current
137
+ selection. No data in range → a plain "No usage recorded in this range" note instead of an
138
+ empty chart. The cards are **all-history** totals (independent of the filters); the footer is the
139
+ filtered total, so the two numbers differ by design.
140
+ - **Personalization integration** (auto-detected, no configuration): when
141
+ [`dsh-plugin-personalization`](https://www.npmjs.com/package/dsh-plugin-personalization) is
142
+ installed alongside this plugin, a **Usage popup** block is appended to its settings page —
143
+ same `pp-block` / `pp-field` / `pp-modes` layout and the same `Pill` primitive as the shipped
144
+ personalization rows — offering four backgrounds for the consumption modal:
145
+ **Default / Glass / Custom image / Solid color**.
146
+ - **Default** keeps the themed surface; **Glass** is real `backdrop-filter` glass — both the veil
147
+ behind the modal and the panel itself are theme-derived (`--dsw-alias-bg-base`), so light themes
148
+ get a light haze and dark themes a dark one instead of the panel painting a dark slab over the
149
+ page; **Custom image** takes an image URL *or* a local image plus an **Opacity** slider
150
+ (0–100 %, default 85 %) that sets the background layer's own `opacity` — the same semantic as the
151
+ other personalization background rows, so lowering it fades the image over the themed panel
152
+ instead of dimming it; **Solid color** takes a hex colour and derives a light/dark ink palette so
153
+ the cards, chart and tooltips stay readable (the palette also overrides the tooltip surface).
154
+ - A **Glass** switch appears for Custom image and Solid color, so glass composites with them:
155
+ on an image it blurs the image itself (frosted photo), on a solid it makes the colour
156
+ translucent over a blurred page. A **Glass strength** slider (0–48 px, default 22 px) sets the
157
+ blur radius of the panel. The overlay behind the modal is deliberately never blurred.
158
+ - **Clear** wipes the image, the URL field and the file picker in one go. The image row shows a
159
+ **thumbnail** plus the live save state (Saving… / Saved on this device / `error: …`), and
160
+ failures are logged with a `[consumption-badge]` tag instead of failing silently. The picker is a
161
+ button (Choose image… / Change image…), not a raw file input, so saving never leaves a
162
+ misleading "no file chosen" label behind.
163
+ - Local images are **stored on this machine with no size limit**: the picked file is written as a
164
+ Blob into IndexedDB (`dsh-consumption-assets` → `images`), and the preference only keeps a small
165
+ key — so a 20 MB wallpaper no longer blows the `localStorage` quota. On startup the blob is read
166
+ back into a `blob:` URL for the modal; **Clear** deletes the stored record as well.
167
+ If IndexedDB is unavailable the file is read as a data URL instead, and the row says so.
168
+ - The choice is a local preference (`dsh-consumption:modal-style`) and is applied to the open
169
+ modal immediately. Nothing about the conversation or sidebar backgrounds is touched.
170
+ - **Update notice** (same shape as `dsh-plugin-personalization`'s): ~2.5 s after the page loads the
171
+ plugin asks `https://registry.npmjs.org/dsh-consumption-badge/latest` for the newest version
172
+ (24 h cache, 5 s timeout, fails silently). If the installed version is older — and that version
173
+ was not ignored — a non-blocking card appears in the bottom-right corner with
174
+ **current → latest**, the exact `dsh plugin --profile web update dsh-consumption-badge` command,
175
+ and three actions: **Copy update command**, **Ignore this version** (remembered on this machine)
176
+ and **Remind me later** (this page load only). It never blocks the conversation and never covers
177
+ controls.
178
+
179
+ Everything is an **estimate** for reference only — this plugin is not a billing system of record.
180
+
181
+ ), modal cards and both charts' right axis, the settings cards and footer totals, and the account balance (when the balance API reports CNY) |
182
+ | Rates | 1 CNY = n target currency, cached **6 h**. Primary source `open.er-api.com`, fallback `@fawazahmed0/currency-api` via jsDelivr — fetched through the host route `/api/consumption/rates`, and **directly from the browser** when that route is unavailable (both endpoints send `access-control-allow-origin: *`) |
183
+ | Offline | if no live rate can be fetched, a built-in fallback table is used and the settings hint says so |
184
+ | Preference | `localStorage` key `dsh-consumption:currency`; the settings hint shows the active rate, its source and a timestamp |
185
+ | Unchanged | the pricing table, peak/off-peak rules and every stored record stay in CNY — switching currency never rewrites data |
186
+
187
+ ```
188
+ costCNY = (hit/1e6)*hit + (miss/1e6)*miss + (out/1e6)*out [×2 at peak]
189
+ shown = costCNY × rate[currency]
190
+ ```
191
+
74
192
  ## Localization
75
193
 
76
194
  The plugin ships **Chinese and English** copy and follows the DSH shell language live — no reload,
@@ -191,8 +309,9 @@ The host half registers three exact, read-only routes on the DSH web server:
191
309
 
192
310
  | Route | Returns |
193
311
  | --- | --- |
194
- | `GET /api/consumption/turns?sessionId=<id>` | per-turn `{turn, hit, miss, out, time, model}` (log-length cached) |
312
+ | `GET /api/consumption/turns?sessionId=<id>` | per-turn `{turn, hit, miss, out, time, model}` **plus** per-message `{id, turn, step, hit, miss, out, time, model}` (the `id` is the finalized message id the badge receives as `messageId`); log-length cached |
195
313
  | `GET /api/consumption/account` | current provider/model + whether the key is configured |
314
+ | `GET /api/consumption/rates[?force=1]` | `{ok, base:"CNY", rates:{USD:…}, source, fetchedAt, stale}` — 16 curated currencies, 6 h cache; `stale:true` means the built-in fallback table |
196
315
  | `GET /api/consumption/balance[?force=1]` | `{ok, available, currency, total, granted, toppedUp}`; `is_available:false` renders a red "insufficient balance" |
197
316
 
198
317
  Failures are `{ok:false, code, detail, error}`: `code` is language-neutral
@@ -222,7 +341,7 @@ Install the DSH plugin "dsh-consumption-badge" into my web profile and prove it
222
341
  `{"ok":true,...}`. Verify the client half:
223
342
  `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:<port>/plugins/dsh-consumption-badge/client.js`
224
343
  must return `200`.
225
- 5. Open any session and confirm the `⛽ <tokens> | ≈¥<cost> | Peak|Off-peak` pill in the assistant
344
+ 5. Open any session and confirm the `⛽ <tokens> | ≈¥<cost> | Peak|Off-peak | Balance = ¥<total>` pill in the assistant
226
345
  action row; click it and confirm the modal shows the four cards, the dual-axis chart, the
227
346
  balance, and that Esc closes it.
228
347
  6. Keep Settings → Usage open, then switch the shell language between Chinese and English
@@ -255,7 +374,7 @@ plugin by "running" it — a session-scoped dynamic plugin always dies with the
255
374
  - Do not hard-code user-visible English or Chinese in new JSX: call `tt('…')` with the Chinese copy
256
375
  as the key and add the English entry to `DICT_EN`. `check.mjs` fails if any `tt()` key has no
257
376
  English translation or if a translation still contains Chinese.
258
- - Self-check without booting DSH: `node check.mjs` (224 assertions over the route contract, the
377
+ - Self-check without booting DSH: `node check.mjs` (273 assertions over the route contract, the
259
378
  client bundle contract, the pricing/aggregation maths and the bilingual copy). It lives outside
260
379
  the published `files` list.
261
380
 
package/lib/client.js CHANGED
@@ -95,6 +95,152 @@ window.__ModuleLoader__.load({
95
95
  if (n < 100) return n.toFixed(2)
96
96
  return n.toFixed(1)
97
97
  }
98
+
99
+ /* ===================== 显示币种(报价表始终是人民币) ===================== */
100
+ const LS_CURRENCY = 'dsh-consumption:currency'
101
+ const RATES_TTL_MS = 6 * 60 * 60 * 1000
102
+ /** 可选币种 + 符号;顺序与 Host 的 CURRENCY_CODES 一致(CNY 永远是第一项)。 */
103
+ const CURRENCIES = [
104
+ { code: 'CNY', symbol: '¥' },
105
+ { code: 'USD', symbol: '$' },
106
+ { code: 'EUR', symbol: '€' },
107
+ { code: 'GBP', symbol: '£' },
108
+ { code: 'JPY', symbol: 'JP¥' },
109
+ { code: 'HKD', symbol: 'HK$' },
110
+ { code: 'TWD', symbol: 'NT$' },
111
+ { code: 'KRW', symbol: '₩' },
112
+ { code: 'SGD', symbol: 'S$' },
113
+ { code: 'AUD', symbol: 'A$' },
114
+ { code: 'CAD', symbol: 'C$' },
115
+ { code: 'CHF', symbol: 'CHF ' },
116
+ { code: 'INR', symbol: '₹' },
117
+ { code: 'RUB', symbol: '₽' },
118
+ { code: 'BRL', symbol: 'R$' },
119
+ { code: 'THB', symbol: '฿' },
120
+ { code: 'MXN', symbol: 'MX$' },
121
+ ]
122
+
123
+ function currencyMeta(code) {
124
+ for (let i = 0; i < CURRENCIES.length; i += 1) {
125
+ if (CURRENCIES[i].code === code) return CURRENCIES[i]
126
+ }
127
+ return null
128
+ }
129
+ function symbolOf(code) {
130
+ const meta = currencyMeta(code)
131
+ return meta !== null ? meta.symbol : String(code) + ' '
132
+ }
133
+ /** 币种全名:优先用 Intl.DisplayNames(跟随系统语言),取不到就用代码。 */
134
+ function currencyName(code) {
135
+ try {
136
+ if (typeof Intl === 'object' && typeof Intl.DisplayNames === 'function') {
137
+ const names = new Intl.DisplayNames([localeTag()], { type: 'currency' })
138
+ const name = names.of(code)
139
+ if (typeof name === 'string' && name && name !== code) return name
140
+ }
141
+ } catch (e) { /* 回落代码 */ }
142
+ return code
143
+ }
144
+ /** 当前偏好币种(读不到/非法一律人民币)。 */
145
+ function readCurrency() {
146
+ const raw = lsGet(LS_CURRENCY)
147
+ return currencyMeta(raw) !== null ? raw : 'CNY'
148
+ }
149
+ /**
150
+ * 把人民币金额换算成当前偏好币种。
151
+ * @returns {{value: number, code: string, rate: number, converted: boolean}}
152
+ * `converted: false` 表示汇率还没拿到或该币种没有汇率 → 保持人民币。
153
+ */
154
+ function convertCny(value) {
155
+ const n = Number(value) || 0
156
+ // `store` 是模块级常量,渲染期一定已初始化;仍然兜一层,避免任何早期调用撞 TDZ。
157
+ let st = null
158
+ try {
159
+ st = store.state
160
+ } catch (e) {
161
+ st = null
162
+ }
163
+ const code = st !== null && typeof st.currency === 'string' ? st.currency : 'CNY'
164
+ if (code === 'CNY') return { value: n, code: 'CNY', rate: 1, converted: false }
165
+ const payload = st !== null ? st.rates : null
166
+ const table = payload && payload.rates && typeof payload.rates === 'object' ? payload.rates : null
167
+ const rate = table !== null && typeof table[code] === 'number' ? table[code] : null
168
+ if (rate === null || !(rate > 0)) return { value: n, code: 'CNY', rate: 1, converted: false }
169
+ return { value: n * rate, code: code, rate: rate, converted: true }
170
+ }
171
+ /** 金额(按偏好币种换算 + 前缀符号)。 */
172
+ function money(value) {
173
+ const r = convertCny(value)
174
+ return symbolOf(r.code) + fmtYuan(r.value)
175
+ }
176
+ /** 估算金额前缀 `≈`。 */
177
+ function moneyApprox(value) {
178
+ return '≈' + money(value)
179
+ }
180
+ /** 账户余额:原生币种是 CNY 就走换算,其它币种按原样显示。 */
181
+ function moneyBalance(amount, nativeCode) {
182
+ const src = typeof nativeCode === 'string' && nativeCode ? nativeCode : 'CNY'
183
+ if (src !== 'CNY') return symbolOf(src) + fmtYuan(Number(amount) || 0)
184
+ return money(amount)
185
+ }
186
+ /**
187
+ * 浏览器直连的汇率源(两个都带 `access-control-allow-origin: *`)。
188
+ * 用来兜住「Host 半边还没重启、/rates 404」以及 Host 侧出网被挡的情况。
189
+ */
190
+ const RATE_ENDPOINTS = [
191
+ { url: 'https://open.er-api.com/v6/latest/CNY', source: 'open.er-api.com' },
192
+ { url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json', source: 'currency-api' },
193
+ ]
194
+
195
+ /** 把两种公开汇率的响应归一成 `{ok, base, rates, source, fetchedAt}`。 */
196
+ function parseRatesPayload(json, source) {
197
+ if (!json || typeof json !== 'object') return null
198
+ const raw = json.rates && typeof json.rates === 'object' ? json.rates : (json.cny && typeof json.cny === 'object' ? json.cny : null)
199
+ if (raw === null) return null
200
+ const rates = {}
201
+ let found = 0
202
+ for (let i = 0; i < CURRENCIES.length; i += 1) {
203
+ const code = CURRENCIES[i].code
204
+ const v = typeof raw[code] === 'number' ? raw[code] : raw[code.toLowerCase()]
205
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
206
+ rates[code] = v
207
+ found += 1
208
+ }
209
+ }
210
+ if (found === 0) return null
211
+ const stamp = typeof json.time_last_update_unix === 'number'
212
+ ? json.time_last_update_unix * 1000
213
+ : (typeof json.date === 'string' ? Date.parse(json.date) : 0)
214
+ return {
215
+ ok: true,
216
+ base: 'CNY',
217
+ rates: rates,
218
+ source: source,
219
+ fetchedAt: Number.isFinite(stamp) && stamp > 0 ? stamp : 0,
220
+ stale: false,
221
+ via: 'browser',
222
+ }
223
+ }
224
+
225
+ /** 依次尝试浏览器直连的汇率源;全失败返回 null。 */
226
+ function fetchRatesDirect() {
227
+ if (typeof fetch !== 'function') return Promise.resolve(null)
228
+ let chain = Promise.resolve(null)
229
+ for (let i = 0; i < RATE_ENDPOINTS.length; i += 1) {
230
+ const endpoint = RATE_ENDPOINTS[i]
231
+ chain = chain.then(function (found) {
232
+ if (found !== null) return found
233
+ return fetch(endpoint.url, { headers: { accept: 'application/json' } })
234
+ .then(function (res) {
235
+ if (!res || res.ok === false) return null
236
+ return res.json()
237
+ })
238
+ .then(function (json) { return parseRatesPayload(json, endpoint.source) })
239
+ .catch(function () { return null })
240
+ })
241
+ }
242
+ return chain
243
+ }
98
244
  function bucketOfTurns(turns) {
99
245
  const out = { hit: 0, miss: 0, out: 0 }
100
246
  for (let i = 0; i < turns.length; i += 1) {
@@ -138,6 +284,17 @@ window.__ModuleLoader__.load({
138
284
  '账户余额': 'Account balance',
139
285
  '逐轮消耗趋势': 'Per-turn usage trend',
140
286
  '命中 {hit} · 未命中 {miss} · 输出 {out}': 'hit {hit} · miss {miss} · output {out}',
287
+ '本轮消耗(点击查看本会话明细)': 'This turn’s usage (click for session details)',
288
+ '货币': 'Currency',
289
+ '金额按人民币报价折算显示': 'Amounts are priced in CNY and converted for display',
290
+ '当前以人民币显示(可切换成其它币种)': 'Showing CNY — pick another currency to convert',
291
+ '1 CNY = {rate} {code}': '1 CNY = {rate} {code}',
292
+ '汇率来源 {source}': 'rates from {source}',
293
+ '内置兜底汇率': 'built-in fallback',
294
+ '联网失败时使用内置汇率': 'built-in rates used because the lookup failed',
295
+ '正在获取汇率…': 'Fetching exchange rates…',
296
+ '汇率获取失败,暂时按人民币显示:{msg}': 'Could not fetch exchange rates, showing CNY: {msg}',
297
+ '余额 = {amount}': 'Balance = {amount}',
141
298
  '{id}(按 flash 估算)': '{id} (priced as flash)',
142
299
  ' · 峰时 ×2': ' · peak ×2',
143
300
  ' · 谷时': ' · off-peak ×1',
@@ -168,8 +325,8 @@ window.__ModuleLoader__.load({
168
325
  '余额接口不可用': 'Balance API unavailable',
169
326
  '余额不足': 'Insufficient balance',
170
327
  '请前往 DeepSeek 开放平台充值': 'Top up at the DeepSeek open platform',
171
- '赠送 ¥{v}': 'granted ¥{v}',
172
- '充值 ¥{v}': 'topped up ¥{v}',
328
+ '赠送 {amount}': 'granted {amount}',
329
+ '充值 {amount}': 'topped up {amount}',
173
330
  'DeepSeek 账户余额': 'DeepSeek account balance',
174
331
  '空响应': 'Empty response',
175
332
  '余额获取失败': 'Balance request failed',
@@ -282,6 +439,15 @@ window.__ModuleLoader__.load({
282
439
  * Bind the shell's locale service when the composition provides one.
283
440
  * Optional dependency on purpose: without the service every string falls
284
441
  * back to its Chinese key, so the plugin still works standalone.
442
+ *
443
+ * The dictionary namespace is `consumption-badge`. A **hot reload inside an
444
+ * open page** mounts a second fiber while the previous one's registration may
445
+ * still be alive (the shell re-imports the bundle); the locale service rejects
446
+ * a duplicate `(ns, locale)` pair, and the old dictionary would then stay bound
447
+ * forever — new keys added in a later build would render as their raw Chinese
448
+ * key. So: on a duplicate we register under a suffixed namespace instead of
449
+ * giving up, and we hand the disposer to `ctx.effect` so a clean unload frees
450
+ * the plain namespace again.
285
451
  * @param ctx client cordis context.
286
452
  */
287
453
  function attachLocale(ctx) {
@@ -296,15 +462,29 @@ window.__ModuleLoader__.load({
296
462
  service = null
297
463
  }
298
464
  if (service === null || typeof service.register !== 'function' || typeof service.bind !== 'function') return
465
+ // Only the `en` side is registered: the key already is the `zh` copy, and
466
+ // the locale service resolves a miss to the key itself.
467
+ let ns = LOCALE_NS
468
+ let dispose = null
299
469
  try {
300
- // Only the `en` side is registered: the key already is the `zh` copy, and
301
- // the locale service resolves a miss to the key itself.
302
- service.register(LOCALE_NS, 'en', DICT_EN)
470
+ dispose = service.register(ns, 'en', DICT_EN)
303
471
  } catch (e) {
304
- // Already registered (hot re-mount of the same fiber) — binding is enough.
472
+ ns = LOCALE_NS + '#' + BUILD_TAG + '-' + Math.random().toString(36).slice(2, 7)
473
+ try {
474
+ dispose = service.register(ns, 'en', DICT_EN)
475
+ console.warn('[consumption-badge] previous dictionary still registered; bound a private locale namespace ' + ns)
476
+ } catch (e2) {
477
+ console.warn('[consumption-badge] locale dictionary registration failed: ' + msgOf(e2))
478
+ return
479
+ }
305
480
  }
306
- boundTranslate = service.bind(LOCALE_NS)
481
+ boundTranslate = service.bind(ns)
307
482
  localeService = service
483
+ if (typeof dispose === 'function' && ctx && typeof ctx.effect === 'function') {
484
+ try {
485
+ ctx.effect(function () { return dispose })
486
+ } catch (e) { /* effects unavailable (tests): the dictionary simply stays */ }
487
+ }
308
488
  }
309
489
 
310
490
  /**
@@ -340,6 +520,20 @@ window.__ModuleLoader__.load({
340
520
  }
341
521
  }
342
522
 
523
+ /** Active locale id ('zh' when the service is absent). */
524
+ function activeLocaleId() {
525
+ try {
526
+ const snapshot = localeService !== null && typeof localeService.getLocale === 'function' ? localeService.getLocale() : null
527
+ if (snapshot && typeof snapshot.active === 'string' && snapshot.active) return snapshot.active
528
+ } catch (e) { /* fall through */ }
529
+ return 'zh'
530
+ }
531
+
532
+ /** BCP-47 tag for Intl: only zh/en are shipped. */
533
+ function localeTag() {
534
+ return activeLocaleId() === 'en' ? 'en' : 'zh-CN'
535
+ }
536
+
343
537
  /* Host error codes (lib/index.js) → the Chinese key tt() translates. */
344
538
  const HOST_ERROR_KEYS = {
345
539
  'no-credentials': 'credentials 服务不可用',
@@ -454,10 +648,10 @@ window.__ModuleLoader__.load({
454
648
 
455
649
  /* ================= 消耗弹窗背景样式(与个性化插件联动) ================= */
456
650
  const MODAL_STYLE_KEY = 'dsh-consumption:modal-style'
457
- const BUILD_TAG = 'dcb-1.2.2'
651
+ const BUILD_TAG = 'dcb-1.3.1'
458
652
  // 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
459
653
  // 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
460
- const PLUGIN_VERSION = '1.2.2'
654
+ const PLUGIN_VERSION = '1.3.1'
461
655
  const PKG_NAME = 'dsh-consumption-badge'
462
656
  const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/' + PKG_NAME + '/latest'
463
657
  const UPDATE_COMMAND = 'dsh plugin --profile web update ' + PKG_NAME
@@ -830,6 +1024,11 @@ window.__ModuleLoader__.load({
830
1024
  version: 0,
831
1025
  focusPending: false,
832
1026
  turns: [],
1027
+ // 逐条 assistant 消息的用量(messageId → 记录):只用来把胶囊定位到「哪一轮」。
1028
+ messages: [],
1029
+ messageById: new Map(),
1030
+ // turn 号 → /turns 里的折叠记录(胶囊显示的是这一轮的合计)。
1031
+ turnByNumber: new Map(),
833
1032
  turnsState: 'idle',
834
1033
  turnsError: '',
835
1034
  // Error copy is stored language-neutral: the kind names the sentence and
@@ -841,6 +1040,11 @@ window.__ModuleLoader__.load({
841
1040
  accountState: 'idle',
842
1041
  integrations: null,
843
1042
  modalStyle: readModalStyle(),
1043
+ // 显示币种(本地偏好)+ 汇率(Host 路由,6h 缓存):金额展示都走 money()。
1044
+ currency: readCurrency(),
1045
+ rates: null,
1046
+ ratesState: 'idle',
1047
+ ratesError: '',
844
1048
  // 已解析的弹窗背景图地址:URL 直接用,本地图片是 IndexedDB 里的 blob: 地址。
845
1049
  modalImageUrl: '',
846
1050
  // 图片保存过程的可观测状态:'' | 'saving' | 'saved' | 以 'error:' 开头的失败原因
@@ -856,11 +1060,13 @@ window.__ModuleLoader__.load({
856
1060
  balanceErrorCode: '',
857
1061
  balanceErrorFallback: '',
858
1062
  balanceAt: 0,
1063
+ ratesAt: 0,
859
1064
  }
860
1065
  const listeners = new Set()
861
1066
  let turnsInFlight = null
862
1067
  let accountInFlight = null
863
1068
  let balanceInFlight = null
1069
+ let ratesInFlight = null
864
1070
 
865
1071
  function emit() {
866
1072
  state.version += 1
@@ -899,6 +1105,9 @@ window.__ModuleLoader__.load({
899
1105
  if (state.sessionId !== sessionId) {
900
1106
  state.sessionId = sessionId
901
1107
  state.turns = []
1108
+ state.messages = []
1109
+ state.messageById = new Map()
1110
+ state.turnByNumber = new Map()
902
1111
  state.turnsState = 'idle'
903
1112
  state.turnsError = ''
904
1113
  }
@@ -921,7 +1130,33 @@ window.__ModuleLoader__.load({
921
1130
  cached: false,
922
1131
  })
923
1132
  }
1133
+ // 逐条消息:仅当 Host 回了 messages(新增字段)时才覆盖,否则保持空表 ⇒ 徽章回落本会话口径。
1134
+ const rawMessages = res && Array.isArray(res.messages) ? res.messages : []
1135
+ const messages = []
1136
+ const byId = new Map()
1137
+ for (let i = 0; i < rawMessages.length; i += 1) {
1138
+ const m = rawMessages[i]
1139
+ if (!m || typeof m !== 'object') continue
1140
+ const rec = {
1141
+ id: typeof m.id === 'string' ? m.id : '',
1142
+ turn: num(m.turn),
1143
+ step: num(m.step),
1144
+ hit: num(m.hit),
1145
+ miss: num(m.miss),
1146
+ out: num(m.out),
1147
+ time: num(m.time),
1148
+ model: typeof m.model === 'string' ? m.model : '',
1149
+ }
1150
+ messages.push(rec)
1151
+ if (rec.id !== '') byId.set(rec.id, rec)
1152
+ }
1153
+ state.messages = messages
1154
+ state.messageById = byId
924
1155
  state.turns = restoreTurns(sessionId, normalized)
1156
+ // 索引必须和 state.turns 用同一份数据(restoreTurns 会补上历史窗口外的轮次)。
1157
+ const turnByNumber = new Map()
1158
+ for (let i = 0; i < state.turns.length; i += 1) turnByNumber.set(state.turns[i].turn, state.turns[i])
1159
+ state.turnByNumber = turnByNumber
925
1160
  state.turnsState = res && res.ok === false ? 'error' : 'ready'
926
1161
  if (res && res.ok === false) {
927
1162
  state.turnsErrorKind = 'host'
@@ -1008,6 +1243,63 @@ window.__ModuleLoader__.load({
1008
1243
  function bump() {
1009
1244
  emit()
1010
1245
  }
1246
+ /**
1247
+ * 取汇率(Host 端 6h 缓存;`force` 强制刷新 Host 缓存)。
1248
+ * 只要当前币种不是人民币就需要它,取不到时金额回落为人民币显示。
1249
+ */
1250
+ function ensureRates(force) {
1251
+ const now = Date.now()
1252
+ const fresh = state.rates && state.rates.ok && now - (state.ratesAt || 0) < RATES_TTL_MS
1253
+ if (!force && fresh) return Promise.resolve(state.rates)
1254
+ if (ratesInFlight) return ratesInFlight
1255
+ state.ratesState = 'loading'
1256
+ emit()
1257
+ ratesInFlight = Promise.resolve()
1258
+ .then(function () { return fetchJson(API + '/rates' + (force ? '?force=1' : '')) })
1259
+ .catch(function () { return null })
1260
+ .then(function (res) {
1261
+ if (res && typeof res === 'object' && res.ok !== false && res.rates && typeof res.rates === 'object') {
1262
+ return Object.assign({ via: 'host' }, res)
1263
+ }
1264
+ // Host 路由还没就绪(例如还没重启)或出网被挡:浏览器直连公开汇率源。
1265
+ return fetchRatesDirect()
1266
+ })
1267
+ .then(function (res) {
1268
+ if (res && typeof res === 'object' && res.ok !== false && res.rates && typeof res.rates === 'object') {
1269
+ state.rates = res
1270
+ state.ratesAt = Date.now()
1271
+ state.ratesState = 'ready'
1272
+ state.ratesError = ''
1273
+ } else {
1274
+ state.rates = null
1275
+ state.ratesState = 'error'
1276
+ state.ratesError = res && typeof res.error === 'string' ? res.error : 'rates unavailable'
1277
+ }
1278
+ emit()
1279
+ })
1280
+ .catch(function (e) {
1281
+ state.rates = null
1282
+ state.ratesState = 'error'
1283
+ state.ratesError = msgOf(e)
1284
+ emit()
1285
+ })
1286
+ .then(function () { ratesInFlight = null })
1287
+ return ratesInFlight
1288
+ }
1289
+ /** 切换显示币种:落本地偏好 → 需要汇率时顺手取一次 → 通知所有订阅者(即时重绘)。 */
1290
+ function setCurrency(code) {
1291
+ const next = currencyMeta(code) !== null ? code : 'CNY'
1292
+ if (state.currency === next) return
1293
+ state.currency = next
1294
+ lsSet(LS_CURRENCY, next)
1295
+ emit()
1296
+ if (next !== 'CNY') ensureRates(false)
1297
+ }
1298
+ /** 强制刷新汇率(设置页的「刷新汇率」按钮)。 */
1299
+ function refreshRates() {
1300
+ state.rates = null
1301
+ return ensureRates(true)
1302
+ }
1011
1303
  /** 更新弹窗背景配置:合并 → 落本地偏好 → 通知所有订阅者(弹层即时重绘)。 */
1012
1304
  function setModalStyle(patch) {
1013
1305
  const prev = state.modalStyle
@@ -1167,6 +1459,9 @@ window.__ModuleLoader__.load({
1167
1459
  ensureAccount: ensureAccount,
1168
1460
  ensureTurns: ensureTurns,
1169
1461
  ensureBalance: ensureBalance,
1462
+ ensureRates: ensureRates,
1463
+ setCurrency: setCurrency,
1464
+ refreshRates: refreshRates,
1170
1465
  openPanel: openPanel,
1171
1466
  closePanel: closePanel,
1172
1467
  setModalStyle: setModalStyle,
@@ -1254,7 +1549,7 @@ window.__ModuleLoader__.load({
1254
1549
  const y = CHART_PAD.t + innerH * ratio
1255
1550
  grid.push(h('line', { key: 'g' + g, x1: CHART_PAD.l, x2: CHART_PAD.l + innerW, y1: y, y2: y, className: 'dcb-grid-line' }))
1256
1551
  leftLabels.push(h('text', { key: 'lt' + g, x: CHART_PAD.l - 8, y: y + 3.5, textAnchor: 'end', className: 'dcb-axis-label' }, fmtTokens(topToken * (1 - ratio))))
1257
- rightLabels.push(h('text', { key: 'rt' + g, x: CHART_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, '¥' + fmtYuan(topCost * (1 - ratio))))
1552
+ rightLabels.push(h('text', { key: 'rt' + g, x: CHART_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, money(topCost * (1 - ratio))))
1258
1553
  }
1259
1554
  const step = n <= 8 ? 1 : Math.ceil(n / 8)
1260
1555
  const xLabels = []
@@ -1294,7 +1589,7 @@ window.__ModuleLoader__.load({
1294
1589
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入未命中')), h('b', null, fmtTokens(hovered.miss))),
1295
1590
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输出')), h('b', null, fmtTokens(hovered.out))),
1296
1591
  h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('本轮合计')), h('b', null, fmtTokens(hovered.tokens))),
1297
- h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('估算金额')), h('b', null, '≈¥' + fmtYuan(hovered.cost))))
1592
+ h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('估算金额')), h('b', null, moneyApprox(hovered.cost))))
1298
1593
  : null
1299
1594
 
1300
1595
  return h('div', { className: 'dcb-chart-wrap' },
@@ -1357,6 +1652,26 @@ window.__ModuleLoader__.load({
1357
1652
  props.hint ? h('div', { className: 'dcb-card-hint' }, props.hint) : null)
1358
1653
  }
1359
1654
 
1655
+ /**
1656
+ * 从 DOM 里读出「自己在第几轮」:外壳把 assistant-actions 这个槽位渲染在
1657
+ * `<div data-turn-tail="<turn>">` 里面。这是给「Host 还没重启、没回 messageId→turn 映射」
1658
+ * 时用的兜底通道 —— 有 Host 映射时优先用 Host 的。
1659
+ * @param el 胶囊自身的 DOM 节点。
1660
+ * @returns 轮次号,取不到返回 undefined。
1661
+ */
1662
+ function turnFromElement(el) {
1663
+ if (!el || typeof el.closest !== 'function') return undefined
1664
+ let node = null
1665
+ try {
1666
+ node = el.closest('[data-turn-tail]')
1667
+ } catch (e) {
1668
+ node = null
1669
+ }
1670
+ if (!node || typeof node.getAttribute !== 'function') return undefined
1671
+ const n = Number(node.getAttribute('data-turn-tail'))
1672
+ return Number.isFinite(n) && n > 0 ? n : undefined
1673
+ }
1674
+
1360
1675
  function ConsumptionBadge(props) {
1361
1676
  const state = store.state
1362
1677
  const tick = React.useState(0)
@@ -1365,35 +1680,86 @@ window.__ModuleLoader__.load({
1365
1680
  return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
1366
1681
  }, [])
1367
1682
  const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : ''
1683
+ // 槽位 owner 传的是这条 finalized assistant 消息的身份;用它把自己的用量挑出来。
1684
+ const messageId = props && typeof props.messageId === 'string' ? props.messageId : ''
1685
+ const rootRef = React.useRef(null)
1686
+ const domTurnState = React.useState(undefined)
1687
+ const domTurn = domTurnState[0]
1688
+ const setDomTurn = domTurnState[1]
1689
+ React.useEffect(function () {
1690
+ // 每次渲染后都对一遍(槽位可能在 DOM 变动后被重新挂载)。
1691
+ const found = turnFromElement(rootRef.current)
1692
+ if (found !== undefined && found !== domTurn) setDomTurn(found)
1693
+ })
1368
1694
  const projection = props && typeof props.useProjection === 'function' ? props.useProjection('tokenUsage') : undefined
1369
1695
  React.useEffect(function () {
1370
1696
  store.ensureAccount()
1371
1697
  store.ensureTurns(sessionId)
1698
+ // 胶囊里也要显示余额:走同一份 60s 缓存,不额外压测接口。
1699
+ store.ensureBalance(false)
1700
+ // 非人民币显示时才需要汇率(Host 端 6h 缓存)。
1701
+ if (store.state.currency !== 'CNY') store.ensureRates(false)
1372
1702
  }, [sessionId, projection])
1373
1703
  React.useEffect(function () {
1374
- return everyTick(60000, function () { store.bump() })
1704
+ return everyTick(60000, function () {
1705
+ store.bump()
1706
+ store.ensureBalance(false)
1707
+ })
1375
1708
  }, [])
1376
1709
 
1710
+ // 槽位是按「轮」渲染的(TurnTailNodeView 用该轮的 finalNode.messageId 调 useSlots),
1711
+ // 所以这里要的是**这一轮**的合计,而不是某一步、更不是整个会话:
1712
+ // messageId → turn 号(Host 回映射)→ 该 turn 的折叠记录(/turns 本来就有)。
1713
+ const mapped = messageId !== '' ? state.messageById.get(messageId) : undefined
1714
+ // 优先 Host 的 messageId→turn;Host 旧版本没回 messages 时退回 DOM 上的轮次号。
1715
+ const turnNo = mapped !== undefined ? mapped.turn : domTurn
1716
+ const ownTurn = typeof turnNo === 'number' ? state.turnByNumber.get(turnNo) : undefined
1377
1717
  const live = projection && typeof projection === 'object'
1378
- const bucket = live
1379
- ? {
1380
- hit: num(projection.cacheReadTokens),
1381
- miss: num(projection.uncachedInputTokens) + num(projection.cacheWriteTokens),
1382
- out: num(projection.outputTokens),
1383
- }
1384
- : bucketOfTurns(state.turns)
1385
- // Without the live projection the first paint has no numbers yet: show a
1386
- // placeholder instead of a misleading zero.
1387
- const pending = !live && state.turns.length === 0 && state.turnsState !== 'ready'
1388
- const model = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
1389
- const peak = isPeakAt(Date.now())
1390
- const tokens = bucket.hit + bucket.miss + bucket.out
1391
- const cost = costOf(bucket, priceFor(model).table, peak)
1718
+ // 三级口径:本轮合计 → 本会话(投影 / 折叠后的逐轮)→ 还没有数据。
1719
+ let bucket = null
1720
+ let scope = 'turn'
1721
+ let bucketModel = ''
1722
+ let bucketTime = 0
1723
+ if (ownTurn !== undefined) {
1724
+ bucket = { hit: ownTurn.hit, miss: ownTurn.miss, out: ownTurn.out }
1725
+ bucketModel = ownTurn.model
1726
+ bucketTime = ownTurn.time
1727
+ } else if (live) {
1728
+ scope = 'session'
1729
+ bucket = {
1730
+ hit: num(projection.cacheReadTokens),
1731
+ miss: num(projection.uncachedInputTokens) + num(projection.cacheWriteTokens),
1732
+ out: num(projection.outputTokens),
1733
+ }
1734
+ } else if (state.turns.length > 0) {
1735
+ scope = 'session'
1736
+ bucket = bucketOfTurns(state.turns)
1737
+ }
1738
+ // 还没有任何数字时显示占位符,而不是误导性的 0。
1739
+ const pending = bucket === null && state.turnsState !== 'ready'
1740
+ const model = bucketModel
1741
+ || (state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL)
1742
+ const at = bucketTime > 0 ? bucketTime : Date.now()
1743
+ const peak = isPeakAt(at)
1744
+ const tokens = bucket === null ? 0 : bucket.hit + bucket.miss + bucket.out
1745
+ const cost = bucket === null ? 0 : costOf(bucket, priceFor(model).table, peak)
1746
+
1747
+ // 账户余额:成功 → ¥金额;读取中 → …;失败/未配置 → —(原因放 title,不挤占胶囊宽度)。
1748
+ const balance = state.balance
1749
+ let balanceValue = '…'
1750
+ if (balance && balance.ok === true && typeof balance.total === 'number') {
1751
+ balanceValue = moneyBalance(balance.total, balance.currency)
1752
+ } else if (state.balanceState === 'ready' || state.balanceState === 'error') {
1753
+ balanceValue = '—'
1754
+ }
1755
+ const balanceTitle = balanceErrorText() || tt('账户余额')
1392
1756
 
1393
1757
  return h('button', {
1394
1758
  type: 'button',
1395
1759
  className: 'dcb-pill',
1396
- title: tt('本会话消耗(点击查看逐轮明细)'),
1760
+ ref: function (el) { rootRef.current = el || null },
1761
+ title: (scope === 'turn' ? tt('本轮消耗(点击查看本会话明细)') : tt('本会话消耗(点击查看逐轮明细)'))
1762
+ + ' · ' + balanceTitle + ': ' + balanceValue,
1397
1763
  onClick: function (event) {
1398
1764
  try {
1399
1765
  event.preventDefault()
@@ -1405,9 +1771,11 @@ window.__ModuleLoader__.load({
1405
1771
  h('span', { className: 'dcb-pill-glyph' }, '⛽'),
1406
1772
  h('span', null, pending ? '…' : fmtTokens(tokens)),
1407
1773
  h('span', { className: 'dcb-pill-sep' }, '|'),
1408
- h('span', null, pending ? '…' : '≈¥' + fmtYuan(cost)),
1774
+ h('span', null, pending ? '…' : moneyApprox(cost)),
1775
+ h('span', { className: 'dcb-pill-sep' }, '|'),
1776
+ h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(peak ? '峰' : '谷')),
1409
1777
  h('span', { className: 'dcb-pill-sep' }, '|'),
1410
- h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(peak ? '峰' : '谷')))
1778
+ h('span', { className: 'dcb-pill-balance' }, tt('余额 = {amount}', { amount: balanceValue })))
1411
1779
  }
1412
1780
 
1413
1781
  function ConsumptionPanel() {
@@ -1432,6 +1800,7 @@ window.__ModuleLoader__.load({
1432
1800
  if (!open) return undefined
1433
1801
  store.ensureTurns(state.sessionId)
1434
1802
  store.ensureBalance(false)
1803
+ if (store.state.currency !== 'CNY') store.ensureRates(false)
1435
1804
  return everyTick(60000, function () { store.ensureBalance(false) })
1436
1805
  }, [open, state.sessionId])
1437
1806
  if (!open) return null
@@ -1463,11 +1832,10 @@ window.__ModuleLoader__.load({
1463
1832
  balanceHint = tt('请前往 DeepSeek 开放平台充值')
1464
1833
  balanceClass = 'dcb-card-value dcb-bad'
1465
1834
  } else if (balance) {
1466
- const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
1467
- balanceValue = symbol + fmtYuan(balance.total)
1835
+ balanceValue = moneyBalance(balance.total, balance.currency)
1468
1836
  const parts = []
1469
- if (typeof balance.granted === 'number') parts.push(tt('赠送 ¥{v}', { v: fmtYuan(balance.granted) }))
1470
- if (typeof balance.toppedUp === 'number') parts.push(tt('充值 ¥{v}', { v: fmtYuan(balance.toppedUp) }))
1837
+ if (typeof balance.granted === 'number') parts.push(tt('赠送 {amount}', { amount: moneyBalance(balance.granted, balance.currency) }))
1838
+ if (typeof balance.toppedUp === 'number') parts.push(tt('充值 {amount}', { amount: moneyBalance(balance.toppedUp, balance.currency) }))
1471
1839
  balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
1472
1840
  }
1473
1841
 
@@ -1506,7 +1874,7 @@ window.__ModuleLoader__.load({
1506
1874
  h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: tt('Esc 关闭') }, '✕'))),
1507
1875
  h('div', { className: 'dcb-cards' },
1508
1876
  h(Card, { label: tt('累计 Token'), value: fmtTokens(tokens), hint: tt('命中 {hit} · 未命中 {miss} · 输出 {out}', { hit: fmtTokens(aggregated.hit), miss: fmtTokens(aggregated.miss), out: fmtTokens(aggregated.out) }) }),
1509
- h(Card, { label: tt('估算金额'), value: '≈¥' + fmtYuan(totalCost), hint: (price.known ? price.id : tt('{id}(按 flash 估算)', { id: price.id })) + tt(peak ? ' · 峰时 ×2' : ' · 谷时') }),
1877
+ h(Card, { label: tt('估算金额'), value: moneyApprox(totalCost), hint: (price.known ? price.id : tt('{id}(按 flash 估算)', { id: price.id })) + tt(peak ? ' · 峰时 ×2' : ' · 谷时') }),
1510
1878
  h(Card, { label: tt('当前时段'), value: tt(peak ? '高峰' : '空闲'), hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00') }),
1511
1879
  h('div', { className: 'dcb-card' },
1512
1880
  h('div', { className: 'dcb-card-label' }, tt('账户余额')),
@@ -1890,7 +2258,7 @@ window.__ModuleLoader__.load({
1890
2258
  const ratio = g / 4
1891
2259
  const y = HIST_PAD.t + innerH * ratio
1892
2260
  grid.push(h('line', { key: 'hg' + g, x1: HIST_PAD.l, x2: HIST_PAD.l + innerW, y1: y, y2: y, className: 'dcb-grid-line' }))
1893
- rightLabels.push(h('text', { key: 'hr' + g, x: HIST_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, '¥' + fmtYuan(topCost * (1 - ratio))))
2261
+ rightLabels.push(h('text', { key: 'hr' + g, x: HIST_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, money(topCost * (1 - ratio))))
1894
2262
  if (showTokens) {
1895
2263
  leftLabels.push(h('text', { key: 'hl' + g, x: HIST_PAD.l - 8, y: y + 3.5, textAnchor: 'end', className: 'dcb-axis-label' }, fmtTokens(topToken * (1 - ratio))))
1896
2264
  }
@@ -1935,10 +2303,10 @@ window.__ModuleLoader__.load({
1935
2303
  h('div', { className: 'dcb-tooltip-title' },
1936
2304
  hovered.label,
1937
2305
  h('span', { className: 'dcb-tooltip-model' }, tt('{n} 轮', { n: hovered.records }))),
1938
- h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('当日合计')), h('b', null, '≈¥' + fmtYuan(hovered.cost))),
2306
+ h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('当日合计')), h('b', null, moneyApprox(hovered.cost))),
1939
2307
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, 'Token'), h('b', null, fmtTokens(hovered.tokens))),
1940
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null, '≈¥' + fmtYuan(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
1941
- h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null, '≈¥' + fmtYuan(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
2308
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null, moneyApprox(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
2309
+ h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null, moneyApprox(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
1942
2310
  h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('模型')), h('b', null, hovered.models.length ? hovered.models.join(', ') : '—')))
1943
2311
  : null
1944
2312
 
@@ -2002,6 +2370,10 @@ window.__ModuleLoader__.load({
2002
2370
  store.ensureBalance(false)
2003
2371
  return everyTick(60000, function () { store.ensureBalance(false) })
2004
2372
  }, [])
2373
+ // 非人民币显示时才需要汇率(Host 端 6h 缓存,切币种时 setCurrency 已经会取一次)。
2374
+ React.useEffect(function () {
2375
+ if (store.state.currency !== 'CNY') store.ensureRates(false)
2376
+ }, [])
2005
2377
 
2006
2378
  // 工作区签名:id + 标题 + 归属会话(WorkspaceView.sessionIds 即归属账本)。
2007
2379
  const wsSig = props && typeof props.useWorkspaces === 'function'
@@ -2084,6 +2456,29 @@ window.__ModuleLoader__.load({
2084
2456
  }, [records, wsSig])
2085
2457
 
2086
2458
  const st = store.state
2459
+ // 显示币种 + 汇率提示(报价表恒为人民币,这里只是展示换算)。
2460
+ const currency = typeof st.currency === 'string' ? st.currency : 'CNY'
2461
+ const rates = st.rates
2462
+ const rateValue = rates && rates.rates && typeof rates.rates[currency] === 'number' ? rates.rates[currency] : null
2463
+ let rateNote = tt('金额按人民币报价折算显示')
2464
+ if (currency === 'CNY') {
2465
+ rateNote = tt('当前以人民币显示(可切换成其它币种)')
2466
+ } else if (rateValue !== null) {
2467
+ const when = rates && rates.fetchedAt ? new Date(rates.fetchedAt) : null
2468
+ const stamp = when !== null && !Number.isNaN(when.getTime())
2469
+ ? when.toLocaleString(localeTag())
2470
+ : ''
2471
+ rateNote = tt('1 CNY = {rate} {code}', { rate: String(Number(rateValue.toFixed(6))), code: currency })
2472
+ + ' · ' + tt('汇率来源 {source}', { source: rates.stale === true ? tt('内置兜底汇率') : String(rates.source || '') })
2473
+ + (stamp ? ' · ' + stamp : '')
2474
+ + (rates.stale === true ? ' · ' + tt('联网失败时使用内置汇率') : '')
2475
+ } else if (st.ratesState === 'loading') {
2476
+ rateNote = tt('正在获取汇率…')
2477
+ } else if (st.ratesState === 'error') {
2478
+ rateNote = tt('汇率获取失败,暂时按人民币显示:{msg}', { msg: String(st.ratesError || '') })
2479
+ } else {
2480
+ rateNote = tt('金额按人民币报价折算显示')
2481
+ }
2087
2482
  const accountModel = st.account && typeof st.account.model === 'string' && st.account.model ? st.account.model : DEFAULT_MODEL
2088
2483
  const accountPrice = priceFor(accountModel)
2089
2484
  const nowPeak = isPeakAt(Date.now())
@@ -2105,11 +2500,10 @@ window.__ModuleLoader__.load({
2105
2500
  balanceHint = tt('请前往 DeepSeek 开放平台充值')
2106
2501
  balanceClass = 'dcb-card-value dcb-bad'
2107
2502
  } else if (balance) {
2108
- const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
2109
- balanceValue = symbol + fmtYuan(balance.total)
2503
+ balanceValue = moneyBalance(balance.total, balance.currency)
2110
2504
  const parts = []
2111
- if (typeof balance.granted === 'number') parts.push(tt('赠送 ¥{v}', { v: fmtYuan(balance.granted) }))
2112
- if (typeof balance.toppedUp === 'number') parts.push(tt('充值 ¥{v}', { v: fmtYuan(balance.toppedUp) }))
2505
+ if (typeof balance.granted === 'number') parts.push(tt('赠送 {amount}', { amount: moneyBalance(balance.granted, balance.currency) }))
2506
+ if (typeof balance.toppedUp === 'number') parts.push(tt('充值 {amount}', { amount: moneyBalance(balance.toppedUp, balance.currency) }))
2113
2507
  balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
2114
2508
  }
2115
2509
 
@@ -2138,7 +2532,7 @@ window.__ModuleLoader__.load({
2138
2532
  }),
2139
2533
  h(Card, {
2140
2534
  label: tt('估算金额'),
2141
- value: allStats ? '≈¥' + fmtYuan(allStats.totalCost) : '—',
2535
+ value: allStats ? moneyApprox(allStats.totalCost) : '—',
2142
2536
  hint: (accountPrice.known ? accountPrice.id : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
2143
2537
  }),
2144
2538
  h(Card, {
@@ -2197,7 +2591,20 @@ window.__ModuleLoader__.load({
2197
2591
  : null)),
2198
2592
  h('div', { className: 'dcb-field' },
2199
2593
  h('span', { className: 'dcb-field-label' }, tt('数据')),
2200
- h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新')))),
2594
+ h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新'))),
2595
+ // 显示币种:报价表永远是人民币,这里只决定展示时换算成什么货币。
2596
+ h('div', { className: 'dcb-field' },
2597
+ h('label', { className: 'dcb-field-label', htmlFor: 'dcb-currency-select' }, tt('货币')),
2598
+ h('select', {
2599
+ id: 'dcb-currency-select',
2600
+ className: 'dcb-select',
2601
+ value: currency,
2602
+ onChange: function (event) { store.setCurrency(event.target.value) },
2603
+ },
2604
+ CURRENCIES.map(function (c) {
2605
+ return h('option', { key: c.code, value: c.code }, c.code + ' ' + c.symbol.trim() + ' · ' + currencyName(c.code))
2606
+ })))),
2607
+ h('div', { className: 'dcb-set-note dcb-rate-note' }, rateNote),
2201
2608
  h('div', { className: 'dcb-set-body' },
2202
2609
  h('div', { className: 'dcb-hist-legend' },
2203
2610
  h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
@@ -2211,11 +2618,11 @@ window.__ModuleLoader__.load({
2211
2618
  : h('div', { className: 'dcb-hist-empty' }, tt('该范围内暂无消耗记录'))),
2212
2619
  hasData
2213
2620
  ? h('div', { className: 'dcb-sum' },
2214
- h('span', null, tt('总消耗 '), h('b', null, '≈¥' + fmtYuan(stats.totalCost))),
2621
+ h('span', null, tt('总消耗 '), h('b', null, moneyApprox(stats.totalCost))),
2215
2622
  h('span', null, tt('总 Token '), h('b', null, fmtTokens(stats.totalTokens))),
2216
2623
  h('span', null, tt('记录 '), h('b', null, String(stats.count)), tt(' 条')),
2217
- h('span', null, tt('峰时 '), h('b', null, '≈¥' + fmtYuan(stats.peakCost))),
2218
- h('span', null, tt('谷时 '), h('b', null, '≈¥' + fmtYuan(stats.valleyCost))),
2624
+ h('span', null, tt('峰时 '), h('b', null, moneyApprox(stats.peakCost))),
2625
+ h('span', null, tt('谷时 '), h('b', null, moneyApprox(stats.valleyCost))),
2219
2626
  h('span', null, tt('粒度 '), h('b', null, tt(stats.granularity === 'month' ? '按月' : (stats.granularity === 'hour' ? '按小时' : '按日')))))
2220
2627
  : null,
2221
2628
  h('div', { className: 'dcb-hist-aside' },
@@ -2620,6 +3027,7 @@ window.__ModuleLoader__.load({
2620
3027
  .dcb-pill:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
2621
3028
  .dcb-pill-glyph{font-size:11px;line-height:1}
2622
3029
  .dcb-pill-sep{opacity:.35}
3030
+ .dcb-pill-balance{font-variant-numeric:tabular-nums;max-width:160px;overflow:hidden;text-overflow:ellipsis}
2623
3031
  .dcb-tag{font-weight:600}
2624
3032
  .dcb-tag.is-peak{color:var(--dsw-alias-state-error-primary)}
2625
3033
  .dcb-tag.is-valley{color:var(--dsw-alias-state-success-primary)}
@@ -2690,6 +3098,10 @@ window.__ModuleLoader__.load({
2690
3098
  .dcb-field-label{font-size:11px;color:var(--dsw-alias-label-secondary)}
2691
3099
  .dcb-select,.dcb-date{height:28px;padding:0 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;min-width:0}
2692
3100
  .dcb-select{min-width:132px;cursor:pointer}
3101
+ /* 币种下拉要放「代码 + 符号 + 全名」,给它多一点宽度 */
3102
+ #dcb-currency-select{min-width:196px;max-width:300px}
3103
+ /* 汇率说明行会显示汇率 / 来源 / 时间戳,允许换行 */
3104
+ .dcb-rate-note{white-space:normal;line-height:16px;margin:-6px 0 12px}
2693
3105
  .dcb-date{min-width:126px}
2694
3106
  .dcb-select:focus,.dcb-date:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
2695
3107
  .dcb-range{display:flex;align-items:center;gap:6px}
package/lib/index.js CHANGED
@@ -27,6 +27,41 @@ let balanceCache = { at: 0, value: null }
27
27
  /** dsh-plugin-personalization 是否同时安装(null = 尚未探测)。 */
28
28
  let personalizationProbe = null
29
29
 
30
+ /**
31
+ * 可选显示币种(报价表本身始终是人民币,浏览器半边只做展示换算)。
32
+ * 顺序即设置页下拉里的顺序。
33
+ */
34
+ const CURRENCY_CODES = ['USD', 'EUR', 'GBP', 'JPY', 'HKD', 'TWD', 'KRW', 'SGD', 'AUD', 'CAD', 'CHF', 'INR', 'RUB', 'BRL', 'THB', 'MXN']
35
+
36
+ /**
37
+ * 内置兜底汇率(1 CNY = n 目标币种),取自 2026-11-02 的 open.er-api.com。
38
+ * 只在取不到实时汇率(离线/被墙/接口改版)时使用,返回值里会标 `stale: true`。
39
+ */
40
+ const FALLBACK_RATES = {
41
+ USD: 0.150131,
42
+ EUR: 0.134567,
43
+ GBP: 0.112169,
44
+ JPY: 23.670366,
45
+ HKD: 1.177898,
46
+ TWD: 4.889976,
47
+ KRW: 200.722601,
48
+ SGD: 0.191923,
49
+ AUD: 0.213732,
50
+ CAD: 0.2098,
51
+ CHF: 0.123863,
52
+ INR: 14.290818,
53
+ RUB: 12.180268,
54
+ BRL: 0.807624,
55
+ THB: 5.054541,
56
+ MXN: 2.816108,
57
+ }
58
+ const RATES_TTL_MS = 6 * 60 * 60 * 1000
59
+ const RATES_SOURCES = [
60
+ 'https://open.er-api.com/v6/latest/CNY',
61
+ 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json',
62
+ ]
63
+ let ratesCache = { at: 0, value: null }
64
+
30
65
  /**
31
66
  * 探测 dsh-plugin-personalization 是否与本插件共存于同一 profile 的 node_modules:
32
67
  * 只 import 它那个空的 host 入口(不调用 apply),失败即视为未安装。
@@ -69,12 +104,15 @@ function sanitizeKey(value) {
69
104
 
70
105
  /**
71
106
  * 折叠会话日志:assistant/message 上的 usage 按 turn 归并,轮次上下文取当时生效的路由。
107
+ * 同一遍扫描里再按 `message.id` 留一份**逐条消息**记录 —— 徽章要显示「这条回复自己」的用量,
108
+ * 而 turn 会合并同轮多步(多步一问一答时两者不相等)。
72
109
  * @param events 会话事件数组(SessionEvent[])。
73
- * @returns 按 turn 升序的逐轮记录。
110
+ * @returns {{turns: object[], messages: object[]}} turns 按 turn 升序;messages 按日志顺序。
74
111
  */
75
- function foldTurns(events) {
112
+ function foldEvents(events) {
76
113
  const byTurn = new Map()
77
114
  const starts = new Map()
115
+ const messages = []
78
116
  let model = ''
79
117
  let provider = ''
80
118
  for (let i = 0; i < events.length; i += 1) {
@@ -119,6 +157,19 @@ function foldTurns(events) {
119
157
  rec.out += num(u.outputTokens)
120
158
  rec.steps += 1
121
159
  if (!rec.model && model) rec.model = model
160
+ // 逐条消息记录:id 来自 assistant/message 的 message.id(= 槽位给的 messageId)。
161
+ const message = d.message
162
+ const id = message && typeof message.id === 'string' ? message.id : ''
163
+ messages.push({
164
+ id: id,
165
+ turn: turn,
166
+ step: typeof d.step === 'number' ? d.step : 0,
167
+ hit: num(u.cacheReadTokens),
168
+ miss: num(u.inputTokens) + num(u.cacheWriteTokens),
169
+ out: num(u.outputTokens),
170
+ time: 0,
171
+ model: model || '',
172
+ })
122
173
  }
123
174
  const keys = []
124
175
  byTurn.forEach(function (_v, k) { keys.push(k) })
@@ -130,7 +181,12 @@ function foldTurns(events) {
130
181
  rec.time = typeof t0 === 'number' ? t0 : 0
131
182
  turns.push(rec)
132
183
  }
133
- return turns
184
+ // 时间统一按「该轮请求开始的时刻」归属峰谷(与 turns 同口径)。
185
+ for (let i = 0; i < messages.length; i += 1) {
186
+ const t0 = starts.get(messages[i].turn)
187
+ messages[i].time = typeof t0 === 'number' ? t0 : 0
188
+ }
189
+ return { turns: turns, messages: messages }
134
190
  }
135
191
 
136
192
  /** 归一化 /user/balance 响应。 */
@@ -290,18 +346,22 @@ export function apply(ctx) {
290
346
 
291
347
  /** 逐轮用量(带 length 版本缓存,日志没变就不重折)。 */
292
348
  async function handleTurns(sessionId) {
293
- if (!sessionId) return { ok: false, code: 'missing-session-id', error: 'missing sessionId', turns: [] }
349
+ if (!sessionId) return { ok: false, code: 'missing-session-id', error: 'missing sessionId', turns: [], messages: [] }
294
350
  const events = await readEvents(sessionId)
295
- if (events === null) return { ok: false, code: 'log-unavailable', error: 'session log unavailable', turns: [] }
351
+ if (events === null) return { ok: false, code: 'log-unavailable', error: 'session log unavailable', turns: [], messages: [] }
296
352
  const cached = turnCache.get(sessionId)
297
- if (cached && cached.length === events.length) return { ok: true, cached: true, turns: cached.turns }
298
- const turns = foldTurns(events)
353
+ if (cached && cached.length === events.length) {
354
+ return { ok: true, cached: true, turns: cached.turns, messages: cached.messages }
355
+ }
356
+ const folded = foldEvents(events)
357
+ const turns = folded.turns
358
+ const messages = folded.messages
299
359
  if (turnCache.size >= TURN_CACHE_LIMIT && !turnCache.has(sessionId)) {
300
360
  const firstKey = turnCache.keys().next()
301
361
  if (!firstKey.done) turnCache.delete(firstKey.value)
302
362
  }
303
- turnCache.set(sessionId, { length: events.length, turns: turns })
304
- return { ok: true, cached: false, turns: turns }
363
+ turnCache.set(sessionId, { length: events.length, turns: turns, messages: messages })
364
+ return { ok: true, cached: false, turns: turns, messages: messages }
305
365
  }
306
366
 
307
367
  /** 当前模型(计价用),读不到就让浏览器回退 flash。 */
@@ -338,6 +398,93 @@ export function apply(ctx) {
338
398
  return out
339
399
  }
340
400
 
401
+ /**
402
+ * 取一次实时汇率(1 CNY = n 目标币种)。只取 CURRENCY_CODES 里那几种,避免整包塞给浏览器。
403
+ * 通道与本插件的余额请求一致(curl),另用全局 fetch 作为第二通道;
404
+ * 两个源都失败时回内置兜底表并标 `stale: true`(界面会说明这是兜底汇率)。
405
+ * @returns {{rates: object, source: string, fetchedAt: number, stale: boolean, error: string}}
406
+ */
407
+ async function fetchRates() {
408
+ const subprocess = ctx.get('subprocess')
409
+ let lastError = ''
410
+ for (let i = 0; i < RATES_SOURCES.length; i += 1) {
411
+ const url = RATES_SOURCES[i]
412
+ let text = ''
413
+ if (subprocess && typeof subprocess.spawn === 'function') {
414
+ try {
415
+ const handle = subprocess.spawn({
416
+ argv: ['curl', '-sS', '--max-time', '12', url],
417
+ cwd: process.cwd(),
418
+ stdio: { stdin: { data: '' }, stdout: { maxBytes: 262144 }, stderr: { maxBytes: 4096 } },
419
+ graceMs: 3000,
420
+ })
421
+ await handle.done
422
+ const collected = handle.collected || {}
423
+ const outRead = collected.stdout ? collected.stdout.readFrom(0) : null
424
+ text = outRead && typeof outRead.text === 'string' ? outRead.text.trim() : ''
425
+ } catch (e) {
426
+ lastError = errText(e)
427
+ text = ''
428
+ }
429
+ }
430
+ if (!text && typeof fetch === 'function') {
431
+ try {
432
+ const response = await fetch(url)
433
+ if (response && response.ok !== false) text = String(await response.text()).trim()
434
+ } catch (e) {
435
+ lastError = errText(e)
436
+ text = ''
437
+ }
438
+ }
439
+ if (!text) continue
440
+ let json = null
441
+ try {
442
+ json = JSON.parse(text)
443
+ } catch (e) {
444
+ lastError = 'bad rates payload'
445
+ continue
446
+ }
447
+ if (!json || typeof json !== 'object') continue
448
+ const raw = json.rates && typeof json.rates === 'object'
449
+ ? json.rates
450
+ : (json.cny && typeof json.cny === 'object' ? json.cny : null)
451
+ if (!raw) continue
452
+ const rates = {}
453
+ let found = 0
454
+ for (let k = 0; k < CURRENCY_CODES.length; k += 1) {
455
+ const code = CURRENCY_CODES[k]
456
+ const v = typeof raw[code] === 'number' ? raw[code] : raw[code.toLowerCase()]
457
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
458
+ rates[code] = v
459
+ found += 1
460
+ }
461
+ }
462
+ if (found === 0) continue
463
+ const stamp = typeof json.time_last_update_unix === 'number'
464
+ ? json.time_last_update_unix * 1000
465
+ : (typeof json.date === 'string' ? Date.parse(json.date) : 0)
466
+ return {
467
+ rates: rates,
468
+ source: url.indexOf('er-api') >= 0 ? 'open.er-api.com' : 'currency-api',
469
+ fetchedAt: Number.isFinite(stamp) && stamp > 0 ? stamp : 0,
470
+ stale: false,
471
+ error: '',
472
+ }
473
+ }
474
+ return { rates: FALLBACK_RATES, source: 'builtin', fetchedAt: 0, stale: true, error: lastError || 'live rates unavailable' }
475
+ }
476
+
477
+ /** 汇率入口:进程内缓存 6h,`?force=1` 强制刷新。 */
478
+ async function handleRates(force) {
479
+ const now = Date.now()
480
+ if (!force && ratesCache.value && now - ratesCache.at < RATES_TTL_MS) {
481
+ return Object.assign({ ok: true, base: 'CNY', codes: CURRENCY_CODES, cached: true }, ratesCache.value)
482
+ }
483
+ const fetched = await fetchRates()
484
+ ratesCache = { at: Date.now(), value: fetched }
485
+ return Object.assign({ ok: true, base: 'CNY', codes: CURRENCY_CODES, cached: false }, fetched)
486
+ }
487
+
341
488
  disposers.push(ctx.webServer.register({
342
489
  kind: 'exact',
343
490
  path: ROUTE_BASE + '/turns',
@@ -364,6 +511,19 @@ export function apply(ctx) {
364
511
  },
365
512
  }))
366
513
 
514
+ disposers.push(ctx.webServer.register({
515
+ kind: 'exact',
516
+ path: ROUTE_BASE + '/rates',
517
+ handler: async function (req, res) {
518
+ try {
519
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
520
+ sendJson(res, 200, await handleRates(url.searchParams.get('force') === '1'))
521
+ } catch (e) {
522
+ sendJson(res, 500, { ok: false, error: errText(e) })
523
+ }
524
+ },
525
+ }))
526
+
367
527
  disposers.push(ctx.webServer.register({
368
528
  kind: 'exact',
369
529
  path: ROUTE_BASE + '/balance',
@@ -395,4 +555,4 @@ export function apply(ctx) {
395
555
  disposers.length = 0
396
556
  }
397
557
  })
398
- }
558
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-consumption-badge",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "DSH Web UI plugin: a per-message consumption badge (tokens | estimated cost | peak/valley) plus a session detail modal with cumulative tokens, estimated cost, Beijing-time peak/off-peak pricing, a per-turn dual-axis chart, and the DeepSeek account balance",
5
5
  "keywords": [
6
6
  "deepseek-harness",