dsh-balance-widget 0.2.1 → 0.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.en.md CHANGED
@@ -31,6 +31,7 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
31
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".
32
32
  - **Today total cost (estimate)** — Walks every session under `~/.dsh/sessions/` and sums today's (calendar day) token usage × price.
33
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.
34
35
  - **On-demand refresh** — No polling, no background requests; endpoints are only hit when you click the icon. Costs zero tokens to use.
35
36
 
36
37
  ## Why this plugin
@@ -73,18 +74,57 @@ Then restart `dsh web`.
73
74
 
74
75
  ## Configuration
75
76
 
76
- In `~/.dsh/profiles/web/cordis.patch.yml`:
77
+ ### Where the config file lives
78
+
79
+ DSH plugin configuration lives in:
80
+
81
+ ```
82
+ ~/.dsh/profiles/web/cordis.patch.yml
83
+ ```
84
+
85
+ ### All options
86
+
87
+ Append to `cordis.patch.yml` (only change the lines you need; the rest stay at defaults):
77
88
 
78
89
  ```yaml
79
90
  - id: balance-widget
80
91
  name: dsh-balance-widget
81
92
  config:
82
- balanceBaseURL: https://api.deepseek.com # official balance endpoint
83
- balanceApiKeyEnv: DEEPSEEK_API_KEY # credential ref for the API key
84
- requestTimeoutMs: 5000 # balance request timeout
93
+ balanceBaseURL: https://api.deepseek.com # official balance endpoint (rarely changed)
94
+ balanceApiKeyEnv: DEEPSEEK_API_KEY # credential ref for the API key (rarely changed)
95
+ requestTimeoutMs: 5000 # balance request timeout (ms)
85
96
  modelId: deepseek-v4-flash # pricing model (or deepseek-v4-pro)
97
+ lowThreshold: 5 # balance below this (¥) turns the icon amber
98
+ criticalThreshold: 1 # balance below this (¥) turns the icon red
99
+ ```
100
+
101
+ ### Example: custom balance thresholds
102
+
103
+ By default the icon turns **amber below ¥5 and red below ¥1**. To warn at ¥10 / ¥3 instead:
104
+
105
+ ```yaml
106
+ - id: balance-widget
107
+ name: dsh-balance-widget
108
+ config:
109
+ lowThreshold: 10
110
+ criticalThreshold: 3
111
+ ```
112
+
113
+ Restart `dsh web` for changes to take effect.
114
+
115
+ ### Example: price with V4-Pro
116
+
117
+ If you mainly use DeepSeek-V4-Pro, point the pricing model at it for a more accurate estimate:
118
+
119
+ ```yaml
120
+ - id: balance-widget
121
+ name: dsh-balance-widget
122
+ config:
123
+ modelId: deepseek-v4-pro
86
124
  ```
87
125
 
126
+ > **Note**: `cordis.patch.yml` may already contain lines for other plugins — append new lines without touching existing ones.
127
+
88
128
  ## Pricing
89
129
 
90
130
  Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective 2026-08-17. Peak windows are Beijing time 09:00–12:00 and 14:00–18:00; prices are double the off-peak rates:
package/README.md CHANGED
@@ -31,6 +31,7 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右
31
31
  - **最近一次提问成本(估算)** — 从当前会话文件解析最后一个 turn 的 token 用量 × 单价,回答"刚才那条提问花了多少"。
32
32
  - **今天总成本(估算)** — 遍历 `~/.dsh/sessions/` 下所有会话,累加今天(自然日)的 token 用量 × 单价。
33
33
  - **Token 用量** — 同时展示本会话输入(含缓存命中)/ 输出 token 数。
34
+ - **一键充值** — 弹层底部「⚡ 去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
34
35
  - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起查询,不消耗任何 token。
35
36
 
36
37
  ## 架构
@@ -69,18 +70,59 @@ dsh plugin --profile web add "link:$(pwd)"
69
70
 
70
71
  ## 配置
71
72
 
72
- 在 `~/.dsh/profiles/web/cordis.patch.yml` 中:
73
+ ### 配置文件在哪
74
+
75
+ DSH 的插件配置统一放在这个文件里:
76
+
77
+ ```
78
+ ~/.dsh/profiles/web/cordis.patch.yml
79
+ ```
80
+
81
+ > `~` 是你的用户主目录(macOS 是 `/Users/你的用户名`)。
82
+
83
+ ### 全部配置项
84
+
85
+ 在 `cordis.patch.yml` 中追加以下内容(**只改你需要的那几行**,其余保持默认即可):
73
86
 
74
87
  ```yaml
75
88
  - id: balance-widget
76
89
  name: dsh-balance-widget
77
90
  config:
78
- balanceBaseURL: https://api.deepseek.com # 官方余额接口
79
- balanceApiKeyEnv: DEEPSEEK_API_KEY # 凭据服务中的密钥 ref
80
- requestTimeoutMs: 5000 # 余额查询超时
91
+ balanceBaseURL: https://api.deepseek.com # 官方余额接口(一般不用改)
92
+ balanceApiKeyEnv: DEEPSEEK_API_KEY # 凭据服务中的密钥 ref(一般不用改)
93
+ requestTimeoutMs: 5000 # 余额查询超时(毫秒)
81
94
  modelId: deepseek-v4-flash # 成本计价模型(可改 deepseek-v4-pro)
95
+ lowThreshold: 5 # 余额低于此值(¥)图标变黄提醒
96
+ criticalThreshold: 1 # 余额低于此值(¥)图标变红警告
82
97
  ```
83
98
 
99
+ ### 示例:调整余额预警阈值
100
+
101
+ 默认余额 **低于 ¥5 变黄、低于 ¥1 变红**。想改成"低于 ¥10 提醒、低于 ¥3 警告":
102
+
103
+ ```yaml
104
+ - id: balance-widget
105
+ name: dsh-balance-widget
106
+ config:
107
+ lowThreshold: 10
108
+ criticalThreshold: 3
109
+ ```
110
+
111
+ 改完**重启 `dsh web`** 生效。
112
+
113
+ ### 示例:成本按 V4-Pro 计价
114
+
115
+ 如果主要使用 DeepSeek-V4-Pro 模型,把计价模型改掉,成本估算更准:
116
+
117
+ ```yaml
118
+ - id: balance-widget
119
+ name: dsh-balance-widget
120
+ config:
121
+ modelId: deepseek-v4-pro
122
+ ```
123
+
124
+ > **提示**:`cordis.patch.yml` 可能有其他插件的配置行,追加时注意**不要改动已有的行**,只加新内容。
125
+
84
126
  ## 定价说明
85
127
 
86
128
  内置 DeepSeek 官方 2026-08-17 峰谷定价(元 / 百万 tokens),高峰时段为北京时间 09:00–12:00、14:00–18:00,价格为空闲时段两倍:
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_btn[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_btn[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.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_val[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_val[data-level=\"2\"]{color:var(--dsw-alias-state-error-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
@@ -100,6 +106,12 @@ window.__ModuleLoader__.load({
100
106
  if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
101
107
  return String(value);
102
108
  }
109
+ /** Format an epoch ms as HH:MM local time. */
110
+ function formatTime(ms) {
111
+ if (ms === void 0 || ms === null) return "—";
112
+ const d = new Date(ms);
113
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
114
+ }
103
115
  //#endregion
104
116
  //#region lib/types/client/balance-api.js
105
117
  /** Same-origin balance route (host half registers it over ctx.webServer). */
@@ -107,6 +119,8 @@ window.__ModuleLoader__.load({
107
119
  /** Same-origin cost routes. */
108
120
  const LAST_COST_ROUTE = "/api/dsh-balance/last-cost";
109
121
  const TODAY_COST_ROUTE = "/api/dsh-balance/today-cost";
122
+ /** Official DeepSeek platform top-up page (opens in a new tab). */
123
+ const TOP_UP_URL = "https://platform.deepseek.com/top_up";
110
124
  /**
111
125
  * Fetch the account balance from the host proxy.
112
126
  * @returns { ok, balanceInfos?, modelId?, error? }
@@ -125,7 +139,13 @@ window.__ModuleLoader__.load({
125
139
  }
126
140
  const payload = await response.json();
127
141
  if (payload.ok === false) return { ok: false, error: payload.error ?? "unknown balance error" };
128
- return { ok: true, balanceInfos: payload.balance_infos ?? [], modelId: payload.modelId };
142
+ return {
143
+ ok: true,
144
+ balanceInfos: payload.balance_infos ?? [],
145
+ modelId: payload.modelId,
146
+ lowThreshold: payload.lowThreshold,
147
+ criticalThreshold: payload.criticalThreshold
148
+ };
129
149
  } catch (error) {
130
150
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
131
151
  }
@@ -160,6 +180,28 @@ window.__ModuleLoader__.load({
160
180
  return { symbol, total, currency: entry.currency };
161
181
  }
162
182
  //#endregion
183
+ //#region lib/types/client/ExplainIcon.js
184
+ /**
185
+ * A small ⓘ marker with a hover tooltip explaining a term. Wraps the
186
+ * official DSH Tooltip so the popover stays visually consistent.
187
+ * @param {Object} props - label (tooltip text), t (locale).
188
+ */
189
+ function ExplainIcon({ label, t }) {
190
+ return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
191
+ label: label,
192
+ side: "top",
193
+ delayMs: 300,
194
+ children: (0, react_jsx_runtime.jsx)("span", {
195
+ role: "img",
196
+ "aria-label": t("explain.aria"),
197
+ style: { cursor: "help", color: "var(--dsw-alias-label-tertiary)", marginLeft: 3, fontSize: 11, lineHeight: 1 },
198
+ children: (0, react_jsx_runtime.jsx)("span", {
199
+ children: "ⓘ"
200
+ })
201
+ })
202
+ });
203
+ }
204
+ //#endregion
163
205
  //#region lib/types/client/BalanceWidget.js
164
206
  /**
165
207
  * Corner widget: a small balance button in the session header utilities
@@ -175,41 +217,102 @@ window.__ModuleLoader__.load({
175
217
  const [todayCost, setTodayCost] = (0, react.useState)(null);
176
218
  const [error, setError] = (0, react.useState)(null);
177
219
  const [loading, setLoading] = (0, react.useState)(false);
220
+ const [refreshing, setRefreshing] = (0, react.useState)(false);
221
+ const [lastUpdated, setLastUpdated] = (0, react.useState)(null);
222
+ const [lowThreshold, setLowThreshold] = (0, react.useState)(5);
223
+ const [criticalThreshold, setCriticalThreshold] = (0, react.useState)(1);
178
224
  const usage = useProjection("tokenUsage");
179
225
  const priced = priceSession(usage, modelId);
226
+ /** Balance severity: 0=ok, 1=low, 2=critical (thresholds from host config). */
227
+ const balanceLevel = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
228
+ /** Fetch balance only (used for the always-visible corner amount). */
229
+ const refreshBalance = async () => {
230
+ const result = await fetchBalance();
231
+ if (result.ok) {
232
+ setBalance(displayBalance(result.balanceInfos));
233
+ setLastUpdated(Date.now());
234
+ if (typeof result.modelId === "string" && result.modelId !== "") setModelId(result.modelId);
235
+ if (typeof result.lowThreshold === "number" && result.lowThreshold >= 0) setLowThreshold(result.lowThreshold);
236
+ if (typeof result.criticalThreshold === "number" && result.criticalThreshold >= 0) setCriticalThreshold(result.criticalThreshold);
237
+ }
238
+ };
239
+ /** Fetch all data; optionally keeps the popover's silent loading state. */
240
+ const refreshAll = async (showLoading) => {
241
+ if (showLoading) setLoading(true);
242
+ setError(null);
243
+ const [balanceResult, lastResult, todayResult] = await Promise.all([
244
+ fetchBalance(),
245
+ fetchCost(LAST_COST_ROUTE, sessionId),
246
+ fetchCost(TODAY_COST_ROUTE)
247
+ ]);
248
+ // Collect every failure into one fresh message; never accumulate
249
+ // across refreshes (each refresh starts from a clean slate).
250
+ const failures = [];
251
+ if (balanceResult.ok) {
252
+ setBalance(displayBalance(balanceResult.balanceInfos));
253
+ setLastUpdated(Date.now());
254
+ if (typeof balanceResult.modelId === "string" && balanceResult.modelId !== "") setModelId(balanceResult.modelId);
255
+ if (typeof balanceResult.lowThreshold === "number" && balanceResult.lowThreshold >= 0) setLowThreshold(balanceResult.lowThreshold);
256
+ if (typeof balanceResult.criticalThreshold === "number" && balanceResult.criticalThreshold >= 0) setCriticalThreshold(balanceResult.criticalThreshold);
257
+ } else {
258
+ setBalance(null);
259
+ failures.push(`余额: ${balanceResult.error}`);
260
+ }
261
+ if (lastResult.ok) setLastCost(lastResult.cost);
262
+ else if (lastResult.error) failures.push(`最近提问: ${lastResult.error}`);
263
+ if (todayResult.ok) setTodayCost(todayResult.cost);
264
+ else if (todayResult.error) failures.push(`今日: ${todayResult.error}`);
265
+ setError(failures.length > 0 ? failures.join(";") : null);
266
+ if (showLoading) setLoading(false);
267
+ };
268
+ // Fetch balance on mount, then auto-refresh every 60s so the corner
269
+ // amount never goes stale (balance changes only on usage/top-up).
270
+ (0, react.useEffect)(() => {
271
+ refreshBalance();
272
+ const timer = setInterval(refreshBalance, 60000);
273
+ return () => clearInterval(timer);
274
+ }, []);
275
+ // Close the popover when clicking anywhere outside the widget.
276
+ const wrapRef = (0, react.useRef)(null);
277
+ (0, react.useEffect)(() => {
278
+ if (!open) return;
279
+ const onDocClick = (event) => {
280
+ const el = wrapRef.current;
281
+ if (el !== null && !el.contains(event.target)) setOpen(false);
282
+ };
283
+ document.addEventListener("mousedown", onDocClick);
284
+ return () => document.removeEventListener("mousedown", onDocClick);
285
+ }, [open]);
286
+ // While the popover is open, silently refresh costs every 30s so the
287
+ // figures keep up with ongoing conversation activity. The host caches
288
+ // cost computations, so this is cheap.
289
+ (0, react.useEffect)(() => {
290
+ if (!open) return;
291
+ const timer = setInterval(() => {
292
+ refreshAll(false);
293
+ }, 30000);
294
+ return () => clearInterval(timer);
295
+ }, [open]);
180
296
  const toggle = async () => {
181
297
  const next = !open;
182
298
  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
- }
299
+ if (next) await refreshAll(true);
300
+ };
301
+ const onRefresh = async () => {
302
+ setRefreshing(true);
303
+ await refreshAll(false);
304
+ setRefreshing(false);
204
305
  };
205
306
  return (0, react_jsx_runtime.jsx)("div", {
307
+ ref: wrapRef,
206
308
  className: styles.wrap,
207
309
  children: (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, {
208
310
  children: [(0, react_jsx_runtime.jsxs)("button", {
209
311
  type: "button",
210
312
  className: styles.btn,
313
+ "data-level": balanceLevel > 0 ? balanceLevel : undefined,
211
314
  "aria-label": t("aria"),
212
- title: t("title"),
315
+ title: balanceLevel > 0 ? t(`warn.title.${balanceLevel}`) : t("title"),
213
316
  onClick: toggle,
214
317
  children: ["💰", balance !== null && (0, react_jsx_runtime.jsx)("span", {
215
318
  children: `${balance.symbol}${balance.total.toFixed(2)}`
@@ -219,20 +322,54 @@ window.__ModuleLoader__.load({
219
322
  role: "dialog",
220
323
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
221
324
  children: [(0, react_jsx_runtime.jsx)("div", {
325
+ className: styles.head,
326
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
327
+ children: [(0, react_jsx_runtime.jsx)("span", {
328
+ className: styles.title,
329
+ children: t("title")
330
+ }), (0, react_jsx_runtime.jsx)("button", {
331
+ type: "button",
332
+ className: styles.refresh,
333
+ "aria-label": t("refresh.aria"),
334
+ title: t("refresh.title"),
335
+ "data-spinning": refreshing || undefined,
336
+ disabled: refreshing,
337
+ onClick: onRefresh,
338
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, {})
339
+ })]
340
+ })
341
+ }), (0, react_jsx_runtime.jsx)("div", {
222
342
  className: styles.row,
223
343
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
224
344
  children: [(0, react_jsx_runtime.jsx)("span", {
225
- children: t("balance")
345
+ className: styles.label,
346
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
347
+ children: [t("balance"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
348
+ label: t("explain.balance"),
349
+ t
350
+ })]
351
+ })
226
352
  }), (0, react_jsx_runtime.jsx)("span", {
227
353
  className: styles.val,
354
+ "data-level": balanceLevel > 0 ? balanceLevel : undefined,
228
355
  children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
229
356
  })]
230
357
  })
358
+ }), balanceLevel > 0 && (0, react_jsx_runtime.jsx)("div", {
359
+ className: styles.err,
360
+ role: "status",
361
+ children: t(`warn.text.${balanceLevel}`)
231
362
  }), (0, react_jsx_runtime.jsx)("div", {
232
363
  className: styles.row,
233
364
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
234
365
  children: [(0, react_jsx_runtime.jsx)("span", {
235
- children: t("sessionCost")
366
+ className: styles.label,
367
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
368
+ children: [t("sessionCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
369
+ label: t("explain.sessionCost"),
370
+ t
371
+ })]
372
+ })
236
373
  }), (0, react_jsx_runtime.jsx)("span", {
237
374
  className: styles.val,
238
375
  children: priced !== null ? formatCny(priced.total) : "—"
@@ -242,7 +379,13 @@ window.__ModuleLoader__.load({
242
379
  className: styles.row,
243
380
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
244
381
  children: [(0, react_jsx_runtime.jsx)("span", {
245
- children: t("lastPromptCost")
382
+ className: styles.label,
383
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
384
+ children: [t("lastPromptCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
385
+ label: t("explain.lastPromptCost"),
386
+ t
387
+ })]
388
+ })
246
389
  }), (0, react_jsx_runtime.jsx)("span", {
247
390
  className: styles.val,
248
391
  children: loading ? "…" : lastCost !== null ? formatCny(lastCost) : "—"
@@ -252,7 +395,13 @@ window.__ModuleLoader__.load({
252
395
  className: styles.row,
253
396
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
254
397
  children: [(0, react_jsx_runtime.jsx)("span", {
255
- children: t("todayCost")
398
+ className: styles.label,
399
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
400
+ children: [t("todayCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
401
+ label: t("explain.todayCost"),
402
+ t
403
+ })]
404
+ })
256
405
  }), (0, react_jsx_runtime.jsx)("span", {
257
406
  className: styles.val,
258
407
  children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
@@ -262,7 +411,13 @@ window.__ModuleLoader__.load({
262
411
  className: styles.row,
263
412
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
264
413
  children: [(0, react_jsx_runtime.jsx)("span", {
265
- children: t("tokens")
414
+ className: styles.label,
415
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
416
+ children: [t("tokens"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
417
+ label: t("explain.tokens"),
418
+ t
419
+ })]
420
+ })
266
421
  }), (0, react_jsx_runtime.jsx)("span", {
267
422
  className: styles.val,
268
423
  children: `${formatTokens((usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0))} in / ${formatTokens(usage.outputTokens ?? 0)} out`
@@ -274,14 +429,24 @@ window.__ModuleLoader__.load({
274
429
  children: error
275
430
  }), (0, react_jsx_runtime.jsx)("div", {
276
431
  className: styles.hint,
277
- children: t("hint")
432
+ children: lastUpdated !== null ? `${t("updatedAt")} ${formatTime(lastUpdated)} · ${t("hint")}` : t("hint")
278
433
  }), (0, react_jsx_runtime.jsx)("div", {
279
434
  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")
435
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
436
+ children: [(0, react_jsx_runtime.jsx)("a", {
437
+ className: styles.link,
438
+ href: TOP_UP_URL,
439
+ target: "_blank",
440
+ rel: "noreferrer",
441
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
442
+ children: ["⚡", t("topUp")]
443
+ })
444
+ }), (0, react_jsx_runtime.jsx)("button", {
445
+ type: "button",
446
+ className: styles.btn,
447
+ onClick: () => setOpen(false),
448
+ children: t("close")
449
+ })]
285
450
  })
286
451
  })]
287
452
  })
@@ -297,24 +462,52 @@ window.__ModuleLoader__.load({
297
462
  const zh = {
298
463
  "aria": "查看账户余额与成本",
299
464
  "title": "余额与成本",
465
+ "refresh.aria": "刷新数据",
466
+ "refresh.title": "刷新余额与成本",
300
467
  "balance": "账户余额",
301
- "sessionCost": "本会话成本(估算)",
302
- "lastPromptCost": "最近一次提问(估算)",
303
- "todayCost": "今天总成本(估算)",
468
+ "sessionCost": "这个对话已花费",
469
+ "lastPromptCost": "最近一次提问",
470
+ "todayCost": "今天总成本",
304
471
  "tokens": "Token 用量",
305
- "hint": "价格按 DeepSeek 官方峰谷定价估算,实际以账单为准。",
472
+ "explain.aria": "解释",
473
+ "explain.balance": "DeepSeek 账户可用余额,实时查询官方接口(每 60 秒刷新)。",
474
+ "explain.sessionCost": "当前这个对话从开始到现在累计的费用,按官方峰谷定价估算。",
475
+ "explain.lastPromptCost": "你刚发的这条提问(含 AI 回复)的费用估算。",
476
+ "explain.todayCost": "今天所有对话(含其他窗口)的费用合计估算。",
477
+ "explain.tokens": "本会话消耗的 token 数。1 token ≈ 0.75 个英文单词 ≈ 1 个汉字。",
478
+ "warn.title.1": "余额不足 ¥5,建议及时充值",
479
+ "warn.title.2": "余额低于 ¥1,即将耗尽!",
480
+ "warn.text.1": "⚠️ 余额不足 ¥5,建议及时充值。",
481
+ "warn.text.2": "⛔ 余额低于 ¥1,即将耗尽,请立即充值!",
482
+ "updatedAt": "更新于",
483
+ "hint": "价格为估算值,实际以官方账单为准。",
484
+ "topUp": "去充值",
306
485
  "close": "关闭"
307
486
  };
308
487
  /** English dictionary, checked complete against the zh key set. */
309
488
  const en = {
310
489
  "aria": "View account balance and costs",
311
490
  "title": "Balance & cost",
491
+ "refresh.aria": "Refresh data",
492
+ "refresh.title": "Refresh balance and costs",
312
493
  "balance": "Account balance",
313
- "sessionCost": "This session cost (est.)",
314
- "lastPromptCost": "Last prompt (est.)",
315
- "todayCost": "Today total (est.)",
494
+ "sessionCost": "This chat spent",
495
+ "lastPromptCost": "Last prompt",
496
+ "todayCost": "Today total",
316
497
  "tokens": "Token usage",
317
- "hint": "Prices estimated from DeepSeek official peak/off-peak rates; billing is authoritative.",
498
+ "explain.aria": "Explain",
499
+ "explain.balance": "DeepSeek account balance, fetched live from the official API.",
500
+ "explain.sessionCost": "Total estimated cost of this conversation since it started, priced at official peak/off-peak rates.",
501
+ "explain.lastPromptCost": "Estimated cost of your latest prompt, including the AI reply.",
502
+ "explain.todayCost": "Estimated total cost across all conversations today.",
503
+ "explain.tokens": "Tokens used by this session. 1 token ≈ 0.75 English words ≈ 1 CJK character.",
504
+ "warn.title.1": "Balance below ¥5, consider topping up",
505
+ "warn.title.2": "Balance below ¥1, almost exhausted!",
506
+ "warn.text.1": "⚠️ Balance below ¥5, consider topping up.",
507
+ "warn.text.2": "⛔ Balance below ¥1, almost exhausted — top up now!",
508
+ "updatedAt": "Updated",
509
+ "hint": "Prices are estimates; actual billing from the provider is authoritative.",
510
+ "topUp": "Top up",
318
511
  "close": "Close"
319
512
  };
320
513
  //#endregion
package/lib/index.js CHANGED
@@ -46,13 +46,17 @@ const CONFIG_FIELDS = {
46
46
  balanceBaseURL: "string",
47
47
  balanceApiKeyEnv: "string",
48
48
  requestTimeoutMs: "number",
49
- modelId: "string"
49
+ modelId: "string",
50
+ lowThreshold: "number",
51
+ criticalThreshold: "number"
50
52
  };
51
53
  const CONFIG_DEFAULTS = {
52
54
  balanceBaseURL: DEFAULT_BASE_URL,
53
55
  balanceApiKeyEnv: DEFAULT_API_KEY_ENV,
54
56
  requestTimeoutMs: 5000,
55
- modelId: DEFAULT_MODEL_ID
57
+ modelId: DEFAULT_MODEL_ID,
58
+ lowThreshold: 5,
59
+ criticalThreshold: 1
56
60
  };
57
61
 
58
62
  export const Config = {
@@ -102,7 +106,13 @@ function resolveConfig(raw) {
102
106
  : Config.defaults.requestTimeoutMs,
103
107
  modelId: typeof value.modelId === "string" && value.modelId !== ""
104
108
  ? value.modelId
105
- : Config.defaults.modelId
109
+ : Config.defaults.modelId,
110
+ lowThreshold: typeof value.lowThreshold === "number" && Number.isFinite(value.lowThreshold) && value.lowThreshold >= 0
111
+ ? value.lowThreshold
112
+ : Config.defaults.lowThreshold,
113
+ criticalThreshold: typeof value.criticalThreshold === "number" && Number.isFinite(value.criticalThreshold) && value.criticalThreshold >= 0
114
+ ? value.criticalThreshold
115
+ : Config.defaults.criticalThreshold
106
116
  };
107
117
  }
108
118
 
@@ -290,19 +300,50 @@ function awaitImportFs() {
290
300
  return _fs;
291
301
  }
292
302
 
303
+ /** Cache the zstd availability probe (run once per process). */
304
+ let _zstdAvailable = void 0;
305
+
306
+ /**
307
+ * Probe whether the zstd CLI is available, caching the result.
308
+ * macOS does not ship zstd by default, so this must be checked lazily and
309
+ * surfaced as a distinct error rather than a silent "session not found".
310
+ */
311
+ async function isZstdAvailable() {
312
+ if (_zstdAvailable !== void 0) return _zstdAvailable;
313
+ const { execFile } = await import("node:child_process");
314
+ const { promisify } = await import("node:util");
315
+ const execFileP = promisify(execFile);
316
+ try {
317
+ await execFileP("zstd", ["--version"], { timeout: 5000 });
318
+ _zstdAvailable = true;
319
+ } catch {
320
+ _zstdAvailable = false;
321
+ }
322
+ return _zstdAvailable;
323
+ }
324
+
293
325
  /**
294
- * Decompress a .zstd session file to text via the zstd CLI (present on
295
- * macOS/Linux with zstd installed). Returns undefined on failure.
326
+ * Decompress a .zstd session file to text via the zstd CLI.
327
+ * @returns { ok: true, text } | { ok: false, error }
296
328
  */
297
329
  async function decompressSession(path) {
330
+ if (!(await isZstdAvailable())) {
331
+ return {
332
+ ok: false,
333
+ error: "zstd CLI not found — install it (e.g. `brew install zstd`) to read session files"
334
+ };
335
+ }
298
336
  const { execFile } = await import("node:child_process");
299
337
  const { promisify } = await import("node:util");
300
338
  const execFileP = promisify(execFile);
301
339
  try {
302
340
  const { stdout } = await execFileP("zstd", ["-d", "-c", path], { maxBuffer: 512 * 1024 * 1024 });
303
- return stdout;
304
- } catch {
305
- return void 0;
341
+ return { ok: true, text: stdout };
342
+ } catch (error) {
343
+ return {
344
+ ok: false,
345
+ error: `failed to decompress session file: ${error instanceof Error ? error.message : String(error)}`
346
+ };
306
347
  }
307
348
  }
308
349
 
@@ -397,13 +438,33 @@ async function collectAllSessionSamples() {
397
438
  }
398
439
  const perSession = [];
399
440
  for (const file of files) {
400
- const text = await decompressSession(file);
401
- if (text === void 0) continue;
402
- perSession.push(parseSessionUsages(text));
441
+ const result = await decompressSession(file);
442
+ if (!result.ok) continue;
443
+ perSession.push(parseSessionUsages(result.text));
403
444
  }
404
445
  return perSession;
405
446
  }
406
447
 
448
+ /** Simple in-memory TTL cache for cost computations (30s). */
449
+ const costCache = new Map();
450
+ const COST_CACHE_TTL_MS = 30000;
451
+ function cachedOrCompute(key, compute) {
452
+ const hit = costCache.get(key);
453
+ if (hit !== void 0 && Date.now() - hit.at < COST_CACHE_TTL_MS) return hit.value;
454
+ const value = compute();
455
+ costCache.set(key, { at: Date.now(), value });
456
+ return value;
457
+ }
458
+ /** In-flight dedupe: key → shared promise, dropped when settled. */
459
+ const costInflight = new Map();
460
+ function dedupe(key, compute) {
461
+ const pending = costInflight.get(key);
462
+ if (pending !== void 0) return pending;
463
+ const promise = compute().finally(() => costInflight.delete(key));
464
+ costInflight.set(key, promise);
465
+ return promise;
466
+ }
467
+
407
468
  /** Build the route table for the balance widget. */
408
469
  function makeRoutes(ctx, config) {
409
470
  return [
@@ -414,9 +475,19 @@ function makeRoutes(ctx, config) {
414
475
  if (!guard(req, res, "GET")) return;
415
476
  const result = await fetchBalance(ctx, config);
416
477
  if (result.ok) {
417
- writeJson(res, 200, { ...result, modelId: config.modelId });
478
+ writeJson(res, 200, {
479
+ ...result,
480
+ modelId: config.modelId,
481
+ lowThreshold: config.lowThreshold,
482
+ criticalThreshold: config.criticalThreshold
483
+ });
418
484
  } else {
419
- writeJson(res, 502, { error: result.error, modelId: config.modelId });
485
+ writeJson(res, 502, {
486
+ error: result.error,
487
+ modelId: config.modelId,
488
+ lowThreshold: config.lowThreshold,
489
+ criticalThreshold: config.criticalThreshold
490
+ });
420
491
  }
421
492
  }
422
493
  },
@@ -442,14 +513,19 @@ function makeRoutes(ctx, config) {
442
513
  writeJson(res, 404, { error: `session ${sessionId} not found`, modelId: config.modelId });
443
514
  return;
444
515
  }
445
- const text = await decompressSession(file);
446
- if (text === void 0) {
447
- writeJson(res, 502, { error: "failed to read session file", modelId: config.modelId });
516
+ const key = `last:${sessionId}`;
517
+ const compute = async () => {
518
+ const result = await decompressSession(file);
519
+ if (!result.ok) return { error: result.error };
520
+ const samples = parseSessionUsages(result.text);
521
+ return { value: lastTurnCost(samples) };
522
+ };
523
+ const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
524
+ if (outcome.error !== void 0) {
525
+ writeJson(res, 502, { error: outcome.error, modelId: config.modelId });
448
526
  return;
449
527
  }
450
- const samples = parseSessionUsages(text);
451
- const result = lastTurnCost(samples);
452
- writeJson(res, 200, { ...result, modelId: config.modelId });
528
+ writeJson(res, 200, { ...outcome.value, modelId: config.modelId });
453
529
  }
454
530
  },
455
531
  {
@@ -457,9 +533,13 @@ function makeRoutes(ctx, config) {
457
533
  path: API.todayCost,
458
534
  handler: async (req, res) => {
459
535
  if (!guard(req, res, "GET")) return;
460
- const perSession = await collectAllSessionSamples();
461
- const result = todayCost(perSession);
462
- writeJson(res, 200, { ...result, modelId: config.modelId });
536
+ const key = "today";
537
+ const compute = async () => {
538
+ const perSession = await collectAllSessionSamples();
539
+ return { value: todayCost(perSession) };
540
+ };
541
+ const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
542
+ writeJson(res, 200, { ...outcome.value, modelId: config.modelId });
463
543
  }
464
544
  }
465
545
  ];
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.1",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [