balance-dsh 1.0.2 → 1.0.3

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 CHANGED
@@ -4,13 +4,21 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
4
4
 
5
5
  ## 功能
6
6
 
7
- - **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。悬停显示明细(总余额、充值余额),点击金额跳转 DeepSeek 用量页。
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
@@ -42,6 +42,33 @@ window.__ModuleLoader__.load({
42
42
  .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
43
  .balance-usage-line strong{color:var(--dsw-alias-label-primary);font-weight:600}
44
44
  /* ── 跨会话统计面板(单击余额弹出) ── */
45
+ .balance-stats-backdrop{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(15,23,42,.48);backdrop-filter:blur(3px);cursor:default}
46
+ .balance-stats-panel{box-sizing:border-box;width:min(820px,calc(100vw - 40px));max-height:min(720px,calc(100vh - 40px));overflow:auto;padding:22px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.28)}
47
+ .balance-stats-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:18px}
48
+ .balance-stats-title{margin:0;font-size:20px;line-height:28px;font-weight:700}
49
+ .balance-stats-subtitle{margin:4px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px}
50
+ .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
+ .balance-stats-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
52
+ .balance-stats-error{padding:12px;border-radius:10px;color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.10)}
53
+ .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(4,minmax(0,1fr));gap:10px;margin-bottom:20px}
55
+ .balance-stats-card{min-width:0;padding:14px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:12px;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04))}
56
+ .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:18px;font-weight:700;font-variant-numeric:tabular-nums;text-overflow:ellipsis;white-space:nowrap}
58
+ .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
+ .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{overflow-x:auto;padding:4px 2px 8px}
61
+ .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)}
63
+ .balance-heatmap-cell--1{background:rgba(34,197,94,.25)}
64
+ .balance-heatmap-cell--2{background:rgba(34,197,94,.45)}
65
+ .balance-heatmap-cell--3{background:rgba(34,197,94,.68)}
66
+ .balance-heatmap-cell--4{background:rgba(22,163,74,.95)}
67
+ .balance-heatmap-cell--blank{visibility:hidden}
68
+ .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
+ .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
+ .balance-stats-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
71
+ @media(max-width:680px){.balance-stats-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.balance-stats-panel{padding:16px}.balance-stats-backdrop{padding:10px}}
45
72
  /* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
46
73
  全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
47
74
  - wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
@@ -310,13 +337,200 @@ window.__ModuleLoader__.load({
310
337
  fetchBalanceOnce();
311
338
  }
312
339
 
313
- // ─── 余额显示:展开态侧栏底部文字(¥ 图标+金额);收起态悬浮 FAB ──
340
+ async function fetchUsageStats(signal) {
341
+ const res = await fetch("/balance-api?stats=1", { signal });
342
+ if (!res.ok) throw new Error("HTTP " + res.status);
343
+ const data = await res.json();
344
+ if (!data || !data.ok) throw new Error((data && data.error) || "统计加载失败");
345
+ if (!data.stats) throw new Error(data.statsError || "暂无可用的会话统计");
346
+ return data;
347
+ }
348
+
349
+ function StatsCard({ label, value }) {
350
+ return jsx.jsxs("div", {
351
+ className: "balance-stats-card",
352
+ children: [
353
+ jsx.jsx("span", { className: "balance-stats-card__label", children: label }),
354
+ jsx.jsx("span", { className: "balance-stats-card__value", title: value, children: value }),
355
+ ],
356
+ });
357
+ }
358
+
359
+ function UsageStatsPanel({ onClose }) {
360
+ const [state, setState] = react.useState({ loading: true, data: null, error: "" });
361
+ react.useEffect(() => {
362
+ const controller = new AbortController();
363
+ fetchUsageStats(controller.signal).then(
364
+ (data) => setState({ loading: false, data, error: "" }),
365
+ (error) => {
366
+ if (controller.signal.aborted) return;
367
+ setState({ loading: false, data: null, error: error && error.message ? error.message : String(error) });
368
+ },
369
+ );
370
+ return () => controller.abort();
371
+ }, []);
372
+ react.useEffect(() => {
373
+ const onKeyDown = (event) => { if (event.key === "Escape") onClose(); };
374
+ window.addEventListener("keydown", onKeyDown);
375
+ return () => window.removeEventListener("keydown", onKeyDown);
376
+ }, [onClose]);
377
+
378
+ const data = state.data;
379
+ const stats = data && data.stats;
380
+ const days = stats && Array.isArray(stats.days) ? stats.days : [];
381
+ const maxTokens = Math.max(0, ...days.map((day) => Number(day.totalTokens || 0)));
382
+ const firstDay = days[0] && days[0].date;
383
+ const leading = firstDay ? (new Date(firstDay + "T00:00:00Z").getUTCDay() + 6) % 7 : 0;
384
+ const heatmap = [];
385
+ for (let i = 0; i < leading; i++) {
386
+ heatmap.push(jsx.jsx("span", { className: "balance-heatmap-cell balance-heatmap-cell--blank" }, "blank-" + i));
387
+ }
388
+ for (const day of days) {
389
+ const tokens = Number(day.totalTokens || 0);
390
+ const ratio = maxTokens > 0 ? tokens / maxTokens : 0;
391
+ const level = tokens === 0 ? 0 : ratio <= 0.25 ? 1 : ratio <= 0.5 ? 2 : ratio <= 0.75 ? 3 : 4;
392
+ const title = day.date + "\nToken:" + formatTokens(tokens) + "\n费用:¥" + Number(day.cost || 0).toFixed(4) + "\n会话:" + Number(day.sessions || 0);
393
+ heatmap.push(jsx.jsx("span", {
394
+ className: "balance-heatmap-cell" + (level > 0 ? " balance-heatmap-cell--" + level : ""),
395
+ title,
396
+ "aria-label": title,
397
+ }, day.date));
398
+ }
399
+ const balance = data && data.balance;
400
+ const total = stats && stats.total;
401
+ const balanceText = balance
402
+ ? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
403
+ : "不可用";
404
+ const body = state.loading
405
+ ? jsx.jsx("div", { className: "balance-stats-loading", children: "正在汇总会话记录…" })
406
+ : state.error
407
+ ? jsx.jsx("div", { className: "balance-stats-error", children: state.error })
408
+ : jsx.jsxs(jsx.Fragment, {
409
+ children: [
410
+ data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
411
+ jsx.jsxs("div", {
412
+ className: "balance-stats-cards",
413
+ children: [
414
+ jsx.jsx(StatsCard, { label: "账户余额", value: balanceText }),
415
+ jsx.jsx(StatsCard, { label: "90 天计费 Token", value: formatTokens(Number(total?.totalTokens || 0)) }),
416
+ jsx.jsx(StatsCard, { label: "90 天估算费用", value: "¥" + Number(total?.cost || 0).toFixed(4) }),
417
+ jsx.jsx(StatsCard, { label: "已扫描会话", value: String(stats?.scannedSessions || 0) }),
418
+ ],
419
+ }),
420
+ jsx.jsxs("div", {
421
+ className: "balance-stats-section-title",
422
+ children: [
423
+ jsx.jsx("span", { children: "近 90 天使用热力图" }),
424
+ jsx.jsxs("span", {
425
+ className: "balance-stats-legend",
426
+ children: ["少", 0, 1, 2, 3, 4].map((item, index) => index === 0
427
+ ? item
428
+ : jsx.jsx("i", { className: "balance-heatmap-cell balance-heatmap-cell--" + item }, "legend-" + item)).concat(["多"]),
429
+ }),
430
+ ],
431
+ }),
432
+ jsx.jsx("div", { className: "balance-heatmap-wrap", children: jsx.jsx("div", { className: "balance-heatmap", children: heatmap }) }),
433
+ jsx.jsxs("div", {
434
+ className: "balance-stats-footer",
435
+ children: [
436
+ jsx.jsx("span", {
437
+ children: stats?.limited
438
+ ? "为保证性能,仅统计最近 200 个会话;结果缓存 5 分钟。"
439
+ : "按北京时间和实际模型峰谷价格估算;结果缓存 5 分钟。",
440
+ }),
441
+ jsx.jsx("a", {
442
+ className: "balance-stats-link",
443
+ href: "https://platform.deepseek.com/usage",
444
+ target: "_blank",
445
+ rel: "noreferrer",
446
+ children: "查看官方用量",
447
+ }),
448
+ ],
449
+ }),
450
+ ],
451
+ });
452
+ return jsx.jsx("div", {
453
+ className: "balance-stats-backdrop",
454
+ role: "presentation",
455
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
456
+ children: jsx.jsxs("section", {
457
+ className: "balance-stats-panel",
458
+ role: "dialog",
459
+ "aria-modal": "true",
460
+ "aria-label": "余额与使用统计",
461
+ children: [
462
+ jsx.jsxs("header", {
463
+ className: "balance-stats-header",
464
+ children: [
465
+ jsx.jsxs("div", {
466
+ children: [
467
+ jsx.jsx("h2", { className: "balance-stats-title", children: "余额与使用统计" }),
468
+ jsx.jsx("p", { className: "balance-stats-subtitle", children: "单击查看统计 · 双击余额打开 DeepSeek API 用量页" }),
469
+ ],
470
+ }),
471
+ jsx.jsx("button", { className: "balance-stats-close", type: "button", onClick: onClose, "aria-label": "关闭", children: "×" }),
472
+ ],
473
+ }),
474
+ body,
475
+ ],
476
+ }),
477
+ });
478
+ }
479
+
480
+ function WalletIcon() {
481
+ return jsx.jsxs("svg", {
482
+ width: 16,
483
+ height: 16,
484
+ viewBox: "0 0 24 24",
485
+ fill: "none",
486
+ xmlns: "http://www.w3.org/2000/svg",
487
+ stroke: "currentColor",
488
+ strokeWidth: 2,
489
+ strokeLinecap: "round",
490
+ strokeLinejoin: "round",
491
+ "aria-hidden": "true",
492
+ children: [
493
+ jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
494
+ jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
495
+ jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
496
+ ],
497
+ });
498
+ }
499
+
500
+ // ─── 余额显示:单击统计,双击 DeepSeek 用量页,右键刷新 ─────────────
314
501
  function BalanceFooter({ wide }) {
315
502
  const state = useBalance();
316
- const openUsagePage = (e) => { e && e.preventDefault(); window.open("https://platform.deepseek.com/usage", "_blank", "noopener"); };
503
+ const [statsOpen, setStatsOpen] = react.useState(false);
504
+ const clickTimer = react.useRef(null);
505
+ react.useEffect(() => () => {
506
+ if (clickTimer.current !== null) clearTimeout(clickTimer.current);
507
+ }, []);
508
+ const openStats = (event) => {
509
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
510
+ if (clickTimer.current !== null) clearTimeout(clickTimer.current);
511
+ clickTimer.current = setTimeout(() => {
512
+ clickTimer.current = null;
513
+ setStatsOpen(true);
514
+ }, 230);
515
+ };
516
+ const openUsagePage = (event) => {
517
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
518
+ if (clickTimer.current !== null) clearTimeout(clickTimer.current);
519
+ clickTimer.current = null;
520
+ setStatsOpen(false);
521
+ window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
522
+ };
523
+ const interactions = {
524
+ onClick: openStats,
525
+ onDoubleClick: openUsagePage,
526
+ onContextMenu: onBalanceContextMenu,
527
+ title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
528
+ };
529
+ const withPanel = (node) => jsx.jsxs(jsx.Fragment, {
530
+ children: [node, statsOpen ? jsx.jsx(UsageStatsPanel, { onClose: () => setStatsOpen(false) }) : null],
531
+ });
317
532
 
318
533
  if (!wide) {
319
- // 收起态:悬浮 FAB(原样保留)
320
534
  if (state.status === "error" || !state.balance) return null;
321
535
  const b = state.balance;
322
536
  const total = Number.parseFloat(b.total_balance ?? "0");
@@ -331,34 +545,23 @@ window.__ModuleLoader__.load({
331
545
  children: "去充值",
332
546
  });
333
547
  }
334
- const currency = b.currency || "CNY";
335
- const detailText = "总余额 " + b.total_balance + " " + currency + " · 充值余额 " + b.topped_up_balance + " " + currency;
336
- return jsx.jsx("a", {
548
+ return withPanel(jsx.jsx("a", {
337
549
  className: "balance-fab",
338
550
  href: "https://platform.deepseek.com/usage",
339
- target: "_blank",
340
- rel: "noreferrer",
341
- onContextMenu: onBalanceContextMenu,
551
+ ...interactions,
342
552
  children: "¥" + b.total_balance,
343
- });
553
+ }));
344
554
  }
345
555
 
346
- // 展开态:底部文字(加载中/失败/去充值/正常)
347
556
  if (state.status === "error") {
348
- return jsx.jsx("div", {
557
+ return withPanel(jsx.jsx("div", {
349
558
  className: "balance-footer",
350
- title: "余额查询失败:" + state.error,
351
- onContextMenu: onBalanceContextMenu,
559
+ ...interactions,
352
560
  children: jsx.jsx("span", { className: "balance-footer__error", children: "余额查询失败" }),
353
- });
561
+ }));
354
562
  }
355
-
356
563
  if (!state.balance) {
357
- return jsx.jsx("div", {
358
- className: "balance-footer",
359
- onContextMenu: onBalanceContextMenu,
360
- children: "余额加载中…",
361
- });
564
+ return withPanel(jsx.jsx("div", { className: "balance-footer", ...interactions, children: "余额加载中…" }));
362
565
  }
363
566
 
364
567
  const b = state.balance;
@@ -366,96 +569,47 @@ window.__ModuleLoader__.load({
366
569
  const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
367
570
  const currency = b.currency || "CNY";
368
571
  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
572
  if (depleted) {
376
- return jsx.jsx("div", {
573
+ return withPanel(jsx.jsxs("div", {
377
574
  className: "balance-footer",
378
- onContextMenu: onBalanceContextMenu,
575
+ ...interactions,
379
576
  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
- }),
577
+ jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
400
578
  jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
401
579
  jsx.jsx("a", {
402
580
  className: "balance-footer__topup",
403
581
  href: "https://platform.deepseek.com/top_up",
404
582
  target: "_blank",
405
583
  rel: "noreferrer",
584
+ onClick: (event) => event.stopPropagation(),
585
+ onDoubleClick: (event) => event.stopPropagation(),
406
586
  onContextMenu: onBalanceContextMenu,
407
587
  children: "去充值",
408
588
  }),
409
589
  ],
410
- });
590
+ }));
411
591
  }
412
592
 
413
- const valueClass = low ? " balance-footer__value--low" : " balance-footer__value--healthy";
414
- const valueChildren = [
415
- totalText,
416
- " • ",
417
- state.refreshing
418
- ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
419
- : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
420
- ];
421
- const inner = [
422
- jsx.jsx("span", {
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",
593
+ const period = currentPeriod();
594
+ const low = total > 0 && total < 10;
595
+ return withPanel(jsx.jsxs("div", {
596
+ className: "balance-footer",
597
+ ...interactions,
598
+ children: [
599
+ jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
600
+ jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
601
+ jsx.jsxs("span", {
602
+ className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
435
603
  children: [
436
- jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
437
- jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
438
- jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
604
+ totalText,
605
+ " • ",
606
+ state.refreshing
607
+ ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
608
+ : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
439
609
  ],
440
610
  }),
441
- }),
442
- jsx.jsx("span", {
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
- });
611
+ ],
612
+ }));
459
613
  }
460
614
 
461
615
  // ─── 当前会话消耗:输入区 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
- writeJson(res, 200, { ok: false, error: "未设置 DEEPSEEK_API_KEY" });
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
- writeJson(res, 200, { ok: false, error: message });
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",