@virzz/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 +133 -0
- package/lib/client.js +217 -0
- package/lib/index.js +263 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 virzz
|
|
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,133 @@
|
|
|
1
|
+
# @virzz/dsh-plugin-deepseek-balance
|
|
2
|
+
|
|
3
|
+
A [DSH](https://github.com/deepseek-ai/deepseek-harness) plugin that shows the DeepSeek
|
|
4
|
+
official account balance as a row in the sidebar footer, above **Settings**.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
◆ Cordis Plugin 0 running
|
|
8
|
+
▤ DeepSeek 余额 $1058.69 · ¥-0.01
|
|
9
|
+
⚙ 设置
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Shape
|
|
13
|
+
|
|
14
|
+
| Half | File | What it does |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| Host | `lib/index.js` | Owns one resident `node` child, decodes the JSON lines it prints, serves the newest snapshot on `GET /deepseek-balance` |
|
|
17
|
+
| Client | `lib/client.js` | Registers a row in `sidebar.footer.action` and re-reads that route every 15s |
|
|
18
|
+
|
|
19
|
+
The child polls `https://api.deepseek.com/user/balance` with Node's own global `fetch` and
|
|
20
|
+
prints one JSON line per attempt:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{"ok":true,"available":true,"balances":[{"currency":"USD","total":"1058.69","granted":"0.00","toppedUp":"1058.69"}],"fetchedAt":1789030816379}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Design notes:
|
|
27
|
+
|
|
28
|
+
- **No shell, no curl.** `argv` is an explicit `node -e`, resolved through
|
|
29
|
+
`subprocess.resolveExecutable`, so nothing depends on the launching shell's `PATH`.
|
|
30
|
+
- **One process.** The child is spawned once per plugin lifetime and restarted only after it
|
|
31
|
+
exits (5s backoff); it is terminated with the owning fiber.
|
|
32
|
+
- **The key stays on the host.** `DEEPSEEK_API_KEY` is resolved through the `credentials`
|
|
33
|
+
service and passed to the child in its environment — never on the command line, never to
|
|
34
|
+
the browser. The read route returns only balance figures.
|
|
35
|
+
- **Two cadences.** The child collects every 60s; the row re-reads the host's cached
|
|
36
|
+
snapshot every 15s, so no polling happens from the browser.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
Published to npmjs on every release:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install @virzz/dsh-plugin-deepseek-balance
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Also mirrored to the GitHub npm registry:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm config set @virzz:registry https://npm.pkg.github.com
|
|
50
|
+
npm install @virzz/dsh-plugin-deepseek-balance
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To mount it in a DSH profile, the package must also be resolvable from that profile and named
|
|
54
|
+
by a row. With pnpm available that is `dsh plugin --profile web add <spec>`; without pnpm a
|
|
55
|
+
link plus one patch row is equivalent:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
ln -s /path/to/dsh-plugin-deepseek-balance ~/.dsh/profiles/web/node_modules/@virzz/dsh-plugin-deepseek-balance
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
# ~/.dsh/profiles/web/cordis.patch.yml
|
|
63
|
+
- insert:
|
|
64
|
+
- id: deepseek-balance
|
|
65
|
+
name: '@virzz/dsh-plugin-deepseek-balance'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`dsh.client` in `package.json` puts `lib/client.js` into the browser roster that
|
|
69
|
+
`@deepseek-ai/dsh-client-modules` composes into `window.__DSH_BOOT__`. The module id inside
|
|
70
|
+
`lib/client.js` must equal the package name — the workflow checks that before publishing.
|
|
71
|
+
|
|
72
|
+
## Why the client half overrides one slot anchor
|
|
73
|
+
|
|
74
|
+
`sidebar.footer.action` renders as a flex **row**, and its only shipped occupant — the
|
|
75
|
+
Cordis panel row — is `flex: none; width: 100%`, so a second entry in that row collapses to
|
|
76
|
+
zero width. The slot anchor itself renders with `display: contents`, which is what makes the
|
|
77
|
+
one-line fix safe:
|
|
78
|
+
|
|
79
|
+
```css
|
|
80
|
+
[data-slot="sidebar.footer.action"] { display: flex !important; flex-direction: column; }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The anchor becomes a real box, the two entries stack, and no shipped rule or row is touched.
|
|
84
|
+
The row itself reuses the shipped footer row's metrics (42px, 12px radius, right-aligned
|
|
85
|
+
status text in `--dsw-alias-label-tertiary`) so it reads as part of the sidebar foot; in the
|
|
86
|
+
56px rail it collapses to a 36px circle showing the primary currency symbol.
|
|
87
|
+
|
|
88
|
+
## Publish
|
|
89
|
+
|
|
90
|
+
`.github/workflows/publish.yml` runs on a published release, on a `v*` tag push, or by hand
|
|
91
|
+
via **Run workflow**. It has three jobs: a `check` gate, then one publish job per registry.
|
|
92
|
+
|
|
93
|
+
| Registry | Credential | Notes |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| npmjs | **OIDC trusted publishing** — no token at all | the job carries `id-token: write` and the npm CLI exchanges that for a short-lived publish credential; provenance is generated automatically because the repo is public |
|
|
96
|
+
| GitHub Packages | the workflow's own `GITHUB_TOKEN` | needs `packages: write`; nothing to configure |
|
|
97
|
+
|
|
98
|
+
They are separate jobs on purpose: the two use different credentials, and one being
|
|
99
|
+
unconfigured never blocks the other. `publishConfig.registry` is deliberately **not** set —
|
|
100
|
+
it would override each job's `--registry`, and the `check` job fails if it comes back.
|
|
101
|
+
|
|
102
|
+
### Trusted publishing setup
|
|
103
|
+
|
|
104
|
+
Trusted publishing needs npm CLI >= 11.5.1 on Node >= 22.14, so the jobs run Node 24 and the
|
|
105
|
+
npmjs job upgrades npm before publishing. On npmjs.com, at
|
|
106
|
+
**Packages → @virzz/dsh-plugin-deepseek-balance → Settings → Trusted publishing**, add a
|
|
107
|
+
GitHub Actions publisher with these exact, case-sensitive values:
|
|
108
|
+
|
|
109
|
+
| Field | Value |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| Organization or user | `virzz` |
|
|
112
|
+
| Repository | `dsh-plugin-deepseek-balance` |
|
|
113
|
+
| Workflow filename | `publish.yml` |
|
|
114
|
+
| Allowed actions | `npm publish` |
|
|
115
|
+
|
|
116
|
+
**First publish is the exception.** A package that does not exist yet has no settings page to
|
|
117
|
+
attach a trusted publisher to, so version `1.0.0` has to be published once the ordinary way —
|
|
118
|
+
either `npm login && npm publish --access public` from a checkout, or run the workflow with a
|
|
119
|
+
temporary `NPM_TOKEN` secret. Configure the trusted publisher immediately afterwards; every
|
|
120
|
+
later release then publishes with no token, and you can set the package to
|
|
121
|
+
*Require two-factor authentication and disallow tokens*.
|
|
122
|
+
|
|
123
|
+
Bump `version` before releasing: both registries reject a duplicate version.
|
|
124
|
+
|
|
125
|
+
## Test
|
|
126
|
+
|
|
127
|
+
`test/host-smoke.mjs` drives `apply()` with a fake Cordis context whose subprocess provider
|
|
128
|
+
is a real `node:child_process`, then reads the registered route — covering spawn, stdout
|
|
129
|
+
lines, snapshot, and route JSON without a DSH process:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
DEEPSEEK_API_KEY=... node test/host-smoke.mjs
|
|
133
|
+
```
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@virzz/dsh-plugin-deepseek-balance",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const React = require("react");
|
|
8
|
+
|
|
9
|
+
/** Read route served by the host half. Must match ROUTE_PATH in lib/index.js. */
|
|
10
|
+
const ROUTE = "/deepseek-balance";
|
|
11
|
+
/** How often the row re-reads the host's cached snapshot (the child collects every 60s). */
|
|
12
|
+
const REFRESH_MS = 15000;
|
|
13
|
+
/** Currency glyphs; anything else renders as its ISO code. */
|
|
14
|
+
const SYMBOLS = { CNY: "¥", USD: "$", EUR: "€", GBP: "£", JPY: "¥" };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The sidebar foot is a flex ROW whose only shipped occupant (the Cordis panel row) is
|
|
18
|
+
* `flex: none; width: 100%`, so a second entry there collapses to zero width. The slot
|
|
19
|
+
* anchor itself renders with `display: contents`, which is exactly what lets this rule
|
|
20
|
+
* give the two entries a column of their own without touching the shipped rows.
|
|
21
|
+
*/
|
|
22
|
+
const CSS = [
|
|
23
|
+
'[data-slot="sidebar.footer.action"]{display:flex !important;flex-direction:column;gap:2px;width:100%;min-width:0}',
|
|
24
|
+
".dsb-row{box-sizing:border-box;width:100%;height:42px;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer;background:0 0;border:none;border-radius:12px;align-items:center;gap:8px;margin:8px 0 0;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;display:flex;overflow:hidden}",
|
|
25
|
+
".dsb-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
26
|
+
".dsb-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}",
|
|
27
|
+
".dsb-icon{flex:none;place-items:center;width:16px;height:16px;display:inline-flex;color:var(--dsw-alias-label-secondary)}",
|
|
28
|
+
".dsb-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}",
|
|
29
|
+
".dsb-value{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap;flex:none;margin-left:auto;font-size:12px;line-height:16px}",
|
|
30
|
+
'.dsb-row[data-tone="error"] .dsb-value{color:var(--dsw-alias-state-error-primary)}',
|
|
31
|
+
'.dsb-row[data-busy="true"] .dsb-value{opacity:.7}',
|
|
32
|
+
".dsb-row.dsb-rail{width:36px;height:36px;margin:0;padding:0;justify-content:center;gap:0;border-radius:50%}",
|
|
33
|
+
".dsb-row.dsb-rail .dsb-label,.dsb-row.dsb-rail .dsb-value{display:none}",
|
|
34
|
+
".dsb-railGlyph{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1}",
|
|
35
|
+
].join("");
|
|
36
|
+
|
|
37
|
+
function symbolOf(currency) {
|
|
38
|
+
const code = typeof currency === "string" ? currency.toUpperCase() : "";
|
|
39
|
+
if (code === "") return "";
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(SYMBOLS, code)) return SYMBOLS[code];
|
|
41
|
+
return code + " ";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function amountOf(entry) {
|
|
45
|
+
return symbolOf(entry.currency) + entry.total;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The entry whose figure stands for the account: the first non-zero one, else the first. */
|
|
49
|
+
function primaryOf(balances) {
|
|
50
|
+
for (let index = 0; index < balances.length; index += 1) {
|
|
51
|
+
const value = Number(balances[index].total);
|
|
52
|
+
if (Number.isFinite(value) && value !== 0) return balances[index];
|
|
53
|
+
}
|
|
54
|
+
return balances.length > 0 ? balances[0] : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function timeText(ms) {
|
|
58
|
+
if (typeof ms !== "number" || ms <= 0) return "";
|
|
59
|
+
try {
|
|
60
|
+
return new Date(ms).toLocaleTimeString();
|
|
61
|
+
} catch {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A wallet glyph, sized to sit beside the shipped footer rows' marks. */
|
|
67
|
+
function walletGlyph() {
|
|
68
|
+
return React.createElement("svg", {
|
|
69
|
+
width: 16,
|
|
70
|
+
height: 16,
|
|
71
|
+
viewBox: "0 0 16 16",
|
|
72
|
+
fill: "none",
|
|
73
|
+
"aria-hidden": true,
|
|
74
|
+
}, [
|
|
75
|
+
React.createElement("rect", {
|
|
76
|
+
key: "body",
|
|
77
|
+
x: 1.75,
|
|
78
|
+
y: 3.75,
|
|
79
|
+
width: 12.5,
|
|
80
|
+
height: 8.5,
|
|
81
|
+
rx: 2.25,
|
|
82
|
+
stroke: "currentColor",
|
|
83
|
+
strokeWidth: 1.5,
|
|
84
|
+
}),
|
|
85
|
+
React.createElement("circle", { key: "stud", cx: 11.25, cy: 8, r: 1.25, fill: "currentColor" }),
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The sidebar-foot row: it reads the host snapshot and renders the newest figures. */
|
|
90
|
+
function BalanceRow(props) {
|
|
91
|
+
const wide = props === null || props === undefined || props.wide !== false;
|
|
92
|
+
const [state, setState] = React.useState({
|
|
93
|
+
phase: "loading",
|
|
94
|
+
code: "",
|
|
95
|
+
error: "",
|
|
96
|
+
available: true,
|
|
97
|
+
balances: [],
|
|
98
|
+
fetchedAt: 0,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const load = () => {
|
|
102
|
+
fetch(ROUTE, { headers: { accept: "application/json" } })
|
|
103
|
+
.then((response) => response.json().then((data) => ({ status: response.status, data })))
|
|
104
|
+
.then(({ status, data }) => {
|
|
105
|
+
const isObject = data !== null && typeof data === "object";
|
|
106
|
+
if (status !== 200 || !isObject || data.ok !== true) {
|
|
107
|
+
const code = isObject && typeof data.code === "string" ? data.code : "http-" + String(status);
|
|
108
|
+
const message = isObject && typeof data.error === "string" ? data.error : "读取失败";
|
|
109
|
+
setState({ phase: "error", code, error: message, available: false, balances: [], fetchedAt: 0 });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setState({
|
|
113
|
+
phase: "ok",
|
|
114
|
+
code: "",
|
|
115
|
+
error: "",
|
|
116
|
+
available: data.available === true,
|
|
117
|
+
balances: Array.isArray(data.balances) ? data.balances : [],
|
|
118
|
+
fetchedAt: typeof data.fetchedAt === "number" ? data.fetchedAt : 0,
|
|
119
|
+
});
|
|
120
|
+
})
|
|
121
|
+
.catch((error) => {
|
|
122
|
+
const message = error !== null && error !== undefined && typeof error.message === "string" ? error.message : String(error);
|
|
123
|
+
setState({ phase: "error", code: "network", error: message, available: false, balances: [], fetchedAt: 0 });
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
React.useEffect(() => {
|
|
128
|
+
load();
|
|
129
|
+
const id = setInterval(load, REFRESH_MS);
|
|
130
|
+
return () => clearInterval(id);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const balances = state.balances;
|
|
134
|
+
let tone = "loading";
|
|
135
|
+
let label = "DeepSeek 余额";
|
|
136
|
+
let value = "…";
|
|
137
|
+
let detail = "DeepSeek 余额:正在读取宿主采集进程…";
|
|
138
|
+
|
|
139
|
+
if (state.phase === "ok") {
|
|
140
|
+
tone = state.available ? "ok" : "error";
|
|
141
|
+
value = balances.slice(0, 2).map(amountOf).join(" · ");
|
|
142
|
+
const lines = ["DeepSeek 余额(常驻 node 采集)"];
|
|
143
|
+
for (let index = 0; index < balances.length; index += 1) {
|
|
144
|
+
const entry = balances[index];
|
|
145
|
+
lines.push(entry.currency + " " + entry.total + "(赠金 " + entry.granted + " · 充值 " + entry.toppedUp + ")");
|
|
146
|
+
}
|
|
147
|
+
if (state.available !== true) lines.push("该账户当前不可调用");
|
|
148
|
+
const updated = timeText(state.fetchedAt);
|
|
149
|
+
if (updated !== "") lines.push("采集于 " + updated);
|
|
150
|
+
lines.push("node 每 60 秒采集一次 · 界面每 15 秒读取缓存 · 点击立即读取");
|
|
151
|
+
detail = lines.join("\n");
|
|
152
|
+
} else if (state.phase === "error") {
|
|
153
|
+
tone = "error";
|
|
154
|
+
value = state.code === "missing-key" ? "未配置密钥" : "不可用";
|
|
155
|
+
detail = "DeepSeek 余额获取失败\n" + state.error + "\n点击重试 · 每 15 秒自动重试";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (wide !== true) {
|
|
159
|
+
const primary = primaryOf(balances);
|
|
160
|
+
const glyph = state.phase === "ok" ? symbolOf(primary === undefined ? "" : primary.currency) : state.phase === "error" ? "!" : "…";
|
|
161
|
+
return React.createElement("button", {
|
|
162
|
+
type: "button",
|
|
163
|
+
className: "dsb-row dsb-rail",
|
|
164
|
+
"data-tone": tone,
|
|
165
|
+
"data-busy": state.phase === "loading" ? "true" : "false",
|
|
166
|
+
title: detail,
|
|
167
|
+
"aria-label": label + " " + value,
|
|
168
|
+
onClick: load,
|
|
169
|
+
}, React.createElement("span", { className: "dsb-railGlyph" }, glyph));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return React.createElement("button", {
|
|
173
|
+
type: "button",
|
|
174
|
+
className: "dsb-row",
|
|
175
|
+
"data-tone": tone,
|
|
176
|
+
"data-busy": state.phase === "loading" ? "true" : "false",
|
|
177
|
+
title: detail,
|
|
178
|
+
"aria-label": label + " " + value,
|
|
179
|
+
onClick: load,
|
|
180
|
+
}, [
|
|
181
|
+
React.createElement("span", { key: "icon", className: "dsb-icon" }, walletGlyph()),
|
|
182
|
+
React.createElement("span", { key: "label", className: "dsb-label" }, label),
|
|
183
|
+
React.createElement("span", { key: "value", className: "dsb-value" }, value),
|
|
184
|
+
]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The slot registry is the only client service this half reads. */
|
|
188
|
+
const inject = ["slots"];
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Client plugin body: own the stylesheet and the sidebar-foot row.
|
|
192
|
+
*
|
|
193
|
+
* @param ctx - client root context.
|
|
194
|
+
*/
|
|
195
|
+
function apply(ctx) {
|
|
196
|
+
ctx.effect(() => {
|
|
197
|
+
const tag = document.createElement("style");
|
|
198
|
+
tag.dataset.plugin = "dsh-plugin-deepseek-balance";
|
|
199
|
+
tag.textContent = CSS;
|
|
200
|
+
document.head.appendChild(tag);
|
|
201
|
+
return () => {
|
|
202
|
+
tag.remove();
|
|
203
|
+
};
|
|
204
|
+
}, "deepseek-balance: styles");
|
|
205
|
+
ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
206
|
+
name: "sidebar.footer.action",
|
|
207
|
+
id: "deepseek-balance",
|
|
208
|
+
order: 40,
|
|
209
|
+
label: "DeepSeek 余额",
|
|
210
|
+
}, BalanceRow));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
exports.apply = apply;
|
|
214
|
+
exports.inject = inject;
|
|
215
|
+
return module.exports;
|
|
216
|
+
}
|
|
217
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek balance pill — host half.
|
|
3
|
+
*
|
|
4
|
+
* One resident `node` child polls the DeepSeek balance API with Node's own global `fetch`
|
|
5
|
+
* and prints one JSON line per attempt on stdout; this half only decodes those lines and
|
|
6
|
+
* serves the newest snapshot to the browser half over a local read route.
|
|
7
|
+
*
|
|
8
|
+
* No shell and no curl are involved: `argv` is an explicit `node -e`, and the API key
|
|
9
|
+
* reaches the child through its environment, never through the command line. The key never
|
|
10
|
+
* leaves this process either — the read route returns only the balance figures.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-plugin-deepseek-balance
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Polling cadence of the resident child. */
|
|
16
|
+
const COLLECT_MS = 60000
|
|
17
|
+
|
|
18
|
+
/** Read route the browser half polls. Must match ROUTE in lib/client.js. */
|
|
19
|
+
const ROUTE_PATH = '/deepseek-balance'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The one long-lived child program. It polls the balance API itself and prints one JSON
|
|
23
|
+
* line per attempt, which is the whole contract with this half: `{ ok: true, available,
|
|
24
|
+
* balances, fetchedAt }` or `{ ok: false, code, error }`.
|
|
25
|
+
*
|
|
26
|
+
* All inner quotes are single, so this literal needs no escaping.
|
|
27
|
+
*/
|
|
28
|
+
const POLLER = "const KEY = process.env.DEEPSEEK_API_KEY; const URL_BALANCE = 'https://api.deepseek.com/user/balance'; function emit(value) { console.log(JSON.stringify(value)) } async function tick() { try { const response = await fetch(URL_BALANCE, { headers: { Authorization: 'Bearer ' + KEY, Accept: 'application/json' } }); const body = await response.text(); if (!response.ok) { emit({ ok: false, code: 'http-' + response.status, error: 'HTTP ' + response.status + ': ' + body.slice(0, 200) }); return } const payload = JSON.parse(body); const raw = Array.isArray(payload.balance_infos) ? payload.balance_infos : []; const balances = []; for (const entry of raw) { if (entry === null || typeof entry !== 'object') continue; balances.push({ currency: String(entry.currency || ''), total: String(entry.total_balance || ''), granted: String(entry.granted_balance || ''), toppedUp: String(entry.topped_up_balance || '') }) } if (balances.length === 0) { emit({ ok: false, code: 'bad-response', error: '响应中没有余额信息' }); return } emit({ ok: true, available: payload.is_available === true, balances: balances, fetchedAt: Date.now() }) } catch (error) { emit({ ok: false, code: 'request-failed', error: String((error && error.message) || error) }) } } tick(); setInterval(tick, EVERY_MS_PLACEHOLDER)"
|
|
29
|
+
|
|
30
|
+
/** One-line description of any thrown value. */
|
|
31
|
+
function describe(error) {
|
|
32
|
+
if (error === null || error === undefined) return '未知错误'
|
|
33
|
+
if (typeof error === 'string') return error
|
|
34
|
+
if (typeof error.message === 'string') return error.message
|
|
35
|
+
return String(error)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The shell implementation's own default working directory, so the child inherits a real
|
|
40
|
+
* cwd without this plugin guessing a path.
|
|
41
|
+
*
|
|
42
|
+
* @param ctx - host context.
|
|
43
|
+
* @returns an existing directory path.
|
|
44
|
+
*/
|
|
45
|
+
function workingDirectory(ctx) {
|
|
46
|
+
try {
|
|
47
|
+
const shell = ctx.get('shell')
|
|
48
|
+
if (shell !== undefined && typeof shell.resolve === 'function') {
|
|
49
|
+
const spec = shell.resolve({ command: 'true' })
|
|
50
|
+
if (spec !== null && typeof spec === 'object' && typeof spec.workdir === 'string' && spec.workdir !== '') {
|
|
51
|
+
return spec.workdir
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Fall through to the neutral root.
|
|
56
|
+
}
|
|
57
|
+
return '/'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Own the resident collector: one child, restarted only after it exits.
|
|
62
|
+
*
|
|
63
|
+
* @param ctx - host context.
|
|
64
|
+
* @returns the lifecycle hooks the plugin effect drives.
|
|
65
|
+
*/
|
|
66
|
+
function createCollector(ctx) {
|
|
67
|
+
const program = POLLER.replace('EVERY_MS_PLACEHOLDER', String(COLLECT_MS))
|
|
68
|
+
const state = {
|
|
69
|
+
latest: undefined,
|
|
70
|
+
failure: undefined,
|
|
71
|
+
buffer: '',
|
|
72
|
+
stderr: '',
|
|
73
|
+
disposed: false,
|
|
74
|
+
handle: undefined,
|
|
75
|
+
restart: undefined,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The wire shape the browser half renders. */
|
|
79
|
+
function snapshot() {
|
|
80
|
+
if (state.latest !== undefined) {
|
|
81
|
+
const value = state.latest
|
|
82
|
+
if (value.ok === true) {
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
available: value.available === true,
|
|
86
|
+
balances: Array.isArray(value.balances) ? value.balances : [],
|
|
87
|
+
fetchedAt: typeof value.fetchedAt === 'number' ? value.fetchedAt : 0,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: typeof value.code === 'string' ? value.code : 'failed',
|
|
93
|
+
error: typeof value.error === 'string' ? value.error : '未知错误',
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (state.failure !== undefined) return { ok: false, code: state.failure.code, error: state.failure.error }
|
|
97
|
+
return { ok: false, code: 'starting', error: '正在启动 node 采集进程…' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onStdout(chunk) {
|
|
101
|
+
state.buffer += chunk.toString('utf8')
|
|
102
|
+
let index = state.buffer.indexOf('\n')
|
|
103
|
+
while (index >= 0) {
|
|
104
|
+
const line = state.buffer.slice(0, index).trim()
|
|
105
|
+
state.buffer = state.buffer.slice(index + 1)
|
|
106
|
+
if (line !== '') {
|
|
107
|
+
try {
|
|
108
|
+
const value = JSON.parse(line)
|
|
109
|
+
if (value !== null && typeof value === 'object') {
|
|
110
|
+
state.latest = value
|
|
111
|
+
state.failure = undefined
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// A partial or foreign line is ignored; the next one carries the payload.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
index = state.buffer.indexOf('\n')
|
|
118
|
+
}
|
|
119
|
+
if (state.buffer.length > 65536) state.buffer = state.buffer.slice(-4096)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the node executable in the provider's own execution world, so the child never
|
|
124
|
+
* depends on the launching shell's PATH. A provider that cannot answer falls back to the
|
|
125
|
+
* bare name, which spawn resolves exactly as before.
|
|
126
|
+
*/
|
|
127
|
+
async function nodeCommand(subprocess) {
|
|
128
|
+
try {
|
|
129
|
+
const resolved = await subprocess.resolveExecutable('node')
|
|
130
|
+
if (typeof resolved === 'string' && resolved !== '') return resolved
|
|
131
|
+
} catch {
|
|
132
|
+
// No resolver answer: let spawn resolve the bare name.
|
|
133
|
+
}
|
|
134
|
+
return 'node'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function start() {
|
|
138
|
+
if (state.disposed === true) return
|
|
139
|
+
try {
|
|
140
|
+
const subprocess = ctx.get('subprocess')
|
|
141
|
+
const credentials = ctx.get('credentials')
|
|
142
|
+
if (subprocess === undefined || credentials === undefined) {
|
|
143
|
+
// Belt and braces: `inject` normally guarantees these, but a row that activates
|
|
144
|
+
// before them still recovers instead of latching a failure for its whole lifetime.
|
|
145
|
+
state.failure = {
|
|
146
|
+
code: 'unavailable',
|
|
147
|
+
error: '等待 ' + (subprocess === undefined ? 'subprocess' : 'credentials') + ' 服务,5 秒后重试',
|
|
148
|
+
}
|
|
149
|
+
state.restart = ctx.timeout(() => { start() }, 5000)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const resolved = await credentials.resolve('DEEPSEEK_API_KEY')
|
|
153
|
+
if (state.disposed === true) return
|
|
154
|
+
if (resolved === undefined || typeof resolved.value !== 'string' || resolved.value === '') {
|
|
155
|
+
state.failure = { code: 'missing-key', error: '未配置 DEEPSEEK_API_KEY' }
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const command = await nodeCommand(subprocess)
|
|
160
|
+
if (state.disposed === true) return
|
|
161
|
+
|
|
162
|
+
let handle
|
|
163
|
+
try {
|
|
164
|
+
handle = subprocess.spawn({
|
|
165
|
+
argv: [command, '-e', program],
|
|
166
|
+
cwd: workingDirectory(ctx),
|
|
167
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
168
|
+
graceMs: 3000,
|
|
169
|
+
env: { DEEPSEEK_API_KEY: resolved.value },
|
|
170
|
+
})
|
|
171
|
+
} catch (error) {
|
|
172
|
+
state.failure = { code: 'spawn-failed', error: '启动 node 失败:' + describe(error) }
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
state.handle = handle
|
|
177
|
+
handle.stdout?.on('data', onStdout)
|
|
178
|
+
handle.stderr?.on('data', (chunk) => {
|
|
179
|
+
state.stderr = (state.stderr + chunk.toString('utf8')).slice(-600)
|
|
180
|
+
})
|
|
181
|
+
handle.done.then(() => {
|
|
182
|
+
if (state.disposed === true) return
|
|
183
|
+
const tail = state.stderr.trim()
|
|
184
|
+
state.failure = {
|
|
185
|
+
code: 'poller-exited',
|
|
186
|
+
error: 'node 采集进程已退出,5 秒后重启' + (tail === '' ? '' : ':' + tail.slice(0, 300)),
|
|
187
|
+
}
|
|
188
|
+
state.restart = ctx.timeout(() => { start() }, 5000)
|
|
189
|
+
}, (error) => {
|
|
190
|
+
if (state.disposed === true) return
|
|
191
|
+
state.failure = { code: 'poller-failed', error: 'node 采集进程失败:' + describe(error) }
|
|
192
|
+
state.restart = ctx.timeout(() => { start() }, 5000)
|
|
193
|
+
})
|
|
194
|
+
} catch (error) {
|
|
195
|
+
state.failure = { code: 'unavailable', error: '启动采集进程出错:' + describe(error) }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function stop() {
|
|
200
|
+
state.disposed = true
|
|
201
|
+
if (state.restart !== undefined) state.restart()
|
|
202
|
+
try {
|
|
203
|
+
state.handle?.terminate()
|
|
204
|
+
} catch {
|
|
205
|
+
// The child is already gone; nothing to terminate.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { start, stop, snapshot }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const name = 'deepseek-balance'
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Hard dependencies, declared so `apply` cannot run before they exist. `subprocess` and
|
|
216
|
+
* `credentials` are provided by other rows and are not guaranteed to be up when this row
|
|
217
|
+
* activates; reading them only through `ctx.get` at activation time can observe them absent
|
|
218
|
+
* and latch a failure that never clears. `shell` stays optional — it only supplies a default
|
|
219
|
+
* working directory — and `timer` backs the restart backoff.
|
|
220
|
+
*/
|
|
221
|
+
export const inject = ['timer', 'subprocess', 'credentials']
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Plugin body: own the resident collector and publish its snapshot on one exact read route.
|
|
225
|
+
*
|
|
226
|
+
* @param ctx - host root context.
|
|
227
|
+
*/
|
|
228
|
+
export function apply(ctx) {
|
|
229
|
+
const collector = createCollector(ctx)
|
|
230
|
+
|
|
231
|
+
ctx.effect(() => {
|
|
232
|
+
collector.start()
|
|
233
|
+
return collector.stop
|
|
234
|
+
}, 'deepseek-balance: resident collector')
|
|
235
|
+
|
|
236
|
+
const registerRoute = (carrier) => {
|
|
237
|
+
// Read the service through the context rather than through an injected property:
|
|
238
|
+
// `webServer` is deliberately not a hard dependency of this plugin.
|
|
239
|
+
const webServer = carrier.get('webServer')
|
|
240
|
+
if (webServer === undefined) return
|
|
241
|
+
carrier.effect(() => webServer.register({
|
|
242
|
+
kind: 'exact',
|
|
243
|
+
path: ROUTE_PATH,
|
|
244
|
+
handler(req, res) {
|
|
245
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
246
|
+
res.writeHead(405, { allow: 'GET, HEAD' })
|
|
247
|
+
res.end()
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
const body = JSON.stringify(collector.snapshot())
|
|
251
|
+
res.writeHead(200, {
|
|
252
|
+
'content-type': 'application/json; charset=utf-8',
|
|
253
|
+
'content-length': Buffer.byteLength(body),
|
|
254
|
+
'cache-control': 'no-store',
|
|
255
|
+
})
|
|
256
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
257
|
+
},
|
|
258
|
+
}), 'deepseek-balance: read route')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerRoute)
|
|
262
|
+
else registerRoute(ctx)
|
|
263
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@virzz/dsh-plugin-deepseek-balance",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DeepSeek official account balance as a DSH sidebar status row, collected by one resident node process",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dsh": {
|
|
13
|
+
"client": {
|
|
14
|
+
"platform": "web",
|
|
15
|
+
"inject": []
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/index.js",
|
|
20
|
+
"lib/client.js",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"dsh",
|
|
26
|
+
"deepseek",
|
|
27
|
+
"cordis",
|
|
28
|
+
"plugin",
|
|
29
|
+
"balance"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/virzz/dsh-plugin-deepseek-balance.git"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|