balance-dsh 1.0.3 → 1.0.9
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/README.md +2 -2
- package/lib/client.js +216 -80
- package/lib/index.js +67 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
|
|
|
7
7
|
- **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。
|
|
8
8
|
- **收起态 FAB**:侧边栏收起时,余额显示为左下角悬浮胶囊。
|
|
9
9
|
- **自动刷新**:每 60 秒自动刷新一次;每次对话完成(新回复落库)也会立即刷新;右键余额任意区域可手动刷新。
|
|
10
|
-
-
|
|
10
|
+
- **余额与使用统计**:单击余额在余额项旁以轻量动画打开局部快捷菜单,展示账户余额、近 90 天计费 Token、估算费用、扫描会话数和按日使用热力图;点击菜单外区域会播放关闭动画,双击余额打开 DeepSeek 官方 API 用量页。
|
|
11
11
|
- **高峰/空闲计费**:官方高峰窗口为**周一至周五 09:00-12:00、14:00-18:00(北京时间)**,周末全天空闲。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
|
|
12
12
|
- **会话消耗行**:对话框下方实时显示当前会话消耗(新增 tokens + 估算金额),悬停查看完整明细(模型、时段拆分、计费口径)。
|
|
13
13
|
- **`/balance` 命令**:命令行查询余额明细和当前会话消耗。
|
|
@@ -16,7 +16,7 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
|
|
|
16
16
|
|
|
17
17
|
- 热力图按北京时间聚合最近 90 天持久化会话中的 `assistant/message` Token usage。
|
|
18
18
|
- 费用按照每条消息的实际模型和峰谷时段估算,最终金额以 DeepSeek 官方账单为准。
|
|
19
|
-
- 为避免历史会话过多拖慢 DSH,最多扫描最近 200
|
|
19
|
+
- 为避免历史会话过多拖慢 DSH,最多扫描最近 200 个会话;服务端统计和客户端展示结果均缓存 5 分钟,重复打开菜单不会重复请求。
|
|
20
20
|
- 若未配置 API Key,仍可查看本地 Token 与费用统计,但账户余额会显示为不可用。
|
|
21
21
|
|
|
22
22
|
## 安装
|
package/lib/client.js
CHANGED
|
@@ -5,6 +5,7 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
let react = require("react");
|
|
8
|
+
let reactDom = require("react-dom");
|
|
8
9
|
let jsx = require("react/jsx-runtime");
|
|
9
10
|
let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
10
11
|
|
|
@@ -41,25 +42,40 @@ window.__ModuleLoader__.load({
|
|
|
41
42
|
.balance-fab-dark:hover{background:#cf3a3f}
|
|
42
43
|
.balance-usage-line{box-sizing:border-box;display:flex;justify-content:center;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
43
44
|
.balance-usage-line strong{color:var(--dsw-alias-label-primary);font-weight:600}
|
|
44
|
-
/* ──
|
|
45
|
-
.balance-stats-
|
|
46
|
-
.balance-stats-
|
|
47
|
-
.balance-stats-
|
|
48
|
-
.balance-stats-
|
|
45
|
+
/* ── 跨会话统计浮层(余额元素的子级,向右/向上展开) ── */
|
|
46
|
+
.balance-stats-anchor{position:relative;display:flex;min-width:0;overflow:visible}
|
|
47
|
+
.balance-stats-anchor--wide{width:100%}
|
|
48
|
+
.balance-stats-anchor--compact{position:fixed;left:68px;bottom:12px;z-index:10000}
|
|
49
|
+
.balance-stats-anchor--compact .balance-fab{position:static}
|
|
50
|
+
.balance-stats-panel{box-sizing:border-box;position:fixed;z-index:10000;width:460px;max-height:min(620px,calc(100vh - 24px));overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;padding:16px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,.24);transform-origin:left bottom;will-change:transform,opacity;contain:layout style;animation:balance-stats-panel-in .18s var(--ds-ease-in-out,ease-out) both}
|
|
51
|
+
.balance-stats-panel--closing{pointer-events:none;animation:balance-stats-panel-out .14s var(--ds-ease-in-out,ease-in) both}
|
|
52
|
+
@keyframes balance-stats-panel-in{from{opacity:0;transform:translateY(8px) scale(.975)}to{opacity:1;transform:translateY(0) scale(1)}}
|
|
53
|
+
@keyframes balance-stats-panel-out{from{opacity:1;transform:translateY(0) scale(1)}to{opacity:0;transform:translateY(6px) scale(.985)}}
|
|
54
|
+
.balance-stats-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
|
|
55
|
+
.balance-stats-title{margin:0;font-size:16px;line-height:24px;font-weight:700}
|
|
49
56
|
.balance-stats-subtitle{margin:4px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
50
57
|
.balance-stats-close{width:32px;height:32px;flex:none;border:0;border-radius:8px;color:var(--dsw-alias-label-secondary);background:transparent;font-size:22px;line-height:1;cursor:pointer}
|
|
51
58
|
.balance-stats-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
|
|
52
59
|
.balance-stats-error{padding:12px;border-radius:10px;color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.10)}
|
|
53
60
|
.balance-stats-loading{padding:48px 12px;text-align:center;color:var(--dsw-alias-label-secondary)}
|
|
54
|
-
.balance-stats-cards{display:grid;grid-template-columns:repeat(
|
|
55
|
-
.balance-stats-card{min-width:0;padding:
|
|
61
|
+
.balance-stats-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-bottom:16px}
|
|
62
|
+
.balance-stats-card{min-width:0;padding:11px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:10px;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04));animation:balance-stats-card-in .2s var(--ds-ease-in-out,ease-out) both}
|
|
63
|
+
.balance-stats-card:nth-child(2){animation-delay:.025s}.balance-stats-card:nth-child(3){animation-delay:.05s}.balance-stats-card:nth-child(4){animation-delay:.075s}
|
|
64
|
+
@keyframes balance-stats-card-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
|
|
56
65
|
.balance-stats-card__label{display:block;margin-bottom:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
57
|
-
.balance-stats-card__value{display:block;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:
|
|
66
|
+
.balance-stats-card__value{display:block;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:700;font-variant-numeric:tabular-nums;text-overflow:ellipsis;white-space:nowrap}
|
|
58
67
|
.balance-stats-section-title{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 10px;font-size:14px;font-weight:650}
|
|
59
68
|
.balance-stats-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}
|
|
60
|
-
.balance-heatmap-wrap{
|
|
69
|
+
.balance-heatmap-wrap{min-width:0;overflow:hidden;padding:4px 2px 8px}
|
|
61
70
|
.balance-heatmap{display:grid;grid-template-rows:repeat(7,14px);grid-auto-flow:column;grid-auto-columns:14px;gap:4px;width:max-content;min-width:100%}
|
|
62
|
-
.balance-heatmap-cell{width:14px;height:14px;border-radius:3px;background:rgba(127,127,127,.10)}
|
|
71
|
+
.balance-heatmap-cell{display:block;box-sizing:border-box;width:14px;height:14px;padding:0;border:0;border-radius:3px;background:rgba(127,127,127,.10);appearance:none;transition:transform .12s var(--ds-ease-in-out,ease-out),filter .12s var(--ds-ease-in-out,ease-out)}
|
|
72
|
+
.balance-heatmap-cell[aria-label]{cursor:default}
|
|
73
|
+
.balance-heatmap-cell[aria-label]:hover{transform:scale(1.18);filter:brightness(1.14)}
|
|
74
|
+
.balance-heatmap-tooltip{box-sizing:border-box;position:fixed;z-index:10020;max-width:min(300px,calc(100vw - 24px));padding:6px 9px;border:1px solid var(--dsw-alias-border-l2,#45454d);border-radius:6px;background:var(--dsw-alias-bg-module-platform,#27272b);box-shadow:0 6px 18px rgba(0,0,0,.22);color:var(--dsw-alias-label-primary,#f4f4f5);font-size:11px;font-weight:400;line-height:1.45;white-space:nowrap;pointer-events:none;animation:balance-heatmap-tooltip-in .12s var(--ds-ease-in-out,ease-out) both}
|
|
75
|
+
.balance-heatmap-tooltip--left{transform:translateY(-100%)}
|
|
76
|
+
.balance-heatmap-tooltip--center{transform:translate(-50%,-100%)}
|
|
77
|
+
.balance-heatmap-tooltip--right{transform:translateY(-100%)}
|
|
78
|
+
@keyframes balance-heatmap-tooltip-in{from{opacity:0}to{opacity:1}}
|
|
63
79
|
.balance-heatmap-cell--1{background:rgba(34,197,94,.25)}
|
|
64
80
|
.balance-heatmap-cell--2{background:rgba(34,197,94,.45)}
|
|
65
81
|
.balance-heatmap-cell--3{background:rgba(34,197,94,.68)}
|
|
@@ -68,7 +84,8 @@ window.__ModuleLoader__.load({
|
|
|
68
84
|
.balance-stats-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;color:var(--dsw-alias-label-secondary);font-size:11px}
|
|
69
85
|
.balance-stats-link{padding:7px 12px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:8px;color:var(--dsw-alias-label-primary);text-decoration:none;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04))}
|
|
70
86
|
.balance-stats-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
|
|
71
|
-
@media(max-width:680px){.balance-stats-
|
|
87
|
+
@media(max-width:680px){.balance-stats-panel{max-height:calc(100vh - 68px)}}
|
|
88
|
+
@media(prefers-reduced-motion:reduce){.balance-stats-panel,.balance-stats-panel--closing,.balance-stats-card{animation:none}.balance-heatmap-cell{transition:none}}
|
|
72
89
|
/* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
|
|
73
90
|
全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
|
|
74
91
|
- wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
|
|
@@ -82,12 +99,15 @@ window.__ModuleLoader__.load({
|
|
|
82
99
|
`;
|
|
83
100
|
|
|
84
101
|
const tagId = "balance/balance-widget.css";
|
|
85
|
-
if (typeof document !== "undefined"
|
|
86
|
-
|
|
87
|
-
tag
|
|
88
|
-
|
|
102
|
+
if (typeof document !== "undefined") {
|
|
103
|
+
let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]");
|
|
104
|
+
if (tag === null) {
|
|
105
|
+
tag = document.createElement("style");
|
|
106
|
+
tag.dataset.plugin = "balance";
|
|
107
|
+
tag.dataset.pluginCss = tagId;
|
|
108
|
+
document.head.appendChild(tag);
|
|
109
|
+
}
|
|
89
110
|
tag.textContent = css;
|
|
90
|
-
document.head.appendChild(tag);
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
// ─── 价格(与服务端默认保持一致;服务端 /balance-api 返回后会覆盖) ───
|
|
@@ -337,16 +357,38 @@ window.__ModuleLoader__.load({
|
|
|
337
357
|
fetchBalanceOnce();
|
|
338
358
|
}
|
|
339
359
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
return
|
|
360
|
+
const USAGE_STATS_CACHE_TTL = 5 * 60_000;
|
|
361
|
+
let usageStatsCache = null;
|
|
362
|
+
let usageStatsCacheAt = 0;
|
|
363
|
+
let usageStatsInFlight = null;
|
|
364
|
+
|
|
365
|
+
function getCachedUsageStats() {
|
|
366
|
+
return usageStatsCache && Date.now() - usageStatsCacheAt < USAGE_STATS_CACHE_TTL
|
|
367
|
+
? usageStatsCache
|
|
368
|
+
: null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function fetchUsageStats() {
|
|
372
|
+
const cached = getCachedUsageStats();
|
|
373
|
+
if (cached) return Promise.resolve(cached);
|
|
374
|
+
if (usageStatsInFlight) return usageStatsInFlight;
|
|
375
|
+
usageStatsInFlight = fetch("/balance-api?stats=1")
|
|
376
|
+
.then(async (res) => {
|
|
377
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
378
|
+
const data = await res.json();
|
|
379
|
+
if (!data || !data.ok) throw new Error((data && data.error) || "统计加载失败");
|
|
380
|
+
if (!data.stats) throw new Error(data.statsError || "暂无可用的会话统计");
|
|
381
|
+
usageStatsCache = data;
|
|
382
|
+
usageStatsCacheAt = Date.now();
|
|
383
|
+
return data;
|
|
384
|
+
})
|
|
385
|
+
.finally(() => {
|
|
386
|
+
usageStatsInFlight = null;
|
|
387
|
+
});
|
|
388
|
+
return usageStatsInFlight;
|
|
347
389
|
}
|
|
348
390
|
|
|
349
|
-
function StatsCard({ label, value }) {
|
|
391
|
+
const StatsCard = react.memo(function StatsCard({ label, value }) {
|
|
350
392
|
return jsx.jsxs("div", {
|
|
351
393
|
className: "balance-stats-card",
|
|
352
394
|
children: [
|
|
@@ -354,20 +396,27 @@ window.__ModuleLoader__.load({
|
|
|
354
396
|
jsx.jsx("span", { className: "balance-stats-card__value", title: value, children: value }),
|
|
355
397
|
],
|
|
356
398
|
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function UsageStatsPanel({ onClose }) {
|
|
360
|
-
const
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
function UsageStatsPanel({ onClose, panelRef, style, closing }) {
|
|
402
|
+
const liveBalance = useBalance();
|
|
403
|
+
const [heatmapTooltip, setHeatmapTooltip] = react.useState(null);
|
|
404
|
+
const [state, setState] = react.useState(() => {
|
|
405
|
+
const cached = getCachedUsageStats();
|
|
406
|
+
return cached
|
|
407
|
+
? { loading: false, data: cached, error: "" }
|
|
408
|
+
: { loading: true, data: null, error: "" };
|
|
409
|
+
});
|
|
361
410
|
react.useEffect(() => {
|
|
362
|
-
|
|
363
|
-
fetchUsageStats(
|
|
364
|
-
(data) => setState({ loading: false, data, error: "" }),
|
|
411
|
+
let disposed = false;
|
|
412
|
+
fetchUsageStats().then(
|
|
413
|
+
(data) => { if (!disposed) setState({ loading: false, data, error: "" }); },
|
|
365
414
|
(error) => {
|
|
366
|
-
if (
|
|
415
|
+
if (disposed) return;
|
|
367
416
|
setState({ loading: false, data: null, error: error && error.message ? error.message : String(error) });
|
|
368
417
|
},
|
|
369
418
|
);
|
|
370
|
-
return () =>
|
|
419
|
+
return () => { disposed = true; };
|
|
371
420
|
}, []);
|
|
372
421
|
react.useEffect(() => {
|
|
373
422
|
const onKeyDown = (event) => { if (event.key === "Escape") onClose(); };
|
|
@@ -378,25 +427,51 @@ window.__ModuleLoader__.load({
|
|
|
378
427
|
const data = state.data;
|
|
379
428
|
const stats = data && data.stats;
|
|
380
429
|
const days = stats && Array.isArray(stats.days) ? stats.days : [];
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
430
|
+
const showHeatmapTooltip = react.useCallback((event, label) => {
|
|
431
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
432
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
433
|
+
const center = rect.left + rect.width / 2;
|
|
434
|
+
const top = Math.max(12, rect.top - 7);
|
|
435
|
+
if (center < 162) {
|
|
436
|
+
setHeatmapTooltip({ label, align: "left", style: { left: Math.max(12, rect.left), top } });
|
|
437
|
+
} else if (center > viewportWidth - 162) {
|
|
438
|
+
setHeatmapTooltip({ label, align: "right", style: { right: Math.max(12, viewportWidth - rect.right), top } });
|
|
439
|
+
} else {
|
|
440
|
+
setHeatmapTooltip({ label, align: "center", style: { left: center, top } });
|
|
441
|
+
}
|
|
442
|
+
}, []);
|
|
443
|
+
const hideHeatmapTooltip = react.useCallback(() => setHeatmapTooltip(null), []);
|
|
444
|
+
const heatmap = react.useMemo(() => {
|
|
445
|
+
const cells = [];
|
|
446
|
+
const maxTokens = Math.max(0, ...days.map((day) => Number(day.totalTokens || 0)));
|
|
447
|
+
const firstDay = days[0] && days[0].date;
|
|
448
|
+
const leading = firstDay ? (new Date(firstDay + "T00:00:00Z").getUTCDay() + 6) % 7 : 0;
|
|
449
|
+
for (let i = 0; i < leading; i++) {
|
|
450
|
+
cells.push(jsx.jsx("span", { className: "balance-heatmap-cell balance-heatmap-cell--blank" }, "blank-" + i));
|
|
451
|
+
}
|
|
452
|
+
for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
|
|
453
|
+
const day = days[dayIndex];
|
|
454
|
+
const tokens = Number(day.totalTokens || 0);
|
|
455
|
+
const ratio = maxTokens > 0 ? tokens / maxTokens : 0;
|
|
456
|
+
const level = tokens === 0 ? 0 : ratio <= 0.25 ? 1 : ratio <= 0.5 ? 2 : ratio <= 0.75 ? 3 : 4;
|
|
457
|
+
const costText = "¥" + Number(day.cost || 0).toFixed(4);
|
|
458
|
+
const sessionsText = String(Number(day.sessions || 0));
|
|
459
|
+
const ariaLabel = day.date + ",消耗 " + formatTokens(tokens) + " Token,费用 " + costText + ",会话 " + sessionsText;
|
|
460
|
+
const tooltip = day.date + " · 消耗 " + formatTokens(tokens) + " Token · 费用 " + costText + " · 会话 " + sessionsText;
|
|
461
|
+
cells.push(jsx.jsx("button", {
|
|
462
|
+
type: "button",
|
|
463
|
+
tabIndex: -1,
|
|
464
|
+
className: "balance-heatmap-cell" + (level > 0 ? " balance-heatmap-cell--" + level : ""),
|
|
465
|
+
"aria-label": ariaLabel,
|
|
466
|
+
onMouseEnter: (event) => showHeatmapTooltip(event, tooltip),
|
|
467
|
+
onMouseLeave: hideHeatmapTooltip,
|
|
468
|
+
onFocus: (event) => showHeatmapTooltip(event, tooltip),
|
|
469
|
+
onBlur: hideHeatmapTooltip,
|
|
470
|
+
}, day.date));
|
|
471
|
+
}
|
|
472
|
+
return cells;
|
|
473
|
+
}, [days, hideHeatmapTooltip, showHeatmapTooltip]);
|
|
474
|
+
const balance = liveBalance.balance || (data && data.balance);
|
|
400
475
|
const total = stats && stats.total;
|
|
401
476
|
const balanceText = balance
|
|
402
477
|
? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
|
|
@@ -407,7 +482,7 @@ window.__ModuleLoader__.load({
|
|
|
407
482
|
? jsx.jsx("div", { className: "balance-stats-error", children: state.error })
|
|
408
483
|
: jsx.jsxs(jsx.Fragment, {
|
|
409
484
|
children: [
|
|
410
|
-
data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
|
|
485
|
+
!liveBalance.balance && data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
|
|
411
486
|
jsx.jsxs("div", {
|
|
412
487
|
className: "balance-stats-cards",
|
|
413
488
|
children: [
|
|
@@ -449,31 +524,33 @@ window.__ModuleLoader__.load({
|
|
|
449
524
|
}),
|
|
450
525
|
],
|
|
451
526
|
});
|
|
452
|
-
return jsx.
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
"
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
527
|
+
return jsx.jsxs("section", {
|
|
528
|
+
ref: panelRef,
|
|
529
|
+
style,
|
|
530
|
+
className: "balance-stats-panel" + (closing ? " balance-stats-panel--closing" : ""),
|
|
531
|
+
role: "dialog",
|
|
532
|
+
"aria-label": "余额与使用统计",
|
|
533
|
+
children: [
|
|
534
|
+
jsx.jsxs("header", {
|
|
535
|
+
className: "balance-stats-header",
|
|
536
|
+
children: [
|
|
537
|
+
jsx.jsxs("div", {
|
|
538
|
+
children: [
|
|
539
|
+
jsx.jsx("h2", { className: "balance-stats-title", children: "余额与使用统计" }),
|
|
540
|
+
jsx.jsx("p", { className: "balance-stats-subtitle", children: "余额快捷菜单 · 双击打开 DeepSeek API 用量页" }),
|
|
541
|
+
],
|
|
542
|
+
}),
|
|
543
|
+
jsx.jsx("button", { className: "balance-stats-close", type: "button", onClick: onClose, "aria-label": "关闭", children: "×" }),
|
|
544
|
+
],
|
|
545
|
+
}),
|
|
546
|
+
body,
|
|
547
|
+
heatmapTooltip ? reactDom.createPortal(jsx.jsx("div", {
|
|
548
|
+
className: "balance-heatmap-tooltip balance-heatmap-tooltip--" + heatmapTooltip.align,
|
|
549
|
+
style: heatmapTooltip.style,
|
|
550
|
+
role: "tooltip",
|
|
551
|
+
children: heatmapTooltip.label,
|
|
552
|
+
}), document.body) : null,
|
|
553
|
+
],
|
|
477
554
|
});
|
|
478
555
|
}
|
|
479
556
|
|
|
@@ -501,15 +578,61 @@ window.__ModuleLoader__.load({
|
|
|
501
578
|
function BalanceFooter({ wide }) {
|
|
502
579
|
const state = useBalance();
|
|
503
580
|
const [statsOpen, setStatsOpen] = react.useState(false);
|
|
581
|
+
const [statsClosing, setStatsClosing] = react.useState(false);
|
|
582
|
+
const [statsPosition, setStatsPosition] = react.useState({ left: 12, bottom: 56, width: 460 });
|
|
504
583
|
const clickTimer = react.useRef(null);
|
|
584
|
+
const closeTimer = react.useRef(null);
|
|
585
|
+
const anchorRef = react.useRef(null);
|
|
586
|
+
const panelRef = react.useRef(null);
|
|
505
587
|
react.useEffect(() => () => {
|
|
506
588
|
if (clickTimer.current !== null) clearTimeout(clickTimer.current);
|
|
589
|
+
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
|
507
590
|
}, []);
|
|
591
|
+
const closeStats = react.useCallback(() => {
|
|
592
|
+
if (!statsOpen || statsClosing) return;
|
|
593
|
+
setStatsClosing(true);
|
|
594
|
+
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
|
595
|
+
closeTimer.current = setTimeout(() => {
|
|
596
|
+
closeTimer.current = null;
|
|
597
|
+
setStatsOpen(false);
|
|
598
|
+
setStatsClosing(false);
|
|
599
|
+
}, 140);
|
|
600
|
+
}, [statsOpen, statsClosing]);
|
|
601
|
+
react.useEffect(() => {
|
|
602
|
+
if (!statsOpen) return undefined;
|
|
603
|
+
const closeOnOutsideClick = (event) => {
|
|
604
|
+
if (anchorRef.current && anchorRef.current.contains(event.target)) return;
|
|
605
|
+
if (panelRef.current && panelRef.current.contains(event.target)) return;
|
|
606
|
+
closeStats();
|
|
607
|
+
};
|
|
608
|
+
const closeOnResize = () => closeStats();
|
|
609
|
+
document.addEventListener("mousedown", closeOnOutsideClick);
|
|
610
|
+
window.addEventListener("resize", closeOnResize);
|
|
611
|
+
return () => {
|
|
612
|
+
document.removeEventListener("mousedown", closeOnOutsideClick);
|
|
613
|
+
window.removeEventListener("resize", closeOnResize);
|
|
614
|
+
};
|
|
615
|
+
}, [statsOpen, closeStats]);
|
|
508
616
|
const openStats = (event) => {
|
|
509
617
|
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
510
618
|
if (clickTimer.current !== null) clearTimeout(clickTimer.current);
|
|
619
|
+
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
|
620
|
+
closeTimer.current = null;
|
|
621
|
+
setStatsClosing(false);
|
|
622
|
+
const trigger = event && event.currentTarget;
|
|
623
|
+
if (trigger && typeof trigger.getBoundingClientRect === "function") {
|
|
624
|
+
const rect = trigger.getBoundingClientRect();
|
|
625
|
+
const width = Math.max(1, Math.min(460, window.innerWidth - 24));
|
|
626
|
+
const desiredLeft = wide ? rect.right + 8 : rect.left;
|
|
627
|
+
const left = Math.max(12, Math.min(desiredLeft, window.innerWidth - width - 12));
|
|
628
|
+
const bottom = wide
|
|
629
|
+
? Math.max(12, window.innerHeight - rect.bottom)
|
|
630
|
+
: Math.max(12, window.innerHeight - rect.top + 8);
|
|
631
|
+
setStatsPosition({ left, bottom, width });
|
|
632
|
+
}
|
|
511
633
|
clickTimer.current = setTimeout(() => {
|
|
512
634
|
clickTimer.current = null;
|
|
635
|
+
setStatsClosing(false);
|
|
513
636
|
setStatsOpen(true);
|
|
514
637
|
}, 230);
|
|
515
638
|
};
|
|
@@ -517,6 +640,9 @@ window.__ModuleLoader__.load({
|
|
|
517
640
|
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
518
641
|
if (clickTimer.current !== null) clearTimeout(clickTimer.current);
|
|
519
642
|
clickTimer.current = null;
|
|
643
|
+
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
|
644
|
+
closeTimer.current = null;
|
|
645
|
+
setStatsClosing(false);
|
|
520
646
|
setStatsOpen(false);
|
|
521
647
|
window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
|
|
522
648
|
};
|
|
@@ -526,8 +652,18 @@ window.__ModuleLoader__.load({
|
|
|
526
652
|
onContextMenu: onBalanceContextMenu,
|
|
527
653
|
title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
|
|
528
654
|
};
|
|
529
|
-
const withPanel = (node) => jsx.jsxs(
|
|
530
|
-
|
|
655
|
+
const withPanel = (node) => jsx.jsxs("div", {
|
|
656
|
+
ref: anchorRef,
|
|
657
|
+
className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
|
|
658
|
+
children: [
|
|
659
|
+
node,
|
|
660
|
+
statsOpen ? reactDom.createPortal(jsx.jsx(UsageStatsPanel, {
|
|
661
|
+
onClose: closeStats,
|
|
662
|
+
panelRef,
|
|
663
|
+
style: statsPosition,
|
|
664
|
+
closing: statsClosing,
|
|
665
|
+
}), document.body) : null,
|
|
666
|
+
],
|
|
531
667
|
});
|
|
532
668
|
|
|
533
669
|
if (!wide) {
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,55 @@
|
|
|
1
1
|
// dsh 插件:余额与当前会话消耗
|
|
2
2
|
// 服务端:注册 /balance 命令 + /balance-api HTTP 接口(供 Web UI 插件直接渲染)
|
|
3
|
-
import { inspectApiRemoteSession } from "@deepseek-ai/dsh-api-remotes";
|
|
4
|
-
|
|
5
3
|
export const name = "balance-dsh";
|
|
6
4
|
|
|
5
|
+
let legacySessionInspector;
|
|
6
|
+
let legacySessionInspectorLoaded = false;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 跨 DSH 版本读取会话,且不在模块加载阶段绑定可能被删除的命名导出。
|
|
10
|
+
* 新版优先使用 sessionController;其次直接使用 sessionQuery;旧版最后回退
|
|
11
|
+
* @deepseek-ai/dsh-api-remotes.inspectApiRemoteSession。接口全部不可用时抛错,
|
|
12
|
+
* 由子代理统计调用方跳过该会话,不影响余额插件本身启动。
|
|
13
|
+
*/
|
|
14
|
+
async function inspectPersistedSession(ctx, sessionId) {
|
|
15
|
+
const getService = typeof ctx?.get === "function" ? ctx.get.bind(ctx) : null;
|
|
16
|
+
|
|
17
|
+
const sessionController = getService?.("sessionController");
|
|
18
|
+
if (typeof sessionController?.inspect === "function") {
|
|
19
|
+
return sessionController.inspect(sessionId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sessionQuery = getService?.("sessionQuery");
|
|
23
|
+
if (typeof sessionQuery?.observeSession === "function") {
|
|
24
|
+
const observation = await sessionQuery.observeSession(sessionId, { projectionMode: "none" });
|
|
25
|
+
try {
|
|
26
|
+
return {
|
|
27
|
+
meta: observation.header,
|
|
28
|
+
events: [...(observation.events ?? [])],
|
|
29
|
+
};
|
|
30
|
+
} finally {
|
|
31
|
+
observation?.[Symbol.dispose]?.();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!legacySessionInspectorLoaded) {
|
|
36
|
+
legacySessionInspectorLoaded = true;
|
|
37
|
+
try {
|
|
38
|
+
const remotes = await import("@deepseek-ai/dsh-api-remotes");
|
|
39
|
+
if (typeof remotes.inspectApiRemoteSession === "function") {
|
|
40
|
+
legacySessionInspector = remotes.inspectApiRemoteSession;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// 新版可能不再提供旧模块或旧导出;由下方统一处理。
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (typeof legacySessionInspector === "function") {
|
|
47
|
+
return legacySessionInspector(ctx, sessionId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error("当前 DSH 未提供兼容的持久化会话读取接口");
|
|
51
|
+
}
|
|
52
|
+
|
|
7
53
|
const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
8
54
|
|
|
9
55
|
// DeepSeek 目录价(人民币/百万 tokens),按模型区分。默认取 2026-08-17 生效的
|
|
@@ -429,12 +475,12 @@ async function buildUsageStats(ctx, config) {
|
|
|
429
475
|
}
|
|
430
476
|
|
|
431
477
|
/** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
|
|
432
|
-
* @param ctx DSH 上下文(用于 subagents 服务 +
|
|
478
|
+
* @param ctx DSH 上下文(用于 subagents 服务 + 跨版本持久化会话读取)
|
|
433
479
|
* @param sessionId 父会话 id
|
|
434
480
|
* @param config 插件配置
|
|
435
481
|
* 说明:子代理会话结束后会被从 live 会话 store 移除,故不能用 store.list() 枚举;
|
|
436
482
|
* 这里用 subagents 服务的 listDescendants(含已结束/嵌套子代理)取后代 id,
|
|
437
|
-
*
|
|
483
|
+
* 再对每个子代理用兼容读取器取得持久化事件并计算 usage。无子代理返回 {usage:null,debug}。 */
|
|
438
484
|
async function sumSubagentUsage(ctx, sessionId, config) {
|
|
439
485
|
if (ctx == null || sessionId === "") return { usage: null };
|
|
440
486
|
// 用 ctx.get("subagents") 安全获取(非注入,可空);不要直接读 ctx.subagents 属性,
|
|
@@ -466,7 +512,7 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
466
512
|
let count = 0;
|
|
467
513
|
for (const id of childIds) {
|
|
468
514
|
try {
|
|
469
|
-
const inspected = await
|
|
515
|
+
const inspected = await inspectPersistedSession(ctx, id);
|
|
470
516
|
const events = Array.isArray(inspected?.events) ? inspected.events : [];
|
|
471
517
|
const u = sumEventsUsage(events, config);
|
|
472
518
|
if (u === null || u === void 0) continue;
|
|
@@ -548,6 +594,11 @@ function writeJson(res, status, payload) {
|
|
|
548
594
|
|
|
549
595
|
export function apply(ctx, config = {}) {
|
|
550
596
|
let statsCache = { expiresAt: 0, value: null };
|
|
597
|
+
// DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
|
|
598
|
+
// 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
|
|
599
|
+
// 余额不显示。这里收集每次 register 返回的 disposer,并在插件卸载(ctx.on("dispose"))
|
|
600
|
+
// 时统一清理,保证重复 apply 不冲突。
|
|
601
|
+
const disposers = [];
|
|
551
602
|
|
|
552
603
|
// 1) /balance 命令:保留命令行查询入口
|
|
553
604
|
ctx.inject(["commands"], (commandCtx) => {
|
|
@@ -591,7 +642,7 @@ export function apply(ctx, config = {}) {
|
|
|
591
642
|
// /balance-api 不注册(404)。改为只声 inject ["webServer"],子代理在
|
|
592
643
|
// sumSubagentUsage 内部用可空 ctx.get("subagents") 获取。
|
|
593
644
|
ctx.inject(["webServer"], (httpCtx) => {
|
|
594
|
-
httpCtx.webServer.register({
|
|
645
|
+
const balanceRouteDisposer = httpCtx.webServer.register({
|
|
595
646
|
kind: "exact",
|
|
596
647
|
path: "/balance-api",
|
|
597
648
|
handler: async (req, res) => {
|
|
@@ -705,6 +756,15 @@ export function apply(ctx, config = {}) {
|
|
|
705
756
|
}
|
|
706
757
|
},
|
|
707
758
|
});
|
|
708
|
-
|
|
759
|
+
if (typeof balanceRouteDisposer === "function") disposers.push(balanceRouteDisposer);
|
|
709
760
|
});
|
|
761
|
+
|
|
762
|
+
// 插件卸载/dispose 时清理本实例注册的路由,避免 DSH 热重载时旧实例残留
|
|
763
|
+
// 的路由导致新实例 register /balance-api 抛 duplicate route、插件启动失败。
|
|
764
|
+
const release = () => {
|
|
765
|
+
for (const d of disposers) {
|
|
766
|
+
try { d(); } catch { /* ignore */ }
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
if (typeof ctx.on === "function") ctx.on("dispose", release);
|
|
710
770
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "balance-dsh",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
|
|
5
5
|
"author": "Rannist",
|
|
6
6
|
"type": "module",
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"patch": "./cordis.patch.yml"
|
|
27
27
|
},
|
|
28
28
|
"client": {
|
|
29
|
-
"platform": "web",
|
|
30
29
|
"inject": [
|
|
31
30
|
"@deepseek-ai/dsh-client-runtime"
|
|
32
31
|
],
|
|
32
|
+
"platform": "web",
|
|
33
33
|
"immediately": true
|
|
34
34
|
}
|
|
35
35
|
},
|