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 +44 -4
- package/README.md +46 -4
- package/lib/client.js +237 -44
- package/lib/index.js +102 -22
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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;
|
|
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 {
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
"
|
|
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
|
|
314
|
-
"lastPromptCost": "Last prompt
|
|
315
|
-
"todayCost": "Today total
|
|
494
|
+
"sessionCost": "This chat spent",
|
|
495
|
+
"lastPromptCost": "Last prompt",
|
|
496
|
+
"todayCost": "Today total",
|
|
316
497
|
"tokens": "Token usage",
|
|
317
|
-
"
|
|
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
|
|
295
|
-
*
|
|
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
|
|
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
|
|
401
|
-
if (
|
|
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, {
|
|
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, {
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
|
461
|
-
const
|
|
462
|
-
|
|
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.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|