balance-dsh 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -47,7 +47,7 @@ async function inspectPersistedSession(ctx, sessionId) {
47
47
  return legacySessionInspector(ctx, sessionId);
48
48
  }
49
49
 
50
- throw new Error("当前 DSH 未提供兼容的持久化会话读取接口");
50
+ throw new Error("当前 DeepSeek Harness 未提供兼容的持久化会话读取接口");
51
51
  }
52
52
 
53
53
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
@@ -159,8 +159,10 @@ function isPeakAt(timeMs, config) {
159
159
  if (kind === "holiday") return false; // 法定节假日:全天按空闲计费
160
160
  const hour = d.getUTCHours();
161
161
  const weekday = d.getUTCDay(); // 0=周日 … 6=周六
162
- const onDuty = weekdays.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
163
- return onDuty && windows.some((w) => hour >= w.start && hour < w.end);
162
+ // 官方定价页口径(2026-10 核对):周一至周五为工作日高峰区,其余时段
163
+ // 「包括周末及中国法定节假日全天」均为空闲。所以**调休上班的周末也按周末处理**,
164
+ // 不参与峰谷判定(CN_CALENDAR 里的 workday 条目只保留作数据,不再影响计费)。
165
+ return weekdays.includes(weekday) && windows.some((w) => hour >= w.start && hour < w.end);
164
166
  }
165
167
 
166
168
  /** 把模型字符串规整到定价表键;未识别返回 ""(用 Flash 价兜底)。
@@ -343,7 +345,7 @@ function formatBalance(balance) {
343
345
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
344
346
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
345
347
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
346
- const PLUGIN_VERSION = "1.3.0";
348
+ const PLUGIN_VERSION = "2.0.0";
347
349
 
348
350
  /**
349
351
  * 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
@@ -453,12 +455,75 @@ function modelAtTime(switches, time) {
453
455
  return model;
454
456
  }
455
457
 
458
+ /** 网页搜索 / 抓取工具按次计费(官方 2026-10-17 上线 Search/Fetch API 公告):
459
+ * · Search 与 Fetch 同价:**每千次 ¥5**(= ¥0.005 / 次),结果条数不影响价格;
460
+ * · 只有 Search 返回非空结果、Fetch 返回正文且 HTTP 200 才计费,失败/空结果/超时不计费;
461
+ * · **北京时间 2026-11-01 00:00 起开始收费**,此前的调用免费。
462
+ * 会话事件里 tool/call 带 { callId, name, arguments }(name = web_search / web_fetch),
463
+ * tool/result 带 { toolCallId, message: { isError, content } },按 callId 配对就能算钱。 */
464
+ const WEB_TOOL_BILLING_START = Date.parse("2026-11-01T00:00:00+08:00");
465
+ const WEB_TOOL_PRICE_PER_CALL = 5 / 1000;
466
+
467
+ /** 统计网页工具调用:次数、可计费次数与金额。
468
+ * 只认 tool/call 与 tool/result 配对成功、且 isError !== true 的调用(官方口径:失败不计费)。 */
469
+ function webToolUsage(events) {
470
+ const calls = new Map(); // callId -> { name, time }
471
+ for (const event of events ?? []) {
472
+ if (event.type !== "tool/call") continue;
473
+ const name = event.data?.name;
474
+ if (name !== "web_search" && name !== "web_fetch") continue;
475
+ const time = Number(event.time ?? 0);
476
+ if (!Number.isFinite(time)) continue;
477
+ const id = event.data?.callId;
478
+ if (id === void 0 || id === null) continue;
479
+ calls.set(String(id), { name, time });
480
+ }
481
+ let webSearchCalls = 0;
482
+ let webFetchCalls = 0;
483
+ let webSearchBillable = 0;
484
+ let webFetchBillable = 0;
485
+ let webSearchCost = 0;
486
+ let webFetchCost = 0;
487
+ const done = new Set();
488
+ for (const event of events ?? []) {
489
+ if (event.type !== "tool/result") continue;
490
+ const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
491
+ const key = id === void 0 || id === null ? "" : String(id);
492
+ const call = calls.get(key);
493
+ if (call === void 0 || done.has(key)) continue;
494
+ done.add(key);
495
+ if (event.data?.message?.isError === true) continue; // 失败不计费
496
+ if (call.name === "web_search") webSearchCalls += 1;
497
+ else webFetchCalls += 1;
498
+ if (call.time >= WEB_TOOL_BILLING_START) {
499
+ if (call.name === "web_search") {
500
+ webSearchBillable += 1;
501
+ webSearchCost += WEB_TOOL_PRICE_PER_CALL;
502
+ } else {
503
+ webFetchBillable += 1;
504
+ webFetchCost += WEB_TOOL_PRICE_PER_CALL;
505
+ }
506
+ }
507
+ }
508
+ return {
509
+ webSearchCalls,
510
+ webFetchCalls,
511
+ webSearchBillable,
512
+ webFetchBillable,
513
+ webSearchCost,
514
+ webFetchCost,
515
+ webToolCost: webSearchCost + webFetchCost,
516
+ };
517
+ }
518
+
456
519
  /** 从事件数组汇总 usage(按模型分段 + 跨时段分桶)。
457
520
  * 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
458
521
  function sumEventsUsage(events, config, fallbackModel = "") {
459
522
  const switches = collectModelSwitches(events);
460
523
  const starts = stepStartTimes(events);
461
524
  const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
525
+ // 网页搜索 / 抓取:次数 + 按次计费金额(2026-11-01 起才收费)
526
+ const webTools = webToolUsage(events);
462
527
  const latestByStep = new Map();
463
528
  for (const event of events ?? []) {
464
529
  if (event.type !== "assistant/message") continue;
@@ -518,6 +583,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
518
583
  peak,
519
584
  offpeak,
520
585
  byModel: modelList,
586
+ ...webTools,
521
587
  };
522
588
  }
523
589
 
@@ -565,6 +631,30 @@ const DEFAULT_SETTINGS = {
565
631
  showUsagePill: true, // 输入框下方「本会话消耗」胶囊
566
632
  showPeriodLabel: true, // 高峰/空闲标签
567
633
  showFab: true, // 收起态悬浮圆钮
634
+ // 2.0.0:余额行外观。classic = 早期那版「钱包图标 + 余额 + 金额」纯文字行;
635
+ // timeline = 现在的「meta 行 + 扁刻度条 + 金额」。
636
+ footerStyle: "timeline",
637
+ // 2.0.0:左侧导航栏里的插件设置入口(点一下直接打开插件配置页)
638
+ showSidebarEntry: true,
639
+ // 2.0.0:右键刷新时,余额行从左往右扫过一道泛光亮带
640
+ refreshWave: true,
641
+ // 侧栏「余额与用量」入口是否已被点开过。false = 显示红点。
642
+ // 与版本号无关:只有用户真正点开过这个面板才会被写成 true,之后永不回退。
643
+ entrySeen: false,
644
+ // NEW 徽标的计时起点:记录「当前版本首次启动」的时间戳与版本号。
645
+ // 版本一变(用户升级了)就重置计时;满 3 天后客户端自动不再显示 NEW。
646
+ newTagVersion: "",
647
+ newTagSince: 0,
648
+ // 升级前的版本(用户上一次跑的是哪个版本)。
649
+ // 客户端用它和「某项功能的引入版本」比较:只有比它新的功能才标 NEW。
650
+ // 这样跨几个版本一次升上来的用户能看到中间所有新功能,
651
+ // 已经跑过某个版本的用户不会被重复提示。
652
+ newTagFrom: "",
653
+ // 逐项计时:{ [配置项 key]: { v: 引入版本, t: 首次看到的时间戳 } }。
654
+ // 每个新功能各自走自己的 3 天,互不影响:
655
+ // 旧功能没过完 3 天时又升级,也不会被这次升级重新计时——
656
+ // 因为它记录的版本已经是用户见过的版本了。
657
+ newTagSeen: {},
568
658
  // 收起态圆钮外观
569
659
  fabSize: 60, // 直径(px)
570
660
  fabLeft: 44, // 距窗口左缘(px)
@@ -591,6 +681,23 @@ function clampNumber(value, min, max, fallback) {
591
681
  return Math.min(max, Math.max(min, n));
592
682
  }
593
683
 
684
+ /** NEW 逐项计时表:只收 { v: 非空版本串, t: 非负时间戳 } 的条目,
685
+ * 其余一律丢弃(宁可丢一条记录重新计时,也不要带着脏数据跑)。 */
686
+ function normalizeNewTagSeen(raw) {
687
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
688
+ const out = {};
689
+ for (const key of Object.keys(raw)) {
690
+ if (typeof key !== "string" || key === "") continue;
691
+ const rec = raw[key];
692
+ if (rec == null || typeof rec !== "object" || Array.isArray(rec)) continue;
693
+ const v = typeof rec.v === "string" ? rec.v.trim() : "";
694
+ const t = Number(rec.t);
695
+ if (v === "" || !Number.isFinite(t) || t <= 0) continue;
696
+ out[key] = { v, t: Math.floor(t) };
697
+ }
698
+ return out;
699
+ }
700
+
594
701
  /** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
595
702
  * 四个字段都没填则整组丢弃(等价于不覆盖)。 */
596
703
  function normalizePriceOverride(raw) {
@@ -619,6 +726,14 @@ function normalizeSettings(raw) {
619
726
  showUsagePill: bool(src.showUsagePill, d.showUsagePill),
620
727
  showPeriodLabel: bool(src.showPeriodLabel, d.showPeriodLabel),
621
728
  showFab: bool(src.showFab, d.showFab),
729
+ footerStyle: src.footerStyle === "classic" ? "classic" : d.footerStyle,
730
+ showSidebarEntry: bool(src.showSidebarEntry, d.showSidebarEntry),
731
+ refreshWave: bool(src.refreshWave, d.refreshWave),
732
+ entrySeen: bool(src.entrySeen, d.entrySeen),
733
+ newTagVersion: typeof src.newTagVersion === "string" ? src.newTagVersion : d.newTagVersion,
734
+ newTagFrom: typeof src.newTagFrom === "string" ? src.newTagFrom : d.newTagFrom,
735
+ newTagSeen: normalizeNewTagSeen(src.newTagSeen),
736
+ newTagSince: clampNumber(src.newTagSince, 0, Number.MAX_SAFE_INTEGER, d.newTagSince),
622
737
  fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
623
738
  fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
624
739
  statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
@@ -667,21 +782,35 @@ function settingsFilePath(ctx) {
667
782
  return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
668
783
  }
669
784
 
670
- /** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。 */
785
+ /** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。
786
+ * 额外做一件事:老版本写下的文件里没有 showSidebarEntry 这个键,
787
+ * 读到这种「升级前」的文件时把侧边栏入口强制打开一次,让用户第一次升级后
788
+ * 能在左侧导航看到新入口(用户要求:第一次更新的用户强制显示在侧边栏)。
789
+ * 之后再关掉就按用户的选择来,不会反复弹回来。 */
671
790
  async function readSettingsFile(file) {
672
791
  if (file === "") return null;
673
792
  try {
674
793
  const { readFile } = await import("node:fs/promises");
675
794
  const text = await readFile(file, "utf8");
676
- return normalizeSettings(JSON.parse(text));
677
- } catch {
795
+ const parsed = JSON.parse(text);
796
+ const settings = normalizeSettings(parsed);
797
+ if (parsed == null || typeof parsed !== "object" || !("showSidebarEntry" in parsed)) {
798
+ settings.showSidebarEntry = true;
799
+ }
800
+ // entrySeen 不按版本强制:文件里没写 true 的旧用户(升级上来的)
801
+ // 读出来就是未读,红点 + NEW 会提示他「这版加了新东西」。
802
+ return settings;
803
+ } catch (error) {
804
+ // 文件不存在 = 全新安装,这里不能当「未读」处理(否则新用户一装就看到一堆 NEW,
805
+ // 对他来说每一项都是新的,标 NEW 毫无信息量)。用字符串标记区分于「读失败」。
806
+ if (error && error.code === "ENOENT") return "missing";
678
807
  return null;
679
808
  }
680
809
  }
681
810
 
682
811
  /** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。 */
683
812
  async function writeSettingsFile(file, settings) {
684
- if (file === "") throw new Error("无法定位 DSH 配置目录");
813
+ if (file === "") throw new Error("无法定位 DeepSeek Harness 配置目录");
685
814
  const { mkdir, writeFile, rename } = await import("node:fs/promises");
686
815
  const { dirname } = await import("node:path");
687
816
  await mkdir(dirname(file), { recursive: true });
@@ -728,6 +857,35 @@ function tokenTotal(usage) {
728
857
  return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
729
858
  }
730
859
 
860
+ /** 取(或建)某天的统计桶。 */
861
+ function ensureDay(days, key) {
862
+ let day = days.get(key);
863
+ if (day == null) {
864
+ day = {
865
+ date: key,
866
+ ...emptyTokenBucket(),
867
+ totalTokens: 0,
868
+ cost: 0,
869
+ sessionIds: new Set(),
870
+ // —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
871
+ byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
872
+ peak: emptyTokenBucket(),
873
+ offpeak: emptyTokenBucket(),
874
+ peakCost: 0,
875
+ offpeakCost: 0,
876
+ // 网页搜索 / 抓取的工具调用次数与按次费用(官方 ¥5/千次,2026-11-01 起计费)
877
+ webSearchCalls: 0,
878
+ webFetchCalls: 0,
879
+ webSearchBillable: 0,
880
+ webFetchBillable: 0,
881
+ webSearchCost: 0,
882
+ webFetchCost: 0,
883
+ };
884
+ days.set(key, day);
885
+ }
886
+ return day;
887
+ }
888
+
731
889
  /** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
732
890
  function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
733
891
  const switches = collectModelSwitches(events);
@@ -743,26 +901,52 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
743
901
  // 与 sumEventsUsage 同口径:按"请求发起时刻"归属日期与峰谷,拿不到才回退完成时刻
744
902
  latestByStep.set(`${turn}/${step}`, { usage, time: starts.get(`${turn}/${step}`) ?? Number(event.time ?? 0) });
745
903
  }
746
- for (const { usage, time } of latestByStep.values()) {
904
+ // 网页搜索 / 抓取:按调用发生的北京日期归日,金额按官方「每千次 ¥5」、且只在
905
+ // 2026-11-01 00:00(北京)之后计费。tool/call 与 tool/result 配对 + isError 过滤同 webToolUsage。
906
+ const webCalls = new Map(); // callId -> { name, time }
907
+ for (const event of events ?? []) {
908
+ if (event.type !== "tool/call") continue;
909
+ const name = event.data?.name;
910
+ if (name !== "web_search" && name !== "web_fetch") continue;
911
+ const time = Number(event.time ?? 0);
912
+ const id = event.data?.callId;
747
913
  if (!Number.isFinite(time) || time < cutoffMs) continue;
748
- const key = beijingDayKey(time);
749
- let day = days.get(key);
750
- if (day == null) {
751
- day = {
752
- date: key,
753
- ...emptyTokenBucket(),
754
- totalTokens: 0,
755
- cost: 0,
756
- sessionIds: new Set(),
757
- // —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
758
- byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
759
- peak: emptyTokenBucket(),
760
- offpeak: emptyTokenBucket(),
761
- peakCost: 0,
762
- offpeakCost: 0,
763
- };
764
- days.set(key, day);
914
+ if (id === void 0 || id === null) continue;
915
+ webCalls.set(String(id), { name, time });
916
+ }
917
+ if (webCalls.size > 0) {
918
+ const seen = new Set();
919
+ for (const event of events ?? []) {
920
+ if (event.type !== "tool/result") continue;
921
+ const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
922
+ const key = id === void 0 || id === null ? "" : String(id);
923
+ const call = webCalls.get(key);
924
+ if (call === void 0 || seen.has(key)) continue;
925
+ seen.add(key);
926
+ if (event.data?.message?.isError === true) continue;
927
+ const day = ensureDay(days, beijingDayKey(call.time));
928
+ const billable = call.time >= WEB_TOOL_BILLING_START;
929
+ if (call.name === "web_search") {
930
+ day.webSearchCalls += 1;
931
+ if (billable) {
932
+ day.webSearchBillable += 1;
933
+ day.webSearchCost += WEB_TOOL_PRICE_PER_CALL;
934
+ day.cost += WEB_TOOL_PRICE_PER_CALL;
935
+ }
936
+ } else {
937
+ day.webFetchCalls += 1;
938
+ if (billable) {
939
+ day.webFetchBillable += 1;
940
+ day.webFetchCost += WEB_TOOL_PRICE_PER_CALL;
941
+ day.cost += WEB_TOOL_PRICE_PER_CALL;
942
+ }
943
+ }
944
+ day.sessionIds.add(sessionId);
765
945
  }
946
+ }
947
+ for (const { usage, time } of latestByStep.values()) {
948
+ if (!Number.isFinite(time) || time < cutoffMs) continue;
949
+ const day = ensureDay(days, beijingDayKey(time));
766
950
  const model = modelAtTime(switches, time) || fallback;
767
951
  const isPeak = isPeakAt(time, config);
768
952
  // 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
@@ -810,6 +994,76 @@ function recentDayKeys(days, now = Date.now()) {
810
994
  return result;
811
995
  }
812
996
 
997
+ /** 只算「今天」的消耗总额,供余额行上方的 meta 行用。
998
+ * 与 buildUsageStats 的区别:只扫 updatedAt 落在今天的会话,通常只有几个,
999
+ * 所以可以随余额行刷新(缓存 60s),不必等 5 分钟的完整统计。
1000
+ * @returns {Promise<{ cost:number, totalTokens:number, sessions:number }>} */
1001
+ async function buildTodayCost(ctx, config) {
1002
+ const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
1003
+ const controller = getService?.("sessionController");
1004
+ const persistence = getService?.("sessionPersistence");
1005
+ const now = Date.now();
1006
+ // 北京当日 0 点对应的真实时间戳(用 UTC 分量读北京日期,再减回去)
1007
+ const bj = new Date(now + 8 * 60 * 60 * 1000);
1008
+ const dayStart = Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate()) - 8 * 60 * 60 * 1000;
1009
+ const todayKey = bj.getUTCFullYear() + "-" + String(bj.getUTCMonth() + 1).padStart(2, "0") + "-" + String(bj.getUTCDate()).padStart(2, "0");
1010
+ // 只挑今天动过的会话
1011
+ let candidates = [];
1012
+ if (typeof controller?.list === "function") {
1013
+ const listed = await controller.list({});
1014
+ const items = Array.isArray(listed) ? listed : (listed?.items ?? []);
1015
+ candidates = items
1016
+ .map((item) => (item && typeof item === "object" ? { id: item.sessionId ?? item.id, at: Number(item.updatedAt ?? item.createdAt ?? 0) } : null))
1017
+ .filter((x) => x && typeof x.id === "string" && x.id !== "");
1018
+ // list() 已按活动降序;万一没有 updatedAt 字段就保守全扫(上限 STATS_MAX_SESSIONS)
1019
+ if (candidates.some((x) => x.at > 0)) {
1020
+ candidates = candidates.filter((x) => x.at >= dayStart);
1021
+ }
1022
+ } else if (typeof persistence?.list === "function") {
1023
+ const headers = await persistence.list();
1024
+ const list = Array.isArray(headers) ? headers : [];
1025
+ candidates = list
1026
+ .map((h) => (h && typeof h.id === "string" ? { id: h.id, at: Number(h.updatedAt ?? h.createdAt ?? 0) } : null))
1027
+ .filter(Boolean);
1028
+ if (candidates.some((x) => x.at > 0)) {
1029
+ candidates = candidates.filter((x) => x.at >= dayStart);
1030
+ }
1031
+ } else {
1032
+ throw new Error("会话持久化服务不可用");
1033
+ }
1034
+ const selected = candidates
1035
+ .slice(0, clampNumber(config?.statsSessionLimit, 10, 2000, STATS_MAX_SESSIONS))
1036
+ .map((x) => x.id);
1037
+ const daily = new Map();
1038
+ let cursor = 0;
1039
+ const worker = async () => {
1040
+ while (cursor < selected.length) {
1041
+ const sessionId = selected[cursor++];
1042
+ try {
1043
+ const inspected = await inspectPersistedSession(ctx, sessionId);
1044
+ collectDailyUsage(
1045
+ inspected?.events ?? [],
1046
+ config,
1047
+ detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events: inspected?.events ?? [] }),
1048
+ dayStart,
1049
+ daily,
1050
+ sessionId,
1051
+ );
1052
+ } catch {
1053
+ // 单个会话读不出来不影响总额
1054
+ }
1055
+ }
1056
+ };
1057
+ await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
1058
+ const day = daily.get(todayKey);
1059
+ return {
1060
+ date: todayKey,
1061
+ cost: day?.cost ?? 0,
1062
+ totalTokens: day?.totalTokens ?? 0,
1063
+ sessions: day?.sessionIds?.size ?? 0,
1064
+ };
1065
+ }
1066
+
813
1067
  async function buildUsageStats(ctx, config) {
814
1068
  const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
815
1069
  const controller = getService?.("sessionController");
@@ -896,6 +1150,13 @@ async function buildUsageStats(ctx, config) {
896
1150
  offpeak: day.offpeak,
897
1151
  peakCost: day.peakCost,
898
1152
  offpeakCost: day.offpeakCost,
1153
+ webSearchCalls: day.webSearchCalls,
1154
+ webFetchCalls: day.webFetchCalls,
1155
+ webSearchBillable: day.webSearchBillable,
1156
+ webFetchBillable: day.webFetchBillable,
1157
+ webSearchCost: day.webSearchCost,
1158
+ webFetchCost: day.webFetchCost,
1159
+ webToolCost: day.webSearchCost + day.webFetchCost,
899
1160
  };
900
1161
  });
901
1162
  const total = rows.reduce((acc, day) => {
@@ -910,6 +1171,13 @@ async function buildUsageStats(ctx, config) {
910
1171
  addTokenUsage(acc.offpeak, day.offpeak);
911
1172
  acc.peakCost += day.peakCost;
912
1173
  acc.offpeakCost += day.offpeakCost;
1174
+ acc.webSearchCalls += day.webSearchCalls;
1175
+ acc.webFetchCalls += day.webFetchCalls;
1176
+ acc.webSearchBillable += day.webSearchBillable;
1177
+ acc.webFetchBillable += day.webFetchBillable;
1178
+ acc.webSearchCost += day.webSearchCost;
1179
+ acc.webFetchCost += day.webFetchCost;
1180
+ acc.webToolCost += day.webToolCost;
913
1181
  for (const item of day.byModel ?? []) {
914
1182
  let entry = acc.byModel.get(item.model);
915
1183
  if (entry == null) {
@@ -930,6 +1198,13 @@ async function buildUsageStats(ctx, config) {
930
1198
  offpeak: emptyTokenBucket(),
931
1199
  peakCost: 0,
932
1200
  offpeakCost: 0,
1201
+ webSearchCalls: 0,
1202
+ webFetchCalls: 0,
1203
+ webSearchBillable: 0,
1204
+ webFetchBillable: 0,
1205
+ webSearchCost: 0,
1206
+ webFetchCost: 0,
1207
+ webToolCost: 0,
933
1208
  });
934
1209
  return {
935
1210
  days: rows,
@@ -1071,6 +1346,7 @@ function writeJson(res, status, payload) {
1071
1346
  }
1072
1347
 
1073
1348
  export function apply(ctx, config = {}) {
1349
+ let todayCache = { expiresAt: 0, value: null };
1074
1350
  let statsCache = { expiresAt: 0, value: null };
1075
1351
  // 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
1076
1352
  // 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
@@ -1079,9 +1355,35 @@ export function apply(ctx, config = {}) {
1079
1355
  void (async () => {
1080
1356
  settingsState.file = settingsFilePath(ctx);
1081
1357
  const loaded = await readSettingsFile(settingsState.file);
1082
- if (loaded) {
1358
+ let dirty = false;
1359
+ if (loaded === "missing") {
1360
+ // 全新安装:没有任何历史可回顾,红点不亮,
1361
+ // 并立即落盘固化,保证后续每次启动都走「已读」分支。
1362
+ settingsState.value = { ...DEFAULT_SETTINGS, entrySeen: true };
1363
+ dirty = true;
1364
+ } else if (loaded) {
1083
1365
  settingsState.value = loaded;
1084
- effectiveConfig = withSettings(config, loaded);
1366
+ }
1367
+ // NEW 徽标的计时:记下「这个版本首次启动」的时间。
1368
+ // 版本号变了(用户升级了)就重置,否则沿用上次记录的起点——
1369
+ // 否则每次启动都会把计时推到“现在”,永远过不了 3 天。
1370
+ const tagVersion = settingsState.value.newTagVersion || "";
1371
+ const tagSince = Number(settingsState.value.newTagSince) || 0;
1372
+ if (tagVersion !== PLUGIN_VERSION || !(tagSince > 0)) {
1373
+ // 升级:把「刚刚跑过的那个版本」存下来。首次安装时 tagVersion 为空串,
1374
+ // 客户端见空串就把所有 NEW_TAGS 都当新功能(新用户确实都没见过)。
1375
+ settingsState.value.newTagFrom = tagVersion || "";
1376
+ settingsState.value.newTagVersion = PLUGIN_VERSION;
1377
+ settingsState.value.newTagSince = Date.now();
1378
+ dirty = true;
1379
+ }
1380
+ effectiveConfig = withSettings(config, settingsState.value);
1381
+ if (dirty) {
1382
+ try {
1383
+ await writeSettingsFile(settingsState.file, settingsState.value);
1384
+ } catch {
1385
+ // 写盘失败(目录只读等)不影响这一次运行:内存里已经是最新值
1386
+ }
1085
1387
  }
1086
1388
  })();
1087
1389
  // DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
@@ -1178,15 +1480,18 @@ export function apply(ctx, config = {}) {
1178
1480
  let model = "";
1179
1481
  let sessionParam = "";
1180
1482
  let wantStats = false;
1483
+ let wantToday = false;
1181
1484
  try {
1182
1485
  const search = new URL(req.url, "http://localhost").searchParams;
1183
1486
  model = search.get("model") ?? "";
1184
1487
  sessionParam = search.get("session") ?? "";
1185
1488
  wantStats = search.get("stats") === "1";
1489
+ wantToday = search.get("today") === "1";
1186
1490
  } catch {
1187
1491
  model = "";
1188
1492
  sessionParam = "";
1189
1493
  wantStats = false;
1494
+ wantToday = false;
1190
1495
  }
1191
1496
  let stats = null;
1192
1497
  let statsError = "";
@@ -1203,6 +1508,18 @@ export function apply(ctx, config = {}) {
1203
1508
  statsError = error instanceof Error ? error.message : String(error);
1204
1509
  }
1205
1510
  }
1511
+ // 今日消耗:只扫今天动过的会话,60 秒缓存,供余额行上方的 meta 行随余额一起刷新
1512
+ let today = null;
1513
+ if (wantToday) {
1514
+ try {
1515
+ if (todayCache.value == null || todayCache.expiresAt <= Date.now()) {
1516
+ todayCache = { value: await buildTodayCost(httpCtx, effectiveConfig), expiresAt: Date.now() + 60 * 1000 };
1517
+ }
1518
+ today = todayCache.value;
1519
+ } catch {
1520
+ today = null; // 拿不到就不显示,不阻塞余额
1521
+ }
1522
+ }
1206
1523
  // 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
1207
1524
  let usage = null;
1208
1525
  // 子代理后代会话的 usage 汇总(与主会话分开)
@@ -1273,6 +1590,7 @@ export function apply(ctx, config = {}) {
1273
1590
  subagentUsage,
1274
1591
  stats,
1275
1592
  statsError,
1593
+ today,
1276
1594
  });
1277
1595
  },
1278
1596
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.3.0",
4
- "description": "在 DSH 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
3
+ "version": "2.0.0",
4
+ "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",