dsh-balance-widget 0.3.0 → 0.3.2

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,7 +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
+ - **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.
35
35
  - **On-demand refresh** — No polling, no background requests; endpoints are only hit when you click the icon. Costs zero tokens to use.
36
36
 
37
37
  ## Why this plugin
@@ -74,18 +74,57 @@ Then restart `dsh web`.
74
74
 
75
75
  ## Configuration
76
76
 
77
- 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):
78
88
 
79
89
  ```yaml
80
90
  - id: balance-widget
81
91
  name: dsh-balance-widget
82
92
  config:
83
- balanceBaseURL: https://api.deepseek.com # official balance endpoint
84
- balanceApiKeyEnv: DEEPSEEK_API_KEY # credential ref for the API key
85
- 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)
86
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
87
124
  ```
88
125
 
126
+ **Note**: `cordis.patch.yml` may already contain lines for other plugins — append new lines without touching existing ones.
127
+
89
128
  ## Pricing
90
129
 
91
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,7 +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
+ - **一键充值** — 弹层底部「去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
35
35
  - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起查询,不消耗任何 token。
36
36
 
37
37
  ## 架构
@@ -70,18 +70,59 @@ dsh plugin --profile web add "link:$(pwd)"
70
70
 
71
71
  ## 配置
72
72
 
73
- 在 `~/.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` 中追加以下内容(**只改你需要的那几行**,其余保持默认即可):
74
86
 
75
87
  ```yaml
76
88
  - id: balance-widget
77
89
  name: dsh-balance-widget
78
90
  config:
79
- balanceBaseURL: https://api.deepseek.com # 官方余额接口
80
- balanceApiKeyEnv: DEEPSEEK_API_KEY # 凭据服务中的密钥 ref
81
- requestTimeoutMs: 5000 # 余额查询超时
91
+ balanceBaseURL: https://api.deepseek.com # 官方余额接口(一般不用改)
92
+ balanceApiKeyEnv: DEEPSEEK_API_KEY # 凭据服务中的密钥 ref(一般不用改)
93
+ requestTimeoutMs: 5000 # 余额查询超时(毫秒)
82
94
  modelId: deepseek-v4-flash # 成本计价模型(可改 deepseek-v4-pro)
95
+ lowThreshold: 5 # 余额低于此值(¥)图标变黄提醒
96
+ criticalThreshold: 1 # 余额低于此值(¥)图标变红警告
83
97
  ```
84
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
+
85
126
  ## 定价说明
86
127
 
87
128
  内置 DeepSeek 官方 2026-08-17 峰谷定价(元 / 百万 tokens),高峰时段为北京时间 09:00–12:00、14:00–18:00,价格为空闲时段两倍:
package/lib/client.js CHANGED
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
8
8
  let react = require("react");
9
9
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
10
  //#region lib/types/client/styles.js
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
+ 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)}}";
12
12
  const tagId = "dsh-balance-widget/styles.css";
13
13
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
14
14
  const tag = document.createElement("style");
@@ -106,6 +106,12 @@ window.__ModuleLoader__.load({
106
106
  if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
107
107
  return String(value);
108
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
+ }
109
115
  //#endregion
110
116
  //#region lib/types/client/balance-api.js
111
117
  /** Same-origin balance route (host half registers it over ctx.webServer). */
@@ -133,7 +139,13 @@ window.__ModuleLoader__.load({
133
139
  }
134
140
  const payload = await response.json();
135
141
  if (payload.ok === false) return { ok: false, error: payload.error ?? "unknown balance error" };
136
- 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
+ };
137
149
  } catch (error) {
138
150
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
139
151
  }
@@ -206,14 +218,22 @@ window.__ModuleLoader__.load({
206
218
  const [error, setError] = (0, react.useState)(null);
207
219
  const [loading, setLoading] = (0, react.useState)(false);
208
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);
209
224
  const usage = useProjection("tokenUsage");
210
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;
211
228
  /** Fetch balance only (used for the always-visible corner amount). */
212
229
  const refreshBalance = async () => {
213
230
  const result = await fetchBalance();
214
231
  if (result.ok) {
215
232
  setBalance(displayBalance(result.balanceInfos));
233
+ setLastUpdated(Date.now());
216
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);
217
237
  }
218
238
  };
219
239
  /** Fetch all data; optionally keeps the popover's silent loading state. */
@@ -230,7 +250,10 @@ window.__ModuleLoader__.load({
230
250
  const failures = [];
231
251
  if (balanceResult.ok) {
232
252
  setBalance(displayBalance(balanceResult.balanceInfos));
253
+ setLastUpdated(Date.now());
233
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);
234
257
  } else {
235
258
  setBalance(null);
236
259
  failures.push(`余额: ${balanceResult.error}`);
@@ -242,11 +265,34 @@ window.__ModuleLoader__.load({
242
265
  setError(failures.length > 0 ? failures.join(";") : null);
243
266
  if (showLoading) setLoading(false);
244
267
  };
245
- // Always fetch the balance on mount so the corner shows the amount
246
- // without requiring a click first.
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).
247
270
  (0, react.useEffect)(() => {
248
271
  refreshBalance();
272
+ const timer = setInterval(refreshBalance, 60000);
273
+ return () => clearInterval(timer);
249
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]);
250
296
  const toggle = async () => {
251
297
  const next = !open;
252
298
  setOpen(next);
@@ -258,13 +304,15 @@ window.__ModuleLoader__.load({
258
304
  setRefreshing(false);
259
305
  };
260
306
  return (0, react_jsx_runtime.jsx)("div", {
307
+ ref: wrapRef,
261
308
  className: styles.wrap,
262
309
  children: (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, {
263
310
  children: [(0, react_jsx_runtime.jsxs)("button", {
264
311
  type: "button",
265
312
  className: styles.btn,
313
+ "data-level": balanceLevel > 0 ? balanceLevel : undefined,
266
314
  "aria-label": t("aria"),
267
- title: t("title"),
315
+ title: balanceLevel > 0 ? t(`warn.title.${balanceLevel}`) : t("title"),
268
316
  onClick: toggle,
269
317
  children: ["💰", balance !== null && (0, react_jsx_runtime.jsx)("span", {
270
318
  children: `${balance.symbol}${balance.total.toFixed(2)}`
@@ -303,9 +351,14 @@ window.__ModuleLoader__.load({
303
351
  })
304
352
  }), (0, react_jsx_runtime.jsx)("span", {
305
353
  className: styles.val,
354
+ "data-level": balanceLevel > 0 ? balanceLevel : undefined,
306
355
  children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
307
356
  })]
308
357
  })
358
+ }), balanceLevel > 0 && (0, react_jsx_runtime.jsx)("div", {
359
+ className: styles.err,
360
+ role: "status",
361
+ children: t(`warn.text.${balanceLevel}`)
309
362
  }), (0, react_jsx_runtime.jsx)("div", {
310
363
  className: styles.row,
311
364
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
@@ -376,7 +429,7 @@ window.__ModuleLoader__.load({
376
429
  children: error
377
430
  }), (0, react_jsx_runtime.jsx)("div", {
378
431
  className: styles.hint,
379
- children: t("hint")
432
+ children: lastUpdated !== null ? `${t("updatedAt")} ${formatTime(lastUpdated)} · ${t("hint")}` : t("hint")
380
433
  }), (0, react_jsx_runtime.jsx)("div", {
381
434
  className: styles.foot,
382
435
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
@@ -412,7 +465,7 @@ window.__ModuleLoader__.load({
412
465
  "refresh.aria": "刷新数据",
413
466
  "refresh.title": "刷新余额与成本",
414
467
  "balance": "账户余额",
415
- "sessionCost": "本会话成本",
468
+ "sessionCost": "这个对话已花费",
416
469
  "lastPromptCost": "最近一次提问",
417
470
  "todayCost": "今天总成本",
418
471
  "tokens": "Token 用量",
@@ -422,6 +475,11 @@ window.__ModuleLoader__.load({
422
475
  "explain.lastPromptCost": "你刚发的这条提问(含 AI 回复)的费用估算。",
423
476
  "explain.todayCost": "今天所有对话(含其他窗口)的费用合计估算。",
424
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": "更新于",
425
483
  "hint": "价格为估算值,实际以官方账单为准。",
426
484
  "topUp": "去充值",
427
485
  "close": "关闭"
@@ -433,7 +491,7 @@ window.__ModuleLoader__.load({
433
491
  "refresh.aria": "Refresh data",
434
492
  "refresh.title": "Refresh balance and costs",
435
493
  "balance": "Account balance",
436
- "sessionCost": "This session cost",
494
+ "sessionCost": "This chat spent",
437
495
  "lastPromptCost": "Last prompt",
438
496
  "todayCost": "Today total",
439
497
  "tokens": "Token usage",
@@ -443,6 +501,11 @@ window.__ModuleLoader__.load({
443
501
  "explain.lastPromptCost": "Estimated cost of your latest prompt, including the AI reply.",
444
502
  "explain.todayCost": "Estimated total cost across all conversations today.",
445
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",
446
509
  "hint": "Prices are estimates; actual billing from the provider is authoritative.",
447
510
  "topUp": "Top up",
448
511
  "close": "Close"
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
 
@@ -435,6 +445,26 @@ async function collectAllSessionSamples() {
435
445
  return perSession;
436
446
  }
437
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
+
438
468
  /** Build the route table for the balance widget. */
439
469
  function makeRoutes(ctx, config) {
440
470
  return [
@@ -445,9 +475,19 @@ function makeRoutes(ctx, config) {
445
475
  if (!guard(req, res, "GET")) return;
446
476
  const result = await fetchBalance(ctx, config);
447
477
  if (result.ok) {
448
- 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
+ });
449
484
  } else {
450
- 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
+ });
451
491
  }
452
492
  }
453
493
  },
@@ -473,14 +513,19 @@ function makeRoutes(ctx, config) {
473
513
  writeJson(res, 404, { error: `session ${sessionId} not found`, modelId: config.modelId });
474
514
  return;
475
515
  }
476
- const result = await decompressSession(file);
477
- if (!result.ok) {
478
- writeJson(res, 502, { error: result.error, 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 });
479
526
  return;
480
527
  }
481
- const samples = parseSessionUsages(result.text);
482
- const last = lastTurnCost(samples);
483
- writeJson(res, 200, { ...last, modelId: config.modelId });
528
+ writeJson(res, 200, { ...outcome.value, modelId: config.modelId });
484
529
  }
485
530
  },
486
531
  {
@@ -488,9 +533,13 @@ function makeRoutes(ctx, config) {
488
533
  path: API.todayCost,
489
534
  handler: async (req, res) => {
490
535
  if (!guard(req, res, "GET")) return;
491
- const perSession = await collectAllSessionSamples();
492
- const result = todayCost(perSession);
493
- 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 });
494
543
  }
495
544
  }
496
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.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [