dsh-sidebar-balance
给 DeepSeek Harness 的侧边栏底部加一条余额:DeepSeek 账号余额、Moonshot AI CN 余额、计费时段提示。
包名 @local/dsh-sidebar-balance,插件页显示为 侧边栏余额 / Sidebar Balance。
插件图标
- 余额常在眼前 — 侧栏「设置」上方一条
余额 ¥12.34,点开是账户面板:充值余额与赠金余额、刷新、用量/充值入口,以及浏览器内的 DeepSeek 账号登录。
- Moonshot 余额同栏可见 — 面板里另有一节
Moonshot AI CN:可用余额、现金余额、赠金余额,随每分钟轮询一起刷新;DeepSeek 无余额时小芯片自动回落显示 Moonshot 可用余额。余额由 Host 半边经凭据缝读取 MOONSHOTAI_CN_API_KEY(回落 MOONSHOT_API_KEY)后访问 api.moonshot.cn,密钥不出本机。
- 计费时段一眼可辨 — 钱包图标红色 = 计费高峰,绿色 = 空闲优惠(UTC 01:00–04:00 与 06:00–10:00,周一至周五,中国法定节假日除外;节假日按空闲计)。
安装
在 Harness 网页的插件页 → 安装里粘贴这个地址:
https://github.com/WXJ-71/dsh-sidebar-balance
命令行等价写法:
dsh plugin --profile web add https://github.com/WXJ-71/dsh-sidebar-balance
装完在插件列表里启用 @local/dsh-sidebar-balance,刷新页面即可。完整步骤、依赖要求、
卸载与排查见 INSTALL.md。
适用环境
| 项目 | 说明 |
|---|
| 适用系统 / 界面 | DeepSeek Harness 的 Web 界面(dsh web / dsh --profile web):手机浏览器、安卓 DSH 应用内嵌页面、桌面浏览器都适用。清单里以 dsh.client.platform: "web" 声明,不依赖任何桌面端组件。 |
| Harness 版本 | ^0.2.0-rc.2 —— 0.2.0(含 -rc 预发布)起、0.3.0 之前。已写进 package.json 的 peerDependencies:版本不符时插件页直接拒绝安装并提示版本不兼容,不会出现「装上了却不工作」。核验方式见 INSTALL.md。 |
| 已验证版本 | 0.2.0-rc.2 |
| 依赖 | 只用 Harness 自带能力:sidebar.footer.action 槽位、账户 Remote,以及随 dsh 一起发布的 @deepseek-ai/schemastery(仅用于声明 Config,不必额外安装) |
| 账号 | 需要 DeepSeek 开放平台账号:余额由 Harness 的 Host 读取,登录在浏览器里完成(授权回调落在本机回环地址) |
| 网络 | 插件的浏览器半边不直连任何平台;只有 Host 读余额(DeepSeek 经账户 Remote、Moonshot 经 api.moonshot.cn)、以及你点「登录」时才访问外网 |
许可
MIT。
以下为技术说明(English)。
@local/dsh-sidebar-balance
Display name in the plugin page: 侧边栏余额 / Sidebar Balance (from
locale/zh.json and locale/en.json; without them the card falls back to the
raw package name).
Shows the DeepSeek account balance at the foot of the Harness sidebar, above the
Settings row, and signs the Harness into a DeepSeek Platform account from the
browser. It reads the built-in account Remote (account.getState,
account.getBalance, account.startSignIn, account.cancelSignIn), so it never
talks to the Platform itself and needs no configuration.
Why this plugin exists
The shipped account UI (@deepseek-ai/dsh-client-ui-settings-account) registers
only in the Desktop renderer — its apply() starts with
if (!("dshDesktop" in globalThis)) return. In a browser profile there is
therefore no account page and no way to sign in, even though the Host half
(dsh-deepseek-account-platform) supports a loginSource: 'web' PKCE flow whose
callback lands on the loopback Web server. This plugin adds the missing entry
point.
Where it renders
One entry in the sidebar.footer.action slot (id: account-balance):
-
signed in — wallet glyph (see WalletIcon: a 1.25:1 billfold with a snap
clasp, drawn from the owner's two reference icons), 余额/Balance, and the
topped-up amount (¥12.34, or <0.01 for a positive sub-cent balance);
clicking opens a panel with the topped-up and granted wallets, a refresh
action, and the account's Usage / Top-up links.
-
signed out — wallet glyph with 未登录/Not signed in; the panel offers
登录/Sign in.
-
signing in — the state reads 等待登录完成…/Waiting for sign-in… in the
brand color while an attempt is in flight, with 打开授权页 and 取消 actions.
-
read failed — the state turns into the error color and the panel carries
the Remote error text; polling keeps retrying.
-
collapsed sidebar — a 36px circular icon-only button, like the Cordis
panel beside it.
-
billing indicator — the wallet glyph itself is tinted by the window that
is open right now: red for peak, green for off-peak, in both themes and in the
icon-only rail chip as well. The chip's tooltip names the window
(· 计费高峰时段 / · 空闲优惠时段).
-
Moonshot AI CN — the panel carries a second section with the Moonshot
open-platform wallet: available balance plus the cash/voucher breakdown,
refreshed on the same 60-second poll, and the chip falls back to it when
there is no DeepSeek wallet. The page never sees the key — the Host half
reads MOONSHOTAI_CN_API_KEY (falling back to MOONSHOT_API_KEY) through
the credential seam and calls api.moonshot.cn itself.
State refreshes on mount, every 60 seconds, and whenever the tab becomes
visible. While a sign-in attempt is pending the cadence is 2 seconds, so the
chip flips to the balance by itself once the browser finishes.
The billing indicator
DeepSeek bills the standard rate in two UTC windows, Monday to Friday, and the
discount rate in every other hour — weekends and Chinese public holidays in
full (pricing):
| Rate | When (UTC) |
|---|
| peak (wallet red) | 01:00–04:00 and 06:00–10:00, Mon–Fri, except CN holidays |
| off-peak (wallet green) | every other hour, all weekend, and every CN holiday |
peakAt(date) in client.js is a pure function of the instant, so the tint is
right whenever the page re-renders, and useOffPeak only exists to re-render at
the edges: one timer re-arms for the next 00:00/01:00/04:00/06:00/10:00 UTC
boundary, and a visibilitychange listener re-reads the clock because phones
freeze timers while the tab is backgrounded. The window is evaluated in UTC, so
the device's own timezone never changes the tint.
Holidays come from the State Council's yearly arrangement, listed per year in
CN_HOLIDAYS as MM-DD (2026 is 国办发明电〔2025〕7号). The table is the one
part that goes stale: a year that is missing falls back to the weekday rule,
which claims peak on a holiday rather than promising a discount that is not
there. Add the next year's dates from
https://www.gov.cn/zhengce/zhengceku/ when they are published each November.
Shifting a weekend to a workday does not matter: DeepSeek bills weekends
off-peak in full, 调休 or not.
prefers-reduced-motion has nothing to switch off here: the tint is static, so
the window reads the same either way.
The tint is the theme's own state token, painted through currentColor:
--dsw-alias-state-error-primary for peak and --dsw-alias-state-success-primary
for off-peak. Every stroke of the glyph carries it, so the wallet reads as one
colour rather than a tinted detail. Reusing the tokens keeps it legible in light
and dark without a colour of its own, and the icon-only rail chip takes the same
tint — there it is the only signal the chip has.
Two rules set it, and they sit after .dsh-sidebar-balance_icon in the sheet so
they win on the element they share with it. The wallet's blank left panel, which
used to hold the billing dots, stays blank.
Moonshot balance
The panel's Moonshot AI CN section rides the same poll as the DeepSeek read.
The page never sees the key: the Host half registers one same-origin route,
/dsh-sidebar-balance/moonshot.json, which resolves MOONSHOTAI_CN_API_KEY
(then MOONSHOT_API_KEY) through ctx.credentials.resolve per read — the
credential seam's rule, so a rotated key reaches the next poll without a
restart — and calls GET {baseUrl}/users/me/balance. The route answers the
available balance plus the cash/voucher breakdown, cached for 55 seconds so a
burst of polls shares one upstream read; ?refresh=1 bypasses the cache (the
panel's refresh button).
Every request passes through Connection's requestRejection, the same
browser-session trust fence the built-in JSON routes use, so only a page this
Harness would serve can read it. The four statuses the page renders are ready
(amounts), no-credential (a hint naming the missing ref), failed (the
upstream or network error text, trimmed to 200 chars), and disabled (the
Config turned the section off).
Surviving a flaky resolver
Measured on a machine whose DNS intermittently stalls: a cold dns.lookup took
7.3 s while the next one answered in 10 ms, and undici fails the request
at its own ~10 s connect timeout — before this plugin's fetchTimeoutMs can
matter. Two behaviours follow from that, and both are deliberately asymmetric
with the happy path:
- A transport failure gets exactly one retry, 500 ms later, and only when
the cause is one a second attempt can fix (
UND_ERR_CONNECT_TIMEOUT,
ETIMEDOUT, ECONNRESET, EAI_AGAIN, …). A warm resolver answers that retry
instantly, so the read succeeds where a single attempt would have reported an
outage. Deterministic causes (ENOTFOUND, ECONNREFUSED), HTTP errors, and
this plugin's own timeout abort are not retried — a second round trip would
only delay an honest failure.
- Failures are cached for 5 seconds, not
cacheTtlMs. Replaying a blip for
the rest of the read interval is exactly when someone is staring at the panel
wondering why it still says unavailable; with the short failure TTL the next
poll shows the recovery instead.
The error text also carries the syscall-level cause (fetch failed / UND_ERR_CONNECT_TIMEOUT), because undici reports every transport failure as the
bare string fetch failed and hides the real one in cause.cause — without it
an outage is indistinguishable from a bad address.
With no DeepSeek wallet the chip falls back to the Moonshot available balance.
That fallback keeps the wallet glyph neutral: the red/green tint is
DeepSeek's billing window and is dropped — from the icon and from the tooltip —
whenever the chip is speaking for Moonshot. The section carries its own
更新于 stamp, because the two providers refresh on independent reads and one
shared stamp would claim a freshness the Moonshot number may not have.
Failures and keyboard
Every failure carries a code, and the panel renders the code's localized
text rather than raw provider English. The codes are the Host route's (timeout,
payload, http, network, unknown), this half's own (host-route,
wallet), and the account Remote's documented ones (expired, storage,
protocol). A code with no dictionary entry still prints whatever text arrived
— an unmapped message is worth more to a reader than a generic replacement — and
where a localized line and a provider message both exist, the provider's words
follow a · separator and stay in the element's title, so a report stays
diagnosable.
The panel follows the host's modal-layer conventions (its useModalLayer, whose
focusable selector this plugin copies rather than redefining):
- opening moves focus into the panel once it has been measured — the panel
renders hidden until its position is known, and a hidden element cannot take
focus;
- Tab wraps inside the panel instead of walking the page behind it;
- Escape closes only while this panel owns focus, so a host modal opened on top
keeps its own Escape;
- closing hands focus back to whatever opened the panel, unless the close came
from clicking something else — that click owns focus then.
It deliberately declares aria-modal="true" without a scrim: interaction
and keyboard ownership match a modal, but the popover still dismisses on a click
anywhere else and never dims the app.
Config
Both halves are driven by one row config, written in your profile's
cordis.patch.yml. Defaults suit the common case, so the row needs no config
at all:
- id: sidebar-balance
name: '@local/dsh-sidebar-balance'
config:
keyRefs: ['MOONSHOTAI_CN_API_KEY', 'MOONSHOT_API_KEY']
baseUrl: https://api.moonshot.cn/v1
cacheTtlMs: 55000
fetchTimeoutMs: 20000
pollIntervalMs: 60000
showMoonshot: true
| Field | Default | Effect |
|---|
keyRefs | both Moonshot refs | Credential references tried in order; the first configured one wins |
baseUrl | https://api.moonshot.cn/v1 | API root; the wallet endpoint is ${baseUrl}/users/me/balance |
cacheTtlMs | 55000 | How long one upstream read is served to every poll |
fetchTimeoutMs | 20000 | Hard ceiling on one upstream read |
pollIntervalMs | 60000 | Cadence the page polls both providers at |
showMoonshot | true | Off hides the section; the route then answers disabled without touching credentials or the network |
The page cannot read a Host plugin's Config, so the route echoes the two
client-relevant fields (pollIntervalMs, showMoonshot) on every answer and
the panel adopts them — which is why changing the cadence or hiding the section
takes effect on the next poll instead of needing a reload.
The sign-in flow
- The panel's
登录/Sign in action calls
account.startSignIn(client, window.location.origin, 'web').
- The Host runs the PKCE attempt, registers the browser callback route
/oauth/callback on its Web server, and returns the attempt with an
authorizeUrl; the plugin opens it in a new tab.
- Platform approval redirects the tab to
http://127.0.0.1:<port>/oauth/callback?code=…&state=…, which the Host
exchanges and stores as the deepseek-account-platform/default grant.
- The next 2-second poll sees
credential-stored, reads the wallet, and the
chip shows the amount.
window.open runs in the same turn as the click, so it is not treated as a
popup; if the browser blocks it anyway the panel says so and offers the
打开授权页 button instead. Cancelling calls account.cancelSignIn(attempt.id);
if the panel is closed mid-attempt the attempt stays alive on the Host and the
next poll adopts its id again.
Files
package.json — bundle manifest: dsh.bundle.patch, the display icon, the
locale/*.json and package.json exports the metadata reader resolves, plus
the Client declaration (platform: web, immediately, and the load-order
inject).
cordis.patch.yml — inserts the single Host row for this package.
locale/en.json, locale/zh.json — the meta.title / meta.description
shown on the plugin card and component rows.
icon.png — the plugin-card artwork: a 241×256 RGBA PNG (90.9 KiB) cut down
from a 1155×1229 source that already carries a transparent background. Only
the plugin list, bundle list, and inventory rows read it; the sidebar chip
keeps its inline wallet glyph in client.js.
index.js — Host half: one authenticated same-origin route,
/dsh-sidebar-balance/moonshot.json, resolving the Moonshot credential
through ctx.credentials and reading the wallet from api.moonshot.cn with
a short cache, so the key never leaves the Host. It also declares the
plugin's Config.
client.js — the browser half: the sidebar.footer.action registration, the
chip and account panel, the Moonshot section, the sign-in flow, inline styles, the
inline WalletIcon / RefreshIcon glyphs, the peakAt / useOffPeak
billing-window helpers, and the sidebar.balance dictionaries (en/zh).
Maintenance
clientMetadata() in client.js sends x-client-version to the Platform. It
pins the Harness version this bundle was authored against (0.2.0-rc.2); bump
it after upgrading the Harness.
package.json declares peerDependencies: { "@deepseek-ai/dsh": "^0.2.0-rc.2" },
which is what the manager's compatibility preflight reads: outside that range the
plugin is refused at install time instead of failing silently later. The matching
peerDependenciesMeta entry marks it optional so a package manager never tries to
fetch a whole dsh runtime for it.
Do not judge that range by default semver semantics. dsh-app-boot
evaluates it as semver.satisfies(runtime, range, { includePrerelease: true }),
and includePrerelease is what lets one caret range cover the whole 0.2.x
line including its prereleases:
| runtime | ^0.2.0-rc.2 with includePrerelease | without |
|---|
0.2.0-rc.2 | covered | covered |
0.2.1-alpha.1 | covered | not covered |
0.2.9 | covered | covered |
0.3.0-rc.1 | not covered | not covered |
So the range already spans 0.2.0-rc.2 through the end of the 0.2 line; the
next real decision point is 0.3.0, not the next 0.2.x prerelease. The
test-sidebar-balance-host.mjs peer-range block re-checks this table against the
installed runtime so the reasoning above cannot silently rot.
A link: install into a profile keeps this directory live: editing client.js
is picked up by Client HMR when the web dev watcher is running, and by a page
refresh otherwise. Changes to cordis.patch.yml rows need a bundle install (or a
Harness restart) to take effect. See INSTALL.md for the install itself.
The card icon is the exception, and it is not the case the note below covers:
readPluginMeta resolves ./package.json once and then re-reads that file with
readFileSync on every call, inlining the icon bytes as a data: URL. So
replacing icon.png — or pointing icon at another file that already sits
inside the manifest directory — lands on the next plugin-list fetch, which the
page performs on mount. Adding the icon field, a new exports entry, or
locale/*.json for the first time is different: Node caches the package's
resolved exports map for the life of the process, so the running Harness keeps
reading the previous map — and therefore keeps showing the package-name and
default-artwork fallbacks — until dsh web is restarted. Verify new text or a
new icon address without waiting by calling readPluginMeta in a fresh process:
node --input-type=module -e "import { readPluginMeta } from '@deepseek-ai/dsh-app-boot'; \
console.log(JSON.stringify(readPluginMeta('@local/dsh-sidebar-balance', 'file://' + process.env.DSH_PROFILE_DIR + '/'), null, 2))"
icon.png was cut from a 1155×1229 RGBA source with a transparent background:
fit to a 256px box with Lanczos, landing at 241×256 and about 89 KiB. The manifest
cap is 256 KiB, so a 512px render is refused and a 384px one only just fits.
Nothing else in this package depends on that source.
Diagnosing a failed sign-in
The panel prints the Remote error verbatim. Useful mappings:
account start failed / protocol — startSignIn rejected the callback
origin. The Host accepts only http://localhost|127.0.0.1|[::1]:<port> with no
path, and the authorize URL must be <platformOrigin>/dsh/authorize.
expired — the attempt outlived attemptTimeoutMs (10 minutes by default) or
the Browser was closed before consent.
network — the Host could not reach https://platform.deepseek.com.
storage — a stored grant exists but does not parse; signOut (or deleting
the deepseek-account-platform/default record in $DSH_HOME/.credentials.yaml)
clears it.