dsh-balance-widget 0.3.2 → 0.4.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 +10 -0
- package/README.md +10 -0
- package/lib/client.js +108 -211
- package/lib/index.js +176 -6
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
|
|
12
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.
|
|
13
13
|
|
|
14
|
+
## Preview
|
|
15
|
+
|
|
16
|
+
| Balance always visible | Popover on click |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
|  |  |
|
|
19
|
+
|
|
14
20
|
## How it differs from similar plugins
|
|
15
21
|
|
|
16
22
|
| Aspect | This plugin | Others (dsh-balance / dsh-token-price / ...) |
|
|
@@ -32,6 +38,10 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
|
|
|
32
38
|
- **Today total cost (estimate)** — Walks every session under `~/.dsh/sessions/` and sums today's (calendar day) token usage × price.
|
|
33
39
|
- **Token usage** — Also shows the session's input (incl. cache hits) / output tokens.
|
|
34
40
|
- **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.
|
|
41
|
+
- **Sidebar card** — a persistent card at the sidebar footer shows balance, a remaining-ratio bar, and today's total; globally visible, refreshes every 60s.
|
|
42
|
+
- **Remaining-ratio bar** — blue (healthy) → amber (below lowThreshold) → red (below criticalThreshold).
|
|
43
|
+
- **Official price auto-sync** — fetches the DeepSeek official pricing page on startup and every 12h; falls back to built-in rates on failure.
|
|
44
|
+
- **Agent tool** — a `deepseek_billing` tool lets the model answer "how much balance do I have / how much did today cost".
|
|
35
45
|
- **On-demand refresh** — No polling, no background requests; endpoints are only hit when you click the icon. Costs zero tokens to use.
|
|
36
46
|
|
|
37
47
|
## Why this plugin
|
package/README.md
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右上角(角落)渲染一个 💰 图标,点击弹出账户余额与本会话的估算成本。
|
|
13
13
|
|
|
14
|
+
## 效果预览
|
|
15
|
+
|
|
16
|
+
| 余额常驻右上角 | 点击弹出详情 |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
|  |  |
|
|
19
|
+
|
|
14
20
|
## 与同类插件的区别
|
|
15
21
|
|
|
16
22
|
| 特点 | 本插件 | 同类插件(dsh-balance / dsh-token-price 等) |
|
|
@@ -32,6 +38,10 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右
|
|
|
32
38
|
- **今天总成本(估算)** — 遍历 `~/.dsh/sessions/` 下所有会话,累加今天(自然日)的 token 用量 × 单价。
|
|
33
39
|
- **Token 用量** — 同时展示本会话输入(含缓存命中)/ 输出 token 数。
|
|
34
40
|
- **一键充值** — 弹层底部「去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
|
|
41
|
+
- **侧边栏常驻卡片** — 侧边栏底部(设置上方)显示余额 + 剩余比例条 + 今日花费,全局可见,60 秒自动刷新。
|
|
42
|
+
- **余额剩余比例条** — 蓝色(充足)→ 琥珀(低于 lowThreshold)→ 红色(低于 criticalThreshold)三档。
|
|
43
|
+
- **官方价格自动同步** — 启动时 + 每 12 小时抓取 DeepSeek 官方定价页,改价自动跟进;失败回退内置价目表。
|
|
44
|
+
- **模型工具查询** — 新增 `deepseek_billing` 工具,可直接问模型"余额多少/今天花了多少"。
|
|
35
45
|
- **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起查询,不消耗任何 token。
|
|
36
46
|
|
|
37
47
|
## 架构
|
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_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
|
+
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)}}.dshbw_side{display:flex;flex-direction:column;gap:4px;padding:6px 8px;margin:0 8px 4px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}.dshbw_side:hover{background:var(--dsw-alias-interactive-bg-hover)}.dshbw_sideTop{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;line-height:18px}.dshbw_sideAmount{font-weight:600}.dshbw_sideAmount[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_sideAmount[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.dshbw_bar{height:4px;border-radius:2px;background:var(--dsw-alias-border-l2);overflow:hidden}.dshbw_barFill{height:100%;border-radius:2px;transition:width .4s ease}.dshbw_barFill[data-level=\"0\"]{background:var(--dsw-alias-state-business-primary)}.dshbw_barFill[data-level=\"1\"]{background:var(--dsw-alias-state-warn-primary)}.dshbw_barFill[data-level=\"2\"]{background:var(--dsw-alias-state-error-primary)}.dshbw_sideFoot{display:flex;justify-content:space-between;font-size:10px;line-height:14px;color:var(--dsw-alias-label-tertiary)}.dshbw_sideWrap{position:relative}.dshbw_side{display:flex;flex-direction:column;gap:4px;width:100%;padding:6px 8px;margin:0 8px 4px;border:none;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;text-align:left}.dshbw_side:hover{background:var(--dsw-alias-interactive-bg-hover)}.dshbw_sidePop{position:absolute;bottom:calc(100% + 8px);left:8px;z-index:70;min-width:240px;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_sidePopHead{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.dshbw_sidePopTitle{font-weight:600}.dshbw_sidePopClose{background:none;border:none;cursor:pointer;color:var(--dsw-alias-label-tertiary);font-size:16px;line-height:1;padding:0 2px;font-family:inherit}.dshbw_sidePopClose:hover{color:var(--dsw-alias-label-primary)}.dshbw_sidePopFoot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:10px}.dshbw_sidePopRefresh{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:22px;height:22px;border-radius:6px;padding:0;font-family:inherit}.dshbw_sidePopRefresh:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}";
|
|
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");
|
|
@@ -30,7 +30,20 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
"err": "dshbw_err",
|
|
31
31
|
"hint": "dshbw_hint",
|
|
32
32
|
"foot": "dshbw_foot",
|
|
33
|
-
"link": "dshbw_link"
|
|
33
|
+
"link": "dshbw_link",
|
|
34
|
+
"side": "dshbw_side",
|
|
35
|
+
"sideWrap": "dshbw_sideWrap",
|
|
36
|
+
"sideTop": "dshbw_sideTop",
|
|
37
|
+
"sideAmount": "dshbw_sideAmount",
|
|
38
|
+
"bar": "dshbw_bar",
|
|
39
|
+
"barFill": "dshbw_barFill",
|
|
40
|
+
"sideFoot": "dshbw_sideFoot",
|
|
41
|
+
"sidePop": "dshbw_sidePop",
|
|
42
|
+
"sidePopHead": "dshbw_sidePopHead",
|
|
43
|
+
"sidePopTitle": "dshbw_sidePopTitle",
|
|
44
|
+
"sidePopClose": "dshbw_sidePopClose",
|
|
45
|
+
"sidePopFoot": "dshbw_sidePopFoot",
|
|
46
|
+
"sidePopRefresh": "dshbw_sidePopRefresh"
|
|
34
47
|
};
|
|
35
48
|
//#endregion
|
|
36
49
|
//#region lib/types/client/pricing.js
|
|
@@ -180,99 +193,48 @@ window.__ModuleLoader__.load({
|
|
|
180
193
|
return { symbol, total, currency: entry.currency };
|
|
181
194
|
}
|
|
182
195
|
//#endregion
|
|
183
|
-
//#region lib/types/client/
|
|
196
|
+
//#region lib/types/client/SidebarBalanceCard.js
|
|
184
197
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
198
|
+
* Sidebar footer card: balance + remaining-ratio bar + today cost.
|
|
199
|
+
* Root-scoped (global, not per-session) — fetches balance itself and
|
|
200
|
+
* refreshes every 60s. Clicking opens a detail popover (balance, today
|
|
201
|
+
* cost, top-up, refresh) using only global data.
|
|
202
|
+
* @param {Object} props - wide (sidebar expanded), t (locale).
|
|
188
203
|
*/
|
|
189
|
-
function
|
|
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
|
|
205
|
-
//#region lib/types/client/BalanceWidget.js
|
|
206
|
-
/**
|
|
207
|
-
* Corner widget: a small balance button in the session header utilities
|
|
208
|
-
* slot. Clicking opens a popover with the account balance and the current
|
|
209
|
-
* session's estimated spend (priced from the tokenUsage projection).
|
|
210
|
-
* @param {Object} props - useProjection (framework), sessionId (slot scope).
|
|
211
|
-
*/
|
|
212
|
-
function BalanceWidget({ useProjection, sessionId, t }) {
|
|
204
|
+
function SidebarBalanceCard({ wide, t }) {
|
|
213
205
|
const [open, setOpen] = (0, react.useState)(false);
|
|
214
206
|
const [balance, setBalance] = (0, react.useState)(null);
|
|
215
|
-
const [modelId, setModelId] = (0, react.useState)("deepseek-v4-flash");
|
|
216
|
-
const [lastCost, setLastCost] = (0, react.useState)(null);
|
|
217
207
|
const [todayCost, setTodayCost] = (0, react.useState)(null);
|
|
218
|
-
const [error, setError] = (0, react.useState)(null);
|
|
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
208
|
const [lowThreshold, setLowThreshold] = (0, react.useState)(5);
|
|
223
209
|
const [criticalThreshold, setCriticalThreshold] = (0, react.useState)(1);
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
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) => {
|
|
210
|
+
const [error, setError] = (0, react.useState)(null);
|
|
211
|
+
const [loading, setLoading] = (0, react.useState)(false);
|
|
212
|
+
const refresh = async (showLoading) => {
|
|
241
213
|
if (showLoading) setLoading(true);
|
|
242
214
|
setError(null);
|
|
243
|
-
const [
|
|
215
|
+
const [bResult, tResult] = await Promise.all([
|
|
244
216
|
fetchBalance(),
|
|
245
|
-
fetchCost(LAST_COST_ROUTE, sessionId),
|
|
246
217
|
fetchCost(TODAY_COST_ROUTE)
|
|
247
218
|
]);
|
|
248
|
-
// Collect every failure into one fresh message; never accumulate
|
|
249
|
-
// across refreshes (each refresh starts from a clean slate).
|
|
250
219
|
const failures = [];
|
|
251
|
-
if (
|
|
252
|
-
setBalance(displayBalance(
|
|
253
|
-
|
|
254
|
-
if (typeof
|
|
255
|
-
if (typeof balanceResult.lowThreshold === "number" && balanceResult.lowThreshold >= 0) setLowThreshold(balanceResult.lowThreshold);
|
|
256
|
-
if (typeof balanceResult.criticalThreshold === "number" && balanceResult.criticalThreshold >= 0) setCriticalThreshold(balanceResult.criticalThreshold);
|
|
220
|
+
if (bResult.ok) {
|
|
221
|
+
setBalance(displayBalance(bResult.balanceInfos));
|
|
222
|
+
if (typeof bResult.lowThreshold === "number" && bResult.lowThreshold >= 0) setLowThreshold(bResult.lowThreshold);
|
|
223
|
+
if (typeof bResult.criticalThreshold === "number" && bResult.criticalThreshold >= 0) setCriticalThreshold(bResult.criticalThreshold);
|
|
257
224
|
} else {
|
|
258
|
-
|
|
259
|
-
failures.push(`余额: ${balanceResult.error}`);
|
|
225
|
+
failures.push(`余额: ${bResult.error}`);
|
|
260
226
|
}
|
|
261
|
-
if (
|
|
262
|
-
else if (
|
|
263
|
-
if (todayResult.ok) setTodayCost(todayResult.cost);
|
|
264
|
-
else if (todayResult.error) failures.push(`今日: ${todayResult.error}`);
|
|
227
|
+
if (tResult.ok) setTodayCost(tResult.cost);
|
|
228
|
+
else if (tResult.error) failures.push(`今日: ${tResult.error}`);
|
|
265
229
|
setError(failures.length > 0 ? failures.join(";") : null);
|
|
266
230
|
if (showLoading) setLoading(false);
|
|
267
231
|
};
|
|
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
232
|
(0, react.useEffect)(() => {
|
|
271
|
-
|
|
272
|
-
const timer = setInterval(
|
|
233
|
+
refresh(false);
|
|
234
|
+
const timer = setInterval(() => refresh(false), 60000);
|
|
273
235
|
return () => clearInterval(timer);
|
|
274
236
|
}, []);
|
|
275
|
-
// Close the popover when clicking
|
|
237
|
+
// Close the popover when clicking outside the card.
|
|
276
238
|
const wrapRef = (0, react.useRef)(null);
|
|
277
239
|
(0, react.useEffect)(() => {
|
|
278
240
|
if (!open) return;
|
|
@@ -283,144 +245,98 @@ window.__ModuleLoader__.load({
|
|
|
283
245
|
document.addEventListener("mousedown", onDocClick);
|
|
284
246
|
return () => document.removeEventListener("mousedown", onDocClick);
|
|
285
247
|
}, [open]);
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
refreshAll(false);
|
|
293
|
-
}, 30000);
|
|
294
|
-
return () => clearInterval(timer);
|
|
295
|
-
}, [open]);
|
|
248
|
+
const level = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
|
|
249
|
+
// Remaining-ratio: 100% at lowThreshold (or above), 0% at 0.
|
|
250
|
+
let percent = 100;
|
|
251
|
+
if (balance !== null && balance.total < lowThreshold) {
|
|
252
|
+
percent = lowThreshold <= 0 ? 100 : Math.max(0, Math.round(balance.total / lowThreshold * 100));
|
|
253
|
+
}
|
|
296
254
|
const toggle = async () => {
|
|
297
255
|
const next = !open;
|
|
298
256
|
setOpen(next);
|
|
299
|
-
if (next) await
|
|
300
|
-
};
|
|
301
|
-
const onRefresh = async () => {
|
|
302
|
-
setRefreshing(true);
|
|
303
|
-
await refreshAll(false);
|
|
304
|
-
setRefreshing(false);
|
|
257
|
+
if (next) await refresh(true);
|
|
305
258
|
};
|
|
306
259
|
return (0, react_jsx_runtime.jsx)("div", {
|
|
307
260
|
ref: wrapRef,
|
|
308
|
-
className: styles.
|
|
309
|
-
children: (0, react_jsx_runtime.
|
|
310
|
-
children: [(0, react_jsx_runtime.
|
|
261
|
+
className: styles.sideWrap,
|
|
262
|
+
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
263
|
+
children: [(0, react_jsx_runtime.jsx)("button", {
|
|
311
264
|
type: "button",
|
|
312
|
-
className: styles.
|
|
313
|
-
"
|
|
314
|
-
"aria-label": t("aria"),
|
|
315
|
-
title: balanceLevel > 0 ? t(`warn.title.${balanceLevel}`) : t("title"),
|
|
265
|
+
className: styles.side,
|
|
266
|
+
title: t("title"),
|
|
316
267
|
onClick: toggle,
|
|
317
|
-
children: ["💰", balance !== null && (0, react_jsx_runtime.jsx)("span", {
|
|
318
|
-
children: `${balance.symbol}${balance.total.toFixed(2)}`
|
|
319
|
-
})]
|
|
320
|
-
}), open && (0, react_jsx_runtime.jsx)("div", {
|
|
321
|
-
className: styles.pop,
|
|
322
|
-
role: "dialog",
|
|
323
268
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
324
269
|
children: [(0, react_jsx_runtime.jsx)("div", {
|
|
325
|
-
className: styles.
|
|
270
|
+
className: styles.sideTop,
|
|
326
271
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
327
272
|
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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, {})
|
|
273
|
+
children: "💰"
|
|
274
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
275
|
+
className: styles.sideAmount,
|
|
276
|
+
"data-level": level > 0 ? level : undefined,
|
|
277
|
+
children: balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
|
|
339
278
|
})]
|
|
340
279
|
})
|
|
341
280
|
}), (0, react_jsx_runtime.jsx)("div", {
|
|
342
|
-
className: styles.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
label: t("explain.balance"),
|
|
349
|
-
t
|
|
350
|
-
})]
|
|
351
|
-
})
|
|
352
|
-
}), (0, react_jsx_runtime.jsx)("span", {
|
|
353
|
-
className: styles.val,
|
|
354
|
-
"data-level": balanceLevel > 0 ? balanceLevel : undefined,
|
|
355
|
-
children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
|
|
356
|
-
})]
|
|
281
|
+
className: styles.bar,
|
|
282
|
+
"aria-label": t("side.bar"),
|
|
283
|
+
children: (0, react_jsx_runtime.jsx)("div", {
|
|
284
|
+
className: styles.barFill,
|
|
285
|
+
"data-level": level,
|
|
286
|
+
style: { width: `${percent}%` }
|
|
357
287
|
})
|
|
358
|
-
}), balanceLevel > 0 && (0, react_jsx_runtime.jsx)("div", {
|
|
359
|
-
className: styles.err,
|
|
360
|
-
role: "status",
|
|
361
|
-
children: t(`warn.text.${balanceLevel}`)
|
|
362
288
|
}), (0, react_jsx_runtime.jsx)("div", {
|
|
363
|
-
className: styles.
|
|
289
|
+
className: styles.sideFoot,
|
|
364
290
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
365
291
|
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
366
|
-
|
|
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
|
-
})
|
|
292
|
+
children: t("todayCostShort")
|
|
373
293
|
}), (0, react_jsx_runtime.jsx)("span", {
|
|
374
|
-
|
|
375
|
-
children: priced !== null ? formatCny(priced.total) : "—"
|
|
294
|
+
children: todayCost !== null ? formatCny(todayCost) : "—"
|
|
376
295
|
})]
|
|
377
296
|
})
|
|
378
|
-
})
|
|
379
|
-
|
|
297
|
+
})]
|
|
298
|
+
})
|
|
299
|
+
}), open && (0, react_jsx_runtime.jsx)("div", {
|
|
300
|
+
className: styles.sidePop,
|
|
301
|
+
role: "dialog",
|
|
302
|
+
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
303
|
+
children: [(0, react_jsx_runtime.jsx)("div", {
|
|
304
|
+
className: styles.sidePopHead,
|
|
380
305
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
381
306
|
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
382
|
-
className: styles.
|
|
383
|
-
children: (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
className: styles.val,
|
|
391
|
-
children: loading ? "…" : lastCost !== null ? formatCny(lastCost) : "—"
|
|
307
|
+
className: styles.sidePopTitle,
|
|
308
|
+
children: t("title")
|
|
309
|
+
}), (0, react_jsx_runtime.jsx)("button", {
|
|
310
|
+
type: "button",
|
|
311
|
+
className: styles.sidePopClose,
|
|
312
|
+
"aria-label": t("close"),
|
|
313
|
+
onClick: () => setOpen(false),
|
|
314
|
+
children: "×"
|
|
392
315
|
})]
|
|
393
316
|
})
|
|
394
317
|
}), (0, react_jsx_runtime.jsx)("div", {
|
|
395
318
|
className: styles.row,
|
|
396
319
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
397
320
|
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
398
|
-
|
|
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
|
-
})
|
|
321
|
+
children: t("balance")
|
|
405
322
|
}), (0, react_jsx_runtime.jsx)("span", {
|
|
406
323
|
className: styles.val,
|
|
407
|
-
|
|
324
|
+
"data-level": level > 0 ? level : undefined,
|
|
325
|
+
children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
|
|
408
326
|
})]
|
|
409
327
|
})
|
|
410
|
-
}),
|
|
328
|
+
}), level > 0 && (0, react_jsx_runtime.jsx)("div", {
|
|
329
|
+
className: styles.err,
|
|
330
|
+
role: "status",
|
|
331
|
+
children: t(`warn.text.${level}`)
|
|
332
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
411
333
|
className: styles.row,
|
|
412
334
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
413
335
|
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
414
|
-
|
|
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
|
-
})
|
|
336
|
+
children: t("todayCost")
|
|
421
337
|
}), (0, react_jsx_runtime.jsx)("span", {
|
|
422
338
|
className: styles.val,
|
|
423
|
-
children:
|
|
339
|
+
children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
|
|
424
340
|
})]
|
|
425
341
|
})
|
|
426
342
|
}), error !== null && (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -428,24 +344,21 @@ window.__ModuleLoader__.load({
|
|
|
428
344
|
role: "status",
|
|
429
345
|
children: error
|
|
430
346
|
}), (0, react_jsx_runtime.jsx)("div", {
|
|
431
|
-
className: styles.
|
|
432
|
-
children: lastUpdated !== null ? `${t("updatedAt")} ${formatTime(lastUpdated)} · ${t("hint")}` : t("hint")
|
|
433
|
-
}), (0, react_jsx_runtime.jsx)("div", {
|
|
434
|
-
className: styles.foot,
|
|
347
|
+
className: styles.sidePopFoot,
|
|
435
348
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
436
349
|
children: [(0, react_jsx_runtime.jsx)("a", {
|
|
437
350
|
className: styles.link,
|
|
438
351
|
href: TOP_UP_URL,
|
|
439
352
|
target: "_blank",
|
|
440
353
|
rel: "noreferrer",
|
|
441
|
-
children: (
|
|
442
|
-
children: ["⚡", t("topUp")]
|
|
443
|
-
})
|
|
354
|
+
children: t("topUp")
|
|
444
355
|
}), (0, react_jsx_runtime.jsx)("button", {
|
|
445
356
|
type: "button",
|
|
446
|
-
className: styles.
|
|
447
|
-
|
|
448
|
-
|
|
357
|
+
className: styles.sidePopRefresh,
|
|
358
|
+
"aria-label": t("refresh.aria"),
|
|
359
|
+
title: t("refresh.title"),
|
|
360
|
+
onClick: () => refresh(true),
|
|
361
|
+
children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, {})
|
|
449
362
|
})]
|
|
450
363
|
})
|
|
451
364
|
})]
|
|
@@ -465,21 +378,13 @@ window.__ModuleLoader__.load({
|
|
|
465
378
|
"refresh.aria": "刷新数据",
|
|
466
379
|
"refresh.title": "刷新余额与成本",
|
|
467
380
|
"balance": "账户余额",
|
|
468
|
-
"sessionCost": "这个对话已花费",
|
|
469
|
-
"lastPromptCost": "最近一次提问",
|
|
470
381
|
"todayCost": "今天总成本",
|
|
471
|
-
"tokens": "Token 用量",
|
|
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
382
|
"warn.title.1": "余额不足 ¥5,建议及时充值",
|
|
479
383
|
"warn.title.2": "余额低于 ¥1,即将耗尽!",
|
|
480
384
|
"warn.text.1": "⚠️ 余额不足 ¥5,建议及时充值。",
|
|
481
385
|
"warn.text.2": "⛔ 余额低于 ¥1,即将耗尽,请立即充值!",
|
|
482
|
-
"
|
|
386
|
+
"side.bar": "余额剩余比例",
|
|
387
|
+
"todayCostShort": "今日",
|
|
483
388
|
"hint": "价格为估算值,实际以官方账单为准。",
|
|
484
389
|
"topUp": "去充值",
|
|
485
390
|
"close": "关闭"
|
|
@@ -491,21 +396,13 @@ window.__ModuleLoader__.load({
|
|
|
491
396
|
"refresh.aria": "Refresh data",
|
|
492
397
|
"refresh.title": "Refresh balance and costs",
|
|
493
398
|
"balance": "Account balance",
|
|
494
|
-
"sessionCost": "This chat spent",
|
|
495
|
-
"lastPromptCost": "Last prompt",
|
|
496
399
|
"todayCost": "Today total",
|
|
497
|
-
"tokens": "Token usage",
|
|
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
400
|
"warn.title.1": "Balance below ¥5, consider topping up",
|
|
505
401
|
"warn.title.2": "Balance below ¥1, almost exhausted!",
|
|
506
402
|
"warn.text.1": "⚠️ Balance below ¥5, consider topping up.",
|
|
507
403
|
"warn.text.2": "⛔ Balance below ¥1, almost exhausted — top up now!",
|
|
508
|
-
"
|
|
404
|
+
"side.bar": "Balance remaining ratio",
|
|
405
|
+
"todayCostShort": "Today",
|
|
509
406
|
"hint": "Prices are estimates; actual billing from the provider is authoritative.",
|
|
510
407
|
"topUp": "Top up",
|
|
511
408
|
"close": "Close"
|
|
@@ -518,8 +415,9 @@ window.__ModuleLoader__.load({
|
|
|
518
415
|
"locale"
|
|
519
416
|
];
|
|
520
417
|
/**
|
|
521
|
-
* Client plugin body: register the
|
|
522
|
-
*
|
|
418
|
+
* Client plugin body: register the sidebar footer balance card (the
|
|
419
|
+
* single entry point since v0.4.1; the former header widget is removed
|
|
420
|
+
* to avoid duplicate always-visible balances).
|
|
523
421
|
* @param ctx - client root context.
|
|
524
422
|
*/
|
|
525
423
|
function apply(ctx) {
|
|
@@ -527,13 +425,12 @@ window.__ModuleLoader__.load({
|
|
|
527
425
|
zh,
|
|
528
426
|
en
|
|
529
427
|
}), "dsh-balance-widget: dictionaries");
|
|
530
|
-
ctx.slots.inject("
|
|
531
|
-
name: "
|
|
428
|
+
ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
429
|
+
name: "sidebar.footer.action",
|
|
532
430
|
id: "balance-widget",
|
|
533
|
-
order:
|
|
534
|
-
locale: NS
|
|
535
|
-
|
|
536
|
-
}, BalanceWidget));
|
|
431
|
+
order: 10,
|
|
432
|
+
locale: NS
|
|
433
|
+
}, SidebarBalanceCard));
|
|
537
434
|
}
|
|
538
435
|
//#endregion
|
|
539
436
|
exports.apply = apply;
|
package/lib/index.js
CHANGED
|
@@ -221,14 +221,25 @@ async function fetchBalance(ctx, config) {
|
|
|
221
221
|
/**
|
|
222
222
|
* Pricing table (CNY per 1M tokens, official 2026-08-17 peak/off-peak).
|
|
223
223
|
* Mirrors the client half; kept here so host-computed costs use the same
|
|
224
|
-
* numbers.
|
|
224
|
+
* numbers. Mutable: syncOfficialPricing() may replace it with freshly parsed
|
|
225
|
+
* official rates (falling back to these defaults on failure).
|
|
225
226
|
*/
|
|
226
|
-
const
|
|
227
|
+
const DEFAULT_PRICING = {
|
|
227
228
|
"deepseek-v4-flash": { peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 }, offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 } },
|
|
228
229
|
"deepseek-v4-pro": { peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 }, offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 } },
|
|
229
230
|
"deepseek-chat": { peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 }, offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 } },
|
|
230
231
|
"deepseek-reasoner": { peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 }, offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 } }
|
|
231
232
|
};
|
|
233
|
+
let pricingTable = { ...DEFAULT_PRICING };
|
|
234
|
+
/** Source of the current pricing: "default" | "synced". */
|
|
235
|
+
let pricingSource = "default";
|
|
236
|
+
/** Last successful sync time (ms epoch), or null. */
|
|
237
|
+
let pricingSyncedAt = null;
|
|
238
|
+
|
|
239
|
+
/** Official pricing page (parsed for peak/off-peak rates). */
|
|
240
|
+
const PRICING_URL = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/";
|
|
241
|
+
const PRICE_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
242
|
+
|
|
232
243
|
const PRICING_ALIASES = {
|
|
233
244
|
"deepseek-vision": "deepseek-v4-flash",
|
|
234
245
|
"deepseek-official": "deepseek-v4-flash"
|
|
@@ -243,7 +254,7 @@ function isPeak(date) {
|
|
|
243
254
|
/** Map a provider/model id to a pricing key. */
|
|
244
255
|
function pricingKey(modelId) {
|
|
245
256
|
if (modelId === void 0 || modelId === null) return "deepseek-v4-flash";
|
|
246
|
-
if (
|
|
257
|
+
if (pricingTable[modelId] !== void 0) return modelId;
|
|
247
258
|
const alias = PRICING_ALIASES[modelId];
|
|
248
259
|
if (alias !== void 0) return alias;
|
|
249
260
|
// heuristic: pro/reasoner-ish names price as pro
|
|
@@ -258,10 +269,104 @@ function priceUsage(usage, modelId, timeMs) {
|
|
|
258
269
|
const hit = usage.cacheReadTokens ?? 0;
|
|
259
270
|
const output = usage.outputTokens ?? 0;
|
|
260
271
|
if (input + hit + output <= 0) return 0;
|
|
261
|
-
const table = isPeak(new Date(timeMs ?? Date.now())) ?
|
|
272
|
+
const table = isPeak(new Date(timeMs ?? Date.now())) ? pricingTable[pricingKey(modelId)].peak : pricingTable[pricingKey(modelId)].offPeak;
|
|
262
273
|
return (input * table.inputMiss + hit * table.inputHit + output * table.output) / 1e6;
|
|
263
274
|
}
|
|
264
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Parse the official pricing page into our pricingTable shape.
|
|
278
|
+
* The page is HTML; we look for per-model peak/off-peak "per 1M tokens" rates.
|
|
279
|
+
* Returns true on success (pricingTable updated), false on any failure
|
|
280
|
+
* (pricingTable left untouched).
|
|
281
|
+
*/
|
|
282
|
+
async function syncOfficialPricing() {
|
|
283
|
+
try {
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
286
|
+
const response = await fetch(PRICING_URL, { signal: controller.signal });
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
if (!response.ok) return false;
|
|
289
|
+
const html = await response.text();
|
|
290
|
+
const parsed = parsePricingPage(html);
|
|
291
|
+
if (parsed === null || Object.keys(parsed).length === 0) return false;
|
|
292
|
+
// Merge over defaults so any model we cannot parse keeps its fallback.
|
|
293
|
+
const merged = { ...DEFAULT_PRICING };
|
|
294
|
+
for (const [model, rates] of Object.entries(parsed)) merged[model] = rates;
|
|
295
|
+
pricingTable = merged;
|
|
296
|
+
pricingSource = "synced";
|
|
297
|
+
pricingSyncedAt = Date.now();
|
|
298
|
+
return true;
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Best-effort parse of the official pricing page HTML. Looks for the standard
|
|
306
|
+
* "deepseek-v4-flash" / "deepseek-v4-pro" model blocks and per-million-token
|
|
307
|
+
* rates. This is heuristic: it tolerates missing fields and returns a partial
|
|
308
|
+
* table rather than failing the whole sync.
|
|
309
|
+
*/
|
|
310
|
+
function parsePricingPage(html) {
|
|
311
|
+
const out = {};
|
|
312
|
+
const modelIds = ["deepseek-v4-flash", "deepseek-v4-pro"];
|
|
313
|
+
for (const model of modelIds) {
|
|
314
|
+
const block = extractModelBlock(html, model);
|
|
315
|
+
if (block === null) continue;
|
|
316
|
+
const rates = parseRates(block);
|
|
317
|
+
if (rates !== null) out[model] = rates;
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Extract the HTML section mentioning a model id (crude but robust). */
|
|
323
|
+
function extractModelBlock(html, model) {
|
|
324
|
+
const idx = html.indexOf(model);
|
|
325
|
+
if (idx < 0) return null;
|
|
326
|
+
const start = Math.max(0, idx - 400);
|
|
327
|
+
const end = Math.min(html.length, idx + 3000);
|
|
328
|
+
return html.slice(start, end);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Parse per-1M-token rates from a block of HTML text. Looks for numbers near
|
|
333
|
+
* "输入"/"缓存命中"/"输出" (CN page) or "input"/"cache"/"output" (EN page),
|
|
334
|
+
* preferring the off-peak row. Returns { offPeak, peak } or null.
|
|
335
|
+
*/
|
|
336
|
+
function parseRates(block) {
|
|
337
|
+
// Strip tags to plain text for simpler matching.
|
|
338
|
+
const text = block.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
339
|
+
const num = (pattern) => {
|
|
340
|
+
const m = text.match(pattern);
|
|
341
|
+
if (!m) return void 0;
|
|
342
|
+
const v = parseFloat(m[1].replace(/[¥$,\s]/g, ""));
|
|
343
|
+
return Number.isFinite(v) ? v : void 0;
|
|
344
|
+
};
|
|
345
|
+
// Try patterns for off-peak (空闲/off-peak) first, then any.
|
|
346
|
+
const offPeakInputMiss = num(/缓存未命中[^\d]*([\d.]+)/) ?? num(/cache[\s-]*miss[^\d]*([\d.]+)/);
|
|
347
|
+
const offPeakInputHit = num(/缓存命中[^\d]*([\d.]+)/) ?? num(/cache[\s-]*hit[^\d]*([\d.]+)/);
|
|
348
|
+
const offPeakOutput = num(/输出[^\d]*([\d.]+)/) ?? num(/output[^\d]*([\d.]+)/);
|
|
349
|
+
if (offPeakInputMiss === void 0 || offPeakOutput === void 0) return null;
|
|
350
|
+
const peakInputMiss = (offPeakInputMiss ?? 0) * 2;
|
|
351
|
+
const peakInputHit = (offPeakInputHit ?? 0) * 2;
|
|
352
|
+
const peakOutput = (offPeakOutput ?? 0) * 2;
|
|
353
|
+
return {
|
|
354
|
+
peak: { inputMiss: peakInputMiss, inputHit: peakInputHit, output: peakOutput },
|
|
355
|
+
offPeak: { inputMiss: offPeakInputMiss, inputHit: offPeakInputHit ?? 0, output: offPeakOutput }
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Kick off the first sync (fire-and-forget) and schedule the interval. */
|
|
360
|
+
function startPricingSync() {
|
|
361
|
+
syncOfficialPricing().catch(() => {});
|
|
362
|
+
const timer = setInterval(() => {
|
|
363
|
+
syncOfficialPricing().catch(() => {});
|
|
364
|
+
}, PRICE_SYNC_INTERVAL_MS);
|
|
365
|
+
// Unref so the interval never keeps the process alive on its own.
|
|
366
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
367
|
+
return timer;
|
|
368
|
+
}
|
|
369
|
+
|
|
265
370
|
/**
|
|
266
371
|
* Resolve the DSH sessions root directory (defaults to ~/.dsh/sessions).
|
|
267
372
|
* Avoids importing dsh-home-paths to keep zero external dependencies.
|
|
@@ -525,7 +630,7 @@ function makeRoutes(ctx, config) {
|
|
|
525
630
|
writeJson(res, 502, { error: outcome.error, modelId: config.modelId });
|
|
526
631
|
return;
|
|
527
632
|
}
|
|
528
|
-
writeJson(res, 200, { ...outcome.value, modelId: config.modelId });
|
|
633
|
+
writeJson(res, 200, { ...outcome.value, modelId: config.modelId, pricingSource, pricingSyncedAt });
|
|
529
634
|
}
|
|
530
635
|
},
|
|
531
636
|
{
|
|
@@ -539,18 +644,83 @@ function makeRoutes(ctx, config) {
|
|
|
539
644
|
return { value: todayCost(perSession) };
|
|
540
645
|
};
|
|
541
646
|
const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
|
|
542
|
-
writeJson(res, 200, { ...outcome.value, modelId: config.modelId });
|
|
647
|
+
writeJson(res, 200, { ...outcome.value, modelId: config.modelId, pricingSource, pricingSyncedAt });
|
|
543
648
|
}
|
|
544
649
|
}
|
|
545
650
|
];
|
|
546
651
|
}
|
|
547
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Build the `deepseek_billing` agent tool: lets the model query balance and
|
|
655
|
+
* session costs directly ("how much balance do I have?"). Hand-constructed
|
|
656
|
+
* tool object (no defineTool import) to keep zero external dependencies;
|
|
657
|
+
* mirrors the shape defineTool produces.
|
|
658
|
+
*/
|
|
659
|
+
function buildBillingTool(ctx, config) {
|
|
660
|
+
return {
|
|
661
|
+
name: "deepseek_billing",
|
|
662
|
+
description: "Query the DeepSeek account balance and estimated session costs. Use when the user asks about their balance, spending, or token costs. query = 'balance' (account balance), 'cost' (current session estimated cost), or 'both'.",
|
|
663
|
+
parameters: {
|
|
664
|
+
type: "object",
|
|
665
|
+
properties: {
|
|
666
|
+
query: {
|
|
667
|
+
type: "string",
|
|
668
|
+
enum: ["balance", "cost", "both"],
|
|
669
|
+
description: "What to query: balance, cost, or both."
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
required: ["query"]
|
|
673
|
+
},
|
|
674
|
+
async execute(args) {
|
|
675
|
+
const query = args?.query ?? "both";
|
|
676
|
+
const parts = [];
|
|
677
|
+
if (query === "balance" || query === "both") {
|
|
678
|
+
const result = await fetchBalance(ctx, config);
|
|
679
|
+
if (result.ok) {
|
|
680
|
+
const info = (result.balance_infos ?? [])[0];
|
|
681
|
+
if (info !== void 0) {
|
|
682
|
+
parts.push(`Balance: ${info.currency} ${info.total_balance} (granted ${info.granted_balance}, topped up ${info.topped_up_balance})`);
|
|
683
|
+
} else {
|
|
684
|
+
parts.push("Balance: unavailable (no balance info returned)");
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
parts.push(`Balance query failed: ${result.error}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (query === "cost" || query === "both") {
|
|
691
|
+
try {
|
|
692
|
+
const perSession = await collectAllSessionSamples();
|
|
693
|
+
const today = todayCost(perSession);
|
|
694
|
+
parts.push(`Today's estimated cost: CNY ${today.cost.toFixed(4)} (${today.inputTokens} input tokens, ${today.outputTokens} output tokens)`);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
parts.push(`Cost query failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return { result: parts.join("\n") };
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
548
704
|
/** Cordis plugin apply: register routes on the host webServer. */
|
|
549
705
|
export function apply(ctx, config) {
|
|
550
706
|
const resolved = resolveConfig(config);
|
|
551
707
|
const routes = makeRoutes(ctx, resolved);
|
|
552
708
|
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
709
|
+
// Register the agent-facing billing tool when the tools service exists.
|
|
710
|
+
let toolDisposer;
|
|
711
|
+
try {
|
|
712
|
+
if (ctx.tools !== void 0 && typeof ctx.tools.register === "function") {
|
|
713
|
+
toolDisposer = ctx.tools.register(buildBillingTool(ctx, resolved));
|
|
714
|
+
}
|
|
715
|
+
} catch (error) {
|
|
716
|
+
ctx.logger?.warn?.("[dsh-balance-widget] tool registration failed: %s", error instanceof Error ? error.message : String(error));
|
|
717
|
+
}
|
|
718
|
+
// Kick off official price sync (fire-and-forget) and keep the interval
|
|
719
|
+
// handle for teardown.
|
|
720
|
+
const syncTimer = startPricingSync();
|
|
553
721
|
ctx.effect(() => () => {
|
|
722
|
+
clearInterval(syncTimer);
|
|
554
723
|
for (const dispose of disposers) dispose();
|
|
724
|
+
if (typeof toolDisposer === "function") toolDisposer();
|
|
555
725
|
}, "dsh-balance-widget: routes");
|
|
556
726
|
}
|
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.4.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|