dsh-balance-widget 0.4.0 → 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.
Files changed (2) hide show
  1. package/lib/client.js +93 -284
  2. package/package.json +1 -1
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
@@ -186,99 +193,48 @@ window.__ModuleLoader__.load({
186
193
  return { symbol, total, currency: entry.currency };
187
194
  }
188
195
  //#endregion
189
- //#region lib/types/client/ExplainIcon.js
190
- /**
191
- * A small ⓘ marker with a hover tooltip explaining a term. Wraps the
192
- * official DSH Tooltip so the popover stays visually consistent.
193
- * @param {Object} props - label (tooltip text), t (locale).
194
- */
195
- function ExplainIcon({ label, t }) {
196
- return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
197
- label: label,
198
- side: "top",
199
- delayMs: 300,
200
- children: (0, react_jsx_runtime.jsx)("span", {
201
- role: "img",
202
- "aria-label": t("explain.aria"),
203
- style: { cursor: "help", color: "var(--dsw-alias-label-tertiary)", marginLeft: 3, fontSize: 11, lineHeight: 1 },
204
- children: (0, react_jsx_runtime.jsx)("span", {
205
- children: "ⓘ"
206
- })
207
- })
208
- });
209
- }
210
- //#endregion
211
- //#region lib/types/client/BalanceWidget.js
196
+ //#region lib/types/client/SidebarBalanceCard.js
212
197
  /**
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).
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).
217
203
  */
218
- function BalanceWidget({ useProjection, sessionId, t }) {
204
+ function SidebarBalanceCard({ wide, t }) {
219
205
  const [open, setOpen] = (0, react.useState)(false);
220
206
  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
207
  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);
228
208
  const [lowThreshold, setLowThreshold] = (0, react.useState)(5);
229
209
  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
- }
244
- };
245
- /** Fetch all data; optionally keeps the popover's silent loading state. */
246
- 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) => {
247
213
  if (showLoading) setLoading(true);
248
214
  setError(null);
249
- const [balanceResult, lastResult, todayResult] = await Promise.all([
215
+ const [bResult, tResult] = await Promise.all([
250
216
  fetchBalance(),
251
- fetchCost(LAST_COST_ROUTE, sessionId),
252
217
  fetchCost(TODAY_COST_ROUTE)
253
218
  ]);
254
- // Collect every failure into one fresh message; never accumulate
255
- // across refreshes (each refresh starts from a clean slate).
256
219
  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);
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);
263
224
  } else {
264
- setBalance(null);
265
- failures.push(`余额: ${balanceResult.error}`);
225
+ failures.push(`余额: ${bResult.error}`);
266
226
  }
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}`);
227
+ if (tResult.ok) setTodayCost(tResult.cost);
228
+ else if (tResult.error) failures.push(`今日: ${tResult.error}`);
271
229
  setError(failures.length > 0 ? failures.join(";") : null);
272
230
  if (showLoading) setLoading(false);
273
231
  };
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
232
  (0, react.useEffect)(() => {
277
- refreshBalance();
278
- const timer = setInterval(refreshBalance, 60000);
233
+ refresh(false);
234
+ const timer = setInterval(() => refresh(false), 60000);
279
235
  return () => clearInterval(timer);
280
236
  }, []);
281
- // Close the popover when clicking anywhere outside the widget.
237
+ // Close the popover when clicking outside the card.
282
238
  const wrapRef = (0, react.useRef)(null);
283
239
  (0, react.useEffect)(() => {
284
240
  if (!open) return;
@@ -289,144 +245,98 @@ window.__ModuleLoader__.load({
289
245
  document.addEventListener("mousedown", onDocClick);
290
246
  return () => document.removeEventListener("mousedown", onDocClick);
291
247
  }, [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]);
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
+ }
302
254
  const toggle = async () => {
303
255
  const next = !open;
304
256
  setOpen(next);
305
- if (next) await refreshAll(true);
306
- };
307
- const onRefresh = async () => {
308
- setRefreshing(true);
309
- await refreshAll(false);
310
- setRefreshing(false);
257
+ if (next) await refresh(true);
311
258
  };
312
259
  return (0, react_jsx_runtime.jsx)("div", {
313
260
  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", {
261
+ className: styles.sideWrap,
262
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
263
+ children: [(0, react_jsx_runtime.jsx)("button", {
317
264
  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"),
265
+ className: styles.side,
266
+ title: t("title"),
322
267
  onClick: toggle,
323
- children: ["💰", balance !== null && (0, react_jsx_runtime.jsx)("span", {
324
- children: `${balance.symbol}${balance.total.toFixed(2)}`
325
- })]
326
- }), open && (0, react_jsx_runtime.jsx)("div", {
327
- className: styles.pop,
328
- role: "dialog",
329
268
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
330
269
  children: [(0, react_jsx_runtime.jsx)("div", {
331
- className: styles.head,
270
+ className: styles.sideTop,
332
271
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
333
272
  children: [(0, react_jsx_runtime.jsx)("span", {
334
- className: styles.title,
335
- children: t("title")
336
- }), (0, react_jsx_runtime.jsx)("button", {
337
- 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, {})
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)}` : "—"
345
278
  })]
346
279
  })
347
280
  }), (0, react_jsx_runtime.jsx)("div", {
348
- className: styles.row,
349
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
350
- children: [(0, react_jsx_runtime.jsx)("span", {
351
- className: styles.label,
352
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
353
- children: [t("balance"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
354
- label: t("explain.balance"),
355
- t
356
- })]
357
- })
358
- }), (0, react_jsx_runtime.jsx)("span", {
359
- className: styles.val,
360
- "data-level": balanceLevel > 0 ? balanceLevel : undefined,
361
- children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
362
- })]
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}%` }
363
287
  })
364
- }), balanceLevel > 0 && (0, react_jsx_runtime.jsx)("div", {
365
- className: styles.err,
366
- role: "status",
367
- children: t(`warn.text.${balanceLevel}`)
368
288
  }), (0, react_jsx_runtime.jsx)("div", {
369
- className: styles.row,
289
+ className: styles.sideFoot,
370
290
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
371
291
  children: [(0, react_jsx_runtime.jsx)("span", {
372
- className: styles.label,
373
- 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"),
376
- t
377
- })]
378
- })
292
+ children: t("todayCostShort")
379
293
  }), (0, react_jsx_runtime.jsx)("span", {
380
- className: styles.val,
381
- children: priced !== null ? formatCny(priced.total) : "—"
294
+ children: todayCost !== null ? formatCny(todayCost) : "—"
382
295
  })]
383
296
  })
384
- }), (0, react_jsx_runtime.jsx)("div", {
385
- 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,
386
305
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
387
306
  children: [(0, react_jsx_runtime.jsx)("span", {
388
- className: styles.label,
389
- 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"),
392
- t
393
- })]
394
- })
395
- }), (0, react_jsx_runtime.jsx)("span", {
396
- className: styles.val,
397
- 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: "×"
398
315
  })]
399
316
  })
400
317
  }), (0, react_jsx_runtime.jsx)("div", {
401
318
  className: styles.row,
402
319
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
403
320
  children: [(0, react_jsx_runtime.jsx)("span", {
404
- className: styles.label,
405
- 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"),
408
- t
409
- })]
410
- })
321
+ children: t("balance")
411
322
  }), (0, react_jsx_runtime.jsx)("span", {
412
323
  className: styles.val,
413
- children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
324
+ "data-level": level > 0 ? level : undefined,
325
+ children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
414
326
  })]
415
327
  })
416
- }), 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", {
417
333
  className: styles.row,
418
334
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
419
335
  children: [(0, react_jsx_runtime.jsx)("span", {
420
- className: styles.label,
421
- 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"),
424
- t
425
- })]
426
- })
336
+ children: t("todayCost")
427
337
  }), (0, react_jsx_runtime.jsx)("span", {
428
338
  className: styles.val,
429
- children: `${formatTokens((usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0))} in / ${formatTokens(usage.outputTokens ?? 0)} out`
339
+ children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
430
340
  })]
431
341
  })
432
342
  }), error !== null && (0, react_jsx_runtime.jsx)("div", {
@@ -434,24 +344,21 @@ window.__ModuleLoader__.load({
434
344
  role: "status",
435
345
  children: error
436
346
  }), (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,
347
+ className: styles.sidePopFoot,
441
348
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
442
349
  children: [(0, react_jsx_runtime.jsx)("a", {
443
350
  className: styles.link,
444
351
  href: TOP_UP_URL,
445
352
  target: "_blank",
446
353
  rel: "noreferrer",
447
- children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
448
- children: ["⚡", t("topUp")]
449
- })
354
+ children: t("topUp")
450
355
  }), (0, react_jsx_runtime.jsx)("button", {
451
356
  type: "button",
452
- className: styles.btn,
453
- onClick: () => setOpen(false),
454
- 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, {})
455
362
  })]
456
363
  })
457
364
  })]
@@ -461,78 +368,6 @@ window.__ModuleLoader__.load({
461
368
  });
462
369
  }
463
370
  //#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
371
  //#region lib/types/client/locales.js
537
372
  /** Dictionary namespace owned by this plugin. */
538
373
  const NS = "balance-widget";
@@ -543,21 +378,11 @@ window.__ModuleLoader__.load({
543
378
  "refresh.aria": "刷新数据",
544
379
  "refresh.title": "刷新余额与成本",
545
380
  "balance": "账户余额",
546
- "sessionCost": "这个对话已花费",
547
- "lastPromptCost": "最近一次提问",
548
381
  "todayCost": "今天总成本",
549
- "tokens": "Token 用量",
550
- "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 个汉字。",
556
382
  "warn.title.1": "余额不足 ¥5,建议及时充值",
557
383
  "warn.title.2": "余额低于 ¥1,即将耗尽!",
558
384
  "warn.text.1": "⚠️ 余额不足 ¥5,建议及时充值。",
559
385
  "warn.text.2": "⛔ 余额低于 ¥1,即将耗尽,请立即充值!",
560
- "updatedAt": "更新于",
561
386
  "side.bar": "余额剩余比例",
562
387
  "todayCostShort": "今日",
563
388
  "hint": "价格为估算值,实际以官方账单为准。",
@@ -571,21 +396,11 @@ window.__ModuleLoader__.load({
571
396
  "refresh.aria": "Refresh data",
572
397
  "refresh.title": "Refresh balance and costs",
573
398
  "balance": "Account balance",
574
- "sessionCost": "This chat spent",
575
- "lastPromptCost": "Last prompt",
576
399
  "todayCost": "Today total",
577
- "tokens": "Token usage",
578
- "explain.aria": "Explain",
579
- "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.",
584
400
  "warn.title.1": "Balance below ¥5, consider topping up",
585
401
  "warn.title.2": "Balance below ¥1, almost exhausted!",
586
402
  "warn.text.1": "⚠️ Balance below ¥5, consider topping up.",
587
403
  "warn.text.2": "⛔ Balance below ¥1, almost exhausted — top up now!",
588
- "updatedAt": "Updated",
589
404
  "side.bar": "Balance remaining ratio",
590
405
  "todayCostShort": "Today",
591
406
  "hint": "Prices are estimates; actual billing from the provider is authoritative.",
@@ -600,8 +415,9 @@ window.__ModuleLoader__.load({
600
415
  "locale"
601
416
  ];
602
417
  /**
603
- * Client plugin body: register the corner balance widget in the
604
- * 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).
605
421
  * @param ctx - client root context.
606
422
  */
607
423
  function apply(ctx) {
@@ -609,13 +425,6 @@ window.__ModuleLoader__.load({
609
425
  zh,
610
426
  en
611
427
  }), "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
428
  ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
620
429
  name: "sidebar.footer.action",
621
430
  id: "balance-widget",
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.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [