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 +126 -7
- package/lib/client.js +459 -47
- package/lib/index.js +170 -10
- package/package.json +1 -1
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
|
|
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
|
-
⛽
|
|
9
|
+
⛽ 3.2k | ≈¥0.004 | Off-peak | Balance = ¥42.50
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
- **Collapsed pill** –
|
|
12
|
-
|
|
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
|
|
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` (
|
|
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
|
-
'赠送
|
|
172
|
-
'充值
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
651
|
+
const BUILD_TAG = 'dcb-1.3.1'
|
|
458
652
|
// 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
|
|
459
653
|
// 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
|
|
460
|
-
const PLUGIN_VERSION = '1.
|
|
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' },
|
|
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,
|
|
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 () {
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
:
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
|
|
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 ? '…' :
|
|
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:
|
|
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
|
-
|
|
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('赠送
|
|
1470
|
-
if (typeof balance.toppedUp === 'number') parts.push(tt('充值
|
|
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:
|
|
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' },
|
|
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,
|
|
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,
|
|
1941
|
-
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null,
|
|
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
|
-
|
|
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('赠送
|
|
2112
|
-
if (typeof balance.toppedUp === 'number') parts.push(tt('充值
|
|
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 ?
|
|
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,
|
|
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,
|
|
2218
|
-
h('span', null, tt('谷时 '), h('b', null,
|
|
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
|
|
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
|
-
|
|
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)
|
|
298
|
-
|
|
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.
|
|
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",
|