balance-dsh 1.3.1 → 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/README.md +6 -5
- package/lib/client.js +981 -85
- package/lib/index.js +188 -10
- package/package.json +2 -2
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("当前
|
|
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
|
-
|
|
163
|
-
|
|
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 = "
|
|
348
|
+
const PLUGIN_VERSION = "2.0.0";
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
351
|
* 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
|
|
@@ -629,6 +631,30 @@ const DEFAULT_SETTINGS = {
|
|
|
629
631
|
showUsagePill: true, // 输入框下方「本会话消耗」胶囊
|
|
630
632
|
showPeriodLabel: true, // 高峰/空闲标签
|
|
631
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: {},
|
|
632
658
|
// 收起态圆钮外观
|
|
633
659
|
fabSize: 60, // 直径(px)
|
|
634
660
|
fabLeft: 44, // 距窗口左缘(px)
|
|
@@ -655,6 +681,23 @@ function clampNumber(value, min, max, fallback) {
|
|
|
655
681
|
return Math.min(max, Math.max(min, n));
|
|
656
682
|
}
|
|
657
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
|
+
|
|
658
701
|
/** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
|
|
659
702
|
* 四个字段都没填则整组丢弃(等价于不覆盖)。 */
|
|
660
703
|
function normalizePriceOverride(raw) {
|
|
@@ -683,6 +726,14 @@ function normalizeSettings(raw) {
|
|
|
683
726
|
showUsagePill: bool(src.showUsagePill, d.showUsagePill),
|
|
684
727
|
showPeriodLabel: bool(src.showPeriodLabel, d.showPeriodLabel),
|
|
685
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),
|
|
686
737
|
fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
|
|
687
738
|
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
688
739
|
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
@@ -731,21 +782,35 @@ function settingsFilePath(ctx) {
|
|
|
731
782
|
return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
|
|
732
783
|
}
|
|
733
784
|
|
|
734
|
-
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。
|
|
785
|
+
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。
|
|
786
|
+
* 额外做一件事:老版本写下的文件里没有 showSidebarEntry 这个键,
|
|
787
|
+
* 读到这种「升级前」的文件时把侧边栏入口强制打开一次,让用户第一次升级后
|
|
788
|
+
* 能在左侧导航看到新入口(用户要求:第一次更新的用户强制显示在侧边栏)。
|
|
789
|
+
* 之后再关掉就按用户的选择来,不会反复弹回来。 */
|
|
735
790
|
async function readSettingsFile(file) {
|
|
736
791
|
if (file === "") return null;
|
|
737
792
|
try {
|
|
738
793
|
const { readFile } = await import("node:fs/promises");
|
|
739
794
|
const text = await readFile(file, "utf8");
|
|
740
|
-
|
|
741
|
-
|
|
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";
|
|
742
807
|
return null;
|
|
743
808
|
}
|
|
744
809
|
}
|
|
745
810
|
|
|
746
811
|
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。 */
|
|
747
812
|
async function writeSettingsFile(file, settings) {
|
|
748
|
-
if (file === "") throw new Error("无法定位
|
|
813
|
+
if (file === "") throw new Error("无法定位 DeepSeek Harness 配置目录");
|
|
749
814
|
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
750
815
|
const { dirname } = await import("node:path");
|
|
751
816
|
await mkdir(dirname(file), { recursive: true });
|
|
@@ -929,6 +994,76 @@ function recentDayKeys(days, now = Date.now()) {
|
|
|
929
994
|
return result;
|
|
930
995
|
}
|
|
931
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
|
+
|
|
932
1067
|
async function buildUsageStats(ctx, config) {
|
|
933
1068
|
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
934
1069
|
const controller = getService?.("sessionController");
|
|
@@ -1211,6 +1346,7 @@ function writeJson(res, status, payload) {
|
|
|
1211
1346
|
}
|
|
1212
1347
|
|
|
1213
1348
|
export function apply(ctx, config = {}) {
|
|
1349
|
+
let todayCache = { expiresAt: 0, value: null };
|
|
1214
1350
|
let statsCache = { expiresAt: 0, value: null };
|
|
1215
1351
|
// 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
|
|
1216
1352
|
// 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
|
|
@@ -1219,9 +1355,35 @@ export function apply(ctx, config = {}) {
|
|
|
1219
1355
|
void (async () => {
|
|
1220
1356
|
settingsState.file = settingsFilePath(ctx);
|
|
1221
1357
|
const loaded = await readSettingsFile(settingsState.file);
|
|
1222
|
-
|
|
1358
|
+
let dirty = false;
|
|
1359
|
+
if (loaded === "missing") {
|
|
1360
|
+
// 全新安装:没有任何历史可回顾,红点不亮,
|
|
1361
|
+
// 并立即落盘固化,保证后续每次启动都走「已读」分支。
|
|
1362
|
+
settingsState.value = { ...DEFAULT_SETTINGS, entrySeen: true };
|
|
1363
|
+
dirty = true;
|
|
1364
|
+
} else if (loaded) {
|
|
1223
1365
|
settingsState.value = loaded;
|
|
1224
|
-
|
|
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
|
+
}
|
|
1225
1387
|
}
|
|
1226
1388
|
})();
|
|
1227
1389
|
// DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
|
|
@@ -1318,15 +1480,18 @@ export function apply(ctx, config = {}) {
|
|
|
1318
1480
|
let model = "";
|
|
1319
1481
|
let sessionParam = "";
|
|
1320
1482
|
let wantStats = false;
|
|
1483
|
+
let wantToday = false;
|
|
1321
1484
|
try {
|
|
1322
1485
|
const search = new URL(req.url, "http://localhost").searchParams;
|
|
1323
1486
|
model = search.get("model") ?? "";
|
|
1324
1487
|
sessionParam = search.get("session") ?? "";
|
|
1325
1488
|
wantStats = search.get("stats") === "1";
|
|
1489
|
+
wantToday = search.get("today") === "1";
|
|
1326
1490
|
} catch {
|
|
1327
1491
|
model = "";
|
|
1328
1492
|
sessionParam = "";
|
|
1329
1493
|
wantStats = false;
|
|
1494
|
+
wantToday = false;
|
|
1330
1495
|
}
|
|
1331
1496
|
let stats = null;
|
|
1332
1497
|
let statsError = "";
|
|
@@ -1343,6 +1508,18 @@ export function apply(ctx, config = {}) {
|
|
|
1343
1508
|
statsError = error instanceof Error ? error.message : String(error);
|
|
1344
1509
|
}
|
|
1345
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
|
+
}
|
|
1346
1523
|
// 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
|
|
1347
1524
|
let usage = null;
|
|
1348
1525
|
// 子代理后代会话的 usage 汇总(与主会话分开)
|
|
@@ -1413,6 +1590,7 @@ export function apply(ctx, config = {}) {
|
|
|
1413
1590
|
subagentUsage,
|
|
1414
1591
|
stats,
|
|
1415
1592
|
statsError,
|
|
1593
|
+
today,
|
|
1416
1594
|
});
|
|
1417
1595
|
},
|
|
1418
1596
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "balance-dsh",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "在
|
|
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",
|