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 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
+ | ![Balance corner](docs/screenshot-corner.png) | ![Popover detail](docs/screenshot-popover.png) |
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
+ | ![余额常驻](docs/screenshot-corner.png) | ![弹框详情](docs/screenshot-popover.png) |
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/ExplainIcon.js
196
+ //#region lib/types/client/SidebarBalanceCard.js
184
197
  /**
185
- * A small ⓘ marker with a hover tooltip explaining a term. Wraps the
186
- * official DSH Tooltip so the popover stays visually consistent.
187
- * @param {Object} props - label (tooltip text), t (locale).
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 ExplainIcon({ label, t }) {
190
- return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
191
- label: label,
192
- side: "top",
193
- delayMs: 300,
194
- children: (0, react_jsx_runtime.jsx)("span", {
195
- role: "img",
196
- "aria-label": t("explain.aria"),
197
- style: { cursor: "help", color: "var(--dsw-alias-label-tertiary)", marginLeft: 3, fontSize: 11, lineHeight: 1 },
198
- children: (0, react_jsx_runtime.jsx)("span", {
199
- children: "ⓘ"
200
- })
201
- })
202
- });
203
- }
204
- //#endregion
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 usage = useProjection("tokenUsage");
225
- const priced = priceSession(usage, modelId);
226
- /** Balance severity: 0=ok, 1=low, 2=critical (thresholds from host config). */
227
- const balanceLevel = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
228
- /** Fetch balance only (used for the always-visible corner amount). */
229
- const refreshBalance = async () => {
230
- const result = await fetchBalance();
231
- if (result.ok) {
232
- setBalance(displayBalance(result.balanceInfos));
233
- setLastUpdated(Date.now());
234
- if (typeof result.modelId === "string" && result.modelId !== "") setModelId(result.modelId);
235
- if (typeof result.lowThreshold === "number" && result.lowThreshold >= 0) setLowThreshold(result.lowThreshold);
236
- if (typeof result.criticalThreshold === "number" && result.criticalThreshold >= 0) setCriticalThreshold(result.criticalThreshold);
237
- }
238
- };
239
- /** Fetch all data; optionally keeps the popover's silent loading state. */
240
- const refreshAll = async (showLoading) => {
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 [balanceResult, lastResult, todayResult] = await Promise.all([
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 (balanceResult.ok) {
252
- setBalance(displayBalance(balanceResult.balanceInfos));
253
- setLastUpdated(Date.now());
254
- if (typeof balanceResult.modelId === "string" && balanceResult.modelId !== "") setModelId(balanceResult.modelId);
255
- if (typeof balanceResult.lowThreshold === "number" && balanceResult.lowThreshold >= 0) setLowThreshold(balanceResult.lowThreshold);
256
- if (typeof balanceResult.criticalThreshold === "number" && balanceResult.criticalThreshold >= 0) setCriticalThreshold(balanceResult.criticalThreshold);
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
- setBalance(null);
259
- failures.push(`余额: ${balanceResult.error}`);
225
+ failures.push(`余额: ${bResult.error}`);
260
226
  }
261
- if (lastResult.ok) setLastCost(lastResult.cost);
262
- else if (lastResult.error) failures.push(`最近提问: ${lastResult.error}`);
263
- if (todayResult.ok) setTodayCost(todayResult.cost);
264
- else if (todayResult.error) failures.push(`今日: ${todayResult.error}`);
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
- refreshBalance();
272
- const timer = setInterval(refreshBalance, 60000);
233
+ refresh(false);
234
+ const timer = setInterval(() => refresh(false), 60000);
273
235
  return () => clearInterval(timer);
274
236
  }, []);
275
- // Close the popover when clicking anywhere outside the widget.
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
- // 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]);
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 refreshAll(true);
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.wrap,
309
- children: (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, {
310
- children: [(0, react_jsx_runtime.jsxs)("button", {
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.btn,
313
- "data-level": balanceLevel > 0 ? balanceLevel : undefined,
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.head,
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
- className: styles.title,
329
- children: t("title")
330
- }), (0, react_jsx_runtime.jsx)("button", {
331
- type: "button",
332
- className: styles.refresh,
333
- "aria-label": t("refresh.aria"),
334
- title: t("refresh.title"),
335
- "data-spinning": refreshing || undefined,
336
- disabled: refreshing,
337
- onClick: onRefresh,
338
- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, {})
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.row,
343
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
344
- children: [(0, react_jsx_runtime.jsx)("span", {
345
- className: styles.label,
346
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
347
- children: [t("balance"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
348
- label: t("explain.balance"),
349
- t
350
- })]
351
- })
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.row,
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
- className: styles.label,
367
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
368
- children: [t("sessionCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
369
- label: t("explain.sessionCost"),
370
- t
371
- })]
372
- })
292
+ children: t("todayCostShort")
373
293
  }), (0, react_jsx_runtime.jsx)("span", {
374
- className: styles.val,
375
- children: priced !== null ? formatCny(priced.total) : "—"
294
+ children: todayCost !== null ? formatCny(todayCost) : "—"
376
295
  })]
377
296
  })
378
- }), (0, react_jsx_runtime.jsx)("div", {
379
- className: styles.row,
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.label,
383
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
384
- children: [t("lastPromptCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
385
- label: t("explain.lastPromptCost"),
386
- t
387
- })]
388
- })
389
- }), (0, react_jsx_runtime.jsx)("span", {
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
- className: styles.label,
399
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
400
- children: [t("todayCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
401
- label: t("explain.todayCost"),
402
- t
403
- })]
404
- })
321
+ children: t("balance")
405
322
  }), (0, react_jsx_runtime.jsx)("span", {
406
323
  className: styles.val,
407
- children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
324
+ "data-level": level > 0 ? level : undefined,
325
+ children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
408
326
  })]
409
327
  })
410
- }), priced !== null && (0, react_jsx_runtime.jsx)("div", {
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
- className: styles.label,
415
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
416
- children: [t("tokens"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
417
- label: t("explain.tokens"),
418
- t
419
- })]
420
- })
336
+ children: t("todayCost")
421
337
  }), (0, react_jsx_runtime.jsx)("span", {
422
338
  className: styles.val,
423
- children: `${formatTokens((usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0))} in / ${formatTokens(usage.outputTokens ?? 0)} out`
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.hint,
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: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
442
- children: ["⚡", t("topUp")]
443
- })
354
+ children: t("topUp")
444
355
  }), (0, react_jsx_runtime.jsx)("button", {
445
356
  type: "button",
446
- className: styles.btn,
447
- onClick: () => setOpen(false),
448
- children: t("close")
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
- "updatedAt": "更新于",
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
- "updatedAt": "Updated",
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 corner balance widget in the
522
- * conversation session header utilities slot.
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("conversation.session.header.utilities", () => ctx.slots.register({
531
- name: "conversation.session.header.utilities",
428
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
429
+ name: "sidebar.footer.action",
532
430
  id: "balance-widget",
533
- order: 90,
534
- locale: NS,
535
- inject: (sessionId) => ({ sessionId })
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 PRICING = {
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 (PRICING[modelId] !== void 0) return modelId;
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())) ? PRICING[pricingKey(modelId)].peak : PRICING[pricingKey(modelId)].offPeak;
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.3.2",
4
+ "version": "0.4.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [