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