DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

uexx2 /

uexx2/deepseek-balance-widget

Verified

Always-visible DeepSeek API balance pill for DeepSeek Harness, with today's spend and live peak/off-peak pricing.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@1edeb46d

DeepSeek Balance widget

An always-visible DeepSeek API balance pill for the Harness Web UI. It renders one frame-wide status pill in the corner of the window, so the balance is readable in every panel without opening Settings, and refreshes itself on a timer. Opening the card turns its top row into a one-click route to the Platform top-up page.

What it shows

  • The pill shows the balance across the recharge and bonus wallets, formatted exactly the way Platform Web formats it: two decimals, grouped digits, positive amounts truncated to cents, positive sub-cent amounts as <¥0.01, and negative amounts rounded with a 0.01 floor.
  • Hovering or focusing the pill opens a card in two tiers. The upper tier is the balance alone, at the largest type size, because it is the only "what is true now" figure — and the whole row is a link to the Platform top-up page. The lower tier is today's figures at a smaller size behind a divider: spent today, today's top-ups, and the day-start balance. The two tiers are separated by hierarchy rather than by labels.
  • A click re-reads the balance immediately. Otherwise it refreshes once a minute, when the page becomes visible again, and whenever the credential reference changes.

Top-up link. The card's top row is the recharge entry point: it is an <a> pointing at Platform's top-up page, opened with target="_blank" and rel="noreferrer", exactly like the official Account settings button. The destination comes from ctx.remote.account.getState().links.topUpUrl — the same field the official page uses — so a deployment with a different platformOrigin still gets the right host. The lookup is never allowed to break the balance half: it is a separate request that swallows its own failures, and when it fails the link falls back to https://platform.deepseek.com/top_up rather than disappearing. Once a URL is resolved it is not asked for again, and only http(s) values are accepted into href.

State is reported in the pill itself:

Situation Pill
Reading Spinner, muted label
Read, amount available Currency amount
No DeepSeek account credential ——, muted
Credential rejected or request failed ——, red border, retry on click

Where the numbers come from

Balance. The Client half adds no credential path of its own. It calls the account Remote namespace the account controller already publishes (ctx.remote.account.getBalance), so the Platform origin, the stored grant, the client identity headers and the 30-second balance timeout all stay owned by @deepseek-ai/dsh-deepseek-account-platform.

Today's spend. One formula, evaluated from a small stored state:

spent today = day-start balance + topped up today − balance now

It is a state, not an accumulator: reading the same balance twice produces the same number, and it does. A top-up only raises topped up today, so the figure neither jumps on a recharge nor ever goes negative. It needs no endpoint beyond the balance query already in use, so it works with nothing but the account credential the user is already signed in with.

The Client half keeps that state in browser storage under dsh.balance-widget.day-state and updates it on every reading:

Stored field Meaning
day The local day the figures belong to.
start The balance first seen today. It never moves, and it is what the card labels Day-start balance.
last The balance at the previous reading; the difference against it is what makes the formula idempotent.
topUps Money added today, accumulated from every rise the readings show.
spent start + topUps − now, clamped at zero.
  • A new local day resets start, last and topUps to the balance seen first that day, and spent to zero.
  • A state written by an earlier build (no last field) is discarded once and re-anchored to the current balance: that build re-added the whole drop on every reading, so its accumulated figure cannot be trusted. Expect one reset right after the upgrade.
  • The card shows a Topped up today row only on days that had a top-up, so the arithmetic on screen adds up: day-start + topped up − balance now = spent today.

Both wallets count toward that baseline and toward the drain, because bonus credit is spent the same way recharge credit is; the card still shows one balance.

Tokens today used to be a third row. It is gone: the figure needs the Host half, and the Host half only existed to produce it. index.js is now an inert apply(), and the whole accounting implementation — per-day ledger, per-session token deltas, the GET /deepseek-balance/usage route, and the disk read of $DSH_HOME/.credentials.yaml that avoided the unresolvable ctx.credentials service — is kept, unloaded, in host-usage-accounting.js with host-check.mjs as its check. Restoring the row means putting that file back as index.js and re-adding the row and its fetch to client.js.

Consequences worth knowing:

  • The balance is the Platform account balance, so it is empty until the user signs in through Settings → Account. An API-key-only setup has no Platform balance to read.
  • The baseline is set by the first reading of the day, so the day starts when the page is first open rather than at midnight; spend before that first reading is not attributed to the day.
  • A top-up followed by spending stays under-reported until the running total has been exceeded again, because re-anchoring discards the potential to detect the spend that preceded it.
  • The day state is per browser, not per account, and is cleared with site data.
  • The client identity version reported to Platform is 0.2.0-rc.2, matching the settings-account client of this DSH build. It is cosmetic telemetry; the Host forwards the header as-is.
  • The Host half is currently inactive: ENABLE_HOST_ACCOUNTING is false, because the Platform cumulative-spend field it relied on could not be read in this profile. Its accounting code, the GET /deepseek-balance/usage route and its ledger under $DSH_HOME/deepseek-balance-widget/ all remain, so the endpoint and a per-session token counter can be turned back on by flipping that one flag once the Platform read is confirmed on a real machine.

一枚胶囊:余额 + 峰谷价格(one pill, one card)

它住在输入框下方那条状态栏里 —— 就是显示「缓存命中 99.6%」「上下文 27%」的那一行, 横着一行排在那一行的最右端(座位 conversation.composer.dock,order: 100 排在官方 order: 0 之后):

    …… 缓存命中 99.6%   上下文 27%   │ 57m 00s │ 👛 ¥25.98 │ 🙂 梁文峰
                                      └ 还有多久 ┘ └── 余额 ──┘ └ 谁在提醒 ┘
  • 位置靠一条针对宿主容器的规则锁死::has(> .dsh-balance-widget){position:relative} 让「装着我们的那个容器」成为定位参照(它自己原本是 position:static), 然后我们 position:absolute;top:0;right:0 —— 右边缘严格等于那条栏容器的右边缘;
  • 它自身高度是 0:高度由容器那一行(约 20px)决定,所以绝不会把整条状态栏顶高、也不会推动别的行;
  • 样式完全是文字:没有边框、没有底色、没有阴影,字号/行高/颜色都继承那一行, 和旁边的「缓存命中 99.7%」「35%」是同款;
  • 不用 position:absolute:那要靠「容器自带定位上下文」才成立,万一没有就会跑到别的祖先那儿去, 位置完全不可控 —— 这正是前面几版翻车的根源;
  • 为什么不用 margin-left:auto:那条状态栏容器(composer dock)没有写 width,父级又是 align-items:center,所以它历来是「按内容宽、居中」的,右边缘大概落在 「缓存命中 99.7% / 37%」那一带。在这样一个容器里,auto 外边距只会把我们挤到百分比的左边 —— 这正是试了两轮都不对的原因。
  • 唯一的宿主改动就是那条 position:relative:它不改变容器任何可见属性(没有背景、没有边框、 不改宽高、不改对齐),只是让它成为参照物。规则写在插件的样式表里,用 :has() 定向到 「装着我们的那个容器」,没有改官方任何一行代码。
  • 我们自身高度是 0,所以完全不出现在那一行的排版里:无论怎么放,旁边的统计文字一个字都不会被挤动。 代价是它会压在「37%」上面(那 37% 本来就在容器右端),这是「必须排在百分比右边」的必然结果。
  • 三格用两道竖线分开,倒计时在最左(它是会一直动的数,先看到它),名字在最右;
  • 整枚 24px 高,和状态栏那一行齐平。
状态 中间那格 小脸
高峰(贵) 警示色 + 梁文峰 + 距离转空闲的倒计时 皱眉
空闲(便宜) 成功色 + 梁文谷 + 距离转高峰的倒计时 眯眼笑

位置与排版:完全交给座位,不碰别人的 DOM(how it is placed)

官方插件文档 references/ui-plugin.md 里有一条硬要求:

Do not read another plugin's DOM, stylesheet, or component source to estimate placement; choose a slot that already allocates space.

不要读别的插件的 DOM、样式或组件源码来推算位置;挑一个已经分配好空间的座位。

照这条来,本插件:

  • 座位就是 conversation.composer.dock(官方「缓存命中 x% / 上下文 y%」也在同一个座位里), order: 100 排在官方 order: 0 之后;
  • 容器与胶囊本体没有任何 position(不是 absolute、不是 fixed、不是 relative): 它就是那一行里的普通成员,跟着那一行排、跟着那一行高;
  • 没有 querySelector、没有 getBoundingClientRect、没有 ResizeObserver: 一行代码都不去读别人的 DOM(render-check.mjs 里有断言守着这两件事);
  • 因此它既不会压住输入框或对话内容,也不可能因为量错位置而「什么都没有」。

字体与颜色:和那一行一模一样(typography)

字号 / 行高 / 颜色 / 数字对齐全部照抄官方状态栏那一组(值是从官方 CSS 里读出来的):

属性 官方统计 pill 本插件
font-size calc(var(--dsh-content-font-size-secondary,13px) - 1px) 同
line-height calc(20px + var(--dsh-content-font-delta-secondary,0px)) 同
color var(--dsw-alias-label-tertiary) 同
font-variant-numeric tabular-nums 同(余额、倒计时)
内边距 padding: 1px 8px 同(胶囊本体)
字重 不加粗 不加粗

唯一带颜色的是「梁文峰 / 梁文谷」那三个字(高峰警示色 / 空闲成功色)—— 那是刻意的信号,不能跟着变灰,否则就分不出贵贱了。其余文字(倒计时、余额、两个竖线分隔符) 全部走那一行的中性色。

试过但不行的做法(history,免得走回头路)

做法 结果
position:fixed 钉窗口右下角 压住输入框和对话内容 —— 一切问题的起点
给容器 :has(){align-self:stretch} 那条容器看起来本来就是整宽,视觉上没变化
给容器加定位参照 + 自己 right:0 容器是否成为参照不可靠;它的高度只有那一行的 20px,会贴到上边缘
自身 height:0 同上,会贴到那一行的上边缘、看着「不在下面」
以状态栏官方元素的右边缘为基准靠最右 那条状态栏比输入框那一行更靠右 167px,插件会多探出去一大截,看着没对齐
position:fixed + 量百分比的位置去对齐 实测出现「什么都没有」:fixed 把元素从那一行摘了出去,位置不可控
position:relative + 量出来的位移 能对齐,但仍然违反上面那条文档要求(读了别人的 DOM),已删除

卡片里写什么(what the card says)

卡片分上下两半,语言尽量短,去掉了解释性的废话:

余额                          ¥17.43      ← 上半:余额(最大字号),整行可点 = 充值入口
──────────────────────────────────
今日已用金额                   ¥9.32
今日起点余额                   ¥7.06
今日充值                      ¥19.69
18:22 更新                                ← 24 小时制
──────────────────────────────────
DeepSeek API 高峰 / 空闲价格     梁文谷 · 空闲
距离变价                     14h 37m
下次变价                     09:00            ← 只写几点,不写「转高峰」
(周末 / 节假日 / 补班日 才会出现一行说明;普通工作日不重复)
当前单价 · 元 / 百万 tokens
输入 · 缓存命中              0.02        高峰 0.04 元
输入 · 缓存未命中               1        高峰 2 元
输出                         4        高峰 8 元

几个刻意的取舍:

  • 单价一定带单位,但单位只在表头写一次(当前单价 · 元 / 百万 tokens)。 只写 0.02 最容易被误会成"一次调用两分钱",所以单位必须有; 可是每行都挂一遍 元 / 百万 tokens 会把 296px 宽的卡片挤爆, 标签只能断成两行(「输入 · 缓存命中」→「输入 · 缓存」+「命中」),所以收进表头;
  • 高峰时段不渲染空的「高峰参照价」格。那一格原来固定占 min-width:74px, 高峰时没有内容也照样占位,正是它把标签挤到换行的;
  • 空闲时段每行补一句「高峰 x 元」(高峰时段不显示,因为当前价就是高峰价)—— "省了多少"比"现在多少"更有用;
  • 「下次变价」只写几点(09:00,不是今天才带日期 10-10 09:00)。 原来的「距离转高峰 09:00」读起来像"距离转高峰还有 9 点",看不懂; 变成什么看顶部的梁文峰 / 梁文谷就知道,不用再写一遍;
  • 更新时间是 24 小时制(18:22)。原来用 toLocaleTimeString(), 中文下给出「下午6:22:09」——12 小时制还带秒,跟卡片别处的 09:00 对不上;
  • 普通工作日不再重复显示时段说明。顶部已经写着「梁文谷 · 空闲」, 下面再来一句「工作日非高峰时段」只是把同一件事说第二遍。 只在今天和平时不一样时才说明:周末 / 法定节假日 / 调休补班日;
  • 删掉了价目表下面那句「空闲价是高峰价的一半…法定节假日除外」—— 节假日变便宜时顶部自然显示「空闲」,再说一遍是重复;
  • 删掉了「价格时段按 DeepSeek 公布的规则与中国法定节假日安排计算」这类不产生信息的话;
  • 删掉了卡片底部那行「点卡片外面收起」的提示;
  • 提醒文案也瘦了一圈(自检里有一条守着「每行 < 60 字」)。

三格分区点击(左/右收起,中间刷新)

明细卡点击展开。手机上根本没有「悬停」这回事, 只写 onMouseEnter 的话手机上永远看不到卡片内容。

胶囊分三格,关着的时候点哪一格都一样:展开(并顺带刷新一次余额)。 开着的时候三格分工不同:

格子 内容 卡片开着时点它
左 还有多久变价 收起
中 钱包图标 + 余额 刷新余额,卡片不消失
右 梁文峰 / 梁文谷 收起

另外两条收起的路:点卡片外面的空白处、按 Esc(键盘用户的退路, 三格分区是鼠标的用法,键盘走到胶囊上按回车只能展开)。

为什么这么分:这枚胶囊是唯一的刷新入口,中间那格必须永远能刷新; 而收起需要一条比「点空白处」更顺手的路,左右两格面积大又正好闲着。

卡片贴在胶囊正上方,宽度与高度都夹在视口内(width:min(296px,100vw - 32px)、 max-height:min(70vh,520px) + 可滚动),所以不会被屏幕边缘截掉一截。内容分上下两半, 中间一条分隔线:上半是余额与今日花销,下半是当前价格时段(距离变价多久、 下一次变价几点、三项单价:输入·缓存命中 / 输入·缓存未命中 / 输出, 元/百万 tokens,flash)。

到点变价的提醒是另一条贴在胶囊上方的小气泡:进高峰由梁文峰开口,进低谷由梁文谷开口, 点一下关掉,10 秒自动消失。它和卡片互不干扰。

余额读不出来(未登录或请求失败)时,第一格显示 —— 并让整枚胶囊转错误色,但后两格的 价格照常显示 —— 价格是本机算出来的,不该被网络问题拖累。

点「余额」直接去充值(top-up entry)

卡片上半那一行(余额 + 金额)整行就是充值入口,点一下打开 Platform 的充值页。 界面上没有「去充值」之类的提示字:入口就是「余额」这一行本身。

项目 做法
元素 <a href=… target="_blank" rel="noreferrer"> —— 和官方设置页的「充值」按钮同款
地址来源 ctx.remote.account.getState().links.topUpUrl(官方设置页用的也是这个字段)
兜底 读不到就用 https://platform.deepseek.com/top_up,不会让入口消失
屏幕文字 只有「余额」和金额,不加提示字
可点范围 整行(标签 + 金额),不是只有「余额」两个字 —— 手机上手指点下去不容易点空
可点线索 悬停:标签转品牌色 + 下划线 + 手型光标;键盘:一圈焦点框
桌面端 主进程的 setWindowOpenHandler 接管,交给系统默认浏览器打开
网页端 普通新标签页

几个刻意的取舍:

  • 地址不写死。Platform 的域名是账号插件的配置项(platformOrigin), 自建或换过部署的机器上写死的地址会指到别处,所以优先问账号接口要; 只有那次读取失败时才退回公开地址(DEFAULT_TOP_UP_URL)。
  • 它绝不拖累余额。取地址是一个独立的请求,自己吞掉自己的异常, 和余额查询并行发出,不会让数字晚一拍出现,也不会因为它失败就把余额那半变红。
  • 拿到就不再问。域名不会每分钟变一次,所以解析成功一次就记下来,不再重复查询; 失败则不算数,下次读数继续重试。
  • 只认 http(s)。上游万一给回 javascript: 之类的东西,也不会进 href。
  • 余额读失败时它照样在。那时候恰恰最需要去充值,所以错误态下这一行仍然是链接。
  • 不加提示字,可点性靠悬停讲。屏幕上就「余额」两个字,所以悬停时标签必须 转品牌色 + 下划线(cursor:pointer 也在这一行上),否则用户不知道这里能点。
  • 无障碍名字里把「去充值」补回来(余额 ¥17.43 · 去充值)。屏幕上没有提示字, 读屏软件又只会念链接自己的文字,所以这句只存在于 aria-label: 视力用户看到的是干净的「余额」,读屏用户听到的是「余额 ¥17.43 · 去充值」。

为什么不做成官方那种「应用内嵌的平台页」:那套内嵌页是账号插件通过 settings.section 座位注入的,不是公开的服务,别的插件拿不到;去翻它的内部实现正是官方文档明令禁止的事 ("Do not read another plugin's DOM, stylesheet, or component source")。一条普通链接最稳, 而且桌面端照样是系统浏览器打开,体验一致。

为什么 <style> 一定要带 data-plugin(踩过的坑)

DSH 的客户端模块系统有两条规则,合起来就是这个坑:

  1. 任何没有 data-plugin 属性的 <style>,都会被「认领」给当时正在加载的那个插件;
  2. 某个插件热更新(改完代码自动重载)时,系统会把记在它名下的 <style> 全部删掉。

所以样式表只写 data-dsh-plugin(DSH 根本不认这个名字)等于没打标记: 它会被下一个加载的插件抢走,然后在那个插件下次热更新时被一起删掉。 余额胶囊的表现就是从右下角跳到左上角——position:fixed;bottom:16px;right:16px 这条规则没了,它退化成 overlay 层里的一个普通方块,自然就落在左上角。

mountStyles() 因此同时写两个属性:

  • data-plugin="deepseek-balance-widget" —— 真正的所有权标记,DSH 靠它认领与回收;
  • data-dsh-plugin="deepseek-balance-widget" —— 给人看的名字,DSH 不读。

The stylesheet must carry data-plugin set to this package's module id. Without it the loader re-owns the tag for whichever plugin loads next, then deletes it on that plugin's next hot reload — which costs the pill its fixed positioning.

Files

File Role
package.json Bundle manifest: dsh.bundle.patch, dsh.client, display metadata and icon
cordis.patch.yml Inserts the deepseek-balance-widget loader row
index.js Host half; an inert apply(), so the bundle keeps a legal loader row
host-usage-accounting.js The removed per-day accounting, token deltas and usage route, kept unloaded
client.js Client half; the two readers (balance, price), the merged pill and card, decimal formatting and the spend accounting
peak-pricing.js Readable copy of the peak / off-peak schedule logic (importable by node for tests)
locale/{en,zh}.json Plugin Manager card title and description
icon.svg Plugin Manager card artwork
format-check.mjs Development check of the decimal formatting rules
spend-check.mjs Development check of the day-start balance accounting
peak-check.mjs Development check of the peak / off-peak schedule and the change-of-tier reminder
render-check.mjs Development smoke test of the price pill, with a fake React and fake DOM
host-check.mjs Development check of the archived Host accounting, against a fake context

Styling

The pill and its detail card use only --dsw-alias-* tokens and the host font and radius tokens, so they follow the light/dark theme and the locale. Artwork keeps its own colours. The overlay seat is click-through and this entry opts back into pointer events.

Development

node format-check.mjs   # decimal display rules
node spend-check.mjs    # day-start balance accounting
node peak-check.mjs     # peak / off-peak schedule and the change-of-tier reminder
node render-check.mjs   # the price pill itself, rendered with a fake React and fake DOM
node host-check.mjs     # Host accounting, against a fake plugin context

format-check.mjs extracts the formatting region of client.js, runs it in a VM, and compares every sample against the documented display rules recomputed in exact integer nano-units.

spend-check.mjs extracts the formatting and day-state regions with a fake localStorage and a controllable clock, then replays reading sequences for a first reading, a plain drain, a mid-day top-up, a day rollover and an idle day.

peak-check.mjs extracts the peak-pricing regions of client.js — the code that actually ships — into a VM together with the constants, then asserts every boundary minute of the two peak windows, the weekend and public-holiday rules, the make-up workday override, the next switch instant and its countdown text, the price table against the published rates, timezone independence, the localized copy keys, and the reminder gate (fires on a real tier crossing, once per crossing, never while the page is hidden, never for switches the clock skipped over). It also compares the rule data in client.js against the readable copy in peak-pricing.js, so editing one side only fails the check instead of silently shipping two different schedules.

render-check.mjs runs client.js for real against a fake window, a fake document, a tiny fake React (createElement / useState / useEffect) and a fake plugin context, then renders the single registered entry: it must be one pill (balance left, price right, one separator) and one card (balance rows above a divider, unit prices below), the clock crossing 18:00 must swap 梁文峰 for 梁文谷 and pop the notice, clicking the notice must dismiss it, and a failed balance read must leave the price half readable. It is what catches a wrong class name, a missing style, or a notice that cannot be closed — the schedule checks would not.

host-check.mjs mounts the archived Host implementation on a minimal fake context, runs two accounting passes against a fabricated projection cache, and asserts the shape and sanity of the route response plus the exact per-session token delta. It needs the real credential file, and it keeps its own throwaway ledger under the package directory, so it never touches the profile's state. Run it on its own: it uses a one-second tick, so a second copy racing the same state directory makes its first pass ambiguous.

Install

Install it from the plugin market inside DeepSeek Harness, or from a terminal:

dsh plugin add deepseek-balance-widget

Restart DSH afterwards. The pill appears in the lower-right corner of the window; click it to re-read the balance, hover it to open the detail card.

License

MIT

—/ 5

No ratings yet

Verified DSH bundle

Commit 1edeb46d418b

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout