balance-dsh 1.0.2 → 1.0.8
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 +9 -1
- package/lib/client.js +389 -99
- package/lib/index.js +190 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,13 +4,21 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
|
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
7
|
-
- **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"
|
|
7
|
+
- **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。
|
|
8
8
|
- **收起态 FAB**:侧边栏收起时,余额显示为左下角悬浮胶囊。
|
|
9
9
|
- **自动刷新**:每 60 秒自动刷新一次;每次对话完成(新回复落库)也会立即刷新;右键余额任意区域可手动刷新。
|
|
10
|
+
- **余额与使用统计**:单击余额在余额项旁以轻量动画打开局部快捷菜单,展示账户余额、近 90 天计费 Token、估算费用、扫描会话数和按日使用热力图;点击菜单外区域会播放关闭动画,双击余额打开 DeepSeek 官方 API 用量页。
|
|
10
11
|
- **高峰/空闲计费**:官方高峰窗口为**周一至周五 09:00-12:00、14:00-18:00(北京时间)**,周末全天空闲。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
|
|
11
12
|
- **会话消耗行**:对话框下方实时显示当前会话消耗(新增 tokens + 估算金额),悬停查看完整明细(模型、时段拆分、计费口径)。
|
|
12
13
|
- **`/balance` 命令**:命令行查询余额明细和当前会话消耗。
|
|
13
14
|
|
|
15
|
+
### 使用统计口径
|
|
16
|
+
|
|
17
|
+
- 热力图按北京时间聚合最近 90 天持久化会话中的 `assistant/message` Token usage。
|
|
18
|
+
- 费用按照每条消息的实际模型和峰谷时段估算,最终金额以 DeepSeek 官方账单为准。
|
|
19
|
+
- 为避免历史会话过多拖慢 DSH,最多扫描最近 200 个会话;服务端统计和客户端展示结果均缓存 5 分钟,重复打开菜单不会重复请求。
|
|
20
|
+
- 若未配置 API Key,仍可查看本地 Token 与费用统计,但账户余额会显示为不可用。
|
|
21
|
+
|
|
14
22
|
## 安装
|
|
15
23
|
|
|
16
24
|
前提:已安装 DSH Desktop,且知道自己的 profile 目录(通常是 `C:\Users\<你的用户名>\.dsh\profiles\desktop\`)。
|
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,7 +42,50 @@ 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
|
+
/* ── 跨会话统计浮层(余额元素的子级,向右/向上展开) ── */
|
|
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}
|
|
56
|
+
.balance-stats-subtitle{margin:4px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
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}
|
|
58
|
+
.balance-stats-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
|
|
59
|
+
.balance-stats-error{padding:12px;border-radius:10px;color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.10)}
|
|
60
|
+
.balance-stats-loading{padding:48px 12px;text-align:center;color:var(--dsw-alias-label-secondary)}
|
|
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)}}
|
|
65
|
+
.balance-stats-card__label{display:block;margin-bottom:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
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}
|
|
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}
|
|
68
|
+
.balance-stats-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}
|
|
69
|
+
.balance-heatmap-wrap{min-width:0;overflow:hidden;padding:4px 2px 8px}
|
|
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%}
|
|
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}}
|
|
79
|
+
.balance-heatmap-cell--1{background:rgba(34,197,94,.25)}
|
|
80
|
+
.balance-heatmap-cell--2{background:rgba(34,197,94,.45)}
|
|
81
|
+
.balance-heatmap-cell--3{background:rgba(34,197,94,.68)}
|
|
82
|
+
.balance-heatmap-cell--4{background:rgba(22,163,74,.95)}
|
|
83
|
+
.balance-heatmap-cell--blank{visibility:hidden}
|
|
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}
|
|
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))}
|
|
86
|
+
.balance-stats-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
|
|
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}}
|
|
45
89
|
/* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
|
|
46
90
|
全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
|
|
47
91
|
- wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
|
|
@@ -55,12 +99,15 @@ window.__ModuleLoader__.load({
|
|
|
55
99
|
`;
|
|
56
100
|
|
|
57
101
|
const tagId = "balance/balance-widget.css";
|
|
58
|
-
if (typeof document !== "undefined"
|
|
59
|
-
|
|
60
|
-
tag
|
|
61
|
-
|
|
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
|
+
}
|
|
62
110
|
tag.textContent = css;
|
|
63
|
-
document.head.appendChild(tag);
|
|
64
111
|
}
|
|
65
112
|
|
|
66
113
|
// ─── 价格(与服务端默认保持一致;服务端 /balance-api 返回后会覆盖) ───
|
|
@@ -310,13 +357,316 @@ window.__ModuleLoader__.load({
|
|
|
310
357
|
fetchBalanceOnce();
|
|
311
358
|
}
|
|
312
359
|
|
|
313
|
-
|
|
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;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const StatsCard = react.memo(function StatsCard({ label, value }) {
|
|
392
|
+
return jsx.jsxs("div", {
|
|
393
|
+
className: "balance-stats-card",
|
|
394
|
+
children: [
|
|
395
|
+
jsx.jsx("span", { className: "balance-stats-card__label", children: label }),
|
|
396
|
+
jsx.jsx("span", { className: "balance-stats-card__value", title: value, children: value }),
|
|
397
|
+
],
|
|
398
|
+
});
|
|
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
|
+
});
|
|
410
|
+
react.useEffect(() => {
|
|
411
|
+
let disposed = false;
|
|
412
|
+
fetchUsageStats().then(
|
|
413
|
+
(data) => { if (!disposed) setState({ loading: false, data, error: "" }); },
|
|
414
|
+
(error) => {
|
|
415
|
+
if (disposed) return;
|
|
416
|
+
setState({ loading: false, data: null, error: error && error.message ? error.message : String(error) });
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
return () => { disposed = true; };
|
|
420
|
+
}, []);
|
|
421
|
+
react.useEffect(() => {
|
|
422
|
+
const onKeyDown = (event) => { if (event.key === "Escape") onClose(); };
|
|
423
|
+
window.addEventListener("keydown", onKeyDown);
|
|
424
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
425
|
+
}, [onClose]);
|
|
426
|
+
|
|
427
|
+
const data = state.data;
|
|
428
|
+
const stats = data && data.stats;
|
|
429
|
+
const days = stats && Array.isArray(stats.days) ? stats.days : [];
|
|
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);
|
|
475
|
+
const total = stats && stats.total;
|
|
476
|
+
const balanceText = balance
|
|
477
|
+
? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
|
|
478
|
+
: "不可用";
|
|
479
|
+
const body = state.loading
|
|
480
|
+
? jsx.jsx("div", { className: "balance-stats-loading", children: "正在汇总会话记录…" })
|
|
481
|
+
: state.error
|
|
482
|
+
? jsx.jsx("div", { className: "balance-stats-error", children: state.error })
|
|
483
|
+
: jsx.jsxs(jsx.Fragment, {
|
|
484
|
+
children: [
|
|
485
|
+
!liveBalance.balance && data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
|
|
486
|
+
jsx.jsxs("div", {
|
|
487
|
+
className: "balance-stats-cards",
|
|
488
|
+
children: [
|
|
489
|
+
jsx.jsx(StatsCard, { label: "账户余额", value: balanceText }),
|
|
490
|
+
jsx.jsx(StatsCard, { label: "90 天计费 Token", value: formatTokens(Number(total?.totalTokens || 0)) }),
|
|
491
|
+
jsx.jsx(StatsCard, { label: "90 天估算费用", value: "¥" + Number(total?.cost || 0).toFixed(4) }),
|
|
492
|
+
jsx.jsx(StatsCard, { label: "已扫描会话", value: String(stats?.scannedSessions || 0) }),
|
|
493
|
+
],
|
|
494
|
+
}),
|
|
495
|
+
jsx.jsxs("div", {
|
|
496
|
+
className: "balance-stats-section-title",
|
|
497
|
+
children: [
|
|
498
|
+
jsx.jsx("span", { children: "近 90 天使用热力图" }),
|
|
499
|
+
jsx.jsxs("span", {
|
|
500
|
+
className: "balance-stats-legend",
|
|
501
|
+
children: ["少", 0, 1, 2, 3, 4].map((item, index) => index === 0
|
|
502
|
+
? item
|
|
503
|
+
: jsx.jsx("i", { className: "balance-heatmap-cell balance-heatmap-cell--" + item }, "legend-" + item)).concat(["多"]),
|
|
504
|
+
}),
|
|
505
|
+
],
|
|
506
|
+
}),
|
|
507
|
+
jsx.jsx("div", { className: "balance-heatmap-wrap", children: jsx.jsx("div", { className: "balance-heatmap", children: heatmap }) }),
|
|
508
|
+
jsx.jsxs("div", {
|
|
509
|
+
className: "balance-stats-footer",
|
|
510
|
+
children: [
|
|
511
|
+
jsx.jsx("span", {
|
|
512
|
+
children: stats?.limited
|
|
513
|
+
? "为保证性能,仅统计最近 200 个会话;结果缓存 5 分钟。"
|
|
514
|
+
: "按北京时间和实际模型峰谷价格估算;结果缓存 5 分钟。",
|
|
515
|
+
}),
|
|
516
|
+
jsx.jsx("a", {
|
|
517
|
+
className: "balance-stats-link",
|
|
518
|
+
href: "https://platform.deepseek.com/usage",
|
|
519
|
+
target: "_blank",
|
|
520
|
+
rel: "noreferrer",
|
|
521
|
+
children: "查看官方用量",
|
|
522
|
+
}),
|
|
523
|
+
],
|
|
524
|
+
}),
|
|
525
|
+
],
|
|
526
|
+
});
|
|
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
|
+
],
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function WalletIcon() {
|
|
558
|
+
return jsx.jsxs("svg", {
|
|
559
|
+
width: 16,
|
|
560
|
+
height: 16,
|
|
561
|
+
viewBox: "0 0 24 24",
|
|
562
|
+
fill: "none",
|
|
563
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
564
|
+
stroke: "currentColor",
|
|
565
|
+
strokeWidth: 2,
|
|
566
|
+
strokeLinecap: "round",
|
|
567
|
+
strokeLinejoin: "round",
|
|
568
|
+
"aria-hidden": "true",
|
|
569
|
+
children: [
|
|
570
|
+
jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
|
|
571
|
+
jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
|
|
572
|
+
jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
|
|
573
|
+
],
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─── 余额显示:单击统计,双击 DeepSeek 用量页,右键刷新 ─────────────
|
|
314
578
|
function BalanceFooter({ wide }) {
|
|
315
579
|
const state = useBalance();
|
|
316
|
-
const
|
|
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 });
|
|
583
|
+
const clickTimer = react.useRef(null);
|
|
584
|
+
const closeTimer = react.useRef(null);
|
|
585
|
+
const anchorRef = react.useRef(null);
|
|
586
|
+
const panelRef = react.useRef(null);
|
|
587
|
+
react.useEffect(() => () => {
|
|
588
|
+
if (clickTimer.current !== null) clearTimeout(clickTimer.current);
|
|
589
|
+
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
|
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]);
|
|
616
|
+
const openStats = (event) => {
|
|
617
|
+
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
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
|
+
}
|
|
633
|
+
clickTimer.current = setTimeout(() => {
|
|
634
|
+
clickTimer.current = null;
|
|
635
|
+
setStatsClosing(false);
|
|
636
|
+
setStatsOpen(true);
|
|
637
|
+
}, 230);
|
|
638
|
+
};
|
|
639
|
+
const openUsagePage = (event) => {
|
|
640
|
+
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
641
|
+
if (clickTimer.current !== null) clearTimeout(clickTimer.current);
|
|
642
|
+
clickTimer.current = null;
|
|
643
|
+
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
|
644
|
+
closeTimer.current = null;
|
|
645
|
+
setStatsClosing(false);
|
|
646
|
+
setStatsOpen(false);
|
|
647
|
+
window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
|
|
648
|
+
};
|
|
649
|
+
const interactions = {
|
|
650
|
+
onClick: openStats,
|
|
651
|
+
onDoubleClick: openUsagePage,
|
|
652
|
+
onContextMenu: onBalanceContextMenu,
|
|
653
|
+
title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
|
|
654
|
+
};
|
|
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
|
+
],
|
|
667
|
+
});
|
|
317
668
|
|
|
318
669
|
if (!wide) {
|
|
319
|
-
// 收起态:悬浮 FAB(原样保留)
|
|
320
670
|
if (state.status === "error" || !state.balance) return null;
|
|
321
671
|
const b = state.balance;
|
|
322
672
|
const total = Number.parseFloat(b.total_balance ?? "0");
|
|
@@ -331,34 +681,23 @@ window.__ModuleLoader__.load({
|
|
|
331
681
|
children: "去充值",
|
|
332
682
|
});
|
|
333
683
|
}
|
|
334
|
-
|
|
335
|
-
const detailText = "总余额 " + b.total_balance + " " + currency + " · 充值余额 " + b.topped_up_balance + " " + currency;
|
|
336
|
-
return jsx.jsx("a", {
|
|
684
|
+
return withPanel(jsx.jsx("a", {
|
|
337
685
|
className: "balance-fab",
|
|
338
686
|
href: "https://platform.deepseek.com/usage",
|
|
339
|
-
|
|
340
|
-
rel: "noreferrer",
|
|
341
|
-
onContextMenu: onBalanceContextMenu,
|
|
687
|
+
...interactions,
|
|
342
688
|
children: "¥" + b.total_balance,
|
|
343
|
-
});
|
|
689
|
+
}));
|
|
344
690
|
}
|
|
345
691
|
|
|
346
|
-
// 展开态:底部文字(加载中/失败/去充值/正常)
|
|
347
692
|
if (state.status === "error") {
|
|
348
|
-
return jsx.jsx("div", {
|
|
693
|
+
return withPanel(jsx.jsx("div", {
|
|
349
694
|
className: "balance-footer",
|
|
350
|
-
|
|
351
|
-
onContextMenu: onBalanceContextMenu,
|
|
695
|
+
...interactions,
|
|
352
696
|
children: jsx.jsx("span", { className: "balance-footer__error", children: "余额查询失败" }),
|
|
353
|
-
});
|
|
697
|
+
}));
|
|
354
698
|
}
|
|
355
|
-
|
|
356
699
|
if (!state.balance) {
|
|
357
|
-
return jsx.jsx("div", {
|
|
358
|
-
className: "balance-footer",
|
|
359
|
-
onContextMenu: onBalanceContextMenu,
|
|
360
|
-
children: "余额加载中…",
|
|
361
|
-
});
|
|
700
|
+
return withPanel(jsx.jsx("div", { className: "balance-footer", ...interactions, children: "余额加载中…" }));
|
|
362
701
|
}
|
|
363
702
|
|
|
364
703
|
const b = state.balance;
|
|
@@ -366,96 +705,47 @@ window.__ModuleLoader__.load({
|
|
|
366
705
|
const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
|
|
367
706
|
const currency = b.currency || "CNY";
|
|
368
707
|
const totalText = b.total_balance + " " + currency;
|
|
369
|
-
const detailText = "总余额 " + totalText + " · 充值余额 " + b.topped_up_balance + " " + currency;
|
|
370
|
-
const period = currentPeriod();
|
|
371
|
-
const low = total > 0 && total < 10;
|
|
372
|
-
|
|
373
|
-
// 金额胶囊:正常显示 金额•时段(语义色);没余额显示 0(红色),并追加"去充值"胶囊
|
|
374
|
-
// 没余额:保留图标+"余额"字样,去掉具体金额,右侧只显示"去充值"按钮
|
|
375
708
|
if (depleted) {
|
|
376
|
-
return jsx.
|
|
709
|
+
return withPanel(jsx.jsxs("div", {
|
|
377
710
|
className: "balance-footer",
|
|
378
|
-
|
|
711
|
+
...interactions,
|
|
379
712
|
children: [
|
|
380
|
-
jsx.jsx("span", {
|
|
381
|
-
className: "balance-footer__icon",
|
|
382
|
-
children: jsx.jsxs("svg", {
|
|
383
|
-
width: 16,
|
|
384
|
-
height: 16,
|
|
385
|
-
viewBox: "0 0 24 24",
|
|
386
|
-
fill: "none",
|
|
387
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
388
|
-
stroke: "currentColor",
|
|
389
|
-
strokeWidth: 2,
|
|
390
|
-
strokeLinecap: "round",
|
|
391
|
-
strokeLinejoin: "round",
|
|
392
|
-
"aria-hidden": "true",
|
|
393
|
-
children: [
|
|
394
|
-
jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
|
|
395
|
-
jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
|
|
396
|
-
jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
|
|
397
|
-
],
|
|
398
|
-
}),
|
|
399
|
-
}),
|
|
713
|
+
jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
|
|
400
714
|
jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
|
|
401
715
|
jsx.jsx("a", {
|
|
402
716
|
className: "balance-footer__topup",
|
|
403
717
|
href: "https://platform.deepseek.com/top_up",
|
|
404
718
|
target: "_blank",
|
|
405
719
|
rel: "noreferrer",
|
|
720
|
+
onClick: (event) => event.stopPropagation(),
|
|
721
|
+
onDoubleClick: (event) => event.stopPropagation(),
|
|
406
722
|
onContextMenu: onBalanceContextMenu,
|
|
407
723
|
children: "去充值",
|
|
408
724
|
}),
|
|
409
725
|
],
|
|
410
|
-
});
|
|
726
|
+
}));
|
|
411
727
|
}
|
|
412
728
|
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
className: "balance-footer__icon",
|
|
424
|
-
children: jsx.jsxs("svg", {
|
|
425
|
-
width: 16,
|
|
426
|
-
height: 16,
|
|
427
|
-
viewBox: "0 0 24 24",
|
|
428
|
-
fill: "none",
|
|
429
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
430
|
-
stroke: "currentColor",
|
|
431
|
-
strokeWidth: 2,
|
|
432
|
-
strokeLinecap: "round",
|
|
433
|
-
strokeLinejoin: "round",
|
|
434
|
-
"aria-hidden": "true",
|
|
729
|
+
const period = currentPeriod();
|
|
730
|
+
const low = total > 0 && total < 10;
|
|
731
|
+
return withPanel(jsx.jsxs("div", {
|
|
732
|
+
className: "balance-footer",
|
|
733
|
+
...interactions,
|
|
734
|
+
children: [
|
|
735
|
+
jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
|
|
736
|
+
jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
|
|
737
|
+
jsx.jsxs("span", {
|
|
738
|
+
className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
|
|
435
739
|
children: [
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
740
|
+
totalText,
|
|
741
|
+
" • ",
|
|
742
|
+
state.refreshing
|
|
743
|
+
? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
|
|
744
|
+
: jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
|
|
439
745
|
],
|
|
440
746
|
}),
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
className: "balance-footer__label",
|
|
444
|
-
children: "余额",
|
|
445
|
-
}),
|
|
446
|
-
jsx.jsx("span", {
|
|
447
|
-
className: "balance-footer__value" + valueClass,
|
|
448
|
-
onClick: openUsagePage,
|
|
449
|
-
onContextMenu: onBalanceContextMenu,
|
|
450
|
-
title: "打开 DeepSeek 用量页",
|
|
451
|
-
children: valueChildren,
|
|
452
|
-
}),
|
|
453
|
-
];
|
|
454
|
-
return jsx.jsx("div", {
|
|
455
|
-
className: "balance-footer",
|
|
456
|
-
onContextMenu: onBalanceContextMenu,
|
|
457
|
-
children: inner,
|
|
458
|
-
});
|
|
747
|
+
],
|
|
748
|
+
}));
|
|
459
749
|
}
|
|
460
750
|
|
|
461
751
|
// ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
|
package/lib/index.js
CHANGED
|
@@ -284,6 +284,150 @@ function estimateCost(usage, pricing) {
|
|
|
284
284
|
return costOf(usage.peak) * 2 + costOf(usage.offpeak);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
const STATS_DAYS = 90;
|
|
288
|
+
const STATS_MAX_SESSIONS = 200;
|
|
289
|
+
const STATS_CACHE_MS = 5 * 60 * 1000;
|
|
290
|
+
|
|
291
|
+
function beijingDayKey(timeMs) {
|
|
292
|
+
return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function emptyTokenBucket() {
|
|
296
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function addTokenUsage(target, usage) {
|
|
300
|
+
target.inputTokens += usage.inputTokens ?? 0;
|
|
301
|
+
target.outputTokens += usage.outputTokens ?? 0;
|
|
302
|
+
target.cacheReadTokens += usage.cacheReadTokens ?? 0;
|
|
303
|
+
target.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function tokenTotal(usage) {
|
|
307
|
+
return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
|
|
311
|
+
function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
|
|
312
|
+
const switches = collectModelSwitches(events);
|
|
313
|
+
const fallback = fallbackModel || "deepseek-v4-flash";
|
|
314
|
+
const latestByStep = new Map();
|
|
315
|
+
for (const event of events ?? []) {
|
|
316
|
+
if (event.type !== "assistant/message") continue;
|
|
317
|
+
const usage = event.data?.usage;
|
|
318
|
+
const turn = event.data?.turn;
|
|
319
|
+
const step = event.data?.step;
|
|
320
|
+
if (usage == null || turn == null || step == null) continue;
|
|
321
|
+
latestByStep.set(`${turn}/${step}`, { usage, time: Number(event.time ?? 0) });
|
|
322
|
+
}
|
|
323
|
+
for (const { usage, time } of latestByStep.values()) {
|
|
324
|
+
if (!Number.isFinite(time) || time < cutoffMs) continue;
|
|
325
|
+
const key = beijingDayKey(time);
|
|
326
|
+
let day = days.get(key);
|
|
327
|
+
if (day == null) {
|
|
328
|
+
day = { date: key, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessionIds: new Set() };
|
|
329
|
+
days.set(key, day);
|
|
330
|
+
}
|
|
331
|
+
addTokenUsage(day, usage);
|
|
332
|
+
day.totalTokens += tokenTotal({
|
|
333
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
334
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
335
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
336
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
337
|
+
});
|
|
338
|
+
day.sessionIds.add(sessionId);
|
|
339
|
+
const model = modelAtTime(switches, time) || fallback;
|
|
340
|
+
const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
|
|
341
|
+
addTokenUsage(isPeakAt(time, config) ? bucket.peak : bucket.offpeak, usage);
|
|
342
|
+
day.cost += estimateCost(bucket, resolvePricing(config, model));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function recentDayKeys(days, now = Date.now()) {
|
|
347
|
+
const result = [];
|
|
348
|
+
const beijingNow = now + 8 * 60 * 60 * 1000;
|
|
349
|
+
const current = new Date(beijingNow);
|
|
350
|
+
current.setUTCHours(0, 0, 0, 0);
|
|
351
|
+
for (let offset = days - 1; offset >= 0; offset--) {
|
|
352
|
+
result.push(new Date(current.getTime() - offset * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function buildUsageStats(ctx, config) {
|
|
358
|
+
const persistence = typeof ctx.get === "function" ? ctx.get("sessionPersistence") : void 0;
|
|
359
|
+
if (persistence == null || typeof persistence.list !== "function" || typeof persistence.inspect !== "function") {
|
|
360
|
+
throw new Error("会话持久化服务不可用");
|
|
361
|
+
}
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
const cutoffMs = now - (STATS_DAYS - 1) * 24 * 60 * 60 * 1000;
|
|
364
|
+
const headers = await persistence.list();
|
|
365
|
+
const selected = [...headers]
|
|
366
|
+
.filter((header) => header && typeof header.id === "string")
|
|
367
|
+
.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))
|
|
368
|
+
.slice(0, STATS_MAX_SESSIONS);
|
|
369
|
+
const daily = new Map();
|
|
370
|
+
let cursor = 0;
|
|
371
|
+
let scannedSessions = 0;
|
|
372
|
+
let failedSessions = 0;
|
|
373
|
+
const worker = async () => {
|
|
374
|
+
while (cursor < selected.length) {
|
|
375
|
+
const header = selected[cursor++];
|
|
376
|
+
try {
|
|
377
|
+
const inspected = await persistence.inspect(header.id);
|
|
378
|
+
collectDailyUsage(
|
|
379
|
+
inspected?.events ?? [],
|
|
380
|
+
config,
|
|
381
|
+
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events: inspected?.events ?? [] }),
|
|
382
|
+
cutoffMs,
|
|
383
|
+
daily,
|
|
384
|
+
header.id,
|
|
385
|
+
);
|
|
386
|
+
scannedSessions++;
|
|
387
|
+
} catch {
|
|
388
|
+
failedSessions++;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
|
|
393
|
+
|
|
394
|
+
const rows = recentDayKeys(STATS_DAYS, now).map((date) => {
|
|
395
|
+
const day = daily.get(date);
|
|
396
|
+
if (day == null) {
|
|
397
|
+
return { date, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessions: 0 };
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
date,
|
|
401
|
+
inputTokens: day.inputTokens,
|
|
402
|
+
outputTokens: day.outputTokens,
|
|
403
|
+
cacheReadTokens: day.cacheReadTokens,
|
|
404
|
+
cacheWriteTokens: day.cacheWriteTokens,
|
|
405
|
+
totalTokens: day.totalTokens,
|
|
406
|
+
cost: day.cost,
|
|
407
|
+
sessions: day.sessionIds.size,
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
const total = rows.reduce((acc, day) => {
|
|
411
|
+
acc.inputTokens += day.inputTokens;
|
|
412
|
+
acc.outputTokens += day.outputTokens;
|
|
413
|
+
acc.cacheReadTokens += day.cacheReadTokens;
|
|
414
|
+
acc.cacheWriteTokens += day.cacheWriteTokens;
|
|
415
|
+
acc.totalTokens += day.totalTokens;
|
|
416
|
+
acc.cost += day.cost;
|
|
417
|
+
return acc;
|
|
418
|
+
}, { ...emptyTokenBucket(), totalTokens: 0, cost: 0 });
|
|
419
|
+
return {
|
|
420
|
+
days: rows,
|
|
421
|
+
total,
|
|
422
|
+
rangeDays: STATS_DAYS,
|
|
423
|
+
scannedSessions,
|
|
424
|
+
failedSessions,
|
|
425
|
+
availableSessions: headers.length,
|
|
426
|
+
limited: headers.length > selected.length,
|
|
427
|
+
generatedAt: now,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
287
431
|
/** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
|
|
288
432
|
* @param ctx DSH 上下文(用于 subagents 服务 + inspectApiRemoteSession)
|
|
289
433
|
* @param sessionId 父会话 id
|
|
@@ -403,6 +547,8 @@ function writeJson(res, status, payload) {
|
|
|
403
547
|
}
|
|
404
548
|
|
|
405
549
|
export function apply(ctx, config = {}) {
|
|
550
|
+
let statsCache = { expiresAt: 0, value: null };
|
|
551
|
+
|
|
406
552
|
// 1) /balance 命令:保留命令行查询入口
|
|
407
553
|
ctx.inject(["commands"], (commandCtx) => {
|
|
408
554
|
commandCtx.commands.register({
|
|
@@ -457,17 +603,46 @@ export function apply(ctx, config = {}) {
|
|
|
457
603
|
// 可选 ?session= 传入会话 id,服务端返回该会话的分时段 usage(跨时段精确计费用)
|
|
458
604
|
let model = "";
|
|
459
605
|
let sessionParam = "";
|
|
606
|
+
let wantStats = false;
|
|
460
607
|
try {
|
|
461
608
|
const search = new URL(req.url, "http://localhost").searchParams;
|
|
462
609
|
model = search.get("model") ?? "";
|
|
463
610
|
sessionParam = search.get("session") ?? "";
|
|
611
|
+
wantStats = search.get("stats") === "1";
|
|
464
612
|
} catch {
|
|
465
613
|
model = "";
|
|
466
614
|
sessionParam = "";
|
|
615
|
+
wantStats = false;
|
|
616
|
+
}
|
|
617
|
+
let stats = null;
|
|
618
|
+
let statsError = "";
|
|
619
|
+
if (wantStats) {
|
|
620
|
+
try {
|
|
621
|
+
if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
|
|
622
|
+
statsCache = {
|
|
623
|
+
value: await buildUsageStats(httpCtx, config),
|
|
624
|
+
expiresAt: Date.now() + STATS_CACHE_MS,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
stats = statsCache.value;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
statsError = error instanceof Error ? error.message : String(error);
|
|
630
|
+
}
|
|
467
631
|
}
|
|
468
632
|
const apiKey = await resolveApiKey(ctx, config);
|
|
469
633
|
if (apiKey === "") {
|
|
470
|
-
|
|
634
|
+
if (wantStats) {
|
|
635
|
+
writeJson(res, 200, {
|
|
636
|
+
ok: true,
|
|
637
|
+
balance: null,
|
|
638
|
+
balanceError: "未设置 DEEPSEEK_API_KEY",
|
|
639
|
+
pricing: resolvePricing(config, model),
|
|
640
|
+
stats,
|
|
641
|
+
statsError,
|
|
642
|
+
});
|
|
643
|
+
} else {
|
|
644
|
+
writeJson(res, 200, { ok: false, error: "未设置 DEEPSEEK_API_KEY" });
|
|
645
|
+
}
|
|
471
646
|
return;
|
|
472
647
|
}
|
|
473
648
|
// 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
|
|
@@ -510,10 +685,23 @@ export function apply(ctx, config = {}) {
|
|
|
510
685
|
model: effectiveModel,
|
|
511
686
|
usage,
|
|
512
687
|
subagentUsage,
|
|
688
|
+
stats,
|
|
689
|
+
statsError,
|
|
513
690
|
});
|
|
514
691
|
} catch (error) {
|
|
515
692
|
const message = error instanceof Error ? error.message : String(error);
|
|
516
|
-
|
|
693
|
+
if (wantStats) {
|
|
694
|
+
writeJson(res, 200, {
|
|
695
|
+
ok: true,
|
|
696
|
+
balance: null,
|
|
697
|
+
balanceError: message,
|
|
698
|
+
pricing: resolvePricing(config, effectiveModel),
|
|
699
|
+
stats,
|
|
700
|
+
statsError,
|
|
701
|
+
});
|
|
702
|
+
} else {
|
|
703
|
+
writeJson(res, 200, { ok: false, error: message });
|
|
704
|
+
}
|
|
517
705
|
}
|
|
518
706
|
},
|
|
519
707
|
});
|