dsh-balance-widget 0.3.0 → 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 +43 -4
- package/README.md +45 -4
- package/lib/client.js +71 -8
- package/lib/index.js +63 -14
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -74,18 +74,57 @@ Then restart `dsh web`.
|
|
|
74
74
|
|
|
75
75
|
## Configuration
|
|
76
76
|
|
|
77
|
-
|
|
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
|
@@ -70,18 +70,59 @@ dsh plugin --profile web add "link:$(pwd)"
|
|
|
70
70
|
|
|
71
71
|
## 配置
|
|
72
72
|
|
|
73
|
-
|
|
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 {
|
|
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
|
-
//
|
|
246
|
-
//
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
492
|
-
const
|
|
493
|
-
|
|
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.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|