dsh-balance-widget 0.4.0 → 0.4.3

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
@@ -164,7 +164,7 @@ Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective
164
164
 
165
165
  - Config tree: `dsh --profile web --dump-config` should show a `balance-widget` entry.
166
166
  - Balance route: after restarting dsh web, `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` should return `{ ok, balance_infos, modelId }`.
167
- - Cost routes: `curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=<sessionId>"` and `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` should return `{ cost, inputTokens, outputTokens, modelId }`.
167
+ - Cost routes: `curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=SESSION_ID"` and `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` should return `{ cost, inputTokens, outputTokens, modelId }`.
168
168
 
169
169
  ## License
170
170
 
package/README.md CHANGED
@@ -162,7 +162,7 @@ DSH 的插件配置统一放在这个文件里:
162
162
 
163
163
  - 配置树:`dsh --profile web --dump-config` 应出现 `balance-widget` 条目
164
164
  - 余额路由:重启 dsh web 后 `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` 应返回 `{ ok, balance_infos, modelId }`
165
- - 成本路由:`curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=<会话id>"` 与 `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` 应返回 `{ cost, inputTokens, outputTokens, modelId }`
165
+ - 成本路由:`curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=SESSION_ID"` 与 `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` 应返回 `{ cost, inputTokens, outputTokens, modelId }`
166
166
 
167
167
  ## License
168
168
 
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)}}.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)}";
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");
@@ -32,11 +32,18 @@ window.__ModuleLoader__.load({
32
32
  "foot": "dshbw_foot",
33
33
  "link": "dshbw_link",
34
34
  "side": "dshbw_side",
35
+ "sideWrap": "dshbw_sideWrap",
35
36
  "sideTop": "dshbw_sideTop",
36
37
  "sideAmount": "dshbw_sideAmount",
37
38
  "bar": "dshbw_bar",
38
39
  "barFill": "dshbw_barFill",
39
- "sideFoot": "dshbw_sideFoot"
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"
40
47
  };
41
48
  //#endregion
42
49
  //#region lib/types/client/pricing.js
@@ -125,9 +132,24 @@ window.__ModuleLoader__.load({
125
132
  /** Same-origin cost routes. */
126
133
  const LAST_COST_ROUTE = "/api/dsh-balance/last-cost";
127
134
  const TODAY_COST_ROUTE = "/api/dsh-balance/today-cost";
135
+ const ACTIVE_COST_ROUTE = "/api/dsh-balance/active-cost";
128
136
  /** Official DeepSeek platform top-up page (opens in a new tab). */
129
137
  const TOP_UP_URL = "https://platform.deepseek.com/top_up";
130
138
  /**
139
+ * Fetch the active session's costs (last prompt + session total).
140
+ * @returns { ok, lastPrompt?, sessionTotal?, error? }
141
+ */
142
+ async function fetchActiveCost() {
143
+ try {
144
+ const response = await fetch(ACTIVE_COST_ROUTE, { headers: { accept: "application/json" } });
145
+ const payload = await response.json().catch(() => ({}));
146
+ if (!response.ok) return { ok: false, error: payload.error ?? `active-cost responded ${response.status}` };
147
+ return { ok: true, lastPrompt: payload.lastPrompt?.cost ?? 0, sessionTotal: payload.sessionTotal?.cost ?? 0 };
148
+ } catch (error) {
149
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
150
+ }
151
+ }
152
+ /**
131
153
  * Fetch the account balance from the host proxy.
132
154
  * @returns { ok, balanceInfos?, modelId?, error? }
133
155
  */
@@ -208,77 +230,73 @@ window.__ModuleLoader__.load({
208
230
  });
209
231
  }
210
232
  //#endregion
211
- //#region lib/types/client/BalanceWidget.js
233
+ //#region lib/types/client/SidebarBalanceCard.js
212
234
  /**
213
- * Corner widget: a small balance button in the session header utilities
214
- * slot. Clicking opens a popover with the account balance and the current
215
- * session's estimated spend (priced from the tokenUsage projection).
216
- * @param {Object} props - useProjection (framework), sessionId (slot scope).
235
+ * Sidebar footer card: balance + remaining-ratio bar + today cost.
236
+ * Root-scoped (global, not per-session) — fetches balance itself and
237
+ * refreshes every 60s. Clicking opens a detail popover (balance, today
238
+ * cost, top-up, refresh) using only global data.
239
+ * @param {Object} props - wide (sidebar expanded), t (locale).
217
240
  */
218
- function BalanceWidget({ useProjection, sessionId, t }) {
241
+ function SidebarBalanceCard({ wide, t }) {
219
242
  const [open, setOpen] = (0, react.useState)(false);
220
243
  const [balance, setBalance] = (0, react.useState)(null);
221
- const [modelId, setModelId] = (0, react.useState)("deepseek-v4-flash");
222
- const [lastCost, setLastCost] = (0, react.useState)(null);
223
- const [todayCost, setTodayCost] = (0, react.useState)(null);
224
- const [error, setError] = (0, react.useState)(null);
225
- const [loading, setLoading] = (0, react.useState)(false);
226
- const [refreshing, setRefreshing] = (0, react.useState)(false);
227
- const [lastUpdated, setLastUpdated] = (0, react.useState)(null);
244
+ const [lastPrompt, setLastPrompt] = (0, react.useState)(null);
245
+ const [sessionTotal, setSessionTotal] = (0, react.useState)(null);
246
+ const [todayWs, setTodayWs] = (0, react.useState)(null);
247
+ const [todayAll, setTodayAll] = (0, react.useState)(null);
228
248
  const [lowThreshold, setLowThreshold] = (0, react.useState)(5);
229
249
  const [criticalThreshold, setCriticalThreshold] = (0, react.useState)(1);
230
- const usage = useProjection("tokenUsage");
231
- const priced = priceSession(usage, modelId);
232
- /** Balance severity: 0=ok, 1=low, 2=critical (thresholds from host config). */
233
- const balanceLevel = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
234
- /** Fetch balance only (used for the always-visible corner amount). */
235
- const refreshBalance = async () => {
236
- const result = await fetchBalance();
237
- if (result.ok) {
238
- setBalance(displayBalance(result.balanceInfos));
239
- setLastUpdated(Date.now());
240
- if (typeof result.modelId === "string" && result.modelId !== "") setModelId(result.modelId);
241
- if (typeof result.lowThreshold === "number" && result.lowThreshold >= 0) setLowThreshold(result.lowThreshold);
242
- if (typeof result.criticalThreshold === "number" && result.criticalThreshold >= 0) setCriticalThreshold(result.criticalThreshold);
243
- }
250
+ const [error, setError] = (0, react.useState)(null);
251
+ const [loading, setLoading] = (0, react.useState)(false);
252
+ /** Fetch today's costs (workspace + all) from the dual-value route. */
253
+ const fetchTodayCosts = async () => {
254
+ const response = await fetch(TODAY_COST_ROUTE, { headers: { accept: "application/json" } });
255
+ const payload = await response.json().catch(() => ({}));
256
+ if (!response.ok) return { ok: false, error: payload.error ?? `today-cost responded ${response.status}` };
257
+ return {
258
+ ok: true,
259
+ workspace: payload.workspace?.cost ?? 0,
260
+ all: payload.all?.cost ?? 0
261
+ };
244
262
  };
245
- /** Fetch all data; optionally keeps the popover's silent loading state. */
246
- const refreshAll = async (showLoading) => {
263
+ const refresh = async (showLoading) => {
247
264
  if (showLoading) setLoading(true);
248
265
  setError(null);
249
- const [balanceResult, lastResult, todayResult] = await Promise.all([
266
+ const [bResult, aResult, tResult] = await Promise.all([
250
267
  fetchBalance(),
251
- fetchCost(LAST_COST_ROUTE, sessionId),
252
- fetchCost(TODAY_COST_ROUTE)
268
+ fetchActiveCost(),
269
+ fetchTodayCosts()
253
270
  ]);
254
- // Collect every failure into one fresh message; never accumulate
255
- // across refreshes (each refresh starts from a clean slate).
256
271
  const failures = [];
257
- if (balanceResult.ok) {
258
- setBalance(displayBalance(balanceResult.balanceInfos));
259
- setLastUpdated(Date.now());
260
- if (typeof balanceResult.modelId === "string" && balanceResult.modelId !== "") setModelId(balanceResult.modelId);
261
- if (typeof balanceResult.lowThreshold === "number" && balanceResult.lowThreshold >= 0) setLowThreshold(balanceResult.lowThreshold);
262
- if (typeof balanceResult.criticalThreshold === "number" && balanceResult.criticalThreshold >= 0) setCriticalThreshold(balanceResult.criticalThreshold);
272
+ if (bResult.ok) {
273
+ setBalance(displayBalance(bResult.balanceInfos));
274
+ if (typeof bResult.lowThreshold === "number" && bResult.lowThreshold >= 0) setLowThreshold(bResult.lowThreshold);
275
+ if (typeof bResult.criticalThreshold === "number" && bResult.criticalThreshold >= 0) setCriticalThreshold(bResult.criticalThreshold);
263
276
  } else {
264
- setBalance(null);
265
- failures.push(`余额: ${balanceResult.error}`);
277
+ failures.push(`余额: ${bResult.error}`);
278
+ }
279
+ if (aResult.ok) {
280
+ setLastPrompt(aResult.lastPrompt);
281
+ setSessionTotal(aResult.sessionTotal);
282
+ } else {
283
+ failures.push(`会话: ${aResult.error}`);
284
+ }
285
+ if (tResult.ok) {
286
+ setTodayWs(tResult.workspace);
287
+ setTodayAll(tResult.all);
288
+ } else {
289
+ failures.push(`今日: ${tResult.error}`);
266
290
  }
267
- if (lastResult.ok) setLastCost(lastResult.cost);
268
- else if (lastResult.error) failures.push(`最近提问: ${lastResult.error}`);
269
- if (todayResult.ok) setTodayCost(todayResult.cost);
270
- else if (todayResult.error) failures.push(`今日: ${todayResult.error}`);
271
291
  setError(failures.length > 0 ? failures.join(";") : null);
272
292
  if (showLoading) setLoading(false);
273
293
  };
274
- // Fetch balance on mount, then auto-refresh every 60s so the corner
275
- // amount never goes stale (balance changes only on usage/top-up).
276
294
  (0, react.useEffect)(() => {
277
- refreshBalance();
278
- const timer = setInterval(refreshBalance, 60000);
295
+ refresh(false);
296
+ const timer = setInterval(() => refresh(false), 60000);
279
297
  return () => clearInterval(timer);
280
298
  }, []);
281
- // Close the popover when clicking anywhere outside the widget.
299
+ // Close the popover when clicking outside the card.
282
300
  const wrapRef = (0, react.useRef)(null);
283
301
  (0, react.useEffect)(() => {
284
302
  if (!open) return;
@@ -289,59 +307,73 @@ window.__ModuleLoader__.load({
289
307
  document.addEventListener("mousedown", onDocClick);
290
308
  return () => document.removeEventListener("mousedown", onDocClick);
291
309
  }, [open]);
292
- // While the popover is open, silently refresh costs every 30s so the
293
- // figures keep up with ongoing conversation activity. The host caches
294
- // cost computations, so this is cheap.
295
- (0, react.useEffect)(() => {
296
- if (!open) return;
297
- const timer = setInterval(() => {
298
- refreshAll(false);
299
- }, 30000);
300
- return () => clearInterval(timer);
301
- }, [open]);
310
+ const level = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
311
+ // Remaining-ratio: 100% at lowThreshold (or above), 0% at 0.
312
+ let percent = 100;
313
+ if (balance !== null && balance.total < lowThreshold) {
314
+ percent = lowThreshold <= 0 ? 100 : Math.max(0, Math.round(balance.total / lowThreshold * 100));
315
+ }
302
316
  const toggle = async () => {
303
317
  const next = !open;
304
318
  setOpen(next);
305
- if (next) await refreshAll(true);
306
- };
307
- const onRefresh = async () => {
308
- setRefreshing(true);
309
- await refreshAll(false);
310
- setRefreshing(false);
319
+ if (next) await refresh(true);
311
320
  };
312
321
  return (0, react_jsx_runtime.jsx)("div", {
313
322
  ref: wrapRef,
314
- className: styles.wrap,
315
- children: (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, {
316
- children: [(0, react_jsx_runtime.jsxs)("button", {
323
+ className: styles.sideWrap,
324
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
325
+ children: [(0, react_jsx_runtime.jsx)("button", {
317
326
  type: "button",
318
- className: styles.btn,
319
- "data-level": balanceLevel > 0 ? balanceLevel : undefined,
320
- "aria-label": t("aria"),
321
- title: balanceLevel > 0 ? t(`warn.title.${balanceLevel}`) : t("title"),
327
+ className: styles.side,
328
+ title: t("title"),
322
329
  onClick: toggle,
323
- children: ["💰", balance !== null && (0, react_jsx_runtime.jsx)("span", {
324
- children: `${balance.symbol}${balance.total.toFixed(2)}`
325
- })]
330
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
331
+ children: [(0, react_jsx_runtime.jsx)("div", {
332
+ className: styles.sideTop,
333
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
334
+ children: [(0, react_jsx_runtime.jsx)("span", {
335
+ children: "💰"
336
+ }), (0, react_jsx_runtime.jsx)("span", {
337
+ className: styles.sideAmount,
338
+ "data-level": level > 0 ? level : undefined,
339
+ children: balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
340
+ })]
341
+ })
342
+ }), (0, react_jsx_runtime.jsx)("div", {
343
+ className: styles.bar,
344
+ "aria-label": t("side.bar"),
345
+ children: (0, react_jsx_runtime.jsx)("div", {
346
+ className: styles.barFill,
347
+ "data-level": level,
348
+ style: { width: `${percent}%` }
349
+ })
350
+ }), (0, react_jsx_runtime.jsx)("div", {
351
+ className: styles.sideFoot,
352
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
353
+ children: [(0, react_jsx_runtime.jsx)("span", {
354
+ children: t("todayCostShort")
355
+ }), (0, react_jsx_runtime.jsx)("span", {
356
+ children: todayAll !== null ? formatCny(todayAll) : "—"
357
+ })]
358
+ })
359
+ })]
360
+ })
326
361
  }), open && (0, react_jsx_runtime.jsx)("div", {
327
- className: styles.pop,
362
+ className: styles.sidePop,
328
363
  role: "dialog",
329
364
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
330
365
  children: [(0, react_jsx_runtime.jsx)("div", {
331
- className: styles.head,
366
+ className: styles.sidePopHead,
332
367
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
333
368
  children: [(0, react_jsx_runtime.jsx)("span", {
334
- className: styles.title,
369
+ className: styles.sidePopTitle,
335
370
  children: t("title")
336
371
  }), (0, react_jsx_runtime.jsx)("button", {
337
372
  type: "button",
338
- className: styles.refresh,
339
- "aria-label": t("refresh.aria"),
340
- title: t("refresh.title"),
341
- "data-spinning": refreshing || undefined,
342
- disabled: refreshing,
343
- onClick: onRefresh,
344
- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, {})
373
+ className: styles.sidePopClose,
374
+ "aria-label": t("close"),
375
+ onClick: () => setOpen(false),
376
+ children: "×"
345
377
  })]
346
378
  })
347
379
  }), (0, react_jsx_runtime.jsx)("div", {
@@ -357,28 +389,28 @@ window.__ModuleLoader__.load({
357
389
  })
358
390
  }), (0, react_jsx_runtime.jsx)("span", {
359
391
  className: styles.val,
360
- "data-level": balanceLevel > 0 ? balanceLevel : undefined,
392
+ "data-level": level > 0 ? level : undefined,
361
393
  children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
362
394
  })]
363
395
  })
364
- }), balanceLevel > 0 && (0, react_jsx_runtime.jsx)("div", {
396
+ }), level > 0 && (0, react_jsx_runtime.jsx)("div", {
365
397
  className: styles.err,
366
398
  role: "status",
367
- children: t(`warn.text.${balanceLevel}`)
399
+ children: t(`warn.text.${level}`)
368
400
  }), (0, react_jsx_runtime.jsx)("div", {
369
401
  className: styles.row,
370
402
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
371
403
  children: [(0, react_jsx_runtime.jsx)("span", {
372
404
  className: styles.label,
373
405
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
374
- children: [t("sessionCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
375
- label: t("explain.sessionCost"),
406
+ children: [t("lastPrompt"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
407
+ label: t("explain.lastPrompt"),
376
408
  t
377
409
  })]
378
410
  })
379
411
  }), (0, react_jsx_runtime.jsx)("span", {
380
412
  className: styles.val,
381
- children: priced !== null ? formatCny(priced.total) : "—"
413
+ children: loading ? "…" : lastPrompt !== null ? formatCny(lastPrompt) : "—"
382
414
  })]
383
415
  })
384
416
  }), (0, react_jsx_runtime.jsx)("div", {
@@ -387,14 +419,14 @@ window.__ModuleLoader__.load({
387
419
  children: [(0, react_jsx_runtime.jsx)("span", {
388
420
  className: styles.label,
389
421
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
390
- children: [t("lastPromptCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
391
- label: t("explain.lastPromptCost"),
422
+ children: [t("sessionTotal"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
423
+ label: t("explain.sessionTotal"),
392
424
  t
393
425
  })]
394
426
  })
395
427
  }), (0, react_jsx_runtime.jsx)("span", {
396
428
  className: styles.val,
397
- children: loading ? "…" : lastCost !== null ? formatCny(lastCost) : "—"
429
+ children: loading ? "…" : sessionTotal !== null ? formatCny(sessionTotal) : "—"
398
430
  })]
399
431
  })
400
432
  }), (0, react_jsx_runtime.jsx)("div", {
@@ -403,30 +435,30 @@ window.__ModuleLoader__.load({
403
435
  children: [(0, react_jsx_runtime.jsx)("span", {
404
436
  className: styles.label,
405
437
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
406
- children: [t("todayCost"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
407
- label: t("explain.todayCost"),
438
+ children: [t("todayWs"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
439
+ label: t("explain.todayWs"),
408
440
  t
409
441
  })]
410
442
  })
411
443
  }), (0, react_jsx_runtime.jsx)("span", {
412
444
  className: styles.val,
413
- children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
445
+ children: loading ? "…" : todayWs !== null ? formatCny(todayWs) : "—"
414
446
  })]
415
447
  })
416
- }), priced !== null && (0, react_jsx_runtime.jsx)("div", {
448
+ }), (0, react_jsx_runtime.jsx)("div", {
417
449
  className: styles.row,
418
450
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
419
451
  children: [(0, react_jsx_runtime.jsx)("span", {
420
452
  className: styles.label,
421
453
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
422
- children: [t("tokens"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
423
- label: t("explain.tokens"),
454
+ children: [t("todayAll"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
455
+ label: t("explain.todayAll"),
424
456
  t
425
457
  })]
426
458
  })
427
459
  }), (0, react_jsx_runtime.jsx)("span", {
428
460
  className: styles.val,
429
- children: `${formatTokens((usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0))} in / ${formatTokens(usage.outputTokens ?? 0)} out`
461
+ children: loading ? "…" : todayAll !== null ? formatCny(todayAll) : "—"
430
462
  })]
431
463
  })
432
464
  }), error !== null && (0, react_jsx_runtime.jsx)("div", {
@@ -434,24 +466,21 @@ window.__ModuleLoader__.load({
434
466
  role: "status",
435
467
  children: error
436
468
  }), (0, react_jsx_runtime.jsx)("div", {
437
- className: styles.hint,
438
- children: lastUpdated !== null ? `${t("updatedAt")} ${formatTime(lastUpdated)} · ${t("hint")}` : t("hint")
439
- }), (0, react_jsx_runtime.jsx)("div", {
440
- className: styles.foot,
469
+ className: styles.sidePopFoot,
441
470
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
442
471
  children: [(0, react_jsx_runtime.jsx)("a", {
443
472
  className: styles.link,
444
473
  href: TOP_UP_URL,
445
474
  target: "_blank",
446
475
  rel: "noreferrer",
447
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
448
- children: ["⚡", t("topUp")]
449
- })
476
+ children: t("topUp")
450
477
  }), (0, react_jsx_runtime.jsx)("button", {
451
478
  type: "button",
452
- className: styles.btn,
453
- onClick: () => setOpen(false),
454
- children: t("close")
479
+ className: styles.sidePopRefresh,
480
+ "aria-label": t("refresh.aria"),
481
+ title: t("refresh.title"),
482
+ onClick: () => refresh(true),
483
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, {})
455
484
  })]
456
485
  })
457
486
  })]
@@ -461,78 +490,6 @@ window.__ModuleLoader__.load({
461
490
  });
462
491
  }
463
492
  //#endregion
464
- //#region lib/types/client/SidebarBalanceCard.js
465
- /**
466
- * Sidebar footer card: balance + remaining-ratio bar + today cost.
467
- * Root-scoped (global, not per-session) — fetches balance itself and
468
- * refreshes every 60s, matching the corner widget cadence.
469
- * @param {Object} props - wide (sidebar expanded), t (locale).
470
- */
471
- function SidebarBalanceCard({ wide, t }) {
472
- const [balance, setBalance] = (0, react.useState)(null);
473
- const [todayCost, setTodayCost] = (0, react.useState)(null);
474
- const [lowThreshold, setLowThreshold] = (0, react.useState)(5);
475
- const [criticalThreshold, setCriticalThreshold] = (0, react.useState)(1);
476
- const refresh = async () => {
477
- const [bResult, tResult] = await Promise.all([
478
- fetchBalance(),
479
- fetchCost(TODAY_COST_ROUTE)
480
- ]);
481
- if (bResult.ok) {
482
- setBalance(displayBalance(bResult.balanceInfos));
483
- if (typeof bResult.lowThreshold === "number" && bResult.lowThreshold >= 0) setLowThreshold(bResult.lowThreshold);
484
- if (typeof bResult.criticalThreshold === "number" && bResult.criticalThreshold >= 0) setCriticalThreshold(bResult.criticalThreshold);
485
- }
486
- if (tResult.ok) setTodayCost(tResult.cost);
487
- };
488
- (0, react.useEffect)(() => {
489
- refresh();
490
- const timer = setInterval(refresh, 60000);
491
- return () => clearInterval(timer);
492
- }, []);
493
- const level = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
494
- // Remaining-ratio: 100% at lowThreshold (or above), 0% at 0.
495
- let percent = 100;
496
- if (balance !== null && balance.total < lowThreshold) {
497
- percent = lowThreshold <= 0 ? 100 : Math.max(0, Math.round(balance.total / lowThreshold * 100));
498
- }
499
- return (0, react_jsx_runtime.jsx)("div", {
500
- className: styles.side,
501
- title: t("title"),
502
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
503
- children: [(0, react_jsx_runtime.jsx)("div", {
504
- className: styles.sideTop,
505
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
506
- children: [(0, react_jsx_runtime.jsx)("span", {
507
- children: "💰"
508
- }), (0, react_jsx_runtime.jsx)("span", {
509
- className: styles.sideAmount,
510
- "data-level": level > 0 ? level : undefined,
511
- children: balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
512
- })]
513
- })
514
- }), (0, react_jsx_runtime.jsx)("div", {
515
- className: styles.bar,
516
- "aria-label": t("side.bar"),
517
- children: (0, react_jsx_runtime.jsx)("div", {
518
- className: styles.barFill,
519
- "data-level": level,
520
- style: { width: `${percent}%` }
521
- })
522
- }), (0, react_jsx_runtime.jsx)("div", {
523
- className: styles.sideFoot,
524
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
525
- children: [(0, react_jsx_runtime.jsx)("span", {
526
- children: t("todayCostShort")
527
- }), (0, react_jsx_runtime.jsx)("span", {
528
- children: todayCost !== null ? formatCny(todayCost) : "—"
529
- })]
530
- })
531
- })]
532
- })
533
- });
534
- }
535
- //#endregion
536
493
  //#region lib/types/client/locales.js
537
494
  /** Dictionary namespace owned by this plugin. */
538
495
  const NS = "balance-widget";
@@ -543,21 +500,20 @@ window.__ModuleLoader__.load({
543
500
  "refresh.aria": "刷新数据",
544
501
  "refresh.title": "刷新余额与成本",
545
502
  "balance": "账户余额",
546
- "sessionCost": "这个对话已花费",
547
- "lastPromptCost": "最近一次提问",
548
- "todayCost": "今天总成本",
549
- "tokens": "Token 用量",
503
+ "lastPrompt": "最近一次提问",
504
+ "sessionTotal": "本会话成本",
505
+ "todayWs": "今日·本项目",
506
+ "todayAll": "今日·全部",
550
507
  "explain.aria": "解释",
551
- "explain.balance": "DeepSeek 账户可用余额,实时查询官方接口(每 60 秒刷新)。",
552
- "explain.sessionCost": "当前这个对话从开始到现在累计的费用,按官方峰谷定价估算。",
553
- "explain.lastPromptCost": "你刚发的这条提问(含 AI 回复)的费用估算。",
554
- "explain.todayCost": "今天所有对话(含其他窗口)的费用合计估算。",
555
- "explain.tokens": "本会话消耗的 token 数。1 token ≈ 0.75 个英文单词 ≈ 1 个汉字。",
508
+ "explain.balance": "DeepSeek 账户可用余额,实时查询官方接口。",
509
+ "explain.lastPrompt": "最近活跃会话的最后一个问题(含 AI 回复)的费用估算。",
510
+ "explain.sessionTotal": "最近活跃会话从开始到现在的累计费用估算。",
511
+ "explain.todayWs": "当前工作区今天所有会话的费用合计估算。",
512
+ "explain.todayAll": "所有工作区今天所有会话的费用合计估算(含其他项目)。",
556
513
  "warn.title.1": "余额不足 ¥5,建议及时充值",
557
514
  "warn.title.2": "余额低于 ¥1,即将耗尽!",
558
515
  "warn.text.1": "⚠️ 余额不足 ¥5,建议及时充值。",
559
516
  "warn.text.2": "⛔ 余额低于 ¥1,即将耗尽,请立即充值!",
560
- "updatedAt": "更新于",
561
517
  "side.bar": "余额剩余比例",
562
518
  "todayCostShort": "今日",
563
519
  "hint": "价格为估算值,实际以官方账单为准。",
@@ -571,21 +527,20 @@ window.__ModuleLoader__.load({
571
527
  "refresh.aria": "Refresh data",
572
528
  "refresh.title": "Refresh balance and costs",
573
529
  "balance": "Account balance",
574
- "sessionCost": "This chat spent",
575
- "lastPromptCost": "Last prompt",
576
- "todayCost": "Today total",
577
- "tokens": "Token usage",
530
+ "lastPrompt": "Last prompt",
531
+ "sessionTotal": "This session",
532
+ "todayWs": "Today · this project",
533
+ "todayAll": "Today · all",
578
534
  "explain.aria": "Explain",
579
535
  "explain.balance": "DeepSeek account balance, fetched live from the official API.",
580
- "explain.sessionCost": "Total estimated cost of this conversation since it started, priced at official peak/off-peak rates.",
581
- "explain.lastPromptCost": "Estimated cost of your latest prompt, including the AI reply.",
582
- "explain.todayCost": "Estimated total cost across all conversations today.",
583
- "explain.tokens": "Tokens used by this session. 1 token ≈ 0.75 English words ≈ 1 CJK character.",
536
+ "explain.lastPrompt": "Estimated cost of the latest question (incl. the AI reply) in the most recent session.",
537
+ "explain.sessionTotal": "Estimated total cost of the most recent session since it started.",
538
+ "explain.todayWs": "Estimated total cost of today's sessions in the current workspace.",
539
+ "explain.todayAll": "Estimated total cost of today's sessions across all workspaces.",
584
540
  "warn.title.1": "Balance below ¥5, consider topping up",
585
541
  "warn.title.2": "Balance below ¥1, almost exhausted!",
586
542
  "warn.text.1": "⚠️ Balance below ¥5, consider topping up.",
587
543
  "warn.text.2": "⛔ Balance below ¥1, almost exhausted — top up now!",
588
- "updatedAt": "Updated",
589
544
  "side.bar": "Balance remaining ratio",
590
545
  "todayCostShort": "Today",
591
546
  "hint": "Prices are estimates; actual billing from the provider is authoritative.",
@@ -600,8 +555,9 @@ window.__ModuleLoader__.load({
600
555
  "locale"
601
556
  ];
602
557
  /**
603
- * Client plugin body: register the corner balance widget in the
604
- * conversation session header utilities slot.
558
+ * Client plugin body: register the sidebar footer balance card (the
559
+ * single entry point since v0.4.1; the former header widget is removed
560
+ * to avoid duplicate always-visible balances).
605
561
  * @param ctx - client root context.
606
562
  */
607
563
  function apply(ctx) {
@@ -609,13 +565,6 @@ window.__ModuleLoader__.load({
609
565
  zh,
610
566
  en
611
567
  }), "dsh-balance-widget: dictionaries");
612
- ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
613
- name: "conversation.session.header.utilities",
614
- id: "balance-widget",
615
- order: 90,
616
- locale: NS,
617
- inject: (sessionId) => ({ sessionId })
618
- }, BalanceWidget));
619
568
  ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
620
569
  name: "sidebar.footer.action",
621
570
  id: "balance-widget",
package/lib/index.js CHANGED
@@ -24,7 +24,8 @@ export const API = {
24
24
  balance: "/api/dsh-balance/balance",
25
25
  cost: "/api/dsh-balance/cost",
26
26
  lastCost: "/api/dsh-balance/last-cost",
27
- todayCost: "/api/dsh-balance/today-cost"
27
+ todayCost: "/api/dsh-balance/today-cost",
28
+ activeCost: "/api/dsh-balance/active-cost"
28
29
  };
29
30
 
30
31
  /** Default DeepSeek API base (mirrors dsh-llm-deepseek's PUBLIC_BASE_URL). */
@@ -459,6 +460,7 @@ async function decompressSession(path) {
459
460
  function parseSessionUsages(text) {
460
461
  const samples = [];
461
462
  let currentModel = void 0;
463
+ let cwd = void 0;
462
464
  for (const line of text.split("\n")) {
463
465
  if (line === "") continue;
464
466
  let event;
@@ -468,7 +470,9 @@ function parseSessionUsages(text) {
468
470
  continue;
469
471
  }
470
472
  const type = event.type;
471
- if (type === "request/header") {
473
+ if (type === "session") {
474
+ cwd = event.cwd;
475
+ } else if (type === "request/header") {
472
476
  currentModel = event.data?.header?.config?.model ?? currentModel;
473
477
  } else if (type === "assistant/message") {
474
478
  const usage = event.data?.usage;
@@ -482,7 +486,7 @@ function parseSessionUsages(text) {
482
486
  }
483
487
  }
484
488
  }
485
- return samples;
489
+ return { samples, cwd };
486
490
  }
487
491
 
488
492
  /** Cost of the last turn (the most recent user prompt) in one session file. */
@@ -502,19 +506,71 @@ function lastTurnCost(samples) {
502
506
  return { cost, inputTokens: input, outputTokens: output };
503
507
  }
504
508
 
509
+ /** Total cost across every sample of one session (all turns). */
510
+ function sessionTotalCost(samples) {
511
+ let cost = 0;
512
+ let input = 0;
513
+ let output = 0;
514
+ for (const s of samples) {
515
+ cost += priceUsage(s.usage, s.model, s.time);
516
+ input += (s.usage.inputTokens ?? s.usage.uncachedInputTokens ?? 0) + (s.usage.cacheReadTokens ?? 0);
517
+ output += s.usage.outputTokens ?? 0;
518
+ }
519
+ return { cost, inputTokens: input, outputTokens: output };
520
+ }
521
+
505
522
  /**
506
- * Sum today's usage across every session file. "Today" = events whose time
523
+ * Find the session file modified most recently (the "active" session).
524
+ * Walks the sessions tree and picks by mtime; returns the path or undefined.
525
+ */
526
+ async function findMostRecentSessionFile() {
527
+ const root = sessionsRoot();
528
+ try {
529
+ const fs = await awaitImportFs();
530
+ const { statSync } = fs;
531
+ let best = void 0;
532
+ let bestMtime = -1;
533
+ const walk = (dir) => {
534
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
535
+ const full = `${dir}/${entry.name}`;
536
+ if (entry.isDirectory()) {
537
+ walk(full);
538
+ } else if (entry.name === "session.jsonl.zstd") {
539
+ try {
540
+ const mtime = statSync(full).mtimeMs;
541
+ if (mtime > bestMtime) {
542
+ bestMtime = mtime;
543
+ best = full;
544
+ }
545
+ } catch {
546
+ /* ignore unreadable files */
547
+ }
548
+ }
549
+ }
550
+ };
551
+ walk(root);
552
+ return best;
553
+ } catch {
554
+ return void 0;
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Sum today's usage across session files. "Today" = events whose time
507
560
  * falls on the same calendar day (local time) as now.
561
+ * @param {Array<{samples:Array,cwd?:string}>} sessionList - parsed sessions.
562
+ * @param {string} [cwdFilter] - if given, only sessions with this cwd count.
508
563
  */
509
- function todayCost(samplesBySession) {
564
+ function todayCost(sessionList, cwdFilter) {
510
565
  const now = new Date();
511
566
  const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
512
567
  const dayEnd = dayStart + 24 * 60 * 60 * 1000;
513
568
  let cost = 0;
514
569
  let input = 0;
515
570
  let output = 0;
516
- for (const samples of samplesBySession) {
517
- for (const s of samples) {
571
+ for (const session of sessionList) {
572
+ if (cwdFilter !== void 0 && session.cwd !== cwdFilter) continue;
573
+ for (const s of session.samples) {
518
574
  if (s.time < dayStart || s.time >= dayEnd) continue;
519
575
  cost += priceUsage(s.usage, s.model, s.time);
520
576
  input += (s.usage.inputTokens ?? s.usage.uncachedInputTokens ?? 0) + (s.usage.cacheReadTokens ?? 0);
@@ -545,7 +601,8 @@ async function collectAllSessionSamples() {
545
601
  for (const file of files) {
546
602
  const result = await decompressSession(file);
547
603
  if (!result.ok) continue;
548
- perSession.push(parseSessionUsages(result.text));
604
+ const parsed = parseSessionUsages(result.text);
605
+ perSession.push(parsed);
549
606
  }
550
607
  return perSession;
551
608
  }
@@ -622,8 +679,8 @@ function makeRoutes(ctx, config) {
622
679
  const compute = async () => {
623
680
  const result = await decompressSession(file);
624
681
  if (!result.ok) return { error: result.error };
625
- const samples = parseSessionUsages(result.text);
626
- return { value: lastTurnCost(samples) };
682
+ const parsed = parseSessionUsages(result.text);
683
+ return { value: lastTurnCost(parsed.samples) };
627
684
  };
628
685
  const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
629
686
  if (outcome.error !== void 0) {
@@ -640,12 +697,63 @@ function makeRoutes(ctx, config) {
640
697
  if (!guard(req, res, "GET")) return;
641
698
  const key = "today";
642
699
  const compute = async () => {
643
- const perSession = await collectAllSessionSamples();
644
- return { value: todayCost(perSession) };
700
+ const sessionList = await collectAllSessionSamples();
701
+ const all = todayCost(sessionList);
702
+ // "Current workspace": the cwd of the session with the most
703
+ // samples today (best proxy for where the user is working).
704
+ let activeCwd = void 0;
705
+ let activeCount = 0;
706
+ for (const session of sessionList) {
707
+ if (session.cwd === void 0) continue;
708
+ const todaySamples = session.samples.filter((s) => {
709
+ const now = new Date();
710
+ const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
711
+ return s.time >= dayStart && s.time < dayStart + 24 * 60 * 60 * 1000;
712
+ }).length;
713
+ if (todaySamples > activeCount) {
714
+ activeCount = todaySamples;
715
+ activeCwd = session.cwd;
716
+ }
717
+ }
718
+ const workspace = todayCost(sessionList, activeCwd);
719
+ return {
720
+ value: {
721
+ workspace: { ...workspace, cwd: activeCwd },
722
+ all
723
+ }
724
+ };
645
725
  };
646
726
  const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
647
727
  writeJson(res, 200, { ...outcome.value, modelId: config.modelId, pricingSource, pricingSyncedAt });
648
728
  }
729
+ },
730
+ {
731
+ kind: "exact",
732
+ path: API.activeCost,
733
+ handler: async (req, res) => {
734
+ if (!guard(req, res, "GET")) return;
735
+ const file = await findMostRecentSessionFile();
736
+ if (file === void 0) {
737
+ writeJson(res, 404, { error: "no session files found", modelId: config.modelId });
738
+ return;
739
+ }
740
+ const result = await decompressSession(file);
741
+ if (!result.ok) {
742
+ writeJson(res, 502, { error: result.error, modelId: config.modelId });
743
+ return;
744
+ }
745
+ const parsed = parseSessionUsages(result.text);
746
+ const last = lastTurnCost(parsed.samples);
747
+ const total = sessionTotalCost(parsed.samples);
748
+ writeJson(res, 200, {
749
+ lastPrompt: last,
750
+ sessionTotal: total,
751
+ cwd: parsed.cwd,
752
+ modelId: config.modelId,
753
+ pricingSource,
754
+ pricingSyncedAt
755
+ });
756
+ }
649
757
  }
650
758
  ];
651
759
  }
@@ -689,8 +797,8 @@ function buildBillingTool(ctx, config) {
689
797
  }
690
798
  if (query === "cost" || query === "both") {
691
799
  try {
692
- const perSession = await collectAllSessionSamples();
693
- const today = todayCost(perSession);
800
+ const sessionList = await collectAllSessionSamples();
801
+ const today = todayCost(sessionList);
694
802
  parts.push(`Today's estimated cost: CNY ${today.cost.toFixed(4)} (${today.inputTokens} input tokens, ${today.outputTokens} output tokens)`);
695
803
  } catch (error) {
696
804
  parts.push(`Cost query failed: ${error instanceof Error ? error.message : String(error)}`);
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.0",
4
+ "version": "0.4.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [