dsh-balance-widget 0.5.0 → 0.5.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
@@ -24,7 +24,7 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
24
24
  | **Zero external dependencies** | ✅ Imports no `@deepseek-ai/*` packages, no native modules | ❌ Most depend on dsh SDK packages |
25
25
  | **Node 24 ready** | ✅ Works out of the box on any profile layout | ⚠️ Many community plugins still error on Node 24 |
26
26
  | **Boot stability** | ✅ Registers routes via official `ctx.webServer`; never conflicts with apiproxy | ⚠️ Some self-host HTTP servers that crash `dsh web` on boot |
27
- | **On-demand queries** | ✅ Fetches balance only on click; no polling, zero background requests | Some always-on badges refresh on a timer |
27
+ | **Always-fresh auto refresh** | ✅ Sidebar card refreshes balance & costs every 60s; opening the popover triggers an immediate refresh | Some always-on badges refresh on a timer |
28
28
  | **Peak/off-peak pricing** | ✅ Built-in official 2026-08-17 rate table, auto-switches by window | Partial support |
29
29
  | **Security** | ✅ API key stays in the host process; loopback-only guard | Varies |
30
30
 
@@ -40,11 +40,10 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
40
40
  - **Peak/off-peak status** — The card and popover borders are tinted by the current window (orange at peak / green at off-peak), a "Peak/Off-peak" tag sits next to the popover title, and hovering it shows the current price tier (input/output per 1M tokens).
41
41
  - **Token usage** — Also shows the session's input (incl. cache hits) / output tokens.
42
42
  - **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.
43
- - **Sidebar card** — a persistent card at the sidebar footer shows balance and today's cost; globally visible, refreshes every 60s.
43
+ - **Sidebar card** — a persistent card at the sidebar footer shows balance and today's cost; globally visible, auto-refreshes every 60s (balance via the official API, costs parsed locally), and opening the popover triggers an immediate refresh.
44
44
  - **Balance color warning** — the balance number is tinted in three tiers: default (healthy) → amber (below `lowThreshold`) → red (below `criticalThreshold`).
45
45
  - **Official price auto-sync** — fetches the DeepSeek official pricing page on startup and every 12h; falls back to built-in rates on failure.
46
46
  - **Agent tool** — a `deepseek_billing` tool lets the model answer "how much balance do I have / how much did today cost".
47
- - **On-demand refresh** — No polling, no background requests; endpoints are only hit when you click the icon. Costs zero tokens to use.
48
47
 
49
48
  ## Why this plugin
50
49
 
@@ -154,6 +153,15 @@ Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective
154
153
 
155
154
  ## Changelog
156
155
 
156
+ ### v0.5.1 — Cold-start speedup & cleanup
157
+ - 🚀 **Performance**: today-cost cold start dropped from ~5.4s to ~0.01s — only session files modified today are decompressed (mtime filter), parsed results are cached by (path, mtime), and parsing runs in parallel
158
+ - ⏰ **Refresh wording**: README now matches the code — the persistent card auto-refreshes every 60s (the old "no polling" claims contradicted the code and have been corrected)
159
+ - 🌏 **Timezone fix**: peak/off-peak windows are now computed in Beijing time (UTC+8) explicitly instead of the host's local timezone
160
+ - 🧹 **Cleanup**: removed a dead client-side pricing stack (PRICING/priceSession) that was never called; all pricing now goes through the host
161
+ - 📐 **Pricing parsing hardened**: peak rates are parsed explicitly from the official page (no more hard-coded "off-peak × 2"); if a cache-hit rate cannot be parsed the whole table falls back to built-in rates instead of silently pricing cache hits at 0
162
+ - 🏷️ **Card label**: the card footer now reads "Today · all" to make clear it is the global (all-workspaces) figure
163
+ - 🎨 **Visual refresh**: SVG wallet icon replaces 💰, press/pop micro-animations, muted secondary tiers in the popover
164
+
157
165
  ### v0.5.0 — Five-tier costs & peak/off-peak status
158
166
  - ✨ **Added**: the cost breakdown is now five tiers — balance / last prompt / today-this-session / today-this-workspace / today-all-workspaces
159
167
  - The last-prompt row labels the **session name** underneath (based on the most recent session)
@@ -176,7 +184,9 @@ Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective
176
184
 
177
185
  - Config tree: `dsh --profile web --dump-config` should show a `balance-widget` entry.
178
186
  - Balance route: after restarting dsh web, `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` should return `{ ok, balance_infos, modelId }`.
179
- - 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 }`.
187
+ - Session costs: `curl -s http://127.0.0.1:3080/api/dsh-balance/active-cost` should return `{ lastPrompt, todaySession, title, sessionId, peak, workspaceName, ... }`; append `?session=SESSION_ID` to target a specific session.
188
+ - Today costs: `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` should return `{ workspace: { cost, ..., cwd }, all: { cost, ... }, modelId }`.
189
+ - Legacy route: `curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=SESSION_ID"` still works and returns `{ cost, inputTokens, outputTokens, modelId }`.
180
190
 
181
191
  ## License
182
192
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:侧边栏底部常
24
24
  | **零外部依赖** | ✅ 不 import 任何 `@deepseek-ai/*` 包,无原生模块 | ❌ 多数依赖 dsh SDK 包 |
25
25
  | **Node 24 兼容** | ✅ 天然兼容(零依赖设计),任何 profile 布局可加载 | ⚠️ 不少社区插件在 Node 24 下报错 |
26
26
  | **启动稳定性** | ✅ 用官方 `ctx.webServer` 注册路由,不与 apiproxy 冲突 | ⚠️ 有的自建 HTTP 服务导致 dsh web 启动崩溃 |
27
- | **按需查询** | ✅ 点击才查余额,无轮询、零后台请求 | 有的常驻徽章定时刷新 |
27
+ | **常驻自动刷新** | ✅ 侧边栏卡片每 60s 自动刷新余额与成本,弹框点击即强刷 | 有的常驻徽章定时刷新 |
28
28
  | **峰谷定价** | ✅ 内置官方 2026-08-17 峰谷价表,自动按时段切换 | 部分支持 |
29
29
  | **安全性** | ✅ API key 仅在宿主进程,loopback-only 守卫 | 参差不齐 |
30
30
 
@@ -40,11 +40,10 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:侧边栏底部常
40
40
  - **峰谷状态标签** — 卡片与弹框边框按当前时段着色(峰时橙色 / 谷时绿色),弹框标题旁显示「峰时/谷时」标签,悬停可查看当前价格档位(输入/输出单价)。
41
41
  - **Token 用量** — 同时展示输入(含缓存命中)/ 输出 token 数。
42
42
  - **一键充值** — 弹层底部「去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
43
- - **侧边栏常驻卡片** — 侧边栏底部(设置上方)显示余额 + 今日花费,全局可见,60 秒自动刷新。
43
+ - **侧边栏常驻卡片** — 侧边栏底部(设置上方)显示余额 + 今日花费,全局可见;每 60 秒自动刷新(余额走官方接口、成本为本地会话解析),打开弹框时也会立即刷新一次,无需手动操作。
44
44
  - **余额数字变色预警** — 余额数字按三档着色:充足(默认色)→ 琥珀(低于 `lowThreshold`)→ 红色(低于 `criticalThreshold`),一眼判断余额健康度。
45
45
  - **官方价格自动同步** — 启动时 + 每 12 小时抓取 DeepSeek 官方定价页,改价自动跟进;失败回退内置价目表。
46
46
  - **模型工具查询** — 新增 `deepseek_billing` 工具,可直接问模型"余额多少/今天花了多少"。
47
- - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起查询,不消耗任何 token。
48
47
 
49
48
  ## 架构
50
49
 
@@ -151,6 +150,15 @@ DSH 的插件配置统一放在这个文件里:
151
150
 
152
151
  ## 版本历史
153
152
 
153
+ ### v0.5.1 — 冷启动提速与代码清理
154
+ - 🚀 **性能**:今日成本冷启动从 ~5.4s 降至 ~0.01s——只解压当天活跃的会话文件(按 mtime 过滤)+ 解压结果按 (path, mtime) 长期缓存 + 并行解析
155
+ - ⏰ **轮询口径**:README 与代码统一——常驻卡片每 60s 自动刷新(此前文档"无轮询"表述自相矛盾,已更正)
156
+ - 🌏 **峰谷时区修正**:峰/谷判断改为显式按北京时间(UTC+8)计算,不再依赖宿主机时区
157
+ - 🧹 **清理**:移除客户端一套从未调用的死计价代码(PRICING/priceSession),计价统一走宿主
158
+ - 📐 **定价解析加固**:峰价改为从官方页面显式解析(不再硬编码"谷价 ×2");解析不到缓存命中价时整表回退内置价目表,避免静默按 0 计价
159
+ - 🏷️ **卡片语义**:卡片底部"今日"改为"今日·全部",明确是全局(所有工作区)口径
160
+ - 🎨 **外观**:💰 换 SVG 钱包图标、弹框/卡片微交互动画、层级弱化(详情见 v0.5.1 diff)
161
+
154
162
  ### v0.5.0 — 五层级成本与峰谷状态
155
163
  - ✨ **新增**:成本明细改为五层级——余额 / 最近一次提问 / 今日·本会话 / 今日·本工作区 / 今日·所有工作区
156
164
  - 最近一次提问下方标注**会话名**(基于最近活跃会话)
@@ -173,7 +181,9 @@ DSH 的插件配置统一放在这个文件里:
173
181
 
174
182
  - 配置树:`dsh --profile web --dump-config` 应出现 `balance-widget` 条目
175
183
  - 余额路由:重启 dsh web 后 `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` 应返回 `{ ok, balance_infos, modelId }`
176
- - 成本路由:`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 }`
184
+ - 会话成本:`curl -s http://127.0.0.1:3080/api/dsh-balance/active-cost` 应返回 `{ lastPrompt, todaySession, title, sessionId, peak, workspaceName, ... }`;加 `?session=SESSION_ID` 可指定会话
185
+ - 今日成本:`curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` 应返回 `{ workspace: { cost, ..., cwd }, all: { cost, ... }, modelId }`
186
+ - 兼容旧路由:`curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=SESSION_ID"` 仍可用,返回 `{ cost, inputTokens, outputTokens, modelId }`
177
187
 
178
188
  ## License
179
189
 
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:700}.dshbw_sideAmount[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary);font-weight:700}.dshbw_sideAmount[data-level=\"2\"]{color: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)}.dshbw_side[data-peak=\"true\"]{border:1px solid var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, var(--dsw-alias-bg-layer-2))}.dshbw_side[data-peak=\"false\"]{border:1px solid var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, var(--dsw-alias-bg-layer-2))}.dshbw_sidePop[data-peak=\"true\"]{border-color:var(--dsw-alias-state-warn-primary)}.dshbw_sidePop[data-peak=\"false\"]{border-color:var(--dsw-alias-state-success-primary)}.dshbw_peakTag{display:inline-flex;align-items:center;gap:4px;font-size:11px;line-height:16px;padding:1px 6px;border-radius:999px}.dshbw_peakTag[data-peak=\"true\"]{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-tertiary)}.dshbw_peakTag[data-peak=\"false\"]{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-tertiary)}.dshbw_sessionNote{display:flex;align-items:center;gap:4px;margin-top:6px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);min-width:0}.dshbw_sessionName{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}";
11
+ const css = ".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-tier=\"sec\"]{font-weight:400;color:var(--dsw-alias-label-secondary)}.dshbw_val[data-tier=\"ter\"]{font-weight:400;color:var(--dsw-alias-label-tertiary)}.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:12px;line-height:16px;margin-top:6px}.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}.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);font-weight:600}.dshbw_sideAmount[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.dshbw_sideFoot{display:flex;justify-content:space-between;font-size:12px;line-height:16px;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:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;text-align:left;transition:transform 120ms cubic-bezier(0.23,1,0.32,1),background 120ms cubic-bezier(0.23,1,0.32,1)}.dshbw_side:active{transform:scale(.97)}.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:none;border-radius:12px;box-shadow:0 0 1px rgba(0,0,0,.3),0 4px 14px rgba(0,0,0,.1);padding:12px 12px;font-size:12px;line-height:20px;color:var(--dsw-alias-label-primary);animation:dshbw-pop-in 160ms cubic-bezier(0.23,1,0.32,1) both}.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)}.dshbw_side[data-peak=\"true\"]{border:1px solid var(--dsw-alias-state-warn-primary)}.dshbw_side[data-peak=\"false\"]{border:1px solid var(--dsw-alias-state-success-primary)}.dshbw_peakTag{display:inline-flex;align-items:center;gap:4px;font-size:12px;line-height:16px;padding:1px 7px;border-radius:6px}.dshbw_peakTag[data-peak=\"true\"]{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-tertiary)}.dshbw_peakTag[data-peak=\"false\"]{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-tertiary)}.dshbw_sessionNote{display:flex;align-items:center;gap:4px;margin-top:6px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);min-width:0}.dshbw_sessionName{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dshbw_ico{color:var(--dsw-alias-state-business-primary);display:inline-flex;align-items:center}@keyframes dshbw-pop-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.dshbw_sidePopRefresh,.dshbw_sidePopClose{transition:transform 120ms cubic-bezier(0.23,1,0.32,1),color 120ms cubic-bezier(0.23,1,0.32,1)}.dshbw_sidePopRefresh:active{transform:scale(.92)}.dshbw_sidePopClose:active{transform:scale(.92)}";
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");
@@ -18,18 +18,10 @@ window.__ModuleLoader__.load({
18
18
  document.head.appendChild(tag);
19
19
  }
20
20
  var styles = {
21
- "wrap": "dshbw_wrap",
22
- "btn": "dshbw_btn",
23
- "pop": "dshbw_pop",
24
- "head": "dshbw_head",
25
- "title": "dshbw_title",
26
- "refresh": "dshbw_refresh",
27
21
  "row": "dshbw_row",
28
22
  "label": "dshbw_label",
29
23
  "val": "dshbw_val",
30
24
  "err": "dshbw_err",
31
- "hint": "dshbw_hint",
32
- "foot": "dshbw_foot",
33
25
  "link": "dshbw_link",
34
26
  "side": "dshbw_side",
35
27
  "sideWrap": "dshbw_sideWrap",
@@ -47,85 +39,12 @@ window.__ModuleLoader__.load({
47
39
  "sessionName": "dshbw_sessionName"
48
40
  };
49
41
  //#endregion
50
- //#region lib/types/client/pricing.js
51
- /**
52
- * DeepSeek official pricing, in CNY per 1M tokens, effective 2026-08-17
53
- * (peak/off-peak two-tier billing). Peak windows are Beijing time:
54
- * 09:00-12:00 and 14:00-18:00; off-peak is half the peak price.
55
- * Prices are hard-coded from the official announcement and are estimates
56
- * for display — actual billing is authoritative from the provider.
57
- */
58
- const PRICING = {
59
- "deepseek-v4-flash": {
60
- peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 },
61
- offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 }
62
- },
63
- "deepseek-v4-pro": {
64
- peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 },
65
- offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 }
66
- },
67
- "deepseek-chat": {
68
- peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 },
69
- offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 }
70
- },
71
- "deepseek-reasoner": {
72
- peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 },
73
- offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 }
74
- }
75
- };
76
- /** Fall back to Flash pricing for unknown model ids. */
77
- function pricingFor(modelId) {
78
- return PRICING[modelId] ?? PRICING["deepseek-v4-flash"];
79
- }
80
- /**
81
- * Whether `date` falls inside a Beijing-time peak window.
82
- * The host's local timezone is used; the widget is for a CN deployment.
83
- */
84
- function isPeak(date) {
85
- const hour = date.getHours();
86
- return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18);
87
- }
88
- /**
89
- * Price one session's token usage into a CNY estimate.
90
- * @param usage - tokenUsage projection value.
91
- * @param modelId - provider model id used for pricing.
92
- * @returns { input, output, total } in CNY, or null when usage is absent.
93
- */
94
- function priceSession(usage, modelId) {
95
- if (usage === void 0 || usage === null) return null;
96
- const input = usage.uncachedInputTokens ?? 0;
97
- const hit = usage.cacheReadTokens ?? 0;
98
- const write = usage.cacheWriteTokens ?? 0;
99
- const output = usage.outputTokens ?? 0;
100
- if (input + hit + write + output <= 0) return null;
101
- const table = isPeak(new Date()) ? pricingFor(modelId).peak : pricingFor(modelId).offPeak;
102
- const inputCost = (input * table.inputMiss + (hit + write) * table.inputHit) / 1e6;
103
- const outputCost = (output * table.output) / 1e6;
104
- return {
105
- input: inputCost,
106
- output: outputCost,
107
- total: inputCost + outputCost
108
- };
109
- }
110
42
  /** Format a CNY amount: ¥ + 3 significant decimals (drop trailing zeros). */
111
43
  function formatCny(value) {
112
44
  if (value === void 0 || value === null || !Number.isFinite(value)) return "—";
113
45
  const rounded = Math.round(value * 1e4) / 1e4;
114
46
  return `¥${rounded.toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: 4 })}`;
115
47
  }
116
- /** Format token counts compactly: 1234 -> 1.2K. */
117
- function formatTokens(value) {
118
- if (value === void 0 || value === null) return "—";
119
- if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
120
- if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
121
- return String(value);
122
- }
123
- /** Format an epoch ms as HH:MM local time. */
124
- function formatTime(ms) {
125
- if (ms === void 0 || ms === null) return "—";
126
- const d = new Date(ms);
127
- return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
128
- }
129
48
  //#endregion
130
49
  //#region lib/types/client/balance-api.js
131
50
  /** Same-origin balance route (host half registers it over ctx.webServer). */
@@ -354,8 +273,8 @@ window.__ModuleLoader__.load({
354
273
  children: [(0, react_jsx_runtime.jsx)("div", {
355
274
  className: styles.sideTop,
356
275
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
357
- children: [(0, react_jsx_runtime.jsx)("span", {
358
- children: "💰"
276
+ children: [(0, react_jsx_runtime.jsx)("span", { className: "dshbw_ico",
277
+ children: (0, react_jsx_runtime.jsx)("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", children: [(0, react_jsx_runtime.jsx)("rect", { x: 3, y: 6, width: 18, height: 13, rx: 2 }), (0, react_jsx_runtime.jsx)("path", { d: "M3 10h18" })] })
359
278
  }), (0, react_jsx_runtime.jsx)("span", {
360
279
  className: styles.sideAmount,
361
280
  "data-level": level > 0 ? level : undefined,
@@ -438,7 +357,7 @@ window.__ModuleLoader__.load({
438
357
  })]
439
358
  })
440
359
  }), (0, react_jsx_runtime.jsx)("span", {
441
- className: styles.val,
360
+ className: styles.val, "data-tier": "ter",
442
361
  children: loading ? "…" : lastPrompt !== null ? formatCny(lastPrompt) : "—"
443
362
  })]
444
363
  })
@@ -481,7 +400,7 @@ window.__ModuleLoader__.load({
481
400
  })]
482
401
  })
483
402
  }), (0, react_jsx_runtime.jsx)("span", {
484
- className: styles.val,
403
+ className: styles.val, "data-tier": "sec",
485
404
  children: loading ? "…" : todayWs !== null ? formatCny(todayWs) : "—"
486
405
  })]
487
406
  })
@@ -497,7 +416,7 @@ window.__ModuleLoader__.load({
497
416
  })]
498
417
  })
499
418
  }), (0, react_jsx_runtime.jsx)("span", {
500
- className: styles.val,
419
+ className: styles.val, "data-tier": "sec",
501
420
  children: loading ? "…" : todayAll !== null ? formatCny(todayAll) : "—"
502
421
  })]
503
422
  })
@@ -554,7 +473,7 @@ window.__ModuleLoader__.load({
554
473
  "warn.title.2": "余额低于 ¥1,即将耗尽!",
555
474
  "warn.text.1": "⚠️ 余额不足 ¥5,建议及时充值。",
556
475
  "warn.text.2": "⛔ 余额低于 ¥1,即将耗尽,请立即充值!",
557
- "todayCostShort": "今日",
476
+ "todayCostShort": "今日·全部",
558
477
  "peak.active": "峰时",
559
478
  "peak.offpeak": "谷时",
560
479
  "peak.tooltip.active": "当前为峰时(09:00-12:00、14:00-18:00,价格翻倍):输入 ¥{input}/M · 输出 ¥{output}/M",
@@ -585,7 +504,7 @@ window.__ModuleLoader__.load({
585
504
  "warn.title.2": "Balance below ¥1, almost exhausted!",
586
505
  "warn.text.1": "⚠️ Balance below ¥5, consider topping up.",
587
506
  "warn.text.2": "⛔ Balance below ¥1, almost exhausted — top up now!",
588
- "todayCostShort": "Today",
507
+ "todayCostShort": "Today · all",
589
508
  "peak.active": "Peak",
590
509
  "peak.offpeak": "Off-peak",
591
510
  "peak.tooltip.active": "Peak hours now (09:00-12:00, 14:00-18:00, 2× price): input ¥{input}/M · output ¥{output}/M",
package/lib/index.js CHANGED
@@ -246,9 +246,15 @@ const PRICING_ALIASES = {
246
246
  "deepseek-official": "deepseek-v4-flash"
247
247
  };
248
248
 
249
- /** Whether `date` falls inside a Beijing-time peak window. */
249
+ /**
250
+ * Whether `date` falls inside a Beijing-time (UTC+8) peak window.
251
+ * DeepSeek's peak pricing applies to Beijing time 09:00-12:00 and 14:00-18:00
252
+ * regardless of the host's timezone, so the window is computed in UTC+8
253
+ * explicitly instead of the machine's local time.
254
+ */
250
255
  function isPeak(date) {
251
- const hour = date.getHours();
256
+ const beijing = new Date(date.getTime() + 8 * 60 * 60 * 1000);
257
+ const hour = beijing.getUTCHours();
252
258
  return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18);
253
259
  }
254
260
 
@@ -330,30 +336,40 @@ function extractModelBlock(html, model) {
330
336
  }
331
337
 
332
338
  /**
333
- * Parse per-1M-token rates from a block of HTML text. Looks for numbers near
334
- * "输入"/"缓存命中"/"输出" (CN page) or "input"/"cache"/"output" (EN page),
335
- * preferring the off-peak row. Returns { offPeak, peak } or null.
339
+ * Parse per-1M-token rates from a block of HTML text. The pricing table lists
340
+ * rows per metric with "空闲时段 <off-peak> … 高峰时段 <peak>" columns (CN) or
341
+ * "off-peak … peak" (EN); the first model column is the one we price. Each
342
+ * metric is captured as an (offPeak, peak) pair from its labelled row.
343
+ * Returns { offPeak, peak } or null when any off-peak figure is missing
344
+ * (the caller then falls back to the built-in rate table rather than silently
345
+ * pricing cache hits at 0).
336
346
  */
337
347
  function parseRates(block) {
338
348
  // Strip tags to plain text for simpler matching.
339
349
  const text = block.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
340
- const num = (pattern) => {
341
- const m = text.match(pattern);
342
- if (!m) return void 0;
343
- const v = parseFloat(m[1].replace(/[¥$,\s]/g, ""));
344
- return Number.isFinite(v) ? v : void 0;
350
+ const pair = (label) => {
351
+ // label + short gap + "空闲时段 <n>" + gap + "高峰时段 <n>" (first model
352
+ // column). Also tolerate EN "off-peak … peak".
353
+ const re = new RegExp(
354
+ label + "[\\s\\S]{0,120}?(?:空闲时段|off[- ]?peak)\\s*([\\d.]+)[\\s\\S]{0,80}?(?:高峰时段|peak)\\s*([\\d.]+)",
355
+ "i"
356
+ );
357
+ const m = text.match(re);
358
+ if (!m) return null;
359
+ const a = parseFloat(m[1]);
360
+ const b = parseFloat(m[2]);
361
+ return Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null;
345
362
  };
346
- // Try patterns for off-peak (空闲/off-peak) first, then any.
347
- const offPeakInputMiss = num(/缓存未命中[^\d]*([\d.]+)/) ?? num(/cache[\s-]*miss[^\d]*([\d.]+)/);
348
- const offPeakInputHit = num(/缓存命中[^\d]*([\d.]+)/) ?? num(/cache[\s-]*hit[^\d]*([\d.]+)/);
349
- const offPeakOutput = num(/输出[^\d]*([\d.]+)/) ?? num(/output[^\d]*([\d.]+)/);
350
- if (offPeakInputMiss === void 0 || offPeakOutput === void 0) return null;
351
- const peakInputMiss = (offPeakInputMiss ?? 0) * 2;
352
- const peakInputHit = (offPeakInputHit ?? 0) * 2;
353
- const peakOutput = (offPeakOutput ?? 0) * 2;
363
+ const hit = pair("缓存命中");
364
+ const miss = pair("缓存未命中");
365
+ const out = pair("百万tokens输出") ?? pair("输出");
366
+ if (miss === null || out === null) return null;
367
+ // Cache-hit row missing is acceptable only if it cannot be parsed at all —
368
+ // reject rather than guess zero.
369
+ if (hit === null) return null;
354
370
  return {
355
- peak: { inputMiss: peakInputMiss, inputHit: peakInputHit, output: peakOutput },
356
- offPeak: { inputMiss: offPeakInputMiss, inputHit: offPeakInputHit ?? 0, output: offPeakOutput }
371
+ offPeak: { inputMiss: miss[0], inputHit: hit[0], output: out[0] },
372
+ peak: { inputMiss: miss[1], inputHit: hit[1], output: out[1] }
357
373
  };
358
374
  }
359
375
 
@@ -587,7 +603,34 @@ function todayCost(sessionList, cwdFilter) {
587
603
  return { cost, inputTokens: input, outputTokens: output };
588
604
  }
589
605
 
590
- /** Walk every session file under the sessions root, decompress, and parse. */
606
+ /**
607
+ * Decompressed session parse cache, keyed by path and validated against the
608
+ * file's mtime. Long-lived: an untouched file is never re-decompressed, so a
609
+ * cold /today-cost after the outer 30s TTL expires only re-reads files that
610
+ * actually changed. Cleared wholesale when it outgrows a bound.
611
+ */
612
+ const sessionParseCache = new Map();
613
+ const SESSION_PARSE_CACHE_MAX = 600;
614
+
615
+ /** Decompress+parse one file, honoring the (path,mtime) cache. */
616
+ async function parseSessionFileCached(path, mtimeMs) {
617
+ const hit = sessionParseCache.get(path);
618
+ if (hit !== void 0 && hit.mtimeMs === mtimeMs) return hit.parsed;
619
+ const result = await decompressSession(path);
620
+ if (!result.ok) return null;
621
+ const parsed = parseSessionUsages(result.text);
622
+ if (sessionParseCache.size >= SESSION_PARSE_CACHE_MAX) sessionParseCache.clear();
623
+ sessionParseCache.set(path, { mtimeMs, parsed });
624
+ return parsed;
625
+ }
626
+
627
+ /**
628
+ * Walk every session file under the sessions root and parse the ones that can
629
+ * contribute to "today" numbers. Only files whose mtime falls on the current
630
+ * local calendar day can contain today's samples, so older files are skipped
631
+ * entirely (avoids decompressing the whole ~/.dsh/sessions tree on every cold
632
+ * call). Parsing runs in parallel; results are cached by (path, mtime).
633
+ */
591
634
  async function collectAllSessionSamples() {
592
635
  const root = sessionsRoot();
593
636
  const fs = (await awaitImportFs());
@@ -604,14 +647,17 @@ async function collectAllSessionSamples() {
604
647
  } catch {
605
648
  return [];
606
649
  }
607
- const perSession = [];
650
+ const now = new Date();
651
+ const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
652
+ const candidates = [];
608
653
  for (const file of files) {
609
- const result = await decompressSession(file);
610
- if (!result.ok) continue;
611
- const parsed = parseSessionUsages(result.text);
612
- perSession.push(parsed);
654
+ try {
655
+ const mtimeMs = fs.statSync(file).mtimeMs;
656
+ if (mtimeMs >= dayStart) candidates.push({ path: file, mtimeMs });
657
+ } catch { /* file vanished mid-walk */ }
613
658
  }
614
- return perSession;
659
+ const parsed = await Promise.all(candidates.map((c) => parseSessionFileCached(c.path, c.mtimeMs)));
660
+ return parsed.filter((p) => p !== null);
615
661
  }
616
662
 
617
663
  /** Simple in-memory TTL cache for cost computations (30s). */
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 sidebar footer card shows the DeepSeek account balance and today's costs; clicking opens a popover with a five-tier cost breakdown (last prompt with session name / today-this-session / today-this-workspace / today-all-workspaces), peak/off-peak status tags, term explanations, and a one-click top-up link. Zero external dependencies — works on Node 24.",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [