dsh-balance-widget 0.5.5 → 0.6.2

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
@@ -9,7 +9,7 @@
9
9
  [![Node 24](https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square)](https://nodejs.org)
10
10
  [![Zero deps](https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square)]()
11
11
 
12
- A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) Web GUI: a persistent sidebar footer card shows the account balance and today's cost; clicking opens a five-tier cost breakdown (balance / last prompt with its session name / today-this-session / today-this-workspace / today-all-workspaces).
12
+ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) **Web GUI and desktop app**: a persistent sidebar footer card shows the account balance and today's cost; clicking opens a five-tier cost breakdown (balance / last prompt with its session name / today-this-session / today-this-workspace / today-all-workspaces).
13
13
 
14
14
  ## Preview
15
15
 
@@ -28,16 +28,16 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
28
28
  | **Peak/off-peak pricing** | ✅ Built-in official 2026-09-10 rate table, re-synced from the official page at startup and every 12h | Partial support |
29
29
  | **Security** | ✅ API key stays in the host process; loopback-only guard | Varies |
30
30
 
31
- **In one line**: *The zero-dependency, Node 24-ready balance/cost widget that never breaks `dsh web` boot.*
31
+ **In one line**: *The zero-dependency, Node 24-ready balance/cost widget that never breaks `dsh web` or the desktop app on boot.*
32
32
 
33
33
  ## Features
34
34
 
35
- - **Account balance** — On click, the host proxies DeepSeek's official `GET /user/balance` and shows the `¥` balance; the balance number is color-coded by threshold (healthy / amber below `lowThreshold` / red below `criticalThreshold`). The API key is resolved through the host credentials service and never leaves the host process; the browser only talks to same-origin routes.
35
+ - **Account balance** — The balance comes from the host account service `deepseekAccount`, provided by `@deepseek-ai/dsh-deepseek-account-platform` — the official "Settings → Account & balance" page's own source, so both surfaces show the same number and **no API key is needed**; unsigned hosts and older runtimes (0.1.x) fall back to DeepSeek's official `GET /user/balance`. The `¥` balance is color-coded by threshold (healthy / amber below `lowThreshold` / red below `criticalThreshold`). The API key is resolved through the host credentials service and never leaves the host process; the browser only talks to same-origin routes.
36
36
  - **Last prompt cost (estimate)** — Parses the most recent session file and prices the last turn's token usage, answering "how much did that last prompt cost", with the **session name** labeled underneath.
37
37
  - **Today · this session cost (estimate)** — The current session's usage today (calendar day) × DeepSeek's official peak/off-peak price table. Follows the configured model (default `deepseek-v4-flash`, switchable to `deepseek-v4-pro`) and the Beijing-time peak/off-peak windows automatically.
38
38
  - **Today · this workspace cost (estimate)** — Sums today's token usage × price across every session in the current workspace (anchored by the current session).
39
39
  - **Today · all workspaces cost (estimate)** — Walks every session under `~/.dsh/sessions/` and sums today's (calendar day) token usage × price.
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).
40
+ - **Peak/off-peak status** — a status dot before the card's amount is tinted by the current window (amber 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
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.
@@ -63,7 +63,7 @@ host half (lib/index.js)
63
63
 
64
64
  client half (lib/client.js)
65
65
  ctx.slots.inject("sidebar.footer.action")
66
- → persistent sidebar footer card (balance + today's cost, peak-tinted border)
66
+ → persistent sidebar footer card (balance + today's cost, peak/off-peak status dot; a 36×36 icon button while the sidebar is collapsed)
67
67
  → click opens five-tier cost popover + peak tag + ⓘ term explanations
68
68
  ```
69
69
 
@@ -85,6 +85,8 @@ dsh plugin --profile web add "link:$(pwd)"
85
85
 
86
86
  Then restart `dsh web`.
87
87
 
88
+ **The desktop app (DeepSeek Harness.app) is supported too**, with `desktop` as the install target: install it from the desktop app's sidebar "Plugins" panel, or add the package to `~/.dsh/profiles/desktop/package.json` (dependency plus `dsh.profile.bundles`), then restart the desktop app. Because the desktop app is launched by the GUI, note that this plugin shells out to nothing — there is no PATH to configure.
89
+
88
90
  ## Configuration
89
91
 
90
92
  ### Where the config file lives
@@ -165,30 +167,63 @@ This section is for the DSH Store / plugin audit: dependencies, runtime permissi
165
167
 
166
168
  **Dependencies & compatibility**
167
169
  - Zero runtime dependencies: imports no `@deepseek-ai/*` packages; no third-party host deps
168
- - `peerDependencies["@deepseek-ai/dsh"]`: `>=0.1.2-rc.1 <0.2.0` (DSH compatibility range)
170
+ - `peerDependencies["@deepseek-ai/dsh"]`: `>=0.1.2-rc.1 <0.3.0` (DSH compatibility range; 0.1.5 / 0.1.7 / 0.2.0 verified)
169
171
  - `engines.node`: `^22.19.0 || >=24.0.0`
170
172
  - `peerDependencies["react"]`: `^18.2.0` (browser rendering only)
171
173
 
172
174
  **Runtime permissions**
173
175
  - `files`: reads only `~/.dsh/sessions/` session JSONL (cost stats); never writes or mutates any session file
174
- - `network`: only the DeepSeek official endpoints — `api.deepseek.com` (`GET /user/balance`) and `api-docs.deepseek.com` pricing page (fetched every 12h); no third-party proxy
175
- - `commands`: spawns `zstd -d -c` to decompress session files (macOS needs `brew install zstd`); no other commands
176
- - `credentials`: reads `DEEPSEEK_API_KEY` (resolved via the host credentials service), used only in the host process behind a loopback-only route guard; the browser never sees the key
176
+ - `network`: only the DeepSeek official endpoints — `api.deepseek.com` (`GET /user/balance`, used only when unsigned or on an older host; a signed-in runtime reads the host's `deepseekAccount` service instead, so no plugin request leaves the process) and `api-docs.deepseek.com` pricing page (fetched every 12h); no third-party proxy
177
+ - `commands`: **none**. Session logs are decompressed with Node's built-in zstd (`node:zlib`, needs Node >=22.15), so no `zstd` binary and no `PATH` dependency
178
+ - `credentials`: reads `DEEPSEEK_API_KEY` on the fallback path only (resolved via the host credentials service), used only in the host process behind a loopback-only route guard; the browser never sees the key. A signed-in account reads no key at all
177
179
  - All host routes are bound to the loopback address and unreachable externally
178
180
 
179
181
  **External services**
180
- - DeepSeek official balance endpoint `GET /user/balance` (on click / 60s refresh)
182
+ - Host account service `deepseekAccount` (DSH 0.2.0+ with a signed-in account; the balance is the official page's own value, truncated to cents the way that page displays it)
183
+ - DeepSeek official balance endpoint `GET /user/balance` (fallback: unsigned or older host; on click / 60s refresh)
181
184
  - DeepSeek official pricing page (on startup + every 12h, for peak/off-peak rates)
182
185
 
183
186
  **Failure bounds**
184
187
  - Balance fetch failure: the panel shows the error and keeps the last successful snapshot (no interruption)
185
188
  - Pricing fetch failure: falls back to the built-in 2026-09-10 rate table, `pricingSource` marked `default` (`synced` once parsing succeeds)
186
- - Missing `zstd`: returns an actionable error (points to the install command) instead of failing silently
189
+ - Node without built-in zstd (<22.15), or a session file that yields no frame: returns an actionable error instead of failing silently
187
190
  - Missing/corrupt session files: that session is skipped; other sessions are unaffected
188
191
  - All costs are estimates; the provider's bill is authoritative
189
192
 
190
193
  ## Changelog
191
194
 
195
+ ### v0.6.2 — the balance now prefers the official account service (same source as the settings page, no API key needed)
196
+ - ✨ **The balance is read from the host account service `deepseekAccount` (provided by `@deepseek-ai/dsh-deepseek-account-platform`)** whenever DSH >= 0.2.0 ships it **and a DeepSeek account is signed in**. That service is the official "Settings → Account & balance" page's own source, so the card and the settings page show the same number. Amounts follow the official rule — **positive values are truncated to cents** (`55.6787307800000000` → `55.67`, matching the page) — with the purchased wallet from `value[]`, the granted wallet from `bonusWallets[]`, their sum as the total, and one entry per currency
197
+ - 🚪 **Every fallback is unchanged**: when signed out, when the host has no such service (DSH 0.1.x), or when the account read fails (expired token and friends), the balance comes from the original `GET /user/balance` + `DEEPSEEK_API_KEY`, exactly as in 0.6.1; a failed account read logs one warning saying why
198
+ - 🔑 **Signed in, no key is read at all**: the balance no longer hard-depends on `DEEPSEEK_API_KEY`, so an account-only install still shows a balance
199
+ - 🔎 **Auditable**: the balance response carries a `balanceSource` field (`deepseekAccount` or the fallback path), so it is always clear where a number came from
200
+ - 🧪 **Verified**: a fake cordis ctx drives the **real routes** through 13 assertions — account mapping, multi-currency and bonus summation, cent truncation, signed-out fallback, missing-service fallback, throwing-account fallback with a warning, account winning over an API key, the agent billing tool reading the same source, and a guard that the credential key's spelling is never mistaken for the service name
201
+ - 📏 **Measured same-source** (2026-09-29, after a real restart): the official `account/getBalance` returned `¥55.0685054200000000` while the card showed `¥55.06` — identical to the cent
202
+
203
+ > **Why this release, said out loud.**
204
+ > After desktop 0.2.0 shipped, the official app put a signed-in balance page under Settings → Account & balance. **That means the platform now recognises the pain point** — people need to see their balance. It just takes several clicks to reach, which for everyday use still loses to a card that sits in the sidebar and reads at a glance.
205
+ >
206
+ > So this release adapts anyway: the balance is read from the official account service, identical to the settings page to the cent, and no API key is needed. But we are not pretending otherwise — now that the platform has taken the need over, **this plugin will be replaced sooner or later**. A little wistful, yet a tool's job is to make itself unnecessary: **we are entering the farewell period.** From here we only keep compatibility maintenance in step with DSH, with no new features. Thanks for letting it watch your balance all this time.
207
+
208
+ ### v0.6.1 — compatibility range widened to DSH 0.2.0 (upgrading would otherwise drop the plugin silently)
209
+ - 🐛 **Fixed**: DSH 0.2.0 adds a plugin compatibility gate (`evaluatePluginCompatibility` in `dsh-app-boot`). It matches every declared `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` peer range against the running version — **prereleases included** (`semver.satisfies(..., { includePrerelease: true })`) — and a mismatching, non-exempted bundle is **skipped silently at startup** (`loadProfileDirectory` files it under `skippedBundles`: no error, no manifest change). The old declaration `>=0.1.2-rc.1 <0.2.0` happened to cover `0.2.0-rc.1` (which is why the widget works on the current 0.2.0-rc.1 desktop, verified live) but **not 0.2.0 final** — so the first stable-desktop upgrade would have made the sidebar card vanish, with the plugin manager demanding a manual `dsh plugin allow-version` exemption. The range is now `<0.3.0` (verified to cover 0.2.0 / 0.2.1 / 0.3.0-rc.1)
210
+ - ✅ **Live 0.2.0 compatibility check** (against the 0.2.0-rc.1 desktop app, driven in a real browser): all five host routes answer; session logs are still `SESSION_FORMAT_VERSION = 4` and `totalTokens = inputTokens + outputTokens + cacheReadTokens` holds for all 394 usage events (pricing math unchanged); the `sidebar.footer.action` slot, the `dsh.client.platform === "web"` loading gate and the `IconRefreshOutlineRegular` name are all unchanged; every one of the 19 `--dsw-*` tokens the plugin uses still exists (15 of them redefined under `body[data-ds-dark-theme]`, and the popover portalled into `document.body` still inherits the theme); React is still 18.3.1; the 36×36 collapsed-rail button and the popover anchoring behave as before
211
+ - 📦 **Scope**: the compatibility range in `package.json` plus documentation; no runtime code changed
212
+
213
+ ### v0.6.0 — desktop app support, and a UI that matches the desktop shell
214
+ - 🖥️ **Desktop app support (DSH 0.1.7 / DeepSeek Harness.app)**
215
+ - **Session logs are no longer decompressed by the `zstd` CLI** — Node's built-in zstd (`node:zlib`) does it instead. The desktop app is launched by the GUI, so its PATH is only `/usr/bin:/bin:/usr/sbin:/sbin` and the Homebrew `zstd` is invisible: every cost tier (today's spend, last prompt) failed outright there
216
+ - **v4 session logs are decoded frame by frame.** The log is append-only: each flush appends its own zstd frame (a measured 726 KB v4 log held 97 of them). Node's one-shot decompressor returns only the first frame (258 B) — and silently dropping 99% of the data is worse than failing. Frames are now located by their magic number; v0 / v3 / v4 logs decode byte-identically to `zstd -dc`
217
+ - **The refresh glyph is resolved per DSH version.** 0.1.7 renamed product icons to size-neutral weights (`IconRefreshOutlineRegular` plus a `size` prop) and dropped `IconRefreshOutline14`; rendering the missing name as a component throws React #130, and the sidebar slot answers a crashed entry by dropping the whole card — the "card flashes, then vanishes on click" report. The glyph is now resolved from whichever name exists, with a local SVG fallback
218
+ - **The client inject declaration is empty**: `@deepseek-ai/dsh-client-runtime` was never a real package (absent in both 0.1.5 and 0.1.7); the browser half needs only react and the shell's seed modules
219
+ - 🎨 **Visual alignment with the desktop shell**
220
+ - Dropped the card's state border (green off-peak / amber peak, which read as "selected") in favour of a 6px status dot before the amount; fill, 12px radius and 14px amount text now use the same design tokens as the desktop's own session rows
221
+ - The card fills the sidebar column — it used to be flex-shrunk to 131px and overflowed by 8px
222
+ - **New collapsed-rail state**: in the 56px icon rail the card becomes a 36×36 icon button (matching the rail's other buttons) with the status dot as a badge
223
+ - **The popover is portalled into `document.body`**: the sidebar column clips its children (`overflow:hidden`), so in the rail the panel was cut off at the sidebar edge. Its width now follows the card (measured at open time, 240px floor) and re-anchors through a `ResizeObserver` while the sidebar or the window is resized
224
+ - Shadow and focus ring now come from the shell's tokens (`--dsw-shadow-lv3`, `--dsw-focus-ring-*`)
225
+ - 📄 Docs: both README screenshots regenerated for the new look
226
+
192
227
  ### v0.5.5 — README assets now render on the npm package page
193
228
  - 🐛 **Fixed**: the screenshots and the language switch used repository-relative paths (`docs/screenshot-corner.png`, `README.en.md`). The npm package page renders the README body only and does not resolve in-repo paths, so both screenshots and the language link were broken on npm. They are now absolute URLs: screenshots via `raw.githubusercontent.com`, the language switch via a GitHub blob link
194
229
  - 📦 **Scope**: documentation only; no code change
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![Node 24](https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square)](https://nodejs.org)
10
10
  [![Zero deps](https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square)]()
11
11
 
12
- DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:侧边栏底部常驻卡片显示账户余额与今日花费,点击弹出五层级成本明细(余额 / 最近提问·标注会话名 / 今日·本会话 / 今日·本工作区 / 今日·所有工作区)。
12
+ DeepSeek Harness (DSH) **Web GUI 与桌面版**通用的余额与成本小部件:侧边栏底部常驻卡片显示账户余额与今日花费,点击弹出五层级成本明细(余额 / 最近提问·标注会话名 / 今日·本会话 / 今日·本工作区 / 今日·所有工作区)。
13
13
 
14
14
  ## 效果预览
15
15
 
@@ -28,16 +28,16 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:侧边栏底部常
28
28
  | **峰谷定价** | ✅ 内置官方 2026-09-10 峰谷价表,启动时与每 12h 自动从官方页刷新 | 部分支持 |
29
29
  | **安全性** | ✅ API key 仅在宿主进程,loopback-only 守卫 | 参差不齐 |
30
30
 
31
- **一句话**:*零依赖、Node 24 就绪、永不拖垮 dsh web 启动的余额/成本小部件。*
31
+ **一句话**:*零依赖、Node 24 就绪、在 `dsh web` 与桌面版都不拖垮启动的余额/成本小部件。*
32
32
 
33
33
  ## 功能
34
34
 
35
- - **账户余额** — 点击图标时经宿主代理查询 DeepSeek 官方 `GET /user/balance`,展示 `¥` 余额;余额数字按阈值自动变色(充足 / 低于 `lowThreshold` 变黄 / 低于 `criticalThreshold` 变红);API key 只在宿主进程内读取(凭据服务),浏览器不接触密钥。
35
+ - **账户余额** — 余额优先取自宿主账号服务 `deepseekAccount`(由 `@deepseek-ai/dsh-deepseek-account-platform` 提供,即官方「设置 → 账号与余额」页的同一来源,因此两处显示同一个数字,且**不需要 API Key**);未登录或旧版宿主(0.1.x)时回退到 DeepSeek 官方 `GET /user/balance`。展示 `¥` 余额,按阈值自动变色(充足 / 低于 `lowThreshold` 变黄 / 低于 `criticalThreshold` 变红);API key 只在宿主进程内读取(凭据服务),浏览器不接触密钥。
36
36
  - **最近一次提问成本(估算)** — 从最近活跃会话文件解析最后一个 turn 的 token 用量 × 单价,回答"刚才那条提问花了多少";下方标注该会话的**会话名**。
37
37
  - **今日·本会话成本(估算)** — 当前会话今天(自然日)产生的 token 用量 × DeepSeek 官方峰谷定价表计算,随当前会话模型(默认 `deepseek-v4-flash`,可在配置中改为 `deepseek-v4-pro`)与北京时间高峰/空闲时段自动切换。
38
38
  - **今日·本工作区成本(估算)** — 遍历当前工作区(由当前会话锚定)下的所有会话,累加今天的 token 用量 × 单价。
39
39
  - **今日·所有工作区成本(估算)** — 遍历 `~/.dsh/sessions/` 下所有工作区的所有会话,累加今天的 token 用量 × 单价。
40
- - **峰谷状态标签** — 卡片与弹框边框按当前时段着色(峰时橙色 / 谷时绿色),弹框标题旁显示「峰时/谷时」标签,悬停可查看当前价格档位(输入/输出单价)。
40
+ - **峰谷状态标签** — 卡片金额前的状态圆点按当前时段着色(峰时琥珀 / 谷时绿色),弹框标题旁显示「峰时/谷时」标签,悬停可查看当前价格档位(输入/输出单价)。
41
41
  - **Token 用量** — 同时展示输入(含缓存命中)/ 输出 token 数。
42
42
  - **一键充值** — 弹层底部「去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
43
43
  - **侧边栏常驻卡片** — 侧边栏底部(设置上方)显示余额 + 今日花费,全局可见;每 60 秒自动刷新(余额走官方接口、成本为本地会话解析),打开弹框时也会立即刷新一次,无需手动操作。
@@ -58,7 +58,7 @@ host 半区 (lib/index.js)
58
58
 
59
59
  client 半区 (lib/client.js)
60
60
  ctx.slots.inject("sidebar.footer.action")
61
- → 侧边栏底部常驻卡片(余额 + 今日花费,峰/谷时段描边着色)
61
+ → 侧边栏底部常驻卡片(余额 + 今日花费,峰/谷时段状态圆点;侧边栏收起时变为 36×36 图标按钮)
62
62
  → 点击弹出五层级成本明细 + 峰谷标签 + ⓘ 名词解释
63
63
  ```
64
64
 
@@ -80,6 +80,8 @@ dsh plugin --profile web add "link:$(pwd)"
80
80
 
81
81
  装完重启 `dsh web` 生效。
82
82
 
83
+ **桌面版(DeepSeek Harness.app)同样支持**,安装目标是 `desktop` profile:可在桌面版侧边栏的「插件」面板里安装,或把包加进 `~/.dsh/profiles/desktop/package.json` 的依赖与 `dsh.profile.bundles`;装完**重启桌面版**生效。桌面版由 GUI 启动,本插件不依赖外部命令行,无需额外配置 PATH。
84
+
83
85
  ## 配置
84
86
 
85
87
  ### 配置文件在哪
@@ -162,30 +164,63 @@ DSH 的插件配置统一放在这个文件里:
162
164
 
163
165
  **依赖与兼容**
164
166
  - 零运行时依赖:不 import 任何 `@deepseek-ai/*` 包,宿主端无第三方依赖
165
- - `peerDependencies["@deepseek-ai/dsh"]`: `>=0.1.2-rc.1 <0.2.0`(DSH 兼容范围)
167
+ - `peerDependencies["@deepseek-ai/dsh"]`: `>=0.1.2-rc.1 <0.3.0`(DSH 兼容范围;0.1.5 / 0.1.7 / 0.2.0 实测通过)
166
168
  - `engines.node`: `^22.19.0 || >=24.0.0`
167
169
  - `peerDependencies["react"]`: `^18.2.0`(仅浏览器端渲染)
168
170
 
169
171
  **运行时权限**
170
172
  - `files`:只读 `~/.dsh/sessions/` 下的会话 JSONL(成本统计);不写入、不修改任何会话文件
171
- - `network`:仅请求 DeepSeek 官方端点——`api.deepseek.com`(`GET /user/balance` 余额)与 `api-docs.deepseek.com` 定价页(每 12h 抓取);不走任何第三方代理
172
- - `commands`:spawn `zstd -d -c` 解压会话文件(macOS 需 `brew install zstd`);不执行其他命令
173
- - `credentials`:读取 `DEEPSEEK_API_KEY`(经宿主凭据服务解析),仅宿主进程使用、loopback-only 路由守卫;浏览器不接触密钥
173
+ - `network`:仅请求 DeepSeek 官方端点——`api.deepseek.com`(`GET /user/balance`,仅在未登录/旧版宿主时使用;已登录时余额走宿主的 `deepseekAccount` 服务,不产生本插件的网络请求)与 `api-docs.deepseek.com` 定价页(每 12h 抓取);不走任何第三方代理
174
+ - `commands`:**不执行任何命令**。会话解压改用 Node 内置 zstd(`node:zlib`,需 Node ≥22.15),不再依赖 `zstd` 可执行文件与 `PATH`
175
+ - `credentials`:仅在回退路径上读取 `DEEPSEEK_API_KEY`(经宿主凭据服务解析),仅宿主进程使用、loopback-only 路由守卫;浏览器不接触密钥。已登录账号时余额不读任何密钥
174
176
  - 所有 host 路由均绑定 load 回环地址,外部不可达
175
177
 
176
178
  **外部服务**
177
- - DeepSeek 官方余额接口 `GET /user/balance`(点击/60s 刷新时调用)
179
+ - 宿主账号服务 `deepseekAccount`(DSH 0.2.0+ 且已登录账号时;余额与官方账号页同源,金额按官方规则截断到分)
180
+ - DeepSeek 官方余额接口 `GET /user/balance`(回退路径:未登录或旧版宿主;点击/60s 刷新时调用)
178
181
  - DeepSeek 官方定价页(启动时 + 每 12h 抓取,用于峰谷单价)
179
182
 
180
183
  **失败边界**
181
184
  - 余额接口失败:面板提示失败信息,保留上次成功快照(不中断)
182
185
  - 定价页抓取失败:回退内置 2026-09-10 价目表,`pricingSource` 标记为 `default`(解析成功则为 `synced`)
183
- - `zstd` 缺失:返回可读错误提示(指引安装),而非静默失败
186
+ - Node 无内置 zstd(<22.15)或会话文件解不出任何帧:返回可读错误提示,而非静默失败
184
187
  - 会话文件缺失/损坏:跳过该会话,不影响其他会话统计
185
188
  - 所有成本为估算值,实际以官方账单为准
186
189
 
187
190
  ## 版本历史
188
191
 
192
+ ### v0.6.2 — 余额优先读官方账号服务(与设置页同源,不再需要 API Key)
193
+ - ✨ **余额优先取自宿主账号服务 `deepseekAccount`**:当 DSH ≥ 0.2.0 **且已登录 DeepSeek 账号**时,余额改读这个服务——它就是官方「设置 → 账号与余额」页的数据来源,所以卡片与设置页显示同一个数字。金额按官方规则处理:**正金额截断至分**(`55.6787307800000000` → `55.67`,与设置页一致),充值余额 = 官方 `value[]`、赠金余额 = `bonusWallets[]`,总额为两者之和,多币种各自成组
194
+ - 🚪 **降级路径完全不变**:未登录、宿主无该服务(DSH 0.1.x)、或账号读取失败(token 过期等)时,一律回退到原来的 `GET /user/balance` + `DEEPSEEK_API_KEY`,行为与 0.6.1 相同;账号读取失败会记一条 warn 说明原因
195
+ - 🔑 **登录态下余额不读任何密钥**:不再强制依赖 `DEEPSEEK_API_KEY`,纯账号模式(不配 API Key)也能显示余额
196
+ - 🔎 **可审计**:余额响应新增 `balanceSource` 字段(`deepseekAccount` 或回退路径),便于确认这一次的数字从哪来
197
+ - 🧪 **验证**:用假 cordis ctx 驱动**真实路由**跑 13 项断言全通过——账号映射 / 多币种与赠金求和 / 截断到分、未登录回退、服务缺失回退、账号抛错回退并告警、账号优先于 API Key、agent 计费工具同源,以及"凭据 key 的拼写不能被误当成服务名"这条防回归
198
+ - 📏 **实测同源**(2026-09-29,真机重启后):官方 `account/getBalance` 返回 `¥55.0685054200000000`,插件卡片显示 `¥55.06` —— 逐分一致
199
+
200
+ > **关于这个版本的背景,也说给我们自己听。**
201
+ > 桌面版 0.2.0 上线后,官方在「设置 → 账号与余额」里放出了登录后的余额页。**这说明官方已经承认"用户需要随时知道余额"这个痛点**——只是它要多点几下才能看到,日常用起来仍然不如侧边栏常驻、一眼可读。
202
+ >
203
+ > 所以这一版还是做了适配:余额改读官方账号服务,与设置页逐分一致,并且不再需要 API Key。但我们心里清楚,官方既然已经把这个需求接过去了,**这个插件被替代只是时间问题**。有点不舍,不过工具的价值本来就应该由平台自己长出来——**接下来进入告别期**:后续只跟随 DSH 版本做必要的兼容性维护,不再扩展新功能。谢谢一路用它看住余额的每一天。
204
+
205
+ ### v0.6.1 — 兼容范围放宽到 DSH 0.2.0(否则升级后插件会被静默摘掉)
206
+ - 🐛 **修复**:DSH 0.2.0 新增了插件兼容性闸门(`dsh-app-boot` 的 `evaluatePluginCompatibility`),它把 `peerDependencies` 里每个 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 范围与运行时版本比对,**且预发布版本参与范围匹配**(`semver.satisfies(..., { includePrerelease: true })`);不匹配且未被豁免的 bundle 在启动时被**静默跳过**(`loadProfileDirectory` 收进 `skippedBundles`,不报错、也不改 manifest)。旧声明 `>=0.1.2-rc.1 <0.2.0` 恰好覆盖 `0.2.0-rc.1`(所以在 0.2.0-rc.1 桌面上一切正常,实测确认),但**不覆盖 0.2.0 正式版**——桌面版一升级,侧边栏卡片就会直接消失,插件管理器还会要求 `dsh plugin allow-version` 手动豁免。现放宽为 `<0.3.0`(实测覆盖 0.2.0 / 0.2.1 / 0.3.0-rc.1)
207
+ - ✅ **0.2.0 兼容性实测**(在 0.2.0-rc.1 桌面版 + 真实浏览器上运行):宿主五条路由全部正常;会话日志仍为 `SESSION_FORMAT_VERSION = 4`,`totalTokens = inputTokens + outputTokens + cacheReadTokens` 在 394 条 usage 事件上全部成立(计价公式未变);`sidebar.footer.action` 槽位、`dsh.client.platform === "web"` 加载闸门、`IconRefreshOutlineRegular` 图标名均未变;插件用到的 19 个 `--dsw-*` token 全部存在(其中 15 个在 `body[data-ds-dark-theme]` 下重定义,弹层 portal 到 `document.body` 仍能继承主题);React 仍为 18.3.1;收起态 36×36 图标按钮与弹层定位照常
208
+ - 📦 **范围**:仅 `package.json` 的兼容范围声明与文档,运行代码无变化
209
+
210
+ ### v0.6.0 — 适配 DSH 桌面版,界面与桌面端视觉对齐
211
+ - 🖥️ **适配桌面版(DSH 0.1.7 / DeepSeek Harness.app)**
212
+ - **会话解压不再依赖 `zstd` 命令行**,改用 Node 内置 zstd(`node:zlib`)。桌面版由 GUI 启动,进程 PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`,找不到 Homebrew 的 zstd——此前在桌面端「今日花费 / 最近一次提问」等所有成本档位都会直接报错
213
+ - **按帧解压 v4 会话日志**:日志是 append-only 的,每次 flush 追加一个独立 zstd 帧(实测一份 726 KB 的 v4 日志含 97 帧)。Node 的一次性解压只返回第一帧(258 B)——比报错更危险的是它会静默丢掉 99% 以上的数据。现按帧魔数逐帧解码,v0 / v3 / v4 三种日志与 `zstd -dc` 输出字节级一致
214
+ - **刷新图标按版本解析**:0.1.7 把产品图标改为尺寸中性权重(`IconRefreshOutlineRegular` + `size` 属性),旧名 `IconRefreshOutline14` 已不存在;把它当组件渲染会抛 React #130,而侧边栏槽位对崩溃条目的处理是整条摘掉——表现为「卡片闪一下、一点开就消失」。现按可用名解析并带本地 SVG 兜底
215
+ - **客户端注入声明清空**:`@deepseek-ai/dsh-client-runtime` 从不是真实包(0.1.5 与 0.1.7 都没有),浏览器端只依赖 react 与 shell 的 seed 模块
216
+ - 🎨 **视觉贴合桌面端**
217
+ - 去掉卡片的状态边框(谷时绿 / 峰时琥珀,看着像「被选中」);峰/谷改为金额前的 6px 状态圆点。填充色、12px 圆角、金额 14px 字号均对齐桌面端会话行所用的设计 token
218
+ - 卡片占满侧边栏列:此前被 flex 收缩到 131px 宽并横向溢出 8px
219
+ - **新增侧边栏收起态**:在 56px 图标轨里渲染 36×36 图标按钮(与轨道其它按钮同规格),状态点变为图标角标
220
+ - **弹层改挂 `document.body`**:侧边栏列是 `overflow:hidden`,收起态下弹层此前会被裁到只剩左边一小条;面板宽度跟随卡片(打开时实测,240px 起),并用 `ResizeObserver` 在拖动侧边栏 / 缩放窗口时实时跟随
221
+ - 阴影与焦点环改用桌面端 token(`--dsw-shadow-lv3`、`--dsw-focus-ring-*`)
222
+ - 📄 文档:README 两张截图按新外观重新生成
223
+
189
224
  ### v0.5.5 — 修复 README 在 npm 包页面上的显示
190
225
  - 🐛 **修复**:README 的截图与语言切换此前用相对路径(`docs/screenshot-corner.png`、`README.en.md`)。npm 包页面只渲染 README 正文、不解析仓库内的相对路径,所以在 npm 上两张截图和语言链接都是坏的。现改为绝对 URL:截图走 `raw.githubusercontent.com`,语言切换走 GitHub blob 链接
191
226
  - 📦 **范围**:仅文档,代码无变化
Binary file
Binary file
package/lib/client.js CHANGED
@@ -6,9 +6,44 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
8
  let react = require("react");
9
+ let react_dom = require("react-dom");
9
10
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
11
+ /**
12
+ * Glyph of the popover's refresh button. DSH 0.1.7 renamed product icons to
13
+ * size-neutral weights (`IconRefreshOutlineRegular` plus a `size` prop —
14
+ * primitives README, decision 2026-09-16); 0.1.5 exported only the
15
+ * artboard-sized `IconRefreshOutline14`. Resolve once and fall back to a
16
+ * local glyph: rendering `undefined` as a component throws React #130, and
17
+ * the slot answers by dropping the whole card.
18
+ */
19
+ const RefreshGlyph = [
20
+ _deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular,
21
+ _deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineMedium,
22
+ _deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14
23
+ ].find((glyph) => typeof glyph === "function" || typeof glyph === "object" && glyph !== null);
24
+ /** Refresh glyph element, stroke-matched to the card's own 16px icon. */
25
+ function refreshGlyph() {
26
+ if (RefreshGlyph !== void 0) return (0, react_jsx_runtime.jsx)(RefreshGlyph, {
27
+ size: 14
28
+ });
29
+ return (0, react_jsx_runtime.jsxs)("svg", {
30
+ width: 14,
31
+ height: 14,
32
+ viewBox: "0 0 24 24",
33
+ fill: "none",
34
+ stroke: "currentColor",
35
+ strokeWidth: 1.7,
36
+ strokeLinecap: "round",
37
+ strokeLinejoin: "round",
38
+ children: [(0, react_jsx_runtime.jsx)("polyline", {
39
+ points: "23 4 23 10 17 10"
40
+ }), (0, react_jsx_runtime.jsx)("path", {
41
+ d: "M20.49 15a9 9 0 1 1-2.12-9.36L23 10"
42
+ })]
43
+ });
44
+ }
10
45
  //#region lib/types/client/styles.js
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) backwards}.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)}";
46
+ 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:14px;line-height:20px}.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;flex:1 1 auto;min-width:0}.dshbw_sideWrap[data-compact=\"true\"]{flex:0 0 auto}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side{width:36px;height:36px;padding:0;margin:0;gap:0;justify-content:center;align-items:center;border-radius:var(--dsw-radius-md,10px);background:transparent;color:var(--dsw-alias-label-primary)}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side:hover{background:var(--dsw-alias-interactive-bg-hover)}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_sideTop{justify-content:center;gap:0}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_sideAmount,.dshbw_sideWrap[data-compact=\"true\"] .dshbw_sideFoot{display:none}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side::after{content:\"\";position:absolute;top:5px;right:5px;width:6px;height:6px;border-radius:50%;display:none}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side[data-peak=\"true\"]::after{display:block;background:var(--dsw-alias-state-warn-primary)}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side[data-peak=\"false\"]::after{display:block;background:var(--dsw-alias-state-success-primary)}.dshbw_side{position:relative;display:flex;flex-direction:column;gap:4px;width:100%;padding:6px 8px;margin:0 0 2px;border:none;border-radius:12px;background:var(--dsw-alias-interactive-bg-hover);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-active)}.dshbw_sidePop{position:fixed;z-index:70;box-sizing:border-box;min-width:240px;max-width:calc(100vw - 16px);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:var(--dsw-shadow-lv3,0 0 1px 0 #0003,0 0 4px 0 #00000005,0 12px 32px 0 #00000014);padding:12px 12px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);animation:dshbw-pop-in 160ms cubic-bezier(0.23,1,0.32,1) backwards}.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_sideAmount::before{content:\"\";display:none;width:6px;height:6px;border-radius:50%;margin-right:6px;vertical-align:middle}.dshbw_side[data-peak=\"true\"] .dshbw_sideAmount::before{display:inline-block;background:var(--dsw-alias-state-warn-primary)}.dshbw_side[data-peak=\"false\"] .dshbw_sideAmount::before{display:inline-block;background: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)}.dshbw_side:focus-visible,.dshbw_link:focus-visible,.dshbw_sidePopClose:focus-visible,.dshbw_sidePopRefresh:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}";
12
47
  const tagId = "dsh-balance-widget/styles.css";
13
48
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
14
49
  const tag = document.createElement("style");
@@ -185,6 +220,14 @@ window.__ModuleLoader__.load({
185
220
  const [error, setError] = (0, react.useState)(null);
186
221
  const [loading, setLoading] = (0, react.useState)(false);
187
222
  const [peak, setPeak] = (0, react.useState)(null);
223
+ /**
224
+ * Viewport position of the popover. The panel is portalled into
225
+ * document.body because the sidebar column clips its children
226
+ * (`overflow:hidden`), which cut the panel off at the sidebar edge once
227
+ * the shell collapsed into its 56px rail. Body still carries the theme
228
+ * tokens, so a portalled panel keeps its surface colors.
229
+ */
230
+ const [popPos, setPopPos] = (0, react.useState)(null);
188
231
  // The currently selected session id (the session the user is viewing).
189
232
  // This anchors "today · this workspace" to the actual current workspace.
190
233
  const currentSessionId = typeof useSessions === "function"
@@ -242,26 +285,69 @@ window.__ModuleLoader__.load({
242
285
  const timer = setInterval(() => refresh(false), 60000);
243
286
  return () => clearInterval(timer);
244
287
  }, [currentSessionId]);
245
- // Close the popover when clicking outside the card.
288
+ // Close the popover when clicking outside the card or its portalled panel.
246
289
  const wrapRef = (0, react.useRef)(null);
290
+ const popRef = (0, react.useRef)(null);
247
291
  (0, react.useEffect)(() => {
248
292
  if (!open) return;
249
293
  const onDocClick = (event) => {
250
294
  const el = wrapRef.current;
251
- if (el !== null && !el.contains(event.target)) setOpen(false);
295
+ const panel = popRef.current;
296
+ if (el !== null && el.contains(event.target)) return;
297
+ if (panel !== null && panel.contains(event.target)) return;
298
+ setOpen(false);
252
299
  };
253
300
  document.addEventListener("mousedown", onDocClick);
254
301
  return () => document.removeEventListener("mousedown", onDocClick);
255
302
  }, [open]);
303
+ /**
304
+ * Anchor the panel just above the card: same width as the card (never
305
+ * narrower than PANEL_MIN_WIDTH), kept inside the viewport.
306
+ */
307
+ const placePopover = () => {
308
+ const el = wrapRef.current;
309
+ if (el === null) return;
310
+ const rect = el.getBoundingClientRect();
311
+ // The panel matches the card it drops out of, so the two line up
312
+ // whatever width the user gave the sidebar; 240px is the floor for
313
+ // the collapsed rail, where the card is only 36px wide.
314
+ const PANEL_MIN_WIDTH = 240;
315
+ const width = Math.max(PANEL_MIN_WIDTH, Math.round(rect.width));
316
+ setPopPos({
317
+ left: Math.max(8, Math.min(Math.round(rect.left), window.innerWidth - width - 8)),
318
+ bottom: Math.max(8, Math.round(window.innerHeight - rect.top + 8)),
319
+ width: Math.min(width, window.innerWidth - 16)
320
+ });
321
+ };
322
+ // Keep the portalled panel glued to the card while the layout moves:
323
+ // the sidebar can be dragged wider, collapsed or the window resized
324
+ // with the panel still open, and a fixed-position panel cannot follow
325
+ // on its own.
326
+ (0, react.useEffect)(() => {
327
+ if (!open) return;
328
+ const reposition = () => placePopover();
329
+ window.addEventListener("resize", reposition);
330
+ const el = wrapRef.current;
331
+ const observer = typeof ResizeObserver === "function" && el !== null ? new ResizeObserver(reposition) : null;
332
+ if (observer !== null) observer.observe(el);
333
+ return () => {
334
+ window.removeEventListener("resize", reposition);
335
+ if (observer !== null) observer.disconnect();
336
+ };
337
+ }, [open]);
256
338
  const level = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
257
339
  const toggle = async () => {
258
340
  const next = !open;
341
+ if (next) placePopover();
259
342
  setOpen(next);
260
343
  if (next) await refresh(true);
261
344
  };
262
345
  return (0, react_jsx_runtime.jsx)("div", {
263
346
  ref: wrapRef,
264
347
  className: styles.sideWrap,
348
+ // The sidebar shell hands the slot a `wide` flag: collapsed, the
349
+ // foot becomes a 56px icon rail, so the card shrinks to one glyph.
350
+ "data-compact": wide === false ? "true" : undefined,
265
351
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
266
352
  children: [(0, react_jsx_runtime.jsx)("button", {
267
353
  type: "button",
@@ -292,10 +378,12 @@ window.__ModuleLoader__.load({
292
378
  })
293
379
  })]
294
380
  })
295
- }), open && (0, react_jsx_runtime.jsx)("div", {
381
+ }), open && popPos !== null && (0, react_dom.createPortal)((0, react_jsx_runtime.jsx)("div", {
382
+ ref: popRef,
296
383
  className: styles.sidePop,
297
384
  "data-peak": peak !== null ? String(peak.active) : undefined,
298
385
  role: "dialog",
386
+ style: { left: popPos.left, bottom: popPos.bottom, width: popPos.width },
299
387
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
300
388
  children: [(0, react_jsx_runtime.jsx)("div", {
301
389
  className: styles.sidePopHead,
@@ -439,12 +527,12 @@ window.__ModuleLoader__.load({
439
527
  "aria-label": t("refresh.aria"),
440
528
  title: t("refresh.title"),
441
529
  onClick: () => refresh(true),
442
- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, {})
530
+ children: refreshGlyph()
443
531
  })]
444
532
  })
445
533
  })]
446
534
  })
447
- })]
535
+ }), document.body)]
448
536
  })
449
537
  });
450
538
  }
package/lib/index.js CHANGED
@@ -180,10 +180,107 @@ async function resolveApiKey(ctx, config) {
180
180
  }
181
181
 
182
182
  /**
183
- * Query the official DeepSeek /user/balance endpoint.
183
+ * The host account service behind the official "Settings → Account & balance"
184
+ * page. `@deepseek-ai/dsh-deepseek-account-platform` registers it as
185
+ * `deepseekAccount` (its `super(ctx, "deepseekAccount")`, and the account
186
+ * controller's `inject = ["deepseekAccount", ...]`), shipped from DSH 0.2.0 on;
187
+ * on older hosts and profiles without an account backend ctx.get() returns
188
+ * undefined. The package's credential key happens to be spelled differently
189
+ * (`deepseek-account-platform`) and is deliberately not used here.
190
+ */
191
+ const ACCOUNT_SERVICE = "deepseekAccount";
192
+
193
+ /**
194
+ * A platform wallet amount as integer cents, truncated below a cent — the same
195
+ * rule the official page uses for positive amounts ("正金额截断至分").
196
+ * @returns BigInt cents, or null when the value is not a decimal string.
197
+ */
198
+ function centsOf(value) {
199
+ const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(String(value ?? "").trim());
200
+ if (match === null) return null;
201
+ const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? "").slice(0, 2).padEnd(2, "0"));
202
+ return match[1] === "-" ? -cents : cents;
203
+ }
204
+
205
+ /** Render integer cents as a two-decimal string. */
206
+ function formatCents(cents) {
207
+ const negative = cents < 0n;
208
+ const absolute = negative ? -cents : cents;
209
+ const text = `${absolute / 100n}.${String(absolute % 100n).padStart(2, "0")}`;
210
+ return negative ? `-${text}` : text;
211
+ }
212
+
213
+ /**
214
+ * Reshape an account balance view into the /user/balance payload the client
215
+ * already reads (purchased + granted wallets per currency).
216
+ */
217
+ function accountBalancePayload(view) {
218
+ const wallets = Array.isArray(view.value) ? view.value : [];
219
+ const bonuses = Array.isArray(view.bonusWallets) ? view.bonusWallets : [];
220
+ const currencies = [];
221
+ for (const wallet of [...wallets, ...bonuses]) {
222
+ if (typeof wallet?.currency === "string" && !currencies.includes(wallet.currency)) currencies.push(wallet.currency);
223
+ }
224
+ const sumCents = (list, currency) => list.reduce((total, wallet) => {
225
+ if (wallet?.currency !== currency) return total;
226
+ const cents = centsOf(wallet.balance);
227
+ return cents === null ? total : total + cents;
228
+ }, 0n);
229
+ const balance_infos = currencies.map((currency) => {
230
+ const toppedUp = sumCents(wallets, currency);
231
+ const granted = sumCents(bonuses, currency);
232
+ return {
233
+ currency,
234
+ total_balance: formatCents(toppedUp + granted),
235
+ granted_balance: formatCents(granted),
236
+ topped_up_balance: formatCents(toppedUp)
237
+ };
238
+ });
239
+ return {
240
+ is_available: balance_infos.some((info) => Number(info.total_balance) > 0),
241
+ balance_infos,
242
+ balanceSource: ACCOUNT_SERVICE
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Read the balance from the host account service — the official page's own
248
+ * source, so both surfaces show the same number, and no API key is involved.
249
+ * @returns the /user/balance-shaped payload, or undefined when the service is
250
+ * absent, the user is not signed in, or the read fails (caller falls back).
251
+ */
252
+ async function fetchAccountBalance(ctx) {
253
+ let account;
254
+ try {
255
+ account = ctx.get(ACCOUNT_SERVICE);
256
+ } catch {
257
+ return void 0;
258
+ }
259
+ if (account === void 0 || typeof account.getBalance !== "function") return void 0;
260
+ try {
261
+ const view = await account.getBalance({
262
+ version: "dsh-balance-widget",
263
+ locale: Intl.DateTimeFormat().resolvedOptions().locale,
264
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60
265
+ });
266
+ if (view?.status !== "ready") return void 0;
267
+ const payload = accountBalancePayload(view);
268
+ return payload.balance_infos.length === 0 ? void 0 : payload;
269
+ } catch (error) {
270
+ ctx.logger?.warn?.("[dsh-balance-widget] account balance read failed: %s", error instanceof Error ? error.message : String(error));
271
+ return void 0;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Query the account balance: the host's account service when the user signed in
277
+ * with a DeepSeek account, else the official /user/balance endpoint with the API
278
+ * key.
184
279
  * @returns { ok: true, ...payload } | { ok: false, error }
185
280
  */
186
281
  async function fetchBalance(ctx, config) {
282
+ const account = await fetchAccountBalance(ctx);
283
+ if (account !== void 0) return { ok: true, ...account };
187
284
  let apiKey;
188
285
  try {
189
286
  apiKey = await resolveApiKey(ctx, config);
@@ -523,51 +620,58 @@ function awaitImportFs() {
523
620
  return _fs;
524
621
  }
525
622
 
526
- /** Cache the zstd availability probe (run once per process). */
527
- let _zstdAvailable = void 0;
528
-
529
- /**
530
- * Probe whether the zstd CLI is available, caching the result.
531
- * macOS does not ship zstd by default, so this must be checked lazily and
532
- * surfaced as a distinct error rather than a silent "session not found".
533
- */
534
- async function isZstdAvailable() {
535
- if (_zstdAvailable !== void 0) return _zstdAvailable;
536
- const { execFile } = await import("node:child_process");
537
- const { promisify } = await import("node:util");
538
- const execFileP = promisify(execFile);
539
- try {
540
- await execFileP("zstd", ["--version"], { timeout: 5000 });
541
- _zstdAvailable = true;
542
- } catch {
543
- _zstdAvailable = false;
544
- }
545
- return _zstdAvailable;
546
- }
623
+ /** Zstandard frame magic number (RFC 8878 §3.1.1). */
624
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
547
625
 
548
626
  /**
549
- * Decompress a .zstd session file to text via the zstd CLI.
627
+ * Decompress a .zstd session file using Node's built-in zstd (>=22.15), which
628
+ * is what DSH itself writes with — no external CLI and no PATH dependency, so
629
+ * this also works when the host was launched by the desktop app.
630
+ *
631
+ * The log is append-only: each flush appends its own complete frame, so a real
632
+ * log holds dozens of frames back to back (a 459 KB 0.1.7 log measured 97).
633
+ * Node's decompressor stops at the end of the first frame and would silently
634
+ * return a few hundred bytes out of a few hundred kilobytes, so frames are
635
+ * located by their magic number and decoded one at a time. A magic sequence
636
+ * occurring inside compressed data fails to decode and is skipped.
550
637
  * @returns { ok: true, text } | { ok: false, error }
551
638
  */
552
639
  async function decompressSession(path) {
553
- if (!(await isZstdAvailable())) {
640
+ const zlib = await import("node:zlib");
641
+ if (typeof zlib.zstdDecompressSync !== "function") {
554
642
  return {
555
643
  ok: false,
556
- error: "zstd CLI not found — install it (e.g. `brew install zstd`) to read session files"
644
+ error: "this Node build has no built-in zstd — Node >=22.15 is required to read session files"
557
645
  };
558
646
  }
559
- const { execFile } = await import("node:child_process");
560
- const { promisify } = await import("node:util");
561
- const execFileP = promisify(execFile);
647
+ const fs = await awaitImportFs();
648
+ let buf;
562
649
  try {
563
- const { stdout } = await execFileP("zstd", ["-d", "-c", path], { maxBuffer: 512 * 1024 * 1024 });
564
- return { ok: true, text: stdout };
650
+ buf = await fs.promises.readFile(path);
565
651
  } catch (error) {
566
652
  return {
567
653
  ok: false,
568
- error: `failed to decompress session file: ${error instanceof Error ? error.message : String(error)}`
654
+ error: `failed to read session file: ${error instanceof Error ? error.message : String(error)}`
569
655
  };
570
656
  }
657
+ /** Decoded frame payloads, concatenated as bytes so a frame boundary that
658
+ * splits a UTF-8 character still decodes correctly. */
659
+ const parts = [];
660
+ let offset = 0;
661
+ while (offset < buf.length) {
662
+ const start = buf.indexOf(ZSTD_MAGIC, offset);
663
+ if (start === -1) break;
664
+ try {
665
+ parts.push(zlib.zstdDecompressSync(buf.subarray(start)));
666
+ offset = start + ZSTD_MAGIC.length;
667
+ } catch {
668
+ offset = start + 1;
669
+ }
670
+ }
671
+ if (parts.length === 0) {
672
+ return { ok: false, error: `no zstd frame decoded (${buf.length} bytes)` };
673
+ }
674
+ return { ok: true, text: Buffer.concat(parts).toString("utf8") };
571
675
  }
572
676
 
573
677
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-balance-widget",
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.5",
3
+ "description": "Balance & cost widget for the DeepSeek Harness GUI (web and desktop app): 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 tag, term explanations, and a one-click top-up link. Zero external dependencies — works on Node 24.",
4
+ "version": "0.6.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [
@@ -25,16 +25,13 @@
25
25
  "patch": "./cordis.patch.yml"
26
26
  },
27
27
  "client": {
28
- "inject": [
29
- "@deepseek-ai/dsh-client-runtime",
30
- "@deepseek-ai/dsh-client-ui-conversation"
31
- ],
28
+ "inject": [],
32
29
  "platform": "web"
33
30
  }
34
31
  },
35
32
  "peerDependencies": {
36
33
  "react": "^18.2.0",
37
- "@deepseek-ai/dsh": ">=0.1.2-rc.1 <0.2.0"
34
+ "@deepseek-ai/dsh": ">=0.1.2-rc.1 <0.3.0"
38
35
  },
39
36
  "keywords": [
40
37
  "deepseek",