@lyk308/dsh-token-dashboard 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-token-dashboard contributors
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,55 @@
1
+ # dsh-token-dashboard
2
+
3
+ [![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
+
5
+ A DeepSeek Harness plugin that puts your **real-time account balance** and **token usage** on one dashboard. Local-first: reads your DSH session logs and the DeepSeek `/user/balance` API. No cloud account beyond your existing DeepSeek key.
6
+
7
+ ## Features
8
+
9
+ - 💰 **Account balance** — live balance from the DeepSeek official `/user/balance` API (CNY/USD aware), with force-refresh.
10
+ - 📊 **Token usage** — Today / This month / All-time cards, plus request count, cache hit rate, and session count.
11
+ - 📈 **Usage distribution** — toggle between **by model** and **by workspace**, with a proportional share bar (top 8 + "other" aggregate). Percentages match the bar exactly.
12
+ - 🗓 **Activity heatmap** — 30 / 90 / 180 day switch, green-shade intensity maps token usage.
13
+ - 🤖 **Model breakdown** — a table of Req / Input / Cache / Output per model.
14
+
15
+ ## Installation
16
+
17
+ This is a standard DSH bundle plugin, so install it with the official CLI:
18
+
19
+ ```bash
20
+ dsh plugin --profile web add <path-or-git-url-to-this-repo>
21
+ ```
22
+
23
+ If published to npm, you can also install by name:
24
+
25
+ ```bash
26
+ dsh plugin --profile web add dsh-token-dashboard
27
+ ```
28
+
29
+ After installing, **refresh the web UI**. The dashboard appears under **Settings → 用量看板** (Usage Dashboard).
30
+
31
+ > Note: the plugin reads the DeepSeek API key from DSH's credentials service (`DEEPSEEK_API_KEY`). Without a valid key the balance card shows a hint instead of a number; token usage still works because it comes from local session logs.
32
+
33
+ ## Usage
34
+
35
+ 1. Open **Settings → 用量看板**.
36
+ 2. Refresh the server to load the latest data at any time.
37
+ 3. Switch between **by model / by workspace** and the **30 / 90 / 180-day** heatmap ranges.
38
+
39
+ ## Configuration
40
+
41
+ The plugin mostly works with no configuration. The host half resolves sensible defaults in `lib/index.js`:
42
+ - `sessionsRoot` — defaults to `$DSH_HOME/sessions`.
43
+ - `balanceCacheMs` — balance cache TTL (default 300000 ms = 5 min).
44
+ - `sweepIntervalMs` — stats refresh interval (default 60000 ms).
45
+
46
+ ## Development
47
+
48
+ - `lib/index.js` — host half: session scanning (multi-frame zstd), token aggregation, balance proxy.
49
+ - `lib/client.js` — client half: the settings.section UI.
50
+
51
+ Edit `lib/client.js` → refresh the page to see changes. Edit `lib/index.js` → restart the DSH web profile.
52
+
53
+ ## License
54
+
55
+ [MIT](./LICENSE)
package/README.zh.md ADDED
@@ -0,0 +1,55 @@
1
+ # dsh-token-dashboard
2
+
3
+ [![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
+
5
+ 一个 DeepSeek Harness 插件,把**实时账户余额**和 **Token 使用量**整合在一个看板上。本地优先:读取你的 DSH 会话日志 + DeepSeek 官方 `/user/balance` 接口,无需额外云端账号(沿用你已有的 DeepSeek key)。
6
+
7
+ ## 功能
8
+
9
+ - 💰 **账户余额** — 通过 DeepSeek 官方 `/user/balance` 接口实时获取(自动识别 CNY/USD),支持强制刷新。
10
+ - 📊 **Token 使用量** — 今日 / 本月 / 总计三卡,加请求数、缓存命中率、会话数。
11
+ - 📈 **用量分布** — 在**按模型**和**按工作区**之间切换,带比例占比条(前 8 项 + 「其他」聚合),百分比与占比条完全一致。
12
+ - 🗓 **活跃度热力图** — 30 / 90 / 180 天切换,绿色深浅映射 Token 用量。
13
+ - 🤖 **按模型明细** — 每个模型的 Req / Input / Cache / Output 表格。
14
+
15
+ ## 安装
16
+
17
+ 这是一个标准 DSH bundle 插件,用官方 CLI 安装:
18
+
19
+ ```bash
20
+ dsh plugin --profile web add <此仓库的路径或 git 地址>
21
+ ```
22
+
23
+ 如果发布到 npm,也可按包名安装:
24
+
25
+ ```bash
26
+ dsh plugin --profile web add dsh-token-dashboard
27
+ ```
28
+
29
+ 安装后**刷新网页**即可。看板位于 **设置 → 用量看板**。
30
+
31
+ > 说明:插件从 DSH 的凭据服务读取 DeepSeek API Key(`DEEPSEEK_API_KEY`)。没有有效 key 时余额卡片显示引导文案;Token 用量仍可正常统计(来自本地会话日志)。
32
+
33
+ ## 使用方法
34
+
35
+ 1. 打开 **设置 → 用量看板**。
36
+ 2. 随时刷新服务器加载最新数据。
37
+ 3. 切换 **按模型 / 按工作区**,以及 **30 / 90 / 180 天**热力图范围。
38
+
39
+ ## 配置
40
+
41
+ 插件基本无需配置即可使用,host 半在 `lib/index.js` 里解析合理默认值:
42
+ - `sessionsRoot` — 默认为 `$DSH_HOME/sessions`。
43
+ - `balanceCacheMs` — 余额缓存时长(默认 300000 毫秒 = 5 分钟)。
44
+ - `sweepIntervalMs` — 统计刷新间隔(默认 60000 毫秒)。
45
+
46
+ ## 开发
47
+
48
+ - `lib/index.js` — host 半:会话扫描(多帧 zstd)、Token 聚合、余额代理。
49
+ - `lib/client.js` — client 半:settings.section 界面。
50
+
51
+ 改 `lib/client.js` → 刷新页面即可看到变化。改 `lib/index.js` → 重启 DSH web profile。
52
+
53
+ ## 许可证
54
+
55
+ [MIT](./LICENSE)
@@ -0,0 +1,8 @@
1
+ # dsh-token-dashboard bundle patch
2
+ # 作为社区插件层被 dsh plugin add 安装时,本文件插入插件行(host 半 = 包默认导出;
3
+ # client 半 = dsh.client 标记扫描,注册到 settings.section 槽位,避免与 footer.action 冲突)。
4
+ # 注意:不在此处写 config / !!js 表达式,避免 patch 解析失败导致插件被 DSH 回退剔除。
5
+ # 运行参数由 host 半(lib/index.js)自行解析默认值。
6
+ - insert:
7
+ - id: token-dashboard
8
+ name: dsh-token-dashboard
package/lib/client.js ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * dsh-token-dashboard — client half (Token Ledger style).
3
+ *
4
+ * Layout mirrors a token ledger: Balance card, Token-usage stat cards
5
+ * (Today / This month / All time + requests + cache rate), a grouped share bar
6
+ * (by model OR by workspace, toggleable), an Activity heatmap with a
7
+ * 30/90/180-day range switch, and a Models table (Req / Input / Cache /
8
+ * Output). Uses react/jsx-runtime jsx/jsxs correctly and never puts a raw null
9
+ * inside a children array. Wrapped in an ErrorBoundary.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-token-dashboard",
13
+ factory: (require) => {
14
+ const module = { exports: {} };
15
+ const exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
+
18
+ const React = require("react");
19
+ const { useEffect, useState, useCallback, Component } = React;
20
+ const jsx = require("react/jsx-runtime").jsx;
21
+ const jsxs = require("react/jsx-runtime").jsxs;
22
+
23
+ // ---- CSS-ish tokens (follow DSH theme vars) ----
24
+ const CARD = {
25
+ border: "1px solid var(--dsw-alias-border-l1)",
26
+ borderRadius: "12px",
27
+ padding: "16px",
28
+ margin: "12px 0",
29
+ background: "var(--dsw-alias-bg-overlay, transparent)"
30
+ };
31
+ const SEC_TITLE = { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-secondary)", marginBottom: "10px", letterSpacing: "0.02em" };
32
+ const NUM = { fontSize: "26px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", lineHeight: 1.3 };
33
+ const LABEL = { fontSize: "12px", color: "var(--dsw-alias-label-tertiary)" };
34
+ const ROW = { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 0" };
35
+ const MONO = { fontVariantNumeric: "tabular-nums" };
36
+
37
+ function fmt(n) { return (n == null || isNaN(n)) ? "0" : Number(n).toLocaleString(); }
38
+ function fmtMoney(n) { return (n == null || isNaN(n)) ? "0.00" : Number(n).toFixed(2); }
39
+ function pct(v, max) { return max > 0 ? ((v / max) * 100) : 0; }
40
+
41
+ // A segmented control ("By model" / "By workspace" and the heatmap range).
42
+ function Seg({ options, value, onChange }) {
43
+ return jsxs("div", {
44
+ style: { display: "flex", gap: "4px", background: "var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.14))", borderRadius: "8px", padding: "3px" },
45
+ children: options.map((o) => jsx("button", {
46
+ onClick: () => onChange(o.value),
47
+ style: {
48
+ flex: "1 1 auto", minWidth: "0", padding: "5px 14px", fontSize: "12px", borderRadius: "6px", cursor: "pointer",
49
+ border: "none", color: "var(--dsw-alias-label-primary)",
50
+ whiteSpace: "nowrap",
51
+ background: (o.value === value)
52
+ ? "var(--dsw-alias-bg-overlay, rgba(255,255,255,.9))"
53
+ : "transparent",
54
+ fontWeight: (o.value === value) ? 600 : 400
55
+ },
56
+ children: o.label
57
+ }, o.value))
58
+ });
59
+ }
60
+
61
+ // Color scale for heatmap (green shades like the reference).
62
+ function heatColor(val, max) {
63
+ if (val <= 0) return "var(--dsw-alias-border-l1)";
64
+ const t = max > 0 ? (val / max) : 0;
65
+ const a = 0.25 + 0.75 * t;
66
+ return "rgba(76,175,80," + a.toFixed(2) + ")";
67
+ }
68
+
69
+ // A stable color per group entry; the "其他" (other) aggregate is grey so it
70
+ // reads as "everything else", distinct from the named top groups.
71
+ const GROUP_COLORS = ["#4caf50", "#2196f3", "#ff9800", "#9c27b0", "#00bcd4"];
72
+ function groupColor(key, i) {
73
+ if (key === "其他" || key === "other") return "var(--dsw-alias-label-tertiary, #9e9e9e)";
74
+ return GROUP_COLORS[i % GROUP_COLORS.length];
75
+ }
76
+
77
+ class Boundary extends Component {
78
+ constructor(props) { super(props); this.state = { error: null }; }
79
+ static getDerivedStateFromError(error) { return { error }; }
80
+ componentDidCatch(error, info) { console.error("[token-dashboard] render error:", error, info); }
81
+ render() {
82
+ if (this.state.error) {
83
+ return jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", padding: "8px 0" }, children: "渲染出错:" + String(this.state.error?.message || this.state.error) });
84
+ }
85
+ return jsx("div", { children: this.props.children });
86
+ }
87
+ }
88
+
89
+ function TokenDashboardPanel() {
90
+ const [stats, setStats] = useState(null);
91
+ const [balance, setBalance] = useState(null);
92
+ const [error, setError] = useState(null);
93
+ const [loading, setLoading] = useState(true);
94
+ const [balanceLoading, setBalanceLoading] = useState(true);
95
+ const [groupBy, setGroupBy] = useState("model"); // "model" | "workspace"
96
+ const [range, setRange] = useState(90); // 30 | 90 | 180 days
97
+
98
+ const loadStats = useCallback(async () => {
99
+ try {
100
+ const res = await fetch("/api/token-dashboard/stats?refresh=1");
101
+ if (!res.ok) throw new Error("stats http " + res.status);
102
+ const data = await res.json();
103
+ if (data && data.status === "computing") { setTimeout(loadStats, 1200); return; }
104
+ setStats((data && typeof data === "object") ? data : {});
105
+ setLoading(false);
106
+ } catch (e2) { setError(String(e2)); setLoading(false); }
107
+ }, []);
108
+ const loadBalance = useCallback(async (force) => {
109
+ setBalanceLoading(true);
110
+ try {
111
+ const res = await fetch("/api/token-dashboard/balance" + (force ? "?force=1" : ""));
112
+ if (!res.ok) throw new Error("balance http " + res.status);
113
+ setBalance((await res.json()) || {});
114
+ } catch (e2) { setError(String(e2)); }
115
+ finally { setBalanceLoading(false); }
116
+ }, []);
117
+ useEffect(() => { loadStats(); loadBalance(false); }, [loadStats, loadBalance]);
118
+
119
+ if (!loading && stats) {
120
+ // ---- derived ----
121
+ const infos = balance?.data?.balance_infos || [];
122
+ let balInfo = infos.find((x) => x && x.currency === "CNY")
123
+ || infos.find((x) => x && Number(x.total_balance ?? x.balance) > 0)
124
+ || infos[0] || balance?.data || null;
125
+ const balValue = balInfo ? (balInfo.total_balance ?? balInfo.balance ?? null) : null;
126
+ const balCur = balInfo ? (balInfo.currency || "") : "";
127
+
128
+ const total = Number(stats.total) || 0;
129
+ const req = Number(stats.req) || Number(stats.events) || 0;
130
+ const today = Number(stats.today) || 0;
131
+ const thisMonth = Number(stats.thisMonth) || 0;
132
+ const cacheRate = (Number(stats.cacheHits) && total) ? (Number(stats.cacheHits) / total * 100).toFixed(1) : "0.0";
133
+
134
+ // grouped share data. Show top TOP_N entries by total, then fold the
135
+ // rest into a single "其他" (other) aggregate so the card never grows
136
+ // unbounded and tiny shares don't get lost in the bar.
137
+ const TOP_N = 8;
138
+ const groups = groupBy === "model" ? stats.byModel : stats.byWorkspace;
139
+ const allEntries = Object.keys(groups || {}).map((k) => ({ key: k, ...(typeof groups[k] === "object" ? groups[k] : { total: groups[k] }) }))
140
+ .sort((a, b) => b.total - a.total);
141
+ let groupEntries;
142
+ if (allEntries.length > TOP_N) {
143
+ const top = allEntries.slice(0, TOP_N);
144
+ // fold the remainder into a synthetic "其他" entry; keep broken-down
145
+ // counters for the bar/list (we only display total/req, but stay safe).
146
+ const rest = allEntries.slice(TOP_N);
147
+ const other = { key: "其他", req: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, total: 0 };
148
+ for (const r of rest) {
149
+ other.total += Number(r.total) || 0;
150
+ other.req += Number(r.req) || 0;
151
+ other.input += Number(r.input) || 0;
152
+ other.output += Number(r.output) || 0;
153
+ other.cacheRead += Number(r.cacheRead) || 0;
154
+ other.reasoning += Number(r.reasoning) || 0;
155
+ }
156
+ groupEntries = [...top, other];
157
+ } else {
158
+ groupEntries = allEntries;
159
+ }
160
+ // Percentages use the SUM of all group totals as the denominator, so the
161
+ // bar segments (flex-grow by total) and the printed % agree exactly and
162
+ // both reflect "share of all usage".
163
+ const groupSum = groupEntries.reduce((s, g) => s + (Number(g.total) || 0), 0);
164
+
165
+ // heatmap: build days from byDay within range
166
+ const byDay = stats.byDay || {};
167
+ const todayD = new Date();
168
+ const cells = [];
169
+ for (let i = range - 1; i >= 0; i--) {
170
+ const d = new Date(todayD); d.setDate(d.getDate() - i);
171
+ const key = d.toISOString().slice(0, 10);
172
+ cells.push({ key, val: Number(byDay[key]) || 0 });
173
+ }
174
+ const heatMax = Math.max(1, ...cells.map((c) => c.val));
175
+ // group cells into weeks for the grid (7 per week)
176
+ const weeks = [];
177
+ for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
178
+
179
+ // models table rows
180
+ const modelRows = Object.keys(stats.byModel || {})
181
+ .map((m) => ({ key: m, ...stats.byModel[m] }))
182
+ .sort((a, b) => b.total - a.total);
183
+
184
+ // ---- render ----
185
+ const balanceInner = balanceLoading ? jsx("div", { style: { color: "var(--dsw-alias-label-secondary)" }, children: "加载中…" })
186
+ : (balance && balance.status === "ok")
187
+ ? jsxs("div", { children: [
188
+ jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: "6px" }, children: [
189
+ jsx("span", { style: { ...NUM, ...MONO }, children: (balCur === "CNY" ? "¥" : "$") + fmtMoney(balValue) }),
190
+ jsx("span", { style: LABEL, children: balCur })
191
+ ] }),
192
+ jsxs("div", { style: { marginTop: "8px" }, children: [
193
+ jsx("button", { onClick: () => loadBalance(true), style: { cursor: "pointer", fontSize: "12px", padding: "4px 10px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l1)", background: "transparent", color: "var(--dsw-alias-label-primary)" }, children: "强制刷新" }),
194
+ balance.cached ? jsx("span", { style: { fontSize: "11px", color: "var(--dsw-alias-label-tertiary)", marginLeft: "8px" }, children: "缓存" }) : null
195
+ ] })
196
+ ] })
197
+ : jsx("div", { style: { color: "var(--dsw-alias-label-secondary)" }, children: (balance?.message || "未配置或不可用") });
198
+
199
+ const statCards = [
200
+ { label: "今日", val: today },
201
+ { label: "本月", val: thisMonth },
202
+ { label: "总计", val: total }
203
+ ];
204
+ const statBar = jsxs("div", { style: { display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: "10px" }, children: statCards.map((s) =>
205
+ jsxs("div", { style: { background: "var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.08))", borderRadius: "10px", padding: "12px" }, children: [
206
+ jsx("div", { style: LABEL, children: s.label }),
207
+ jsx("div", { style: { ...NUM, ...MONO, fontSize: "22px" }, children: fmt(s.val) })
208
+ ] }, s.label)
209
+ ) });
210
+
211
+ const shareBar = jsxs("div", { children: [
212
+ jsxs("div", { style: { display: "flex", height: "12px", borderRadius: "6px", overflow: "hidden", gap: "2px", backgroundColor: "var(--dsw-alias-border-l1)" }, children: groupEntries.map((g, i) =>
213
+ // flex-grow proportional to total: each segment takes its share of the
214
+ // full width. grow = g.total, shrink = 0, basis = 0 → segments split
215
+ // the row exactly by their token totals (no fixed/min width clobbering).
216
+ jsx("div", { style: { flex: g.total + " 0 0", background: groupColor(g.key, i) }, title: g.key + " " + fmt(g.total) }, g.key)
217
+ ) }),
218
+ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "6px", marginTop: "10px" }, children: groupEntries.map((g, i) =>
219
+ jsxs("div", { style: ROW, children: [
220
+ jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px", minWidth: "0" }, children: [
221
+ jsx("div", { style: { width: "10px", height: "10px", borderRadius: "2px", flexShrink: "0", background: groupColor(g.key, i) } }),
222
+ jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "240px", color: "var(--dsw-alias-label-primary)", fontSize: "13px" }, children: g.key })
223
+ ] }),
224
+ jsxs("div", { style: { display: "flex", gap: "14px", color: "var(--dsw-alias-label-secondary)", ...MONO, fontSize: "13px", flexShrink: "0" }, children: [
225
+ jsx("span", { children: fmt(g.total) }),
226
+ jsx("span", { style: { color: "var(--dsw-alias-label-tertiary)" }, children: pct(g.total, groupSum).toFixed(0) + "%" })
227
+ ] })
228
+ ] }, g.key)
229
+ ) })
230
+ ] });
231
+
232
+ // heatmap grid (weeks as columns is complex; render day cells in a wrap grid)
233
+ const heatGrid = jsxs("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(14px, 1fr))", gap: "3px" }, children: cells.map((c) =>
234
+ jsx("div", { style: { width: "14px", height: "14px", borderRadius: "2px", background: heatColor(c.val, heatMax) }, title: c.key + ": " + fmt(c.val) }, c.key)
235
+ ) });
236
+
237
+ const modelTable = jsxs("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: "13px" }, children: [
238
+ jsxs("thead", { children: [jsxs("tr", { children: [
239
+ jsx("th", { style: thStyle, children: "模型" }), jsx("th", { style: thR, children: "Req" }),
240
+ jsx("th", { style: thR, children: "Input" }), jsx("th", { style: thR, children: "Cache" }),
241
+ jsx("th", { style: thR, children: "Output" })
242
+ ] })] }),
243
+ jsxs("tbody", { children: modelRows.map((r) => jsxs("tr", { style: { borderTop: "1px solid var(--dsw-alias-border-l1)" }, children: [
244
+ jsx("td", { style: tdStyle, children: r.key }),
245
+ jsx("td", { style: tdR, children: r.req }),
246
+ jsx("td", { style: tdR, children: fmt(r.input) }),
247
+ jsx("td", { style: tdR, children: fmt(r.cacheRead) }),
248
+ jsx("td", { style: tdR, children: fmt(r.output) })
249
+ ] }, r.key)) })
250
+ ] });
251
+
252
+ return jsxs("div", { children: [
253
+ jsx("h3", { style: { fontSize: "18px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", marginBottom: "4px" }, children: "用量看板" }),
254
+ (error) ? jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", margin: "6px 0" }, children: "加载出错:" + error }) : null,
255
+
256
+ // Balance
257
+ jsxs("div", { style: CARD, children: [
258
+ jsx("div", { style: SEC_TITLE, children: "💰 账户余额(DeepSeek 官方)" }), balanceInner
259
+ ] }),
260
+
261
+ // Token usage
262
+ jsxs("div", { style: CARD, children: [
263
+ jsx("div", { style: SEC_TITLE, children: "📊 Token 使用量" }),
264
+ statBar,
265
+ jsxs("div", { style: { ...ROW, marginTop: "10px" }, children: [
266
+ jsx("span", { children: "请求数" }), jsx("span", { ...MONO, children: fmt(req) })
267
+ ] }),
268
+ jsxs("div", { style: ROW, children: [
269
+ jsx("span", { children: "缓存命中率" }), jsx("span", { ...MONO, children: cacheRate + "%" })
270
+ ] }),
271
+ jsxs("div", { style: ROW, children: [
272
+ jsx("span", { children: "会话数" }), jsx("span", { ...MONO, children: Number(stats.sessions) || 0 })
273
+ ] })
274
+ ] }),
275
+
276
+ // Grouped share
277
+ jsxs("div", { style: CARD, children: [
278
+ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px", marginBottom: "10px" }, children: [
279
+ jsx("div", { style: SEC_TITLE, children: "📈 用量分布" }),
280
+ jsx(Seg, { options: [{ label: "按模型", value: "model" }, { label: "按工作区", value: "workspace" }], value: groupBy, onChange: setGroupBy })
281
+ ] }),
282
+ shareBar
283
+ ] }),
284
+
285
+ // Activity heatmap
286
+ jsxs("div", { style: CARD, children: [
287
+ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }, children: [
288
+ jsx("div", { style: SEC_TITLE, children: "🗓 活跃度" }),
289
+ jsx(Seg, { options: [{ label: "30天", value: 30 }, { label: "90天", value: 90 }, { label: "180天", value: 180 }], value: range, onChange: setRange })
290
+ ] }),
291
+ heatGrid
292
+ ] }),
293
+
294
+ // Models table
295
+ jsxs("div", { style: CARD, children: [
296
+ jsx("div", { style: SEC_TITLE, children: "🤖 按模型" }),
297
+ modelTable
298
+ ] })
299
+ ] });
300
+ }
301
+
302
+ // loading / no data -> simple spinner-ish text
303
+ return jsxs("div", { children: [
304
+ jsx("h3", { style: { fontSize: "18px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", marginBottom: "12px" }, children: "用量看板" }),
305
+ (error) ? jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", margin: "6px 0" }, children: "加载出错:" + error }) : null,
306
+ jsx("div", { style: { color: "var(--dsw-alias-label-secondary)", padding: "8px 0" }, children: "加载中…" })
307
+ ] });
308
+ }
309
+
310
+ const thStyle = { textAlign: "left", padding: "8px 6px", color: "var(--dsw-alias-label-tertiary)", fontWeight: 500, fontSize: "12px" };
311
+ const thR = { textAlign: "right", padding: "8px 6px", color: "var(--dsw-alias-label-tertiary)", fontWeight: 500, fontSize: "12px", ...MONO };
312
+ const tdStyle = { padding: "8px 6px", color: "var(--dsw-alias-label-primary)" };
313
+ const tdR = { textAlign: "right", padding: "8px 6px", color: "var(--dsw-alias-label-secondary)", ...MONO };
314
+
315
+ function apply(ctx) {
316
+ ctx.slots.inject("settings.section", () => ctx.slots.register(
317
+ {
318
+ name: "settings.section",
319
+ id: "token-dashboard",
320
+ order: 30,
321
+ label: () => {
322
+ try {
323
+ const bound = ctx.locale && typeof ctx.locale.bind === "function" && ctx.locale.bind("token-dashboard");
324
+ const v = bound && typeof bound === "function" ? bound("title") : null;
325
+ return (typeof v === "string" && v !== "title") ? v : "用量看板";
326
+ } catch {
327
+ return "用量看板";
328
+ }
329
+ },
330
+ inject: () => ({})
331
+ },
332
+ (props) => jsx(Boundary, { children: jsx(TokenDashboardPanel, props) })
333
+ ));
334
+ }
335
+
336
+ exports.apply = apply;
337
+ exports.inject = ["slots", "locale"];
338
+ exports.TokenDashboardPanel = TokenDashboardPanel;
339
+ exports.Boundary = Boundary;
340
+ exports.fmt = fmt;
341
+ exports.fmtMoney = fmtMoney;
342
+
343
+ return module.exports;
344
+ }
345
+ });
package/lib/index.js ADDED
@@ -0,0 +1,361 @@
1
+ /**
2
+ * dsh-token-dashboard — host half.
3
+ *
4
+ * Aggregates token usage from DSH persistent session logs (multi-frame zstd
5
+ * JSONL), and proxies DeepSeek `/user/balance` for the account balance card.
6
+ *
7
+ * Runtime contract (dsh bundle host plugin):
8
+ * - default export = the Cordis plugin (a function `(ctx, config) => {...}`).
9
+ * - `inject` = required services: `webServer` (route registration) and
10
+ * `credentials` (DeepSeek API key resolution).
11
+ *
12
+ * The browser half rides `dsh.client` manifest; this file only owns the node
13
+ * half: session scan + aggregated statistics + balance proxy, exposed as a few
14
+ * JSON routes under `/api/token-dashboard/*`.
15
+ */
16
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
17
+ import { join, dirname, basename } from "node:path";
18
+ import { zstdDecompressSync, createZstdDecompress } from "node:zlib";
19
+ import { createReadStream, createWriteStream } from "node:fs";
20
+ import { once } from "node:events";
21
+
22
+ const ZSTD_MAGIC = 0xfd2fb528; // bytes 28 b5 2f fd (little-endian)
23
+
24
+ /**
25
+ * Split a zstd buffer into its constituent frames. DSH writes multi-frame zstd
26
+ * (one frame per chunk). We walk frame headers to find each frame boundary by
27
+ * scanning for the next magic; each slice is one complete zstd frame, which
28
+ * `zstdDecompressSync` decodes independently.
29
+ * @param {Buffer} buf
30
+ * @returns {{start:number,end:number}[]}
31
+ */
32
+ function splitZstdFrames(buf) {
33
+ const frames = [];
34
+ let off = 0;
35
+ while (off < buf.length) {
36
+ if (buf.length - off < 4) break;
37
+ if (buf.readUInt32LE(off) !== ZSTD_MAGIC) break; // torn tail / non-frame
38
+ // frame header descriptor byte
39
+ const fhd = buf.readUInt8(off + 4);
40
+ let hdrLen = 5;
41
+ if (fhd & 0x01) hdrLen += 1; // window descriptor present
42
+ const didBits = (fhd >> 1) & 0x3;
43
+ hdrLen += [0, 1, 2, 4][didBits]; // dict id length
44
+ const fcs = fhd & 0x08;
45
+ if (fcs) {
46
+ const low = buf.readUInt8(off + hdrLen);
47
+ if (low === 0xff) {
48
+ // 8-byte content size (rare in DSH chunked logs); read and skip
49
+ hdrLen += 9;
50
+ } else {
51
+ hdrLen += 1;
52
+ }
53
+ }
54
+ // find next frame magic to bound this frame
55
+ let next = -1;
56
+ for (let i = off + hdrLen; i < buf.length - 3; i++) {
57
+ if (buf.readUInt32LE(i) === ZSTD_MAGIC) { next = i; break; }
58
+ }
59
+ const end = next === -1 ? buf.length : next;
60
+ frames.push({ start: off, end });
61
+ off = end;
62
+ }
63
+ return frames;
64
+ }
65
+
66
+ /**
67
+ * Decompress a (possibly multi-frame) zstd buffer to a UTF-8 string.
68
+ * @param {Buffer} buf
69
+ * @returns {string}
70
+ */
71
+ function zstdDecompressToText(buf) {
72
+ // DSH writes MULTI-frame zstd (one frame per chunk). A naive single-shot
73
+ // `zstdDecompressSync(buf)` silently returns only the FIRST frame's bytes on
74
+ // Node, which is why we must always split into frames and decode each one.
75
+ const frames = splitZstdFrames(buf);
76
+ if (frames.length === 0) {
77
+ // fallback: maybe a genuine single-frame buffer whose magic walk failed
78
+ try {
79
+ const out = zstdDecompressSync(buf);
80
+ return out.toString("utf8");
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
85
+ const parts = [];
86
+ for (const { start, end } of frames) {
87
+ try {
88
+ parts.push(zstdDecompressSync(buf.subarray(start, end)));
89
+ } catch {
90
+ // skip a corrupt frame; keep the rest of the log's data
91
+ }
92
+ }
93
+ if (parts.length === 0) return "";
94
+ return Buffer.concat(parts).toString("utf8");
95
+ }
96
+
97
+ /** Normalize a token-usage event into our source-neutral shape. */
98
+ function usagesFromEvent(obj, acc, currentModel) {
99
+ if (!obj || typeof obj !== "object") return;
100
+ const data = obj.data;
101
+ if (!data) return;
102
+ const chunk = data.chunk;
103
+ if (!chunk) return;
104
+ const usage = chunk.usage;
105
+ if (!usage) return;
106
+ // Support both camelCase (DSH) and snake_case (OpenAI) keys.
107
+ const input = usage.inputTokens ?? usage.promptTokens ?? usage.prompt_tokens ?? 0;
108
+ const output = usage.outputTokens ?? usage.completionTokens ?? usage.completion_tokens ?? 0;
109
+ const cacheRead = usage.cacheReadTokens ?? usage.cachedTokens ?? usage.cached_tokens ?? 0;
110
+ const reasoning = usage.reasoningTokens ?? usage.reasoning_tokens ?? 0;
111
+ const sum = input + output + cacheRead + reasoning;
112
+ acc.total += sum;
113
+ acc.input += input;
114
+ acc.output += output;
115
+ acc.cacheRead += cacheRead;
116
+ acc.reasoning += reasoning;
117
+ acc.events += 1;
118
+ acc.req += 1;
119
+ acc.cacheHits += cacheRead;
120
+ // per-model attribution: prefer the model on the chunk/data, else the tracked current model.
121
+ // Store per-model BROKEN-DOWN counters so the UI can render Req/Input/Cache/Output/Total.
122
+ const model = chunk.model || data.model || obj.model || currentModel || "unknown";
123
+ if (model) {
124
+ let m = acc.byModel[model];
125
+ if (m === undefined) { m = acc.byModel[model] = { req: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, total: 0 }; }
126
+ m.req += 1;
127
+ m.input += input;
128
+ m.output += output;
129
+ m.cacheRead += cacheRead;
130
+ m.reasoning += reasoning;
131
+ m.total += sum;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Recursively walk the sessions root for `session.jsonl[.zstd]` files.
137
+ * @param {string} root
138
+ * @returns {string[]} absolute paths
139
+ */
140
+ function findSessionLogs(root) {
141
+ const out = [];
142
+ if (!existsSync(root)) return out;
143
+ const walk = (dir) => {
144
+ let entries;
145
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
146
+ for (const e of entries) {
147
+ const p = join(dir, e.name);
148
+ if (e.isDirectory()) walk(p);
149
+ else if (/^session\.jsonl(\.zstd)?$/.test(e.name)) out.push(p);
150
+ }
151
+ };
152
+ walk(root);
153
+ return out;
154
+ }
155
+
156
+ /** Aggregate token stats across all session logs under `root`. */
157
+ function aggregateTokens(root) {
158
+ const acc = {
159
+ total: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0,
160
+ events: 0, req: 0, cacheHits: 0, sessions: 0, files: 0,
161
+ byModel: {}, byDay: {}, byWorkspace: {}
162
+ };
163
+ const files = findSessionLogs(root);
164
+ acc.files = files.length;
165
+ const now = new Date();
166
+ const todayKey = now.toISOString().slice(0, 10);
167
+ const monthKey = todayKey.slice(0, 7);
168
+ for (const file of files) {
169
+ let bytes;
170
+ try { bytes = readFileSync(file); } catch { continue; }
171
+ const text = file.endsWith(".zstd") ? zstdDecompressToText(bytes) : bytes.toString("utf8");
172
+ if (!text) continue;
173
+ acc.sessions += 1;
174
+ const lines = text.split("\n");
175
+ let ws = "unknown";
176
+ let currentModel = null;
177
+ for (const line of lines) {
178
+ if (!line) continue;
179
+ let o;
180
+ try { o = JSON.parse(line); } catch { continue; }
181
+ if (o.type === "session" && o.cwd) ws = o.cwd;
182
+ // Track the active model. DSH records it on:
183
+ // - request/header: data.header.config.model (authoritative)
184
+ // - request/context: data.model
185
+ // - assistant/message: data.usage.model
186
+ if (o.type === "request/header") {
187
+ const m = o.data?.header?.config?.model || o.data?.model || null;
188
+ if (m) currentModel = m;
189
+ } else if (o.type === "assistant/message") {
190
+ const m = o.data?.usage?.model || o.data?.model || null;
191
+ if (m) currentModel = m;
192
+ } else if (o.type === "request/context") {
193
+ const m = o.data?.model || null;
194
+ if (m) currentModel = m;
195
+ }
196
+ // Per-day + per-workspace accumulation must happen per event BEFORE the
197
+ // generic usagesFromEvent bump, so we mirror the same broken-down shape.
198
+ const usageBlock = o.data?.chunk?.usage;
199
+ if (usageBlock) {
200
+ const inTok = usageBlock.inputTokens ?? usageBlock.prompt_tokens ?? 0;
201
+ const outTok = usageBlock.outputTokens ?? usageBlock.completion_tokens ?? 0;
202
+ const cTok = usageBlock.cacheReadTokens ?? usageBlock.cachedTokens ?? 0;
203
+ const rTok = usageBlock.reasoningTokens ?? usageBlock.reasoning_tokens ?? 0;
204
+ const sum = inTok + outTok + cTok + rTok;
205
+ const t = o.time ?? o.createdAt;
206
+ const day = t ? new Date(t).toISOString().slice(0, 10) : null;
207
+ if (day !== null) {
208
+ acc.byDay[day] = (acc.byDay[day] || 0) + sum;
209
+ if (day === todayKey) acc.today = (acc.today || 0) + sum;
210
+ if (day.slice(0, 7) === monthKey) acc.thisMonth = (acc.thisMonth || 0) + sum;
211
+ }
212
+ // per-workspace broken-down counters
213
+ let w = acc.byWorkspace[ws];
214
+ if (w === undefined) { w = acc.byWorkspace[ws] = { req: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, total: 0 }; }
215
+ w.req += 1;
216
+ w.input += inTok;
217
+ w.output += outTok;
218
+ w.cacheRead += cTok;
219
+ w.reasoning += rTok;
220
+ w.total += sum;
221
+ }
222
+ usagesFromEvent(o, acc, currentModel);
223
+ }
224
+ }
225
+ return acc;
226
+ }
227
+
228
+ /**
229
+ * Proxy DeepSeek `/user/balance`.
230
+ * @param {string} apiKey
231
+ * @returns {Promise<object>}
232
+ */
233
+ async function fetchBalance(apiKey) {
234
+ if (!apiKey) return { status: "unconfigured", message: "未配置 DeepSeek API Key" };
235
+ const res = await fetch("https://api.deepseek.com/user/balance", {
236
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }
237
+ });
238
+ if (!res.ok) return { status: "error", httpStatus: res.status, message: res.statusText };
239
+ const json = await res.json();
240
+ return { status: "ok", data: json };
241
+ }
242
+
243
+ /** A simple JSON response helper for the node:http handler. */
244
+ function sendJson(res, status, payload) {
245
+ const body = JSON.stringify(payload);
246
+ res.writeHead(status, {
247
+ "Content-Type": "application/json; charset=utf-8",
248
+ "Cache-Control": "no-store"
249
+ });
250
+ res.end(body);
251
+ }
252
+
253
+ /**
254
+ * The Cordis plugin. `ctx` is the DSH context, `config` the merged config from
255
+ * the bundle patch row.
256
+ */
257
+ export function apply(ctx, config) {
258
+ // sessionsRoot defaults to $DSH_HOME/sessions (config no longer forces it via
259
+ // the patch; the host half resolves its own default so the minimal patch
260
+ // `- insert: { id, name }` always parses and mounts).
261
+ const dshHome = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || ".", ".dsh");
262
+ const sessionsRoot = config?.sessionsRoot || join(dshHome, "sessions");
263
+ const balanceCacheMs = config?.balanceCacheMs ?? 300000;
264
+ const sweepIntervalMs = config?.sweepIntervalMs ?? 60000;
265
+ const logLevel = config?.logLevel ?? "info";
266
+
267
+ // ---- stats cache: keep a synchronous-but-heavy full scan OFF the request
268
+ // thread. Aggregate once every STATS_TTL_MS, serve cached results meanwhile.
269
+ // A sync scan of the whole sessions tree takes ~1.5s and would block the DSH
270
+ // event loop (freezing every concurrent request, SSE, and model call) if run
271
+ // inline in a handler — that is exactly the "everything goes slow / UI goes
272
+ // blank" symptom. Caching makes the request path O(1) and defers the costly
273
+ // scan to a background refresh that never blocks a response.
274
+ const STATS_TTL_MS = 60_000;
275
+ let statsCache = { at: 0, payload: null, computing: false };
276
+
277
+ function scheduleStatsRefresh() {
278
+ if (statsCache.computing) return; // already running; back off
279
+ statsCache.computing = true;
280
+ // Defer to the next tick so the current handler / event loop is not held.
281
+ setImmediate(() => {
282
+ try {
283
+ const s = aggregateTokens(sessionsRoot);
284
+ statsCache = { at: Date.now(), payload: s, computing: false };
285
+ } catch (error) {
286
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
287
+ statsCache = { ...statsCache, computing: false };
288
+ }
289
+ });
290
+ }
291
+ // Warm the cache once on boot so the first open returns data immediately.
292
+ scheduleStatsRefresh();
293
+
294
+ // --- route: token stats (served from cache; never blocks the event loop) ---
295
+ ctx.webServer.register({
296
+ kind: "exact",
297
+ path: "/api/token-dashboard/stats",
298
+ handler: async (req, res) => {
299
+ try {
300
+ const url = new URL(req.url ?? "/", "http://x");
301
+ const force = url.searchParams.get("refresh") === "1";
302
+ if (force) scheduleStatsRefresh();
303
+ else if (!statsCache.computing && Date.now() - statsCache.at > STATS_TTL_MS) scheduleStatsRefresh();
304
+ const cached = statsCache.payload;
305
+ if (cached) {
306
+ sendJson(res, 200, { ...cached, cached: true });
307
+ return;
308
+ }
309
+ // No cache yet (first request after boot, scan still warming). Return
310
+ // immediately with a "computing" marker instead of blocking on the scan.
311
+ sendJson(res, 200, { status: "computing", cached: false });
312
+ } catch (error) {
313
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
314
+ sendJson(res, 500, { status: "error", message: String(error) });
315
+ }
316
+ }
317
+ });
318
+
319
+ // --- route: balance (with cache + ?force=1) ---
320
+ let balanceCache = { at: 0, payload: null };
321
+ ctx.webServer.register({
322
+ kind: "exact",
323
+ path: "/api/token-dashboard/balance",
324
+ handler: async (req, res) => {
325
+ try {
326
+ const url = new URL(req.url ?? "/", "http://x");
327
+ const force = url.searchParams.get("force") === "1";
328
+ const now = Date.now();
329
+ if (!force && balanceCache.payload && now - balanceCache.at < balanceCacheMs) {
330
+ sendJson(res, 200, { ...balanceCache.payload, cached: true });
331
+ return;
332
+ }
333
+ // Resolve the DeepSeek API key from DSH credentials (service fetched
334
+ // lazily via ctx.get, mirroring dsh-llm-deepseek's own key path).
335
+ let apiKey;
336
+ try {
337
+ const credentials = ctx.get("credentials");
338
+ const hit = credentials?.resolve ? await credentials.resolve("DEEPSEEK_API_KEY") : null;
339
+ apiKey = typeof hit === "string" ? hit : (hit?.value ?? null);
340
+ } catch {
341
+ apiKey = null;
342
+ }
343
+ if (!apiKey) apiKey = process.env.DEEPSEEK_API_KEY || null;
344
+ const payload = await fetchBalance(apiKey);
345
+ balanceCache = { at: now, payload };
346
+ sendJson(res, 200, { ...payload, cached: false });
347
+ } catch (error) {
348
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
349
+ sendJson(res, 500, { status: "error", message: String(error) });
350
+ }
351
+ }
352
+ });
353
+
354
+ ctx.logger.info(`[token-dashboard] mounted; sessionsRoot=${sessionsRoot}`);
355
+ }
356
+
357
+ export const inject = ["webServer"];
358
+ export const name = "token-dashboard";
359
+
360
+ // Re-export helpers for the standalone workspace self-test (not used by Cordis).
361
+ export { aggregateTokens, zstdDecompressToText, findSessionLogs };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@lyk308/dsh-token-dashboard",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness 实时看板:账户余额 + Token 使用量(按模型/工作区/缓存命中),本地读取会话日志 + DeepSeek /user/balance。",
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
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
16
+ "client": {
17
+ "inject": [
18
+ "@deepseek-ai/dsh-client-runtime",
19
+ "@deepseek-ai/dsh-client-locale",
20
+ "@deepseek-ai/dsh-client-ui-slots",
21
+ "@deepseek-ai/dsh-client-ui-primitives"
22
+ ],
23
+ "platform": "web"
24
+ }
25
+ },
26
+ "files": [
27
+ "lib/index.js",
28
+ "lib/client.js",
29
+ "cordis.patch.yml",
30
+ "README.md",
31
+ "README.zh.md",
32
+ "LICENSE"
33
+ ],
34
+ "keywords": [
35
+ "deepseek-harness",
36
+ "dsh",
37
+ "plugin",
38
+ "token",
39
+ "balance",
40
+ "usage",
41
+ "dashboard"
42
+ ],
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/lyk308/dsh-token-dashboard.git"
47
+ },
48
+ "homepage": "https://github.com/lyk308/dsh-token-dashboard"
49
+ }