dsh-balance-widget 0.2.0 → 0.3.0

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.en.md CHANGED
@@ -2,14 +2,12 @@
2
2
 
3
3
  [中文](README.md) | **English**
4
4
 
5
- <p align="center">
6
- <img src="https://img.shields.io/npm/v/dsh-balance-widget?style=flat-square&label=npm" alt="npm">
7
- <img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
8
- <img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
9
- <img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
10
- <img src="https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square" alt="Node 24">
11
- <img src="https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square" alt="Zero deps">
12
- </p>
5
+ [![npm](https://img.shields.io/npm/v/dsh-balance-widget?style=flat-square&label=npm)](https://www.npmjs.com/package/dsh-balance-widget)
6
+ [![Stars](https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars)](https://github.com/LL-cmyk-so/dsh-balance-widget)
7
+ [![License](https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square)](https://github.com/LL-cmyk-so/dsh-balance-widget/blob/main/LICENSE)
8
+ [![Last commit](https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square)](https://github.com/LL-cmyk-so/dsh-balance-widget)
9
+ [![Node 24](https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square)](https://nodejs.org)
10
+ [![Zero deps](https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square)]()
13
11
 
14
12
  A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) Web GUI: a 💰 icon in the corner of the conversation session header. Click it to see your DeepSeek account balance and the current session's estimated spend.
15
13
 
@@ -33,6 +31,7 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
33
31
  - **Last prompt cost (estimate)** — Parses the current session file and prices the last turn's token usage, answering "how much did that last prompt cost".
34
32
  - **Today total cost (estimate)** — Walks every session under `~/.dsh/sessions/` and sums today's (calendar day) token usage × price.
35
33
  - **Token usage** — Also shows the session's input (incl. cache hits) / output tokens.
34
+ - **One-click top-up** — a "⚡ Top up" link in the popover footer jumps to the official DeepSeek top-up page (platform.deepseek.com/top_up) in a new tab.
36
35
  - **On-demand refresh** — No polling, no background requests; endpoints are only hit when you click the icon. Costs zero tokens to use.
37
36
 
38
37
  ## Why this plugin
@@ -100,6 +99,18 @@ Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective
100
99
 
101
100
  `deepseek-chat` / `deepseek-reasoner` aliases map to Flash / Pro pricing respectively. Costs are **estimates**; actual billing from the provider is authoritative.
102
101
 
102
+ ## Changelog
103
+
104
+ ### v0.2.0 — Last prompt & today total cost
105
+ - ✨ **Added**: popover now shows "last prompt cost" and "today total cost"
106
+ - Last prompt: prices the current session's last turn from the session file
107
+ - Today total: walks every session under `~/.dsh/sessions/` and sums today's usage
108
+ - 🐛 **Fixed**: session-id prefix duplication in the last-cost route (both `session-`-prefixed and bare ids resolve)
109
+
110
+ ### v0.1.0 — Initial release
111
+ - 🎉 Account balance (official `/user/balance`) + session cost (estimate) + token usage
112
+ - On-demand refresh: no polling, queries only on click, costs zero tokens
113
+
103
114
  ## Verify
104
115
 
105
116
  - Config tree: `dsh --profile web --dump-config` should show a `balance-widget` entry.
package/README.md CHANGED
@@ -2,14 +2,12 @@
2
2
 
3
3
  **中文** | [English](README.en.md)
4
4
 
5
- <p align="center">
6
- <img src="https://img.shields.io/npm/v/dsh-balance-widget?style=flat-square&label=npm" alt="npm">
7
- <img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
8
- <img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
9
- <img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
10
- <img src="https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square" alt="Node 24">
11
- <img src="https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square" alt="Zero deps">
12
- </p>
5
+ [![npm](https://img.shields.io/npm/v/dsh-balance-widget?style=flat-square&label=npm)](https://www.npmjs.com/package/dsh-balance-widget)
6
+ [![Stars](https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars)](https://github.com/LL-cmyk-so/dsh-balance-widget)
7
+ [![License](https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square)](https://github.com/LL-cmyk-so/dsh-balance-widget/blob/main/LICENSE)
8
+ [![Last commit](https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square)](https://github.com/LL-cmyk-so/dsh-balance-widget)
9
+ [![Node 24](https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square)](https://nodejs.org)
10
+ [![Zero deps](https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square)]()
13
11
 
14
12
  DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右上角(角落)渲染一个 💰 图标,点击弹出账户余额与本会话的估算成本。
15
13
 
@@ -33,6 +31,7 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右
33
31
  - **最近一次提问成本(估算)** — 从当前会话文件解析最后一个 turn 的 token 用量 × 单价,回答"刚才那条提问花了多少"。
34
32
  - **今天总成本(估算)** — 遍历 `~/.dsh/sessions/` 下所有会话,累加今天(自然日)的 token 用量 × 单价。
35
33
  - **Token 用量** — 同时展示本会话输入(含缓存命中)/ 输出 token 数。
34
+ - **一键充值** — 弹层底部「⚡ 去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
36
35
  - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起查询,不消耗任何 token。
37
36
 
38
37
  ## 架构
@@ -96,6 +95,18 @@ dsh plugin --profile web add "link:$(pwd)"
96
95
 
97
96
  `deepseek-chat` / `deepseek-reasoner` 别名分别映射到 Flash / Pro 价格。成本为**估算值**,实际以官方账单为准。
98
97
 
98
+ ## 版本历史
99
+
100
+ ### v0.2.0 — 最近一次提问与今日总成本
101
+ - ✨ **新增**:弹层增加「最近一次提问成本」与「今天总成本」两项
102
+ - 最近一次提问:解析当前会话最后一个 turn 的 token 用量 × 单价
103
+ - 今天总成本:遍历 `~/.dsh/sessions/` 下所有会话,累加今天(自然日)用量
104
+ - 🐛 **修复**:last-cost 路由的 session-id 前缀重复问题(带/不带 `session-` 前缀均可解析)
105
+
106
+ ### v0.1.0 — 初始版本
107
+ - 🎉 账户余额(官方 `/user/balance`)+ 本会话成本(估算)+ Token 用量
108
+ - 按需刷新:无轮询,点击才查询,不消耗 token
109
+
99
110
  ## 验证
100
111
 
101
112
  - 配置树:`dsh --profile web --dump-config` 应出现 `balance-widget` 条目
package/lib/client.js CHANGED
@@ -6,8 +6,9 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
8
  let react = require("react");
9
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
10
  //#region lib/types/client/styles.js
10
- const css = ".dshbw_wrap{position:relative;display:inline-flex;align-items:center}.dshbw_btn{background:none;border:none;cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit}.dshbw_btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_pop{position:absolute;top:calc(100% + 6px);right:0;z-index:60;min-width:230px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l3);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.18);padding:12px 14px;font-size:12px;line-height:20px;color:var(--dsw-alias-label-primary)}.dshbw_row{display:flex;justify-content:space-between;gap:12px;padding:2px 0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_err{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;margin-top:6px}.dshbw_hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;margin-top:6px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:6px}.dshbw_foot{margin-top:8px;text-align:right}";
11
+ const css = ".dshbw_wrap{position:relative;display:inline-flex;align-items:center}.dshbw_btn{background:none;border:none;cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit}.dshbw_btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_pop{position:absolute;top:calc(100% + 6px);right:0;z-index:60;min-width:230px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l3);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.18);padding:12px 14px;font-size:12px;line-height:20px;color:var(--dsw-alias-label-primary)}.dshbw_head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}.dshbw_title{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_refresh{background:none;border:1px solid var(--dsw-alias-border-l2);cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;font-size:13px;line-height:1;padding:0;font-family:inherit}.dshbw_refresh:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_refresh[data-spinning=true]{animation:dshbw-spin .8s linear infinite}.dshbw_row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:2px 0}.dshbw_label{display:inline-flex;align-items:center;min-width:0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_err{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;margin-top:6px}.dshbw_hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;margin-top:6px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:6px}.dshbw_foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px}.dshbw_link{background:none;border:none;cursor:pointer;color:var(--dsw-alias-state-business-primary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit;text-decoration:none}.dshbw_link:hover{color:var(--dsw-alias-state-business-primary);text-decoration:underline}@keyframes dshbw-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}";
11
12
  const tagId = "dsh-balance-widget/styles.css";
12
13
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
13
14
  const tag = document.createElement("style");
@@ -20,11 +21,16 @@ window.__ModuleLoader__.load({
20
21
  "wrap": "dshbw_wrap",
21
22
  "btn": "dshbw_btn",
22
23
  "pop": "dshbw_pop",
24
+ "head": "dshbw_head",
25
+ "title": "dshbw_title",
26
+ "refresh": "dshbw_refresh",
23
27
  "row": "dshbw_row",
28
+ "label": "dshbw_label",
24
29
  "val": "dshbw_val",
25
30
  "err": "dshbw_err",
26
31
  "hint": "dshbw_hint",
27
- "foot": "dshbw_foot"
32
+ "foot": "dshbw_foot",
33
+ "link": "dshbw_link"
28
34
  };
29
35
  //#endregion
30
36
  //#region lib/types/client/pricing.js
@@ -107,6 +113,8 @@ window.__ModuleLoader__.load({
107
113
  /** Same-origin cost routes. */
108
114
  const LAST_COST_ROUTE = "/api/dsh-balance/last-cost";
109
115
  const TODAY_COST_ROUTE = "/api/dsh-balance/today-cost";
116
+ /** Official DeepSeek platform top-up page (opens in a new tab). */
117
+ const TOP_UP_URL = "https://platform.deepseek.com/top_up";
110
118
  /**
111
119
  * Fetch the account balance from the host proxy.
112
120
  * @returns { ok, balanceInfos?, modelId?, error? }
@@ -160,6 +168,28 @@ window.__ModuleLoader__.load({
160
168
  return { symbol, total, currency: entry.currency };
161
169
  }
162
170
  //#endregion
171
+ //#region lib/types/client/ExplainIcon.js
172
+ /**
173
+ * A small ⓘ marker with a hover tooltip explaining a term. Wraps the
174
+ * official DSH Tooltip so the popover stays visually consistent.
175
+ * @param {Object} props - label (tooltip text), t (locale).
176
+ */
177
+ function ExplainIcon({ label, t }) {
178
+ return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
179
+ label: label,
180
+ side: "top",
181
+ delayMs: 300,
182
+ children: (0, react_jsx_runtime.jsx)("span", {
183
+ role: "img",
184
+ "aria-label": t("explain.aria"),
185
+ style: { cursor: "help", color: "var(--dsw-alias-label-tertiary)", marginLeft: 3, fontSize: 11, lineHeight: 1 },
186
+ children: (0, react_jsx_runtime.jsx)("span", {
187
+ children: "ⓘ"
188
+ })
189
+ })
190
+ });
191
+ }
192
+ //#endregion
163
193
  //#region lib/types/client/BalanceWidget.js
164
194
  /**
165
195
  * Corner widget: a small balance button in the session header utilities
@@ -175,32 +205,57 @@ window.__ModuleLoader__.load({
175
205
  const [todayCost, setTodayCost] = (0, react.useState)(null);
176
206
  const [error, setError] = (0, react.useState)(null);
177
207
  const [loading, setLoading] = (0, react.useState)(false);
208
+ const [refreshing, setRefreshing] = (0, react.useState)(false);
178
209
  const usage = useProjection("tokenUsage");
179
210
  const priced = priceSession(usage, modelId);
211
+ /** Fetch balance only (used for the always-visible corner amount). */
212
+ const refreshBalance = async () => {
213
+ const result = await fetchBalance();
214
+ if (result.ok) {
215
+ setBalance(displayBalance(result.balanceInfos));
216
+ if (typeof result.modelId === "string" && result.modelId !== "") setModelId(result.modelId);
217
+ }
218
+ };
219
+ /** Fetch all data; optionally keeps the popover's silent loading state. */
220
+ const refreshAll = async (showLoading) => {
221
+ if (showLoading) setLoading(true);
222
+ setError(null);
223
+ const [balanceResult, lastResult, todayResult] = await Promise.all([
224
+ fetchBalance(),
225
+ fetchCost(LAST_COST_ROUTE, sessionId),
226
+ fetchCost(TODAY_COST_ROUTE)
227
+ ]);
228
+ // Collect every failure into one fresh message; never accumulate
229
+ // across refreshes (each refresh starts from a clean slate).
230
+ const failures = [];
231
+ if (balanceResult.ok) {
232
+ setBalance(displayBalance(balanceResult.balanceInfos));
233
+ if (typeof balanceResult.modelId === "string" && balanceResult.modelId !== "") setModelId(balanceResult.modelId);
234
+ } else {
235
+ setBalance(null);
236
+ failures.push(`余额: ${balanceResult.error}`);
237
+ }
238
+ if (lastResult.ok) setLastCost(lastResult.cost);
239
+ else if (lastResult.error) failures.push(`最近提问: ${lastResult.error}`);
240
+ if (todayResult.ok) setTodayCost(todayResult.cost);
241
+ else if (todayResult.error) failures.push(`今日: ${todayResult.error}`);
242
+ setError(failures.length > 0 ? failures.join(";") : null);
243
+ if (showLoading) setLoading(false);
244
+ };
245
+ // Always fetch the balance on mount so the corner shows the amount
246
+ // without requiring a click first.
247
+ (0, react.useEffect)(() => {
248
+ refreshBalance();
249
+ }, []);
180
250
  const toggle = async () => {
181
251
  const next = !open;
182
252
  setOpen(next);
183
- if (next) {
184
- setLoading(true);
185
- setError(null);
186
- const [balanceResult, lastResult, todayResult] = await Promise.all([
187
- fetchBalance(),
188
- fetchCost(LAST_COST_ROUTE, sessionId),
189
- fetchCost(TODAY_COST_ROUTE)
190
- ]);
191
- if (balanceResult.ok) {
192
- setBalance(displayBalance(balanceResult.balanceInfos));
193
- if (typeof balanceResult.modelId === "string" && balanceResult.modelId !== "") setModelId(balanceResult.modelId);
194
- } else {
195
- setBalance(null);
196
- setError(balanceResult.error);
197
- }
198
- if (lastResult.ok) setLastCost(lastResult.cost);
199
- else if (!lastResult.ok && lastResult.error) setError((error ?? "") + (error ? " " : "") + `last: ${lastResult.error}`);
200
- if (todayResult.ok) setTodayCost(todayResult.cost);
201
- else if (!todayResult.ok && todayResult.error) setError((error ?? "") + (error ? " " : "") + `today: ${todayResult.error}`);
202
- setLoading(false);
203
- }
253
+ if (next) await refreshAll(true);
254
+ };
255
+ const onRefresh = async () => {
256
+ setRefreshing(true);
257
+ await refreshAll(false);
258
+ setRefreshing(false);
204
259
  };
205
260
  return (0, react_jsx_runtime.jsx)("div", {
206
261
  className: styles.wrap,
@@ -219,10 +274,33 @@ window.__ModuleLoader__.load({
219
274
  role: "dialog",
220
275
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
221
276
  children: [(0, react_jsx_runtime.jsx)("div", {
277
+ className: styles.head,
278
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
279
+ children: [(0, react_jsx_runtime.jsx)("span", {
280
+ className: styles.title,
281
+ children: t("title")
282
+ }), (0, react_jsx_runtime.jsx)("button", {
283
+ type: "button",
284
+ className: styles.refresh,
285
+ "aria-label": t("refresh.aria"),
286
+ title: t("refresh.title"),
287
+ "data-spinning": refreshing || undefined,
288
+ disabled: refreshing,
289
+ onClick: onRefresh,
290
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, {})
291
+ })]
292
+ })
293
+ }), (0, react_jsx_runtime.jsx)("div", {
222
294
  className: styles.row,
223
295
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
224
296
  children: [(0, react_jsx_runtime.jsx)("span", {
225
- children: t("balance")
297
+ className: styles.label,
298
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
299
+ children: [t("balance"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
300
+ label: t("explain.balance"),
301
+ t
302
+ })]
303
+ })
226
304
  }), (0, react_jsx_runtime.jsx)("span", {
227
305
  className: styles.val,
228
306
  children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
@@ -232,7 +310,13 @@ window.__ModuleLoader__.load({
232
310
  className: styles.row,
233
311
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
234
312
  children: [(0, react_jsx_runtime.jsx)("span", {
235
- children: t("sessionCost")
313
+ className: styles.label,
314
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
315
+ children: [t("sessionCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
316
+ label: t("explain.sessionCost"),
317
+ t
318
+ })]
319
+ })
236
320
  }), (0, react_jsx_runtime.jsx)("span", {
237
321
  className: styles.val,
238
322
  children: priced !== null ? formatCny(priced.total) : "—"
@@ -242,7 +326,13 @@ window.__ModuleLoader__.load({
242
326
  className: styles.row,
243
327
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
244
328
  children: [(0, react_jsx_runtime.jsx)("span", {
245
- children: t("lastPromptCost")
329
+ className: styles.label,
330
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
331
+ children: [t("lastPromptCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
332
+ label: t("explain.lastPromptCost"),
333
+ t
334
+ })]
335
+ })
246
336
  }), (0, react_jsx_runtime.jsx)("span", {
247
337
  className: styles.val,
248
338
  children: loading ? "…" : lastCost !== null ? formatCny(lastCost) : "—"
@@ -252,7 +342,13 @@ window.__ModuleLoader__.load({
252
342
  className: styles.row,
253
343
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
254
344
  children: [(0, react_jsx_runtime.jsx)("span", {
255
- children: t("todayCost")
345
+ className: styles.label,
346
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
347
+ children: [t("todayCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
348
+ label: t("explain.todayCost"),
349
+ t
350
+ })]
351
+ })
256
352
  }), (0, react_jsx_runtime.jsx)("span", {
257
353
  className: styles.val,
258
354
  children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
@@ -262,7 +358,13 @@ window.__ModuleLoader__.load({
262
358
  className: styles.row,
263
359
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
264
360
  children: [(0, react_jsx_runtime.jsx)("span", {
265
- children: t("tokens")
361
+ className: styles.label,
362
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
363
+ children: [t("tokens"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
364
+ label: t("explain.tokens"),
365
+ t
366
+ })]
367
+ })
266
368
  }), (0, react_jsx_runtime.jsx)("span", {
267
369
  className: styles.val,
268
370
  children: `${formatTokens((usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0))} in / ${formatTokens(usage.outputTokens ?? 0)} out`
@@ -277,11 +379,21 @@ window.__ModuleLoader__.load({
277
379
  children: t("hint")
278
380
  }), (0, react_jsx_runtime.jsx)("div", {
279
381
  className: styles.foot,
280
- children: (0, react_jsx_runtime.jsx)("button", {
281
- type: "button",
282
- className: styles.btn,
283
- onClick: () => setOpen(false),
284
- children: t("close")
382
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
383
+ children: [(0, react_jsx_runtime.jsx)("a", {
384
+ className: styles.link,
385
+ href: TOP_UP_URL,
386
+ target: "_blank",
387
+ rel: "noreferrer",
388
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
389
+ children: ["⚡", t("topUp")]
390
+ })
391
+ }), (0, react_jsx_runtime.jsx)("button", {
392
+ type: "button",
393
+ className: styles.btn,
394
+ onClick: () => setOpen(false),
395
+ children: t("close")
396
+ })]
285
397
  })
286
398
  })]
287
399
  })
@@ -297,24 +409,42 @@ window.__ModuleLoader__.load({
297
409
  const zh = {
298
410
  "aria": "查看账户余额与成本",
299
411
  "title": "余额与成本",
412
+ "refresh.aria": "刷新数据",
413
+ "refresh.title": "刷新余额与成本",
300
414
  "balance": "账户余额",
301
- "sessionCost": "本会话成本(估算)",
302
- "lastPromptCost": "最近一次提问(估算)",
303
- "todayCost": "今天总成本(估算)",
415
+ "sessionCost": "本会话成本",
416
+ "lastPromptCost": "最近一次提问",
417
+ "todayCost": "今天总成本",
304
418
  "tokens": "Token 用量",
305
- "hint": "价格按 DeepSeek 官方峰谷定价估算,实际以账单为准。",
419
+ "explain.aria": "解释",
420
+ "explain.balance": "DeepSeek 账户可用余额,实时查询官方接口(每 60 秒刷新)。",
421
+ "explain.sessionCost": "当前这个对话从开始到现在累计的费用,按官方峰谷定价估算。",
422
+ "explain.lastPromptCost": "你刚发的这条提问(含 AI 回复)的费用估算。",
423
+ "explain.todayCost": "今天所有对话(含其他窗口)的费用合计估算。",
424
+ "explain.tokens": "本会话消耗的 token 数。1 token ≈ 0.75 个英文单词 ≈ 1 个汉字。",
425
+ "hint": "价格为估算值,实际以官方账单为准。",
426
+ "topUp": "去充值",
306
427
  "close": "关闭"
307
428
  };
308
429
  /** English dictionary, checked complete against the zh key set. */
309
430
  const en = {
310
431
  "aria": "View account balance and costs",
311
432
  "title": "Balance & cost",
433
+ "refresh.aria": "Refresh data",
434
+ "refresh.title": "Refresh balance and costs",
312
435
  "balance": "Account balance",
313
- "sessionCost": "This session cost (est.)",
314
- "lastPromptCost": "Last prompt (est.)",
315
- "todayCost": "Today total (est.)",
436
+ "sessionCost": "This session cost",
437
+ "lastPromptCost": "Last prompt",
438
+ "todayCost": "Today total",
316
439
  "tokens": "Token usage",
317
- "hint": "Prices estimated from DeepSeek official peak/off-peak rates; billing is authoritative.",
440
+ "explain.aria": "Explain",
441
+ "explain.balance": "DeepSeek account balance, fetched live from the official API.",
442
+ "explain.sessionCost": "Total estimated cost of this conversation since it started, priced at official peak/off-peak rates.",
443
+ "explain.lastPromptCost": "Estimated cost of your latest prompt, including the AI reply.",
444
+ "explain.todayCost": "Estimated total cost across all conversations today.",
445
+ "explain.tokens": "Tokens used by this session. 1 token ≈ 0.75 English words ≈ 1 CJK character.",
446
+ "hint": "Prices are estimates; actual billing from the provider is authoritative.",
447
+ "topUp": "Top up",
318
448
  "close": "Close"
319
449
  };
320
450
  //#endregion
package/lib/index.js CHANGED
@@ -290,19 +290,50 @@ function awaitImportFs() {
290
290
  return _fs;
291
291
  }
292
292
 
293
+ /** Cache the zstd availability probe (run once per process). */
294
+ let _zstdAvailable = void 0;
295
+
296
+ /**
297
+ * Probe whether the zstd CLI is available, caching the result.
298
+ * macOS does not ship zstd by default, so this must be checked lazily and
299
+ * surfaced as a distinct error rather than a silent "session not found".
300
+ */
301
+ async function isZstdAvailable() {
302
+ if (_zstdAvailable !== void 0) return _zstdAvailable;
303
+ const { execFile } = await import("node:child_process");
304
+ const { promisify } = await import("node:util");
305
+ const execFileP = promisify(execFile);
306
+ try {
307
+ await execFileP("zstd", ["--version"], { timeout: 5000 });
308
+ _zstdAvailable = true;
309
+ } catch {
310
+ _zstdAvailable = false;
311
+ }
312
+ return _zstdAvailable;
313
+ }
314
+
293
315
  /**
294
- * Decompress a .zstd session file to text via the zstd CLI (present on
295
- * macOS/Linux with zstd installed). Returns undefined on failure.
316
+ * Decompress a .zstd session file to text via the zstd CLI.
317
+ * @returns { ok: true, text } | { ok: false, error }
296
318
  */
297
319
  async function decompressSession(path) {
320
+ if (!(await isZstdAvailable())) {
321
+ return {
322
+ ok: false,
323
+ error: "zstd CLI not found — install it (e.g. `brew install zstd`) to read session files"
324
+ };
325
+ }
298
326
  const { execFile } = await import("node:child_process");
299
327
  const { promisify } = await import("node:util");
300
328
  const execFileP = promisify(execFile);
301
329
  try {
302
330
  const { stdout } = await execFileP("zstd", ["-d", "-c", path], { maxBuffer: 512 * 1024 * 1024 });
303
- return stdout;
304
- } catch {
305
- return void 0;
331
+ return { ok: true, text: stdout };
332
+ } catch (error) {
333
+ return {
334
+ ok: false,
335
+ error: `failed to decompress session file: ${error instanceof Error ? error.message : String(error)}`
336
+ };
306
337
  }
307
338
  }
308
339
 
@@ -397,9 +428,9 @@ async function collectAllSessionSamples() {
397
428
  }
398
429
  const perSession = [];
399
430
  for (const file of files) {
400
- const text = await decompressSession(file);
401
- if (text === void 0) continue;
402
- perSession.push(parseSessionUsages(text));
431
+ const result = await decompressSession(file);
432
+ if (!result.ok) continue;
433
+ perSession.push(parseSessionUsages(result.text));
403
434
  }
404
435
  return perSession;
405
436
  }
@@ -442,14 +473,14 @@ function makeRoutes(ctx, config) {
442
473
  writeJson(res, 404, { error: `session ${sessionId} not found`, modelId: config.modelId });
443
474
  return;
444
475
  }
445
- const text = await decompressSession(file);
446
- if (text === void 0) {
447
- writeJson(res, 502, { error: "failed to read session file", modelId: config.modelId });
476
+ const result = await decompressSession(file);
477
+ if (!result.ok) {
478
+ writeJson(res, 502, { error: result.error, modelId: config.modelId });
448
479
  return;
449
480
  }
450
- const samples = parseSessionUsages(text);
451
- const result = lastTurnCost(samples);
452
- writeJson(res, 200, { ...result, modelId: config.modelId });
481
+ const samples = parseSessionUsages(result.text);
482
+ const last = lastTurnCost(samples);
483
+ writeJson(res, 200, { ...last, modelId: config.modelId });
453
484
  }
454
485
  },
455
486
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-balance-widget",
3
3
  "description": "Balance & cost widget for the dsh web GUI: a corner icon in the conversation session header that shows the DeepSeek account balance and the current session's estimated spend on click. Host half proxies the official /user/balance API over ctx.webServer (loopback-only); client half renders the icon and popover. Zero external dependencies — works on Node 24.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [