@ruoyang/dsh-plugin-deepseek-balance 1.0.0
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/LICENSE +21 -0
- package/README.md +159 -0
- package/client.js +265 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +9 -0
- package/index.js +249 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ruoyang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# @ruoyang/dsh-plugin-deepseek-balance
|
|
2
|
+
|
|
3
|
+
A floating badge in the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI
|
|
4
|
+
showing the remaining balance of the `DEEPSEEK_API_KEY` credential the harness itself uses for model
|
|
5
|
+
calls.
|
|
6
|
+
|
|
7
|
+
> 中文说明见下方 [中文](#中文)。
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
| Part | Behaviour |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| Status dot | green = balance available, amber = account cannot call the API, grey = querying, red = query failed |
|
|
14
|
+
| Badge text | total balance in the primary currency, e.g. `¥4.94` |
|
|
15
|
+
| Hover panel | per-currency totals, granted vs topped-up split, whether the account is callable, and the query time |
|
|
16
|
+
| Interaction | click to refresh, drag to reposition, automatic refresh every 60 seconds |
|
|
17
|
+
|
|
18
|
+
The badge itself is a pill pinned to the lower-right corner; drag it anywhere and it stays there for
|
|
19
|
+
the session.
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- A DeepSeek Harness installation with the **Web** surface (`dsh web`).
|
|
24
|
+
- `DEEPSEEK_API_KEY` configured — via the launching environment, a `.env` file, or
|
|
25
|
+
**Settings → Models**. The plugin resolves it per query through the credentials seam, so a key you
|
|
26
|
+
rotate takes effect on the next refresh with no restart.
|
|
27
|
+
- Node.js **>= 18.17** on the host (the host half uses global `fetch` and `AbortSignal.timeout`).
|
|
28
|
+
|
|
29
|
+
The package deliberately declares **no `peerDependencies`** on `@deepseek-ai/dsh-*`, so it installs
|
|
30
|
+
across harness versions instead of being pinned to one. It also has **no runtime dependencies** and
|
|
31
|
+
no install scripts.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Using the `dsh` CLI:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dsh plugin --profile web add @ruoyang/dsh-plugin-deepseek-balance
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
In the Web UI: **Settings → Plugins**, then install by the package name above.
|
|
42
|
+
|
|
43
|
+
Through an agent session, the equivalent is the `plugin_manager` tool with
|
|
44
|
+
`action: install_bundle` and that package name as `target`.
|
|
45
|
+
|
|
46
|
+
A new bundle activates immediately; if the badge does not appear, reload the page once so the
|
|
47
|
+
browser picks up the new client module.
|
|
48
|
+
|
|
49
|
+
## Uninstall
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
dsh plugin --profile web remove @ruoyang/dsh-plugin-deepseek-balance
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
One bundle, two halves, mounted by a single Loader row:
|
|
58
|
+
|
|
59
|
+
| File | Role |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `cordis.patch.yml` | inserts the row `deepseek-balance` |
|
|
62
|
+
| `index.js` | Host half: serves `GET /deepseek-balance/api` |
|
|
63
|
+
| `client.js` | Client half: a lazy module-system factory that registers into the `shell.overlay` slot |
|
|
64
|
+
|
|
65
|
+
**Your key never reaches the browser.** The host half reads it through `ctx.credentials` and calls
|
|
66
|
+
`https://api.deepseek.com/user/balance` itself; the browser half only fetches the same-origin JSON
|
|
67
|
+
snapshot the host produces. Failures come back as `{ "ok": false, "code": …, "message": … }` with the
|
|
68
|
+
error's `cause` chain flattened into `message`, so a transport problem is diagnosable without logs.
|
|
69
|
+
|
|
70
|
+
The plugin declares no settings and no `Config` schema — there is nothing to configure.
|
|
71
|
+
|
|
72
|
+
## Troubleshooting
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
curl http://127.0.0.1:3080/deepseek-balance/api # the JSON snapshot the badge renders
|
|
76
|
+
curl "http://127.0.0.1:3080/deepseek-balance/api?force=1" # bypass the host half's 20s cache
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Response | Meaning |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `{"ok":true,…}` | working |
|
|
82
|
+
| `{"ok":false,"code":"NO_KEY"}` | no `DEEPSEEK_API_KEY` is configured |
|
|
83
|
+
| `{"ok":false,"code":"HTTP_401"}` | the key is rejected by DeepSeek |
|
|
84
|
+
| `{"ok":false,"code":"TRANSPORT"}` | the host could not reach `api.deepseek.com` |
|
|
85
|
+
|
|
86
|
+
A 404 on that path means the row did not mount — check that the bundle is enabled in
|
|
87
|
+
**Settings → Plugins**.
|
|
88
|
+
|
|
89
|
+
## Known limitations
|
|
90
|
+
|
|
91
|
+
- Visible badge text is currently Chinese only; it does not yet go through the Client locale service,
|
|
92
|
+
so an English UI still shows Chinese labels in the hover panel.
|
|
93
|
+
- The badge position resets on page reload; it is not persisted.
|
|
94
|
+
|
|
95
|
+
## 中文
|
|
96
|
+
|
|
97
|
+
在 DeepSeek Harness 的 Web 界面右下角悬浮显示**当前 `DEEPSEEK_API_KEY` 的剩余余额**。
|
|
98
|
+
|
|
99
|
+
**它做什么**:状态点(绿=有余额 / 黄=账号不可调用 / 灰=查询中 / 红=查询失败)、胶囊显示主币种
|
|
100
|
+
总余额、悬停展开各币种总余额与赠金/充值拆分、点击刷新、按住拖动、每 60 秒自动刷新。
|
|
101
|
+
|
|
102
|
+
**要求**:带 Web 界面的 Harness(`dsh web`);已配置 `DEEPSEEK_API_KEY`(环境变量、`.env` 或
|
|
103
|
+
设置里的 Models 页均可,插件每次查询都重新解析,换密钥不用重启);宿主机 Node ≥ 18.17。
|
|
104
|
+
|
|
105
|
+
**安装**:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
dsh plugin --profile web add @ruoyang/dsh-plugin-deepseek-balance
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
或在 Web 界面 **设置 → 插件** 里按包名安装。装好后如果徽标没出现,刷新一次页面。
|
|
112
|
+
|
|
113
|
+
**卸载**:`dsh plugin --profile web remove @ruoyang/dsh-plugin-deepseek-balance`
|
|
114
|
+
|
|
115
|
+
**密钥安全**:密钥全程留在宿主机侧,由宿主半经 `ctx.credentials` 读出后直接请求
|
|
116
|
+
`api.deepseek.com`;浏览器半只读同源的 JSON 快照,**密钥不会进入浏览器**。
|
|
117
|
+
|
|
118
|
+
**排查**:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
curl http://127.0.0.1:3080/deepseek-balance/api # 徽标渲染用的 JSON
|
|
122
|
+
curl "http://127.0.0.1:3080/deepseek-balance/api?force=1" # 绕过宿主半 20 秒缓存
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
返回 `NO_KEY` = 没配密钥,`HTTP_401` = 密钥被拒,`TRANSPORT` = 宿主机连不上
|
|
126
|
+
`api.deepseek.com`;404 = 行没挂上,去 **设置 → 插件** 看是否启用。
|
|
127
|
+
|
|
128
|
+
**已知限制**:徽标内的可见文字目前是硬编码中文,还没接 Client locale 服务,所以英文界面下悬停
|
|
129
|
+
面板仍是中文;徽标位置刷新页面后重置。
|
|
130
|
+
|
|
131
|
+
## Releasing (maintainer)
|
|
132
|
+
|
|
133
|
+
The default registry on a machine set up for fast Chinese installs is
|
|
134
|
+
`registry.npmmirror.com` — a **read-only mirror**. It serves installs but accepts no publishes, so a
|
|
135
|
+
bare `npm publish` fails with `ENEEDAUTH` against it. Pass the official registry explicitly:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
cd dsh-plugins/deepseek-balance
|
|
139
|
+
npm login --registry=https://registry.npmjs.org/
|
|
140
|
+
npm whoami --registry=https://registry.npmjs.org/ # must print the owner of the @ruoyang scope
|
|
141
|
+
npm publish --registry=https://registry.npmjs.org/
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Why the flag and not just `.npmrc`: npm resolves config as
|
|
145
|
+
`cli > env > project > user > global`, and a shell that inherited `npm_config_registry` (npx exports
|
|
146
|
+
it, so any shell started by an npx-launched `dsh` carries it) overrides every file. `npm config ls`
|
|
147
|
+
shows it as `registry = "…" ; overridden by env`. The project `.npmrc` here is a convenience for
|
|
148
|
+
clean shells; the flag is what always works.
|
|
149
|
+
|
|
150
|
+
A scoped package can only be published by the account that owns the scope, so `npm whoami` must
|
|
151
|
+
match `@ruoyang` — either that is your npm username, or `ruoyang` is an org you belong to.
|
|
152
|
+
|
|
153
|
+
For a new release, bump `version` in `package.json` first: npm refuses to republish an existing
|
|
154
|
+
version, and a re-`npm pack` at the same version silently overwrites the local tarball while the
|
|
155
|
+
already-installed copy keeps the old bytes.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
[MIT](./LICENSE)
|
package/client.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the DeepSeek balance badge.
|
|
3
|
+
*
|
|
4
|
+
* One occupant of the frame-wide `shell.overlay` slot (`@deepseek-ai/dsh-client-ui-layout`
|
|
5
|
+
* declares it): a draggable pill showing the remaining balance of the credential
|
|
6
|
+
* the harness itself uses for model calls. The host half owns the credential and
|
|
7
|
+
* the upstream request; this half only reads the JSON snapshot it serves.
|
|
8
|
+
*
|
|
9
|
+
* Hand-written in the client module system's lazy-CJS factory format, so no
|
|
10
|
+
* build step is involved. `id` must equal the package name. React comes from the
|
|
11
|
+
* browser module table via `require`.
|
|
12
|
+
*
|
|
13
|
+
* The factory itself stays side-effect free: the stylesheet, the interval and the
|
|
14
|
+
* slot registration are all created inside `apply` and owned by that fiber.
|
|
15
|
+
*/
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: '@ruoyang/dsh-plugin-deepseek-balance',
|
|
18
|
+
factory(require) {
|
|
19
|
+
const React = require('react')
|
|
20
|
+
|
|
21
|
+
/** Snapshot route served by this package's host half. */
|
|
22
|
+
const API_PATH = '/deepseek-balance/api'
|
|
23
|
+
/** Auto-refresh cadence, matching the host half's own cache window. */
|
|
24
|
+
const REFRESH_MS = 60000
|
|
25
|
+
/** Display symbol per reported currency; anything else is shown with its code. */
|
|
26
|
+
const SYMBOLS = { CNY: '¥', USD: '$' }
|
|
27
|
+
/** Id of the one style node this fiber owns. */
|
|
28
|
+
const STYLE_ID = 'dsh-balance-styles'
|
|
29
|
+
|
|
30
|
+
const CSS = [
|
|
31
|
+
'.dsh-balance-root{position:fixed;z-index:60;pointer-events:auto;user-select:none;',
|
|
32
|
+
'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;',
|
|
33
|
+
'font-size:12px;line-height:1.45;}',
|
|
34
|
+
'.dsh-balance-pill{display:flex;align-items:center;gap:6px;padding:6px 11px;border-radius:999px;',
|
|
35
|
+
'background:var(--dsw-alias-bg-overlay,rgba(24,24,27,.94));',
|
|
36
|
+
'color:var(--dsw-alias-label-primary,#fafafa);',
|
|
37
|
+
'border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));',
|
|
38
|
+
'box-shadow:0 6px 20px rgba(0,0,0,.28);cursor:grab;touch-action:none;backdrop-filter:blur(8px);}',
|
|
39
|
+
'.dsh-balance-pill:active{cursor:grabbing;}',
|
|
40
|
+
'.dsh-balance-dot{width:7px;height:7px;border-radius:50%;flex:none;',
|
|
41
|
+
'background:var(--dsw-alias-label-secondary,#a1a1aa);}',
|
|
42
|
+
'.dsh-balance-tone-ok .dsh-balance-dot{background:var(--dsw-alias-state-success-primary,#22c55e);}',
|
|
43
|
+
'.dsh-balance-tone-warn .dsh-balance-dot{background:var(--dsw-alias-state-warn-primary,#f59e0b);}',
|
|
44
|
+
'.dsh-balance-tone-error .dsh-balance-dot{background:var(--dsw-alias-state-error-primary,#ef4444);}',
|
|
45
|
+
'.dsh-balance-label{font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:.01em;white-space:nowrap;}',
|
|
46
|
+
'.dsh-balance-spin{opacity:.65;animation:dsh-balance-spin .9s linear infinite;}',
|
|
47
|
+
'@keyframes dsh-balance-spin{to{transform:rotate(360deg)}}',
|
|
48
|
+
'.dsh-balance-panel{display:none;margin-bottom:8px;padding:10px 12px;min-width:190px;border-radius:12px;',
|
|
49
|
+
'background:var(--dsw-alias-bg-overlay,rgba(24,24,27,.96));',
|
|
50
|
+
'color:var(--dsw-alias-label-primary,#fafafa);',
|
|
51
|
+
'border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.12));',
|
|
52
|
+
'box-shadow:0 10px 30px rgba(0,0,0,.32);backdrop-filter:blur(10px);}',
|
|
53
|
+
'.dsh-balance-root:hover .dsh-balance-panel{display:block;}',
|
|
54
|
+
'.dsh-balance-row{display:flex;justify-content:space-between;gap:16px;padding:2px 0;}',
|
|
55
|
+
'.dsh-balance-key{color:var(--dsw-alias-label-secondary,#a1a1aa);white-space:pre;}',
|
|
56
|
+
'.dsh-balance-val{font-variant-numeric:tabular-nums;white-space:nowrap;}',
|
|
57
|
+
'.dsh-balance-err{color:var(--dsw-alias-state-error-primary,#ef4444);max-width:230px;',
|
|
58
|
+
'white-space:normal;word-break:break-word;text-align:right;}',
|
|
59
|
+
'.dsh-balance-hint{margin-top:7px;padding-top:6px;font-size:11px;',
|
|
60
|
+
'border-top:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.1));',
|
|
61
|
+
'color:var(--dsw-alias-label-secondary,#a1a1aa);}',
|
|
62
|
+
].join('')
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read one snapshot from this package's host half.
|
|
66
|
+
*
|
|
67
|
+
* The host half answers structured failures with HTTP 200, so a rejected
|
|
68
|
+
* promise here means the route itself was unreachable.
|
|
69
|
+
*
|
|
70
|
+
* @param force - bypass the host half's cache.
|
|
71
|
+
* @returns the snapshot payload.
|
|
72
|
+
*/
|
|
73
|
+
function requestSnapshot(force) {
|
|
74
|
+
return fetch(API_PATH + (force === true ? '?force=1' : ''), { headers: { accept: 'application/json' } })
|
|
75
|
+
.then((response) => response.json())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Render a thrown value as one short line. */
|
|
79
|
+
function describe(error) {
|
|
80
|
+
return String(error !== null && typeof error === 'object' && 'message' in error ? error.message : error)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Display symbol for a reported currency. */
|
|
84
|
+
function symbolOf(currency) {
|
|
85
|
+
return SYMBOLS[currency] !== undefined ? SYMBOLS[currency] : currency + ' '
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `HH:MM:SS` for a snapshot timestamp. */
|
|
89
|
+
function formatTime(ms) {
|
|
90
|
+
const date = new Date(ms)
|
|
91
|
+
const pad = (value) => (value < 10 ? '0' + String(value) : String(value))
|
|
92
|
+
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One key/value line of the hover panel. */
|
|
96
|
+
function row(key, value, extraClass) {
|
|
97
|
+
return React.createElement(
|
|
98
|
+
'div',
|
|
99
|
+
{ className: 'dsh-balance-row', key },
|
|
100
|
+
React.createElement('span', { className: 'dsh-balance-key' }, key),
|
|
101
|
+
React.createElement(
|
|
102
|
+
'span',
|
|
103
|
+
{ className: 'dsh-balance-val' + (extraClass === undefined ? '' : ' ' + extraClass) },
|
|
104
|
+
value,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the hover panel: one block per reported currency, then availability
|
|
111
|
+
* and freshness. The failure branch names the credential so a missing key is
|
|
112
|
+
* obvious without opening logs.
|
|
113
|
+
*/
|
|
114
|
+
function panelRows(data, failure, phase) {
|
|
115
|
+
const rows = []
|
|
116
|
+
if (data !== null) {
|
|
117
|
+
if (data.infos.length === 0) {
|
|
118
|
+
rows.push(row('余额', '无数据'))
|
|
119
|
+
} else {
|
|
120
|
+
for (const info of data.infos) {
|
|
121
|
+
const symbol = symbolOf(info.currency)
|
|
122
|
+
rows.push(row(info.currency + ' 总余额', symbol + info.total))
|
|
123
|
+
rows.push(row(' 赠金 / 充值', symbol + info.granted + ' / ' + symbol + info.toppedUp))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
rows.push(row('可调用', data.isAvailable === true ? '是' : '否'))
|
|
127
|
+
rows.push(row('更新于', formatTime(data.at)))
|
|
128
|
+
} else if (phase === 'loading') {
|
|
129
|
+
rows.push(row('状态', '查询中 …'))
|
|
130
|
+
rows.push(row('凭据', 'DEEPSEEK_API_KEY'))
|
|
131
|
+
} else {
|
|
132
|
+
rows.push(row('错误', failure === null ? '余额暂不可用' : failure, 'dsh-balance-err'))
|
|
133
|
+
rows.push(row('凭据', 'DEEPSEEK_API_KEY'))
|
|
134
|
+
}
|
|
135
|
+
rows.push(React.createElement('div', { className: 'dsh-balance-hint', key: 'hint' },
|
|
136
|
+
'点击刷新 · 按住拖动可移动位置'))
|
|
137
|
+
return rows
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
inject: ['slots', 'timer'],
|
|
142
|
+
apply(ctx) {
|
|
143
|
+
ctx.effect(() => {
|
|
144
|
+
if (document.getElementById(STYLE_ID) === null) {
|
|
145
|
+
const node = document.createElement('style')
|
|
146
|
+
node.id = STYLE_ID
|
|
147
|
+
node.textContent = CSS
|
|
148
|
+
document.head.append(node)
|
|
149
|
+
}
|
|
150
|
+
return () => { document.getElementById(STYLE_ID)?.remove() }
|
|
151
|
+
}, 'deepseek-balance: styles')
|
|
152
|
+
|
|
153
|
+
// Defined inside apply so the component closes over this fiber's context
|
|
154
|
+
// and can own its own interval. One mount per client run keeps the
|
|
155
|
+
// component identity stable across re-renders.
|
|
156
|
+
function BalanceBadge() {
|
|
157
|
+
const [snapshot, setSnapshot] = React.useState({ phase: 'loading' })
|
|
158
|
+
const [busy, setBusy] = React.useState(false)
|
|
159
|
+
const [position, setPosition] = React.useState({ right: 22, bottom: 22 })
|
|
160
|
+
const drag = React.useRef(null)
|
|
161
|
+
|
|
162
|
+
React.useEffect(() => {
|
|
163
|
+
let alive = true
|
|
164
|
+
const pull = (force) => {
|
|
165
|
+
requestSnapshot(force).then(
|
|
166
|
+
(data) => { if (alive) setSnapshot({ phase: 'ready', data }) },
|
|
167
|
+
(error) => { if (alive) setSnapshot({ phase: 'error', message: describe(error) }) },
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
pull(false)
|
|
171
|
+
const stop = ctx.interval(() => { pull(true) }, REFRESH_MS)
|
|
172
|
+
return () => { alive = false; stop() }
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
const refresh = () => {
|
|
176
|
+
setBusy(true)
|
|
177
|
+
requestSnapshot(true)
|
|
178
|
+
.then(
|
|
179
|
+
(data) => setSnapshot({ phase: 'ready', data }),
|
|
180
|
+
(error) => setSnapshot({ phase: 'error', message: describe(error) }),
|
|
181
|
+
)
|
|
182
|
+
.then(() => setBusy(false))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const onPointerDown = (event) => {
|
|
186
|
+
if (event.button !== 0) return
|
|
187
|
+
try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { /* still draggable */ }
|
|
188
|
+
drag.current = {
|
|
189
|
+
x: event.clientX,
|
|
190
|
+
y: event.clientY,
|
|
191
|
+
right: position.right,
|
|
192
|
+
bottom: position.bottom,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const onPointerMove = (event) => {
|
|
197
|
+
const started = drag.current
|
|
198
|
+
if (started === null) return
|
|
199
|
+
let right = started.right - (event.clientX - started.x)
|
|
200
|
+
let bottom = started.bottom - (event.clientY - started.y)
|
|
201
|
+
right = Math.max(4, Math.min(right, window.innerWidth - 60))
|
|
202
|
+
bottom = Math.max(4, Math.min(bottom, window.innerHeight - 32))
|
|
203
|
+
setPosition({ right, bottom })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const onPointerUp = (event) => {
|
|
207
|
+
const started = drag.current
|
|
208
|
+
drag.current = null
|
|
209
|
+
try { event.currentTarget.releasePointerCapture(event.pointerId) } catch (error) { /* nothing captured */ }
|
|
210
|
+
if (started === null) return
|
|
211
|
+
const moved = Math.abs(event.clientX - started.x) + Math.abs(event.clientY - started.y)
|
|
212
|
+
if (moved < 5) refresh()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const data = snapshot.phase === 'ready' && snapshot.data !== null && snapshot.data.ok === true
|
|
216
|
+
? snapshot.data
|
|
217
|
+
: null
|
|
218
|
+
const failure = snapshot.phase === 'error'
|
|
219
|
+
? snapshot.message
|
|
220
|
+
: (snapshot.phase === 'ready' && snapshot.data !== null && snapshot.data.ok !== true
|
|
221
|
+
? snapshot.data.message
|
|
222
|
+
: null)
|
|
223
|
+
|
|
224
|
+
let tone = 'idle'
|
|
225
|
+
let label = '余额 …'
|
|
226
|
+
if (snapshot.phase === 'error' || failure !== null) {
|
|
227
|
+
tone = 'error'
|
|
228
|
+
label = '余额不可用'
|
|
229
|
+
} else if (data !== null) {
|
|
230
|
+
tone = data.isAvailable === true ? 'ok' : 'warn'
|
|
231
|
+
label = symbolOf(data.currency) + data.total
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return React.createElement(
|
|
235
|
+
'div',
|
|
236
|
+
{
|
|
237
|
+
className: 'dsh-balance-root dsh-balance-tone-' + tone,
|
|
238
|
+
style: { right: position.right + 'px', bottom: position.bottom + 'px' },
|
|
239
|
+
},
|
|
240
|
+
React.createElement('div', { className: 'dsh-balance-panel' }, panelRows(data, failure, snapshot.phase)),
|
|
241
|
+
React.createElement(
|
|
242
|
+
'div',
|
|
243
|
+
{
|
|
244
|
+
className: 'dsh-balance-pill',
|
|
245
|
+
title: 'DeepSeek 余额 · 点击刷新 · 拖动移动',
|
|
246
|
+
onPointerDown,
|
|
247
|
+
onPointerMove,
|
|
248
|
+
onPointerUp,
|
|
249
|
+
onPointerCancel: onPointerUp,
|
|
250
|
+
},
|
|
251
|
+
React.createElement('span', { className: 'dsh-balance-dot' }),
|
|
252
|
+
React.createElement('span', { className: 'dsh-balance-label' }, label),
|
|
253
|
+
busy ? React.createElement('span', { className: 'dsh-balance-spin' }, '↻') : null,
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register(
|
|
259
|
+
{ name: 'shell.overlay', id: 'deepseek-balance', order: 200 },
|
|
260
|
+
BalanceBadge,
|
|
261
|
+
)), 'deepseek-balance: overlay badge')
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# deepseek-balance: a floating badge showing the remaining balance of the
|
|
2
|
+
# DEEPSEEK_API_KEY credential the harness itself uses for model calls.
|
|
3
|
+
#
|
|
4
|
+
# The Host half (index.js) serves GET /deepseek-balance/api; the Client half
|
|
5
|
+
# (client.js, declared through dsh.client) registers the badge into the
|
|
6
|
+
# frame-wide shell.overlay slot. Both halves belong to this one row.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: deepseek-balance
|
|
9
|
+
name: '@ruoyang/dsh-plugin-deepseek-balance'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="DeepSeek balance">
|
|
2
|
+
<circle cx="32" cy="32" r="27" fill="#4D6BFE"/>
|
|
3
|
+
<g fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<path d="M22 19 L32 30 L42 19"/>
|
|
5
|
+
<path d="M32 30 L32 47"/>
|
|
6
|
+
<path d="M22 34 L42 34"/>
|
|
7
|
+
<path d="M22 41 L42 41"/>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek balance badge — host half.
|
|
3
|
+
*
|
|
4
|
+
* Serves one JSON route the package's browser half polls: the remaining balance
|
|
5
|
+
* of the credential the harness already uses for its own model calls
|
|
6
|
+
* (`DEEPSEEK_API_KEY`, resolved through the credentials seam on every query, so
|
|
7
|
+
* a rotated key reaches the next refresh with no restart).
|
|
8
|
+
*
|
|
9
|
+
* The browser half is a `dsh.client` bundle this package declares, so the client
|
|
10
|
+
* module system loads and serves it; this half owns only the data.
|
|
11
|
+
*
|
|
12
|
+
* @module @local/deepseek-balance
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Credential reference resolved per query; the same key the model route uses. */
|
|
16
|
+
const KEY_REF = 'DEEPSEEK_API_KEY'
|
|
17
|
+
/** Official balance endpoint: GET /user/balance. */
|
|
18
|
+
const BALANCE_URL = 'https://api.deepseek.com/user/balance'
|
|
19
|
+
/** Route the badge polls. */
|
|
20
|
+
const API_PATH = '/deepseek-balance/api'
|
|
21
|
+
/** Serve one cached snapshot for this long before paying for another request. */
|
|
22
|
+
const CACHE_MS = 20000
|
|
23
|
+
/** Per-attempt upstream bound. */
|
|
24
|
+
const TIMEOUT_MS = 20000
|
|
25
|
+
/** Attempts per query: one retry covers the transient transport blips seen here. */
|
|
26
|
+
const ATTEMPTS = 2
|
|
27
|
+
/** Backoff between attempts, owned by the cordis timer so it dies with the fiber. */
|
|
28
|
+
const RETRY_DELAY_MS = 400
|
|
29
|
+
|
|
30
|
+
/** The host capabilities this plugin needs before it can serve anything. */
|
|
31
|
+
export const inject = ['webServer', 'timer']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register the balance route.
|
|
35
|
+
*
|
|
36
|
+
* `credentials` is read optionally and degrades to a structured error the badge
|
|
37
|
+
* renders, never a stuck row.
|
|
38
|
+
*
|
|
39
|
+
* @param ctx - the plugin's Cordis context.
|
|
40
|
+
*/
|
|
41
|
+
export function apply(ctx) {
|
|
42
|
+
let cached = null
|
|
43
|
+
let inflight = null
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the API key through the credentials seam.
|
|
47
|
+
*
|
|
48
|
+
* Resolution is per call by design: it layers the launching environment, the
|
|
49
|
+
* provider-managed store and `.env` files, so a key saved or rotated at
|
|
50
|
+
* runtime is picked up by the next query.
|
|
51
|
+
*
|
|
52
|
+
* @returns the key, or undefined when nothing is configured.
|
|
53
|
+
*/
|
|
54
|
+
async function resolveKey() {
|
|
55
|
+
const credentials = ctx.get('credentials')
|
|
56
|
+
if (credentials === undefined) return undefined
|
|
57
|
+
try {
|
|
58
|
+
const resolved = await credentials.resolve(KEY_REF)
|
|
59
|
+
if (resolved === undefined || resolved === null) return undefined
|
|
60
|
+
const value = resolved.value
|
|
61
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Flatten a thrown value and its `cause` chain into one line.
|
|
69
|
+
*
|
|
70
|
+
* Node's fetch reports a bare "fetch failed"; the actionable part (ECONNRESET,
|
|
71
|
+
* ENOTFOUND, a TLS error) only ever appears on `cause`, so a message that
|
|
72
|
+
* stops at the top level is undiagnosable.
|
|
73
|
+
*
|
|
74
|
+
* @param error - the thrown value.
|
|
75
|
+
* @returns a short single-line description.
|
|
76
|
+
*/
|
|
77
|
+
function messageOf(error) {
|
|
78
|
+
const parts = []
|
|
79
|
+
let cursor = error
|
|
80
|
+
while (cursor !== null && cursor !== undefined && parts.length < 4) {
|
|
81
|
+
if (typeof cursor === 'object' && typeof cursor.message === 'string') {
|
|
82
|
+
parts.push(cursor.message + (typeof cursor.code === 'string' ? ' [' + cursor.code + ']' : ''))
|
|
83
|
+
cursor = cursor.cause
|
|
84
|
+
} else {
|
|
85
|
+
parts.push(String(cursor))
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return (parts.length === 0 ? String(error) : parts.join(' <- ')).slice(0, 400)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Project one `balance_infos[]` entry onto the four scalars the badge shows. */
|
|
93
|
+
function readInfo(item) {
|
|
94
|
+
const text = (value) => (typeof value === 'string' ? value : String(value))
|
|
95
|
+
return {
|
|
96
|
+
currency: typeof item.currency === 'string' ? item.currency : '?',
|
|
97
|
+
total: text(item.total_balance),
|
|
98
|
+
granted: text(item.granted_balance),
|
|
99
|
+
toppedUp: text(item.topped_up_balance),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the owned JSON snapshot from the upstream body.
|
|
105
|
+
*
|
|
106
|
+
* CNY is the primary display currency when present, which is what a DeepSeek
|
|
107
|
+
* Platform account is normally denominated in; USD accounts fall back to the
|
|
108
|
+
* first reported entry.
|
|
109
|
+
*
|
|
110
|
+
* @param data - parsed `/user/balance` body.
|
|
111
|
+
* @returns the badge payload.
|
|
112
|
+
*/
|
|
113
|
+
function shape(data) {
|
|
114
|
+
const infos = []
|
|
115
|
+
if (Array.isArray(data.balance_infos)) {
|
|
116
|
+
for (const item of data.balance_infos) {
|
|
117
|
+
if (item === null || typeof item !== 'object') continue
|
|
118
|
+
infos.push(readInfo(item))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let primary = null
|
|
122
|
+
for (const info of infos) if (info.currency === 'CNY') primary = info
|
|
123
|
+
if (primary === null && infos.length > 0) primary = infos[0]
|
|
124
|
+
const base = primary === null
|
|
125
|
+
? { currency: 'CNY', total: '0.00', granted: '0.00', toppedUp: '0.00' }
|
|
126
|
+
: primary
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
isAvailable: data.is_available === true,
|
|
130
|
+
currency: base.currency,
|
|
131
|
+
total: base.total,
|
|
132
|
+
granted: base.granted,
|
|
133
|
+
toppedUp: base.toppedUp,
|
|
134
|
+
infos,
|
|
135
|
+
at: Date.now(),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One upstream attempt.
|
|
141
|
+
*
|
|
142
|
+
* Never throws: every outcome is a payload plus whether another attempt could
|
|
143
|
+
* plausibly help (a transport failure, or a 5xx).
|
|
144
|
+
*
|
|
145
|
+
* @param key - the resolved API key.
|
|
146
|
+
* @returns the payload and its retryability.
|
|
147
|
+
*/
|
|
148
|
+
async function attempt(key) {
|
|
149
|
+
let response
|
|
150
|
+
try {
|
|
151
|
+
response = await fetch(BALANCE_URL, {
|
|
152
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
|
|
153
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
154
|
+
})
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { retryable: true, payload: { ok: false, code: 'TRANSPORT', message: messageOf(error), at: Date.now() } }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const body = await response.text()
|
|
160
|
+
if (response.status !== 200) {
|
|
161
|
+
return {
|
|
162
|
+
retryable: response.status >= 500,
|
|
163
|
+
payload: { ok: false, code: 'HTTP_' + String(response.status), message: body.slice(0, 300), at: Date.now() },
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
let data
|
|
167
|
+
try {
|
|
168
|
+
data = JSON.parse(body)
|
|
169
|
+
} catch {
|
|
170
|
+
return { retryable: false, payload: { ok: false, code: 'BAD_JSON', message: '余额响应不是合法 JSON', at: Date.now() } }
|
|
171
|
+
}
|
|
172
|
+
return { retryable: false, payload: shape(data) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Query the official endpoint, retrying a retryable failure once.
|
|
177
|
+
*
|
|
178
|
+
* @returns the badge payload.
|
|
179
|
+
*/
|
|
180
|
+
async function runQuery() {
|
|
181
|
+
const key = await resolveKey()
|
|
182
|
+
if (key === undefined) {
|
|
183
|
+
return { ok: false, code: 'NO_KEY', message: '未找到 ' + KEY_REF + ' 凭据,请先在 Models 页配置密钥', at: Date.now() }
|
|
184
|
+
}
|
|
185
|
+
let outcome = await attempt(key)
|
|
186
|
+
for (let done = 1; done < ATTEMPTS && outcome.retryable; done += 1) {
|
|
187
|
+
await ctx.timeout(RETRY_DELAY_MS)
|
|
188
|
+
outcome = await attempt(key)
|
|
189
|
+
}
|
|
190
|
+
return outcome.payload
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Query with a short cache and single-flight collapse.
|
|
195
|
+
*
|
|
196
|
+
* Several open tabs polling at once, or one tab polling while a manual
|
|
197
|
+
* refresh is in flight, share one upstream request.
|
|
198
|
+
*
|
|
199
|
+
* @param force - bypass the cache (the badge's manual refresh).
|
|
200
|
+
* @returns the badge payload.
|
|
201
|
+
*/
|
|
202
|
+
async function query(force) {
|
|
203
|
+
const now = Date.now()
|
|
204
|
+
if (force !== true && cached !== null && now - cached.at < CACHE_MS) return cached.value
|
|
205
|
+
if (inflight !== null) return inflight
|
|
206
|
+
const pending = (async () => {
|
|
207
|
+
try {
|
|
208
|
+
return await runQuery()
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return { ok: false, code: 'UNEXPECTED', message: messageOf(error), at: Date.now() }
|
|
211
|
+
}
|
|
212
|
+
})()
|
|
213
|
+
inflight = pending
|
|
214
|
+
let value
|
|
215
|
+
try {
|
|
216
|
+
value = await pending
|
|
217
|
+
} finally {
|
|
218
|
+
if (inflight === pending) inflight = null
|
|
219
|
+
}
|
|
220
|
+
cached = { at: Date.now(), value }
|
|
221
|
+
return value
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
ctx.effect(() => ctx.webServer.register({
|
|
225
|
+
kind: 'exact',
|
|
226
|
+
path: API_PATH,
|
|
227
|
+
handler: async (req, res) => {
|
|
228
|
+
let force = false
|
|
229
|
+
try {
|
|
230
|
+
force = new URL(req.url ?? API_PATH, 'http://localhost').searchParams.get('force') === '1'
|
|
231
|
+
} catch {
|
|
232
|
+
// a malformed query string simply means "not forced"
|
|
233
|
+
}
|
|
234
|
+
let value
|
|
235
|
+
try {
|
|
236
|
+
value = await query(force)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
value = { ok: false, code: 'UNEXPECTED', message: messageOf(error), at: Date.now() }
|
|
239
|
+
}
|
|
240
|
+
const body = JSON.stringify(value)
|
|
241
|
+
res.writeHead(200, {
|
|
242
|
+
'content-type': 'application/json; charset=utf-8',
|
|
243
|
+
'cache-control': 'no-store',
|
|
244
|
+
'content-length': Buffer.byteLength(body),
|
|
245
|
+
})
|
|
246
|
+
res.end(body)
|
|
247
|
+
},
|
|
248
|
+
}), 'deepseek-balance: balance api')
|
|
249
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ruoyang/dsh-plugin-deepseek-balance",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Floating DeepSeek API balance badge for the DeepSeek Harness Web UI, backed by the harness's own DEEPSEEK_API_KEY credential",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"deepseek",
|
|
12
|
+
"dsh",
|
|
13
|
+
"dsh-plugin",
|
|
14
|
+
"harness",
|
|
15
|
+
"balance",
|
|
16
|
+
"cordis"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.17.0"
|
|
20
|
+
},
|
|
21
|
+
"icon": "./icon.svg",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.js",
|
|
24
|
+
"./client": "./client.js",
|
|
25
|
+
"./package.json": "./package.json",
|
|
26
|
+
"./locale/*.json": "./locale/*.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.js",
|
|
30
|
+
"client.js",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"icon.svg",
|
|
33
|
+
"locale/*.json",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"immediately": true,
|
|
44
|
+
"inject": [
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|