ccus-cli 0.1.21 → 0.1.23
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 +27 -2
- package/dist/cli.js +247 -1
- package/dist/lib/aggregate.js +76 -31
- package/dist/lib/api-mode.js +437 -0
- package/dist/lib/chart-assets.js +20 -1
- package/dist/lib/dashboard.js +28 -7
- package/dist/lib/paths.js +10 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
- `ccus aggregate`:读取一个目录里的多人 export bundle(`.json.gz` 或 `.json`),输出明细、按天、按周三个 CSV。
|
|
10
10
|
- `ccus aggregate serve`:同样以 bundle 目录为输入,启动本地多人 dashboard 页面,不落地任何文件。
|
|
11
11
|
- `ccus sync`:定时把当前周数据包导出并复制到一个目标目录(如团队共享盘),目标目录下按周自动建子目录;日常由 statusline 兜底触发,也可挂系统计划任务做到严格每天定时。
|
|
12
|
+
- `ccus api`:走第三方 API(如智谱 GLM Coding Plan)而非官方订阅时,主动拉取 5h/7d 额度填进 statusline 与导出;默认从环境变量读 token,带缓存,失败静默。
|
|
12
13
|
- `ccus open`:用系统文件管理器打开 ccus 本地存储目录(事件日志、exports、dashboard 都在里面);加 `--print` 只输出目录路径、不打开。
|
|
13
14
|
- `ccus update`:主动检查 npm 上是否有新版本,有则提示手动升级命令;`ccus --version` 查看当前版本。
|
|
14
15
|
|
|
@@ -146,11 +147,11 @@ ccus aggregate serve --input-dir ./team-exports
|
|
|
146
147
|
其中:
|
|
147
148
|
|
|
148
149
|
- `5 小时使用量百分比` 是 **展示指标**,来自 Claude 自身字段 `rate_limits.five_hour.used_percentage`
|
|
149
|
-
-
|
|
150
|
+
- 使用率趋势图会在同一张图上叠加三条线:实线为 5 小时使用率(`rate_limits.five_hour`)、虚线为 7 天使用率(`rate_limits.seven_day`),两者各自独立按时间桶聚合、共用固定 0–100% 的左侧 Y 轴;紫色「7d 分区叠加累计」是把 7 天额度锯齿波还原成的累计真实使用量(口径同团队看板的 `sevenDayCumulativeUsagePct`),量纲可超 100%、单独走**右侧自适应 Y 轴**,按分段峰谷和单调非递减地累积上升,终点即顶部卡片的累计值
|
|
150
151
|
- **折线与纵向柱状图均由 [uPlot](https://github.com/leeoniya/uPlot) 渲染**:鼠标在绘图区**任意位置**悬停即可通过十字线 + **跟随鼠标的 tooltip** 看到该处各条线的当前读数(不必精确落在采样点上;遇到几乎垂直的窄尖峰,把鼠标移到竖线顶端即可读到峰值);纵向柱还会在每根柱顶标注数值。底部为**自绘图例**(实时读数集中在 tooltip 里)。uPlot 库与样式已**内联进生成的 HTML**,`dashboard build` 产物用 `file://` 离线打开也能完整渲染与交互,不依赖任何 CDN
|
|
151
152
|
- 页面新增 **每日用户消息数** 纵向柱状图:按自然日统计的真实用户请求数,口径与导出契约的 `userMessageCount` 一致(来自 `~/.claude/projects/**/*.jsonl`),不是 statusline 采样数,也仅用于页面展示、不进任何导出/聚合契约
|
|
152
153
|
- 跨多天的窗口(如 `this-week` / `last-week`)使用率曲线会自动改用小时桶聚合,避免一周生成上千个点;x 轴刻度由 uPlot 按时间跨度自适应(短窗口显示 `HH:mm` **24 小时制**、跨天的刻度再补一行日期),不用 am/pm
|
|
153
|
-
- 顶部统计卡展示 `Latest 5h usage`、`Peak 5h usage`、`
|
|
154
|
+
- 顶部统计卡展示 `Latest 5h usage`、`Peak 5h usage`、`7d 分区叠加累计`(主数值为 7 天额度分区叠加累计真实使用量,小字补充峰值与最新值)、`用户消息数`(窗口内每日真实用户请求数合计)
|
|
154
155
|
- `--range today / this-week / last-week / 24h` 是 **你要查看的采样历史时间窗口**(`last-week` 指上一个完整周一到周日)
|
|
155
156
|
- statusline 日志本身主要保存 `rawPayload` 与外部补充字段;默认导出时会同时保留原始事件,并额外汇总 `~/.claude/projects/**/*.jsonl` 中的会话 usage
|
|
156
157
|
|
|
@@ -230,6 +231,30 @@ ccus sync status # 查看目标目录、周期
|
|
|
230
231
|
|
|
231
232
|
> 记得先 `ccus sync config --target DIR` 配好目标目录,调度任务才有地方可同步。
|
|
232
233
|
|
|
234
|
+
## API 模式(第三方额度)
|
|
235
|
+
|
|
236
|
+
走第三方 API(如智谱 GLM Coding Plan)而非 Claude 官方订阅时,statusline payload 里没有 `rate_limits`,ccus 默认显示 `5h -- | 7d --`。开启 API 模式后,ccus 会在 `statusline emit` 时主动拉取第三方额度,填进 `rawPayload.rate_limits`,复用现有展示/落盘/导出/聚合管线(不改变 schemaVersion 与导出契约)。
|
|
237
|
+
|
|
238
|
+
智谱 GLM 同样是「每 5 小时窗口 + 每周窗口」额度,语义与 5h/7d 对应,开箱即用:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
# token 默认从环境变量 ANTHROPIC_AUTH_TOKEN 读(Claude Code 用第三方 API 时通常已注入)
|
|
242
|
+
ccus api config --enable --provider zhipu --project <项目ID> --organization <组织ID>
|
|
243
|
+
ccus api test # 立即拉一次,验证配置是否生效、额度是否解析正确
|
|
244
|
+
ccus api status # 查看配置与缓存新鲜度
|
|
245
|
+
ccus api config # 不带参数:打印当前配置
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
要点:
|
|
249
|
+
|
|
250
|
+
- **开启 / 关闭**:`ccus api config --enable` 开启、`--disable` 关闭(关闭后 statusline 回退成 `5h -- | 7d --` 或官方 `rate_limits`),不带参数只查看当前配置;配置(provider/url/token 等)保留,关了再开不用重填。另有 `--no-token` / `--no-header` / `--no-extractor` 清除对应单项。
|
|
251
|
+
- **Token 来源**:默认读环境变量 `ANTHROPIC_AUTH_TOKEN`(可用 `--token-env NAME` 改);读不到时可用 `--token VAL` 兜底(注意:兜底 token 会明文写入 `api-config.json`)。
|
|
252
|
+
- **缓存**:statusline 高频调用,额度按 TTL 缓存(默认 5 分钟,`--ttl 5m` 改),拉取失败自动回退上次缓存;缓存文件 `api-quota-cache.json`。
|
|
253
|
+
- **智谱请求头**:除 `Authorization` 外,`--project` / `--organization` 会作为 `bigmodel-project` / `bigmodel-organization` 头发出(团队版需要)。接口对请求来源敏感,若返回空响应疑似被拦,可用 `ccus api config --user-agent "<浏览器UA>"` 改 UA。
|
|
254
|
+
- **自定义 provider**:`--provider custom` 配其它厂商。简单响应用 `--url` / `--method` / `--header "K: V; K2: V2"`(header 值支持 `{{token}}` 占位)/ `--five-hour-path` / `--seven-day-path`(点分路径,如 `data.limits.0.percentage`)映射字段;响应结构复杂(数组筛选/排序)时用 `--extractor-file script.js` 写一段 JS 函数,接收响应对象、返回 `{ fiveHour, sevenDay }` 或 cc-switch 风格的 `[{used}, {used}]`,配了 extractor 就优先于点分路径。脚本在 ccus 进程内执行,只放信任的来源。
|
|
255
|
+
|
|
256
|
+
> API 模式填充的 5h/7d 会和官方 `rate_limits` 走同一条聚合管线(含 7d 累计去毛刺算法,该算法针对 Claude 官方锯齿波设计);不同厂商的额度曲线语义可能不完全一致,混用聚合时注意。
|
|
257
|
+
|
|
233
258
|
## 调试
|
|
234
259
|
|
|
235
260
|
出问题时(比如 statusline 不出数据、导出/聚合结果不对),可以打开详细日志:
|
package/dist/cli.js
CHANGED
|
@@ -54,6 +54,7 @@ const open_1 = require("./lib/open");
|
|
|
54
54
|
const payload_1 = require("./lib/payload");
|
|
55
55
|
const paths_1 = require("./lib/paths");
|
|
56
56
|
const scheduler_1 = require("./lib/scheduler");
|
|
57
|
+
const api_mode_1 = require("./lib/api-mode");
|
|
57
58
|
const sync_1 = require("./lib/sync");
|
|
58
59
|
const storage_1 = require("./lib/storage");
|
|
59
60
|
const time_1 = require("./lib/time");
|
|
@@ -61,7 +62,7 @@ const update_check_1 = require("./lib/update-check");
|
|
|
61
62
|
const version_1 = require("./lib/version");
|
|
62
63
|
/** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
|
|
63
64
|
function printHelp() {
|
|
64
|
-
process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH]\n ccus statusline emit [--data-dir PATH] [--input FILE] [--no-store]\n ccus dashboard build [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard open [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard serve [--range today|this-week|last-week|5h] [--port 0] [--host 127.0.0.1] [--open] [--data-dir PATH]\n ccus export [RANGE] [--out FILE] [--data-dir PATH] (RANGE: this-week|tw, last-week|lw, today, 5h; e.g. ccus export lw)\n ccus sessions [RANGE] [--out FILE] [--data-dir PATH] (把 ~/.claude/projects 本周活跃 session 打包成 zip,名如 projects_<dates>_<user>.zip)\n ccus aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--suffix NAME | --no-suffix] [--data-dir PATH]\n ccus sync install [--print] [--data-dir PATH] (注册每周五 18:00 的系统调度器)\n ccus sync uninstall [--print] (卸载系统调度器)\n ccus sync status [--data-dir PATH]\n ccus open [--data-dir PATH] [--print]\n ccus update [--data-dir PATH]\n ccus --version\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
|
|
65
|
+
process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH]\n ccus statusline emit [--data-dir PATH] [--input FILE] [--no-store]\n ccus dashboard build [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard open [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard serve [--range today|this-week|last-week|5h] [--port 0] [--host 127.0.0.1] [--open] [--data-dir PATH]\n ccus export [RANGE] [--out FILE] [--data-dir PATH] (RANGE: this-week|tw, last-week|lw, today, 5h; e.g. ccus export lw)\n ccus sessions [RANGE] [--out FILE] [--data-dir PATH] (把 ~/.claude/projects 本周活跃 session 打包成 zip,名如 projects_<dates>_<user>.zip)\n ccus aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--suffix NAME | --no-suffix] [--data-dir PATH]\n ccus sync install [--print] [--data-dir PATH] (注册每周五 18:00 的系统调度器)\n ccus sync uninstall [--print] (卸载系统调度器)\n ccus sync status [--data-dir PATH]\n ccus api config [--enable|--disable] [--provider zhipu|custom] [--token-env NAME] [--token VAL] [--url URL] [--project P] [--organization O] [--ttl 5m] [--extractor-file FILE] [--data-dir PATH]\n ccus api test [--data-dir PATH] (立即拉取第三方额度并打印,验证配置是否生效)\n ccus api status [--data-dir PATH]\n ccus open [--data-dir PATH] [--print]\n ccus update [--data-dir PATH]\n ccus --version\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
|
|
65
66
|
}
|
|
66
67
|
/** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
|
|
67
68
|
function parseOptions(args) {
|
|
@@ -161,6 +162,15 @@ async function handleStatuslineEmit(options) {
|
|
|
161
162
|
record.gitUserName = gitIdentity.userName;
|
|
162
163
|
record.gitUserEmail = gitIdentity.userEmail;
|
|
163
164
|
record.gitUserAccount = (0, time_1.extractGitEmailAccount)(gitIdentity.userEmail);
|
|
165
|
+
// API 模式:若启用,主动拉取第三方额度(智谱等),填进 rawPayload.rate_limits,
|
|
166
|
+
// 让落盘与 computeStatuslineEvent 都能算出 usage;失败静默回退缓存,绝不污染单行 statusline 契约。
|
|
167
|
+
const apiConfig = (0, api_mode_1.readApiConfig)(dataDir);
|
|
168
|
+
if (apiConfig.enabled) {
|
|
169
|
+
const quota = await (0, api_mode_1.resolveApiQuota)(dataDir, apiConfig, process.env);
|
|
170
|
+
if (quota) {
|
|
171
|
+
(0, api_mode_1.applyQuotaToPayload)(record.rawPayload, quota);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
164
174
|
if (noStore) {
|
|
165
175
|
(0, debug_1.debugLog)("statusline", "no-store enabled, skipping appendEvent");
|
|
166
176
|
}
|
|
@@ -826,6 +836,223 @@ async function handleBackgroundSync(options) {
|
|
|
826
836
|
(0, debug_1.debugLog)("sync", "background sync failed", error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
827
837
|
}
|
|
828
838
|
}
|
|
839
|
+
/** 解析时长字面量为毫秒:纯数字按 ms,支持 ms/s/m/h 后缀。非法返回 null。 */
|
|
840
|
+
function parseDurationMs(raw) {
|
|
841
|
+
if (raw === undefined) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
const text = raw.trim().toLowerCase();
|
|
845
|
+
if (text === "") {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
const match = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
849
|
+
if (!match) {
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
const n = Number(match[1]);
|
|
853
|
+
const unit = match[2] ?? "ms";
|
|
854
|
+
const mult = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000;
|
|
855
|
+
return Math.round(n * mult);
|
|
856
|
+
}
|
|
857
|
+
/** 把毫秒时长格式化成人类可读(整分钟显示成 m,否则 ms)。 */
|
|
858
|
+
function formatDurationMs(ms) {
|
|
859
|
+
if (ms >= 60_000 && ms % 60_000 === 0) {
|
|
860
|
+
return `${ms / 60_000}m`;
|
|
861
|
+
}
|
|
862
|
+
return `${ms}ms`;
|
|
863
|
+
}
|
|
864
|
+
/** 解析 --header "K1: V1; K2: V2" 为 header map;按首个冒号拆 K/V,非法条目跳过。 */
|
|
865
|
+
function parseHeaderFlag(raw) {
|
|
866
|
+
if (raw === undefined) {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
const out = {};
|
|
870
|
+
for (const segment of raw.split(";")) {
|
|
871
|
+
const idx = segment.indexOf(":");
|
|
872
|
+
if (idx <= 0) {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const key = segment.slice(0, idx).trim();
|
|
876
|
+
const value = segment.slice(idx + 1).trim();
|
|
877
|
+
if (key !== "") {
|
|
878
|
+
out[key] = value;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
/** 脱敏展示 token:只露首尾各 3 位。 */
|
|
884
|
+
function maskSecret(value) {
|
|
885
|
+
if (!value) {
|
|
886
|
+
return "(未配置)";
|
|
887
|
+
}
|
|
888
|
+
if (value.length <= 8) {
|
|
889
|
+
return "***";
|
|
890
|
+
}
|
|
891
|
+
return `${value.slice(0, 3)}***${value.slice(-3)}`;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* `ccus api config`:只读写 API 模式配置,不触发拉取。
|
|
895
|
+
*
|
|
896
|
+
* 带任意参数时合并并持久化到 api-config.json;不带参数仅打印当前配置。
|
|
897
|
+
* `--url` 按 `--provider` 落到 zhipu 或 custom 段;`--header "K: V; K2: V2"` 仅作用于 custom。
|
|
898
|
+
*/
|
|
899
|
+
async function handleApiConfig(options) {
|
|
900
|
+
const dataDir = getDataDir(options);
|
|
901
|
+
const current = (0, api_mode_1.readApiConfig)(dataDir);
|
|
902
|
+
const enable = getBooleanOption(options, "enable");
|
|
903
|
+
const disable = getBooleanOption(options, "disable");
|
|
904
|
+
const provider = getStringOption(options, "provider");
|
|
905
|
+
const tokenEnv = getStringOption(options, "token-env");
|
|
906
|
+
const token = getStringOption(options, "token");
|
|
907
|
+
const clearToken = getBooleanOption(options, "no-token");
|
|
908
|
+
const ttlMs = parseDurationMs(getStringOption(options, "ttl"));
|
|
909
|
+
const timeoutMs = parseDurationMs(getStringOption(options, "timeout"));
|
|
910
|
+
const userAgent = getStringOption(options, "user-agent");
|
|
911
|
+
const url = getStringOption(options, "url");
|
|
912
|
+
const project = getStringOption(options, "project");
|
|
913
|
+
const organization = getStringOption(options, "organization");
|
|
914
|
+
const method = getStringOption(options, "method");
|
|
915
|
+
const headers = parseHeaderFlag(getStringOption(options, "header"));
|
|
916
|
+
const clearHeaders = getBooleanOption(options, "no-header");
|
|
917
|
+
const fiveHourPath = getStringOption(options, "five-hour-path");
|
|
918
|
+
const sevenDayPath = getStringOption(options, "seven-day-path");
|
|
919
|
+
const extractorInline = getStringOption(options, "extractor");
|
|
920
|
+
const extractorFile = getStringOption(options, "extractor-file");
|
|
921
|
+
const clearExtractor = getBooleanOption(options, "no-extractor");
|
|
922
|
+
let extractorValue;
|
|
923
|
+
if (clearExtractor) {
|
|
924
|
+
extractorValue = "";
|
|
925
|
+
}
|
|
926
|
+
else if (extractorInline !== undefined) {
|
|
927
|
+
extractorValue = extractorInline;
|
|
928
|
+
}
|
|
929
|
+
else if (extractorFile !== undefined) {
|
|
930
|
+
const fsp = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
|
|
931
|
+
extractorValue = await fsp.readFile(node_path_1.default.resolve(extractorFile), "utf8");
|
|
932
|
+
}
|
|
933
|
+
const targetProvider = provider === "custom" || provider === "zhipu" ? provider : current.provider;
|
|
934
|
+
const next = {
|
|
935
|
+
...current,
|
|
936
|
+
enabled: enable ? true : disable ? false : current.enabled,
|
|
937
|
+
provider: targetProvider,
|
|
938
|
+
tokenEnv: tokenEnv ?? current.tokenEnv,
|
|
939
|
+
token: clearToken ? null : token !== undefined ? token : current.token,
|
|
940
|
+
cacheTtlMs: ttlMs ?? current.cacheTtlMs,
|
|
941
|
+
timeoutMs: timeoutMs ?? current.timeoutMs,
|
|
942
|
+
userAgent: userAgent ?? current.userAgent,
|
|
943
|
+
zhipu: {
|
|
944
|
+
url: targetProvider === "zhipu" && url !== undefined ? url : current.zhipu.url,
|
|
945
|
+
project: project ?? current.zhipu.project,
|
|
946
|
+
organization: organization ?? current.zhipu.organization,
|
|
947
|
+
},
|
|
948
|
+
custom: {
|
|
949
|
+
url: targetProvider === "custom" && url !== undefined ? url : current.custom.url,
|
|
950
|
+
method: method ?? current.custom.method,
|
|
951
|
+
headers: clearHeaders ? {} : headers ?? current.custom.headers,
|
|
952
|
+
fiveHourPath: fiveHourPath ?? current.custom.fiveHourPath,
|
|
953
|
+
sevenDayPath: sevenDayPath ?? current.custom.sevenDayPath,
|
|
954
|
+
extractor: extractorValue !== undefined ? extractorValue : current.custom.extractor,
|
|
955
|
+
},
|
|
956
|
+
};
|
|
957
|
+
const changed = enable ||
|
|
958
|
+
disable ||
|
|
959
|
+
clearToken ||
|
|
960
|
+
clearHeaders ||
|
|
961
|
+
provider !== undefined ||
|
|
962
|
+
tokenEnv !== undefined ||
|
|
963
|
+
token !== undefined ||
|
|
964
|
+
ttlMs !== null ||
|
|
965
|
+
timeoutMs !== null ||
|
|
966
|
+
userAgent !== undefined ||
|
|
967
|
+
url !== undefined ||
|
|
968
|
+
project !== undefined ||
|
|
969
|
+
organization !== undefined ||
|
|
970
|
+
method !== undefined ||
|
|
971
|
+
headers !== null ||
|
|
972
|
+
fiveHourPath !== undefined ||
|
|
973
|
+
sevenDayPath !== undefined ||
|
|
974
|
+
extractorValue !== undefined;
|
|
975
|
+
if (changed) {
|
|
976
|
+
await (0, api_mode_1.writeApiConfig)(dataDir, next);
|
|
977
|
+
(0, debug_1.debugLog)("api-mode", "config updated", next);
|
|
978
|
+
}
|
|
979
|
+
const header = changed ? "API 模式配置已更新:" : "当前 API 模式配置:";
|
|
980
|
+
const lines = [
|
|
981
|
+
` 启用: ${next.enabled ? "是" : "否"}`,
|
|
982
|
+
` provider: ${next.provider}`,
|
|
983
|
+
` token 环境变量: ${next.tokenEnv}`,
|
|
984
|
+
` token 兜底: ${maskSecret(next.token)}`,
|
|
985
|
+
` 当前生效 token: ${maskSecret((0, api_mode_1.resolveApiToken)(next))}`,
|
|
986
|
+
` 缓存 TTL: ${formatDurationMs(next.cacheTtlMs)}`,
|
|
987
|
+
` 请求超时: ${formatDurationMs(next.timeoutMs)}`,
|
|
988
|
+
` User-Agent: ${next.userAgent}`,
|
|
989
|
+
` [zhipu] url: ${next.zhipu.url}`,
|
|
990
|
+
` [zhipu] project: ${next.zhipu.project || "(无)"}`,
|
|
991
|
+
` [zhipu] organization: ${next.zhipu.organization || "(无)"}`,
|
|
992
|
+
` [custom] url: ${next.custom.url || "(未配置)"}`,
|
|
993
|
+
` [custom] method: ${next.custom.method}`,
|
|
994
|
+
` [custom] headers: ${Object.keys(next.custom.headers).length > 0 ? JSON.stringify(next.custom.headers) : "(无)"}`,
|
|
995
|
+
` [custom] fiveHourPath: ${next.custom.fiveHourPath}`,
|
|
996
|
+
` [custom] sevenDayPath: ${next.custom.sevenDayPath}`,
|
|
997
|
+
` [custom] extractor: ${next.custom.extractor.trim() !== "" ? `(已配置,${next.custom.extractor.length} 字符;优先于点分路径)` : "(无,用点分路径)"}`,
|
|
998
|
+
];
|
|
999
|
+
process.stdout.write(`${header}\n${lines.join("\n")}\n`);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* `ccus api test`:立即拉取一次第三方额度并打印,验证配置是否生效。
|
|
1003
|
+
*
|
|
1004
|
+
* 直接打真实接口(绕过缓存),成功打印 5h/7d/level,失败把原因打到 stderr 并返回非 0 退出码。
|
|
1005
|
+
*/
|
|
1006
|
+
async function handleApiTest(options) {
|
|
1007
|
+
const dataDir = getDataDir(options);
|
|
1008
|
+
const config = (0, api_mode_1.readApiConfig)(dataDir);
|
|
1009
|
+
if (!config.enabled) {
|
|
1010
|
+
process.stdout.write("API 模式未启用,先用 `ccus api config --enable` 开启。\n");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
process.stdout.write(`正在用 provider=${config.provider} 拉取额度(超时 ${formatDurationMs(config.timeoutMs)})...\n`);
|
|
1014
|
+
try {
|
|
1015
|
+
const quota = await (0, api_mode_1.fetchQuota)(config, process.env);
|
|
1016
|
+
const fiveHour = quota.fiveHour !== null ? `${quota.fiveHour.toFixed(1)}%` : "--";
|
|
1017
|
+
const sevenDay = quota.sevenDay !== null ? `${quota.sevenDay.toFixed(1)}%` : "--";
|
|
1018
|
+
process.stdout.write(`✅ 5h ${fiveHour} | 7d ${sevenDay}${quota.level ? ` | level ${quota.level}` : ""}\n`);
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1022
|
+
process.stderr.write(`❌ 拉取失败:${message}\n`);
|
|
1023
|
+
process.exitCode = 1;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/** `ccus api status`:打印当前 API 模式配置摘要与额度缓存新鲜度。 */
|
|
1027
|
+
async function handleApiStatus(options) {
|
|
1028
|
+
const dataDir = getDataDir(options);
|
|
1029
|
+
const config = (0, api_mode_1.readApiConfig)(dataDir);
|
|
1030
|
+
const cache = (0, api_mode_1.readApiQuotaCacheSync)(dataDir);
|
|
1031
|
+
const now = Date.now();
|
|
1032
|
+
let fresh = false;
|
|
1033
|
+
let ageLabel = "(无缓存)";
|
|
1034
|
+
if (cache) {
|
|
1035
|
+
const fetched = Date.parse(cache.fetchedAt);
|
|
1036
|
+
if (Number.isFinite(fetched)) {
|
|
1037
|
+
const ageMs = now - fetched;
|
|
1038
|
+
fresh = ageMs < config.cacheTtlMs;
|
|
1039
|
+
ageLabel = `${formatDurationMs(ageMs)} 前${fresh ? "" : "(已过期)"}`;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const lines = [
|
|
1043
|
+
` 启用: ${config.enabled ? "是" : "否"}`,
|
|
1044
|
+
` provider: ${config.provider}`,
|
|
1045
|
+
` 当前生效 token: ${maskSecret((0, api_mode_1.resolveApiToken)(config))}`,
|
|
1046
|
+
` 缓存 TTL: ${formatDurationMs(config.cacheTtlMs)}`,
|
|
1047
|
+
` 缓存状态: ${ageLabel}`,
|
|
1048
|
+
];
|
|
1049
|
+
if (cache) {
|
|
1050
|
+
const fiveHour = cache.fiveHour !== null ? `${cache.fiveHour.toFixed(1)}%` : "--";
|
|
1051
|
+
const sevenDay = cache.sevenDay !== null ? `${cache.sevenDay.toFixed(1)}%` : "--";
|
|
1052
|
+
lines.push(` 缓存额度: 5h ${fiveHour} | 7d ${sevenDay}${cache.level ? ` | level ${cache.level}` : ""}`);
|
|
1053
|
+
}
|
|
1054
|
+
process.stdout.write(`API 模式状态:\n${lines.join("\n")}\n`);
|
|
1055
|
+
}
|
|
829
1056
|
/** 顶层命令分发入口。 */
|
|
830
1057
|
async function main(args = process.argv.slice(2)) {
|
|
831
1058
|
(0, debug_1.setDebugEnabled)((0, debug_1.resolveDebugEnabled)(args));
|
|
@@ -875,6 +1102,25 @@ async function main(args = process.argv.slice(2)) {
|
|
|
875
1102
|
await handleSync(parseOptions(args.slice(1)));
|
|
876
1103
|
return;
|
|
877
1104
|
}
|
|
1105
|
+
if (group === "api") {
|
|
1106
|
+
if (action === "config") {
|
|
1107
|
+
await handleApiConfig(parseOptions(rest));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (action === "test") {
|
|
1111
|
+
await handleApiTest(parseOptions(rest));
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (action === "status") {
|
|
1115
|
+
await handleApiStatus(parseOptions(rest));
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (action && !action.startsWith("--")) {
|
|
1119
|
+
throw new Error(`Unsupported api argument: ${action}. Use \`ccus api config\`, \`ccus api test\` or \`ccus api status\`.`);
|
|
1120
|
+
}
|
|
1121
|
+
await handleApiStatus(parseOptions(args.slice(1)));
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
878
1124
|
if (group === "__sync") {
|
|
879
1125
|
await handleBackgroundSync(parseOptions(args.slice(1)));
|
|
880
1126
|
return;
|
package/dist/lib/aggregate.js
CHANGED
|
@@ -4,7 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.loadWeeklyExportBundles = loadWeeklyExportBundles;
|
|
7
|
+
exports.computeCumulativeSevenDayCurve = computeCumulativeSevenDayCurve;
|
|
7
8
|
exports.computeCumulativeSevenDay = computeCumulativeSevenDay;
|
|
9
|
+
exports.deburrSevenDayEvents = deburrSevenDayEvents;
|
|
10
|
+
exports.buildSevenDayCurveFromEvents = buildSevenDayCurveFromEvents;
|
|
8
11
|
exports.buildPersonSevenDayCurve = buildPersonSevenDayCurve;
|
|
9
12
|
exports.buildAggregatedDetailRows = buildAggregatedDetailRows;
|
|
10
13
|
exports.buildAggregatedDailyRows = buildAggregatedDailyRows;
|
|
@@ -265,7 +268,8 @@ function bundleEventsByDate(bundle) {
|
|
|
265
268
|
*/
|
|
266
269
|
const SEVEN_DAY_RESET_RATIO = 0.5;
|
|
267
270
|
/**
|
|
268
|
-
* 7
|
|
271
|
+
* 7 天额度累计真实使用量的**逐点曲线**:对一组事件取非 null `sevenDayUsagePct`、按时间升序,用**分段峰谷和**
|
|
272
|
+
* 还原,并在每个样本处输出「截至此刻的累计值」,得到一条单调非递减曲线。
|
|
269
273
|
*
|
|
270
274
|
* 把曲线按 reset(样本跌破当前段峰值的 {@link SEVEN_DAY_RESET_RATIO})切成若干上升段,
|
|
271
275
|
* 每段贡献「段内峰值 − 段内谷值」,累计 = 各段贡献之和。等价于「正增量累加,但忽略未跌破段峰一半的小回落」。
|
|
@@ -274,21 +278,23 @@ const SEVEN_DAY_RESET_RATIO = 0.5;
|
|
|
274
278
|
* 朴素累加会把每次上抖都计成真实增长,导致严重高估(实测 gaoshang 102 vs 分段 50)。
|
|
275
279
|
* 分段峰谷和对抖动 / aging 回落鲁棒,又能正确累计「涨到峰 → 归零 → 再涨」的多段真实使用。
|
|
276
280
|
*
|
|
277
|
-
*
|
|
281
|
+
* 每个点的累计 = 已锁定的各完整段贡献 + 当前段 (segMax − segMin);段内更新 / reset 都不会让它回落,
|
|
282
|
+
* 所以曲线单调非递减、终点恒等于 {@link computeCumulativeSevenDay} 的标量返回值。输入建议先经 deburr 去毛刺。
|
|
283
|
+
* 无有效样本返回空数组;单样本返回单点 0。
|
|
278
284
|
*/
|
|
279
|
-
function
|
|
280
|
-
const
|
|
281
|
-
.
|
|
282
|
-
.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return null;
|
|
285
|
+
function computeCumulativeSevenDayCurve(events) {
|
|
286
|
+
const sorted = [...events]
|
|
287
|
+
.filter((event) => event.sevenDayUsagePct !== null)
|
|
288
|
+
.sort((left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime());
|
|
289
|
+
if (sorted.length === 0) {
|
|
290
|
+
return [];
|
|
286
291
|
}
|
|
292
|
+
const points = [{ timestamp: sorted[0].timestamp, cumulative: 0 }];
|
|
287
293
|
let cumulative = 0;
|
|
288
|
-
let segMin =
|
|
289
|
-
let segMax =
|
|
290
|
-
for (let index = 1; index <
|
|
291
|
-
const value =
|
|
294
|
+
let segMin = sorted[0].sevenDayUsagePct;
|
|
295
|
+
let segMax = sorted[0].sevenDayUsagePct;
|
|
296
|
+
for (let index = 1; index < sorted.length; index += 1) {
|
|
297
|
+
const value = sorted[index].sevenDayUsagePct;
|
|
292
298
|
if (value > segMax) {
|
|
293
299
|
segMax = value;
|
|
294
300
|
}
|
|
@@ -302,9 +308,18 @@ function computeCumulativeSevenDay(events) {
|
|
|
302
308
|
// 段内小回落(抖动 / aging):只更新谷值,不分段、不重复计数。
|
|
303
309
|
segMin = value;
|
|
304
310
|
}
|
|
311
|
+
points.push({ timestamp: sorted[index].timestamp, cumulative: (0, time_1.roundNumber)(cumulative + (segMax - segMin), 1) ?? 0 });
|
|
305
312
|
}
|
|
306
|
-
|
|
307
|
-
|
|
313
|
+
return points;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* 7 天额度累计真实使用量(标量):取 {@link computeCumulativeSevenDayCurve} 曲线终点。
|
|
317
|
+
*
|
|
318
|
+
* 无有效样本返回 null(区别于 0:0 表示有样本但无净增长);单样本返回 0。
|
|
319
|
+
*/
|
|
320
|
+
function computeCumulativeSevenDay(events) {
|
|
321
|
+
const curve = computeCumulativeSevenDayCurve(events);
|
|
322
|
+
return curve.length === 0 ? null : curve[curve.length - 1].cumulative;
|
|
308
323
|
}
|
|
309
324
|
/** 7d 曲线读数去毛刺的最小持续时长:短于此的中间读数段视为 stale / 瞬时异常。 */
|
|
310
325
|
const SEVEN_DAY_MIN_HOLD_MS = 2 * 60 * 1000;
|
|
@@ -312,10 +327,14 @@ const SEVEN_DAY_MIN_HOLD_MS = 2 * 60 * 1000;
|
|
|
312
327
|
* 7d 曲线读数去毛刺:真实 7d 信号变化慢、每个档位会被高频采样连续覆盖几十上百个样本,
|
|
313
328
|
* 而 stale 缓存读数 / 瞬时异常只会短暂出现(秒级,如 baseline 很低时偶发跳到 30 又落回)。
|
|
314
329
|
*
|
|
315
|
-
* 把**中间**持续短于 {@link SEVEN_DAY_MIN_HOLD_MS}
|
|
316
|
-
*
|
|
330
|
+
* 把**中间**持续短于 {@link SEVEN_DAY_MIN_HOLD_MS} 的读数段替换为最近的已保留前值,抹掉这些尖峰;
|
|
331
|
+
* 首尾段无条件保留(端点缺上下文判断持续性)。这等价于人眼在曲线图上自动忽略短毛刺。
|
|
317
332
|
*
|
|
318
|
-
*
|
|
333
|
+
* 持续时长默认按「下一段起始 − 本段起始」度量(密集采样下约等于本段实际持续);但当**多样本**段后面紧跟一段
|
|
334
|
+
* 比阈值还大的**采集间隙**时,该度量会把间隙也算进去、把只持续几十秒的短尖峰“撑”过阈值而漏抹,
|
|
335
|
+
* 此时改用**段内真实跨度(最后样本 − 第一样本)**判定。单样本段无段内跨度、无法据此判断,仍走原度量。
|
|
336
|
+
*
|
|
337
|
+
* 仅对密集采样的真实曲线生效:稀疏数据(每个值只有一两个样本、间隔很大)的单样本中间段仍按原度量保留,
|
|
319
338
|
* 不会被误删,所以 spec 的稀疏示例与单样本段不受影响。输入须按 timestamp 升序。
|
|
320
339
|
*/
|
|
321
340
|
function deburrSevenDayEvents(events, minHoldMs = SEVEN_DAY_MIN_HOLD_MS) {
|
|
@@ -329,14 +348,28 @@ function deburrSevenDayEvents(events, minHoldMs = SEVEN_DAY_MIN_HOLD_MS) {
|
|
|
329
348
|
while (end + 1 < events.length && events[end + 1].sevenDayUsagePct === events[index].sevenDayUsagePct) {
|
|
330
349
|
end += 1;
|
|
331
350
|
}
|
|
332
|
-
runs.push({
|
|
351
|
+
runs.push({
|
|
352
|
+
value: events[index].sevenDayUsagePct,
|
|
353
|
+
startIdx: index,
|
|
354
|
+
endIdx: end,
|
|
355
|
+
startTime: new Date(events[index].timestamp).getTime(),
|
|
356
|
+
endTime: new Date(events[end].timestamp).getTime(),
|
|
357
|
+
});
|
|
333
358
|
index = end + 1;
|
|
334
359
|
}
|
|
335
360
|
const result = [...events];
|
|
336
361
|
let lastKept = runs[0].value;
|
|
337
362
|
for (let k = 0; k < runs.length; k += 1) {
|
|
338
363
|
const isEdge = k === 0 || k === runs.length - 1;
|
|
339
|
-
|
|
364
|
+
// 默认持续时长 = 下一段起始 − 本段起始(密集采样下约等于本段实际持续)。
|
|
365
|
+
const toNext = k + 1 < runs.length ? runs[k + 1].startTime - runs[k].startTime : Number.POSITIVE_INFINITY;
|
|
366
|
+
// 本段最后样本到下一段起始的采集间隙:密集采样下很小,遇到数据采集中断时会变大。
|
|
367
|
+
const gapAfter = k + 1 < runs.length ? runs[k + 1].startTime - runs[k].endTime : Number.POSITIVE_INFINITY;
|
|
368
|
+
// 多样本段(段内本身有跨度)后面若紧跟一段比阈值还大的采集间隙,说明 toNext 把间隙也算进了持续时长,
|
|
369
|
+
// 会把本只持续几十秒的短尖峰“撑”过阈值而漏抹;此时改用段内真实跨度判定,正确识别这类 stale 尖峰。
|
|
370
|
+
// 单样本段(无段内跨度)无法据此判断真实持续,仍走 toNext,避免误伤稀疏单样本数据。
|
|
371
|
+
const multiSample = runs[k].endIdx > runs[k].startIdx;
|
|
372
|
+
const holdMs = multiSample && gapAfter > minHoldMs ? runs[k].endTime - runs[k].startTime : toNext;
|
|
340
373
|
if (isEdge || holdMs >= minHoldMs) {
|
|
341
374
|
lastKept = runs[k].value;
|
|
342
375
|
continue;
|
|
@@ -347,6 +380,28 @@ function deburrSevenDayEvents(events, minHoldMs = SEVEN_DAY_MIN_HOLD_MS) {
|
|
|
347
380
|
}
|
|
348
381
|
return result;
|
|
349
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* 把一组事件整理成一条可用于累计计算的 7d 曲线:取非 null `sevenDayUsagePct`、按时间升序、
|
|
385
|
+
* 对完全相同 timestamp 去重,再做读数去毛刺。
|
|
386
|
+
*
|
|
387
|
+
* 单台机器的本地日志(dashboard 个人看板)与多机合并(aggregate)都走这同一套整理逻辑,
|
|
388
|
+
* 保证两边的累计口径不会漂移。
|
|
389
|
+
*/
|
|
390
|
+
function buildSevenDayCurveFromEvents(events) {
|
|
391
|
+
const sorted = [...events]
|
|
392
|
+
.filter((event) => event.sevenDayUsagePct !== null)
|
|
393
|
+
.sort((left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime());
|
|
394
|
+
const seen = new Set();
|
|
395
|
+
const deduped = [];
|
|
396
|
+
for (const event of sorted) {
|
|
397
|
+
if (seen.has(event.timestamp)) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
seen.add(event.timestamp);
|
|
401
|
+
deduped.push(event);
|
|
402
|
+
}
|
|
403
|
+
return deburrSevenDayEvents(deduped);
|
|
404
|
+
}
|
|
350
405
|
/**
|
|
351
406
|
* 同一 personKey 的 7d 累计曲线:把该人**所有 bundle**(非仅 winner)的 rawEvents 计算成事件、
|
|
352
407
|
* 取非 null `sevenDayUsagePct`、按 timestamp 升序合并、对完全相同 timestamp 去重,再做读数去毛刺,
|
|
@@ -380,17 +435,7 @@ function buildPersonSevenDayCurve(bundles) {
|
|
|
380
435
|
}
|
|
381
436
|
const curves = new Map();
|
|
382
437
|
for (const [personKey, events] of collected.entries()) {
|
|
383
|
-
|
|
384
|
-
const seen = new Set();
|
|
385
|
-
const deduped = [];
|
|
386
|
-
for (const event of sorted) {
|
|
387
|
-
if (seen.has(event.timestamp)) {
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
seen.add(event.timestamp);
|
|
391
|
-
deduped.push(event);
|
|
392
|
-
}
|
|
393
|
-
curves.set(personKey, deburrSevenDayEvents(deduped));
|
|
438
|
+
curves.set(personKey, buildSevenDayCurveFromEvents(events));
|
|
394
439
|
}
|
|
395
440
|
personSevenDayCurveCache.set(bundles, curves);
|
|
396
441
|
return curves;
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ZHIPU_EXTRACTOR = void 0;
|
|
7
|
+
exports.defaultApiConfig = defaultApiConfig;
|
|
8
|
+
exports.readApiConfig = readApiConfig;
|
|
9
|
+
exports.writeApiConfig = writeApiConfig;
|
|
10
|
+
exports.resolveApiToken = resolveApiToken;
|
|
11
|
+
exports.extractCustomQuota = extractCustomQuota;
|
|
12
|
+
exports.runExtractor = runExtractor;
|
|
13
|
+
exports.normalizeExtractorResult = normalizeExtractorResult;
|
|
14
|
+
exports.fetchQuota = fetchQuota;
|
|
15
|
+
exports.readApiQuotaCacheSync = readApiQuotaCacheSync;
|
|
16
|
+
exports.resolveApiQuota = resolveApiQuota;
|
|
17
|
+
exports.applyQuotaToPayload = applyQuotaToPayload;
|
|
18
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
19
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
20
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
21
|
+
const node_https_1 = __importDefault(require("node:https"));
|
|
22
|
+
const debug_1 = require("./debug");
|
|
23
|
+
const paths_1 = require("./paths");
|
|
24
|
+
/** 默认智谱额度查询地址(团队视图 query)。 */
|
|
25
|
+
const DEFAULT_ZHIPU_URL = "https://open.bigmodel.cn/api/monitor/usage/quota/limit?type=2";
|
|
26
|
+
/** 默认从哪个环境变量读 token:Claude Code 用第三方 API 时通常注入它。 */
|
|
27
|
+
const DEFAULT_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN";
|
|
28
|
+
/** 额度缓存 TTL:statusline 高频调用,默认 5 分钟拉一次。 */
|
|
29
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
/** 单次额度请求超时。 */
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
32
|
+
/** 默认请求 UA;遇反爬虫可在配置里改成浏览器 UA。 */
|
|
33
|
+
const DEFAULT_USER_AGENT = "ccus/statusline";
|
|
34
|
+
/** 构造一份带默认值的 API 模式配置。 */
|
|
35
|
+
function defaultApiConfig() {
|
|
36
|
+
return {
|
|
37
|
+
enabled: false,
|
|
38
|
+
provider: "zhipu",
|
|
39
|
+
tokenEnv: DEFAULT_TOKEN_ENV,
|
|
40
|
+
token: null,
|
|
41
|
+
cacheTtlMs: DEFAULT_CACHE_TTL_MS,
|
|
42
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
43
|
+
userAgent: DEFAULT_USER_AGENT,
|
|
44
|
+
zhipu: { url: DEFAULT_ZHIPU_URL, project: "", organization: "" },
|
|
45
|
+
custom: {
|
|
46
|
+
url: "",
|
|
47
|
+
method: "GET",
|
|
48
|
+
headers: {},
|
|
49
|
+
fiveHourPath: "data.five_hour.used_percentage",
|
|
50
|
+
sevenDayPath: "data.seven_day.used_percentage",
|
|
51
|
+
extractor: "",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** 仅把普通对象视作可继续读取字段的记录类型。 */
|
|
56
|
+
function isRecord(value) {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
/** 宽松读取数字,兼容 number 和可转成 number 的字符串。 */
|
|
60
|
+
function toNumber(value) {
|
|
61
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
65
|
+
const parsed = Number(value);
|
|
66
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 同步读取 API 模式配置;缺省补默认值,任何异常都回退到「未启用」的安全默认。
|
|
72
|
+
*
|
|
73
|
+
* statusline 兜底路径需要同步、极快、绝不抛错,所以这里用同步 IO(照搬 readSyncConfig)。
|
|
74
|
+
*/
|
|
75
|
+
function readApiConfig(dataDir) {
|
|
76
|
+
const base = defaultApiConfig();
|
|
77
|
+
try {
|
|
78
|
+
const raw = node_fs_1.default.readFileSync((0, paths_1.getApiConfigPath)(dataDir), "utf8");
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
return {
|
|
81
|
+
enabled: parsed.enabled === true,
|
|
82
|
+
provider: parsed.provider === "custom" ? "custom" : "zhipu",
|
|
83
|
+
tokenEnv: typeof parsed.tokenEnv === "string" && parsed.tokenEnv.trim() !== "" ? parsed.tokenEnv : base.tokenEnv,
|
|
84
|
+
token: typeof parsed.token === "string" && parsed.token.trim() !== "" ? parsed.token : null,
|
|
85
|
+
cacheTtlMs: typeof parsed.cacheTtlMs === "number" && Number.isFinite(parsed.cacheTtlMs) && parsed.cacheTtlMs >= 0 ? parsed.cacheTtlMs : base.cacheTtlMs,
|
|
86
|
+
timeoutMs: typeof parsed.timeoutMs === "number" && Number.isFinite(parsed.timeoutMs) && parsed.timeoutMs > 0 ? parsed.timeoutMs : base.timeoutMs,
|
|
87
|
+
userAgent: typeof parsed.userAgent === "string" && parsed.userAgent.trim() !== "" ? parsed.userAgent : base.userAgent,
|
|
88
|
+
zhipu: {
|
|
89
|
+
url: typeof parsed.zhipu?.url === "string" && parsed.zhipu.url.trim() !== "" ? parsed.zhipu.url : base.zhipu.url,
|
|
90
|
+
project: typeof parsed.zhipu?.project === "string" ? parsed.zhipu.project : "",
|
|
91
|
+
organization: typeof parsed.zhipu?.organization === "string" ? parsed.zhipu.organization : "",
|
|
92
|
+
},
|
|
93
|
+
custom: {
|
|
94
|
+
url: typeof parsed.custom?.url === "string" ? parsed.custom.url : base.custom.url,
|
|
95
|
+
method: typeof parsed.custom?.method === "string" && parsed.custom.method.trim() !== "" ? parsed.custom.method : base.custom.method,
|
|
96
|
+
headers: isRecord(parsed.custom?.headers) ? parsed.custom.headers : base.custom.headers,
|
|
97
|
+
fiveHourPath: typeof parsed.custom?.fiveHourPath === "string" && parsed.custom.fiveHourPath.trim() !== "" ? parsed.custom.fiveHourPath : base.custom.fiveHourPath,
|
|
98
|
+
sevenDayPath: typeof parsed.custom?.sevenDayPath === "string" && parsed.custom.sevenDayPath.trim() !== "" ? parsed.custom.sevenDayPath : base.custom.sevenDayPath,
|
|
99
|
+
extractor: typeof parsed.custom?.extractor === "string" ? parsed.custom.extractor : "",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return base;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** 写入 API 模式配置;目录可能尚未创建,先 mkdir。 */
|
|
108
|
+
async function writeApiConfig(dataDir, config) {
|
|
109
|
+
await promises_1.default.mkdir(dataDir, { recursive: true });
|
|
110
|
+
await promises_1.default.writeFile((0, paths_1.getApiConfigPath)(dataDir), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
111
|
+
}
|
|
112
|
+
/** 解析实际 token:优先环境变量,读不到再用配置兜底。 */
|
|
113
|
+
function resolveApiToken(config, env = process.env) {
|
|
114
|
+
const fromEnv = (env[config.tokenEnv] ?? "").trim();
|
|
115
|
+
if (fromEnv !== "") {
|
|
116
|
+
return fromEnv;
|
|
117
|
+
}
|
|
118
|
+
return typeof config.token === "string" && config.token.trim() !== "" ? config.token : null;
|
|
119
|
+
}
|
|
120
|
+
/** 把 header 值里的 {{token}} / {{apikey}} 占位符替换成实际 token;token 为空则替换为空串。 */
|
|
121
|
+
function renderHeader(value, token) {
|
|
122
|
+
return value.replaceAll(/{{\s*(token|apikey)\s*}}/gi, token ?? "");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 智谱 GLM Coding Plan 的内置 extractor 脚本(zhipu provider 的预设)。
|
|
126
|
+
*
|
|
127
|
+
* data.limits 里筛 type==="TOKENS_LIMIT",按 nextResetTime 升序,第 1 条 = 5h、第 2 条 = 每周;
|
|
128
|
+
* success !== true / code !== 200 / 缺 data.limits 时返回会让归一化判 null 的值。
|
|
129
|
+
* zhipu 不再单独维护提取函数,与 custom 走同一条 `runExtractor` 路径,只是 extractor 用这组内置脚本。
|
|
130
|
+
*/
|
|
131
|
+
exports.ZHIPU_EXTRACTOR = `function(response) {
|
|
132
|
+
if (!response || typeof response !== "object") return null;
|
|
133
|
+
if (response.success !== undefined && response.success !== true) return null;
|
|
134
|
+
if (response.code !== undefined && response.code !== 200) return null;
|
|
135
|
+
const data = response.data;
|
|
136
|
+
if (!data || typeof data !== "object") return null;
|
|
137
|
+
const limits = data.limits;
|
|
138
|
+
if (!Array.isArray(limits)) return null;
|
|
139
|
+
const tokenLimits = limits
|
|
140
|
+
.filter((l) => l && l.type === "TOKENS_LIMIT")
|
|
141
|
+
.map((l) => ({ p: l.percentage, r: l.nextResetTime }))
|
|
142
|
+
.sort((a, b) => (typeof a.r === "number" ? a.r : Infinity) - (typeof b.r === "number" ? b.r : Infinity));
|
|
143
|
+
return {
|
|
144
|
+
fiveHour: tokenLimits[0] ? tokenLimits[0].p : null,
|
|
145
|
+
sevenDay: tokenLimits[1] ? tokenLimits[1].p : null,
|
|
146
|
+
level: typeof data.level === "string" && data.level !== "" ? data.level : null,
|
|
147
|
+
};
|
|
148
|
+
}`;
|
|
149
|
+
/** 按点分路径取值,支持数组索引(如 data.limits.0.percentage)。 */
|
|
150
|
+
function getByPath(value, path) {
|
|
151
|
+
const parts = path
|
|
152
|
+
.split(".")
|
|
153
|
+
.map((part) => part.trim())
|
|
154
|
+
.filter((part) => part.length > 0);
|
|
155
|
+
let current = value;
|
|
156
|
+
for (const part of parts) {
|
|
157
|
+
if (current === null || current === undefined) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(current)) {
|
|
161
|
+
const idx = Number(part);
|
|
162
|
+
if (!Number.isInteger(idx)) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
current = current[idx];
|
|
166
|
+
}
|
|
167
|
+
else if (isRecord(current)) {
|
|
168
|
+
current = current[part];
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
/** 自定义 provider 响应提取:按点分路径取 5h / 7d 数字。 */
|
|
177
|
+
function extractCustomQuota(json, fiveHourPath, sevenDayPath) {
|
|
178
|
+
const fiveHour = toNumber(getByPath(json, fiveHourPath));
|
|
179
|
+
const sevenDay = toNumber(getByPath(json, sevenDayPath));
|
|
180
|
+
if (fiveHour === null && sevenDay === null) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return { fiveHour, sevenDay, level: null };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 用用户提供的 extractor 脚本从响应里提取额度。
|
|
187
|
+
*
|
|
188
|
+
* 脚本经 `new Function` 在受限作用域求值,求值结果应为函数,再以响应对象调用。
|
|
189
|
+
* 返回值兼容三种:`{ fiveHour, sevenDay }`、`[{used|percentage|number}, {used|percentage|number}]`(cc-switch 风格)、`[number, number]`。
|
|
190
|
+
* 任何异常或非法返回都静默返回 null(debugLog 到 stderr),不抛出、不影响 statusline。
|
|
191
|
+
*
|
|
192
|
+
* 注意:脚本在 ccus 进程内以当前权限执行,只配置信任的来源。
|
|
193
|
+
*/
|
|
194
|
+
function runExtractor(script, response) {
|
|
195
|
+
const trimmed = script.trim();
|
|
196
|
+
if (trimmed === "") {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const factory = new Function(`"use strict"; return (${trimmed});`);
|
|
201
|
+
const extractor = factory();
|
|
202
|
+
if (typeof extractor !== "function") {
|
|
203
|
+
(0, debug_1.debugLog)("api-mode", "extractor script did not evaluate to a function");
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return normalizeExtractorResult(extractor(response));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
(0, debug_1.debugLog)("api-mode", "extractor script failed", error instanceof Error ? error.message : String(error));
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** 把 extractor 返回值归一化成 ApiQuota,兼容对象 / cc-switch 数组 / 数字数组。 */
|
|
214
|
+
function normalizeExtractorResult(result) {
|
|
215
|
+
if (result === null || result === undefined) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
if (isRecord(result)) {
|
|
219
|
+
const fiveHour = toNumber(result.fiveHour);
|
|
220
|
+
const sevenDay = toNumber(result.sevenDay);
|
|
221
|
+
if (fiveHour === null && sevenDay === null) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const levelRaw = result.level;
|
|
225
|
+
const level = typeof levelRaw === "string" && levelRaw.trim() !== "" ? levelRaw : null;
|
|
226
|
+
return { fiveHour, sevenDay, level };
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(result)) {
|
|
229
|
+
const fiveHour = extractArrayNumber(result[0]);
|
|
230
|
+
const sevenDay = extractArrayNumber(result[1]);
|
|
231
|
+
if (fiveHour === null && sevenDay === null) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return { fiveHour, sevenDay, level: null };
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
/** 从 extractor 数组单项里取数字:number 自身,或 {used|percentage|used_percentage}。 */
|
|
239
|
+
function extractArrayNumber(item) {
|
|
240
|
+
if (typeof item === "number") {
|
|
241
|
+
return Number.isFinite(item) ? item : null;
|
|
242
|
+
}
|
|
243
|
+
if (isRecord(item)) {
|
|
244
|
+
return toNumber(item.used) ?? toNumber(item.percentage) ?? toNumber(item.used_percentage);
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
/** 按 url 协议发起 HTTP(S) 请求,返回 {status, body};超时或错误 reject(仿 fetchLatestVersion)。 */
|
|
249
|
+
function httpRequest(url, opts) {
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = new URL(url);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
reject(new Error(`invalid url: ${url}`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const transport = parsed.protocol === "http:" ? node_http_1.default : node_https_1.default;
|
|
260
|
+
const request = transport.request(url, { method: opts.method, headers: opts.headers }, (response) => {
|
|
261
|
+
const status = response.statusCode ?? 0;
|
|
262
|
+
let body = "";
|
|
263
|
+
response.setEncoding("utf8");
|
|
264
|
+
response.on("data", (chunk) => {
|
|
265
|
+
body += chunk;
|
|
266
|
+
});
|
|
267
|
+
response.on("end", () => resolve({ status, body }));
|
|
268
|
+
});
|
|
269
|
+
request.setTimeout(opts.timeoutMs, () => {
|
|
270
|
+
request.destroy(new Error("api quota request timed out"));
|
|
271
|
+
});
|
|
272
|
+
request.on("error", reject);
|
|
273
|
+
request.end();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/** 根据配置与 token 构造一次额度请求(含 extractor);配置不完整返回 null。 */
|
|
277
|
+
function prepareQuotaRequest(config, token) {
|
|
278
|
+
if (config.provider === "custom") {
|
|
279
|
+
const url = config.custom.url.trim();
|
|
280
|
+
if (url === "") {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const headers = { "User-Agent": config.userAgent };
|
|
284
|
+
for (const [key, value] of Object.entries(config.custom.headers)) {
|
|
285
|
+
headers[key] = renderHeader(value, token);
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
url,
|
|
289
|
+
method: config.custom.method.toUpperCase() === "POST" ? "POST" : "GET",
|
|
290
|
+
headers,
|
|
291
|
+
extract: (json) => {
|
|
292
|
+
if (config.custom.extractor.trim() !== "") {
|
|
293
|
+
return runExtractor(config.custom.extractor, json);
|
|
294
|
+
}
|
|
295
|
+
return extractCustomQuota(json, config.custom.fiveHourPath, config.custom.sevenDayPath);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// 智谱:Authorization 裸 token + 可选 project/organization 头。
|
|
300
|
+
const headers = {
|
|
301
|
+
Authorization: token ?? "",
|
|
302
|
+
"Content-Type": "application/json",
|
|
303
|
+
"User-Agent": config.userAgent,
|
|
304
|
+
};
|
|
305
|
+
if (config.zhipu.project.trim() !== "") {
|
|
306
|
+
headers["bigmodel-project"] = config.zhipu.project.trim();
|
|
307
|
+
}
|
|
308
|
+
if (config.zhipu.organization.trim() !== "") {
|
|
309
|
+
headers["bigmodel-organization"] = config.zhipu.organization.trim();
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
url: config.zhipu.url,
|
|
313
|
+
method: "GET",
|
|
314
|
+
headers,
|
|
315
|
+
extract: (json) => runExtractor(exports.ZHIPU_EXTRACTOR, json),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* 真正发起一次请求并解析额度。
|
|
320
|
+
*
|
|
321
|
+
* 任何失败都 reject(HTTP 非 2xx、空响应、解析失败、字段缺失),由调用方决定静默/回退;
|
|
322
|
+
* 错误信息尽量包含可排查原因(如反爬虫空响应提示改 UA)。
|
|
323
|
+
*/
|
|
324
|
+
async function fetchQuota(config, env = process.env) {
|
|
325
|
+
const token = resolveApiToken(config, env);
|
|
326
|
+
const prepared = prepareQuotaRequest(config, token);
|
|
327
|
+
if (!prepared) {
|
|
328
|
+
throw new Error(`API 模式 provider=${config.provider} 配置不完整(缺少 url)`);
|
|
329
|
+
}
|
|
330
|
+
const { status, body } = await httpRequest(prepared.url, {
|
|
331
|
+
method: prepared.method,
|
|
332
|
+
headers: prepared.headers,
|
|
333
|
+
timeoutMs: config.timeoutMs,
|
|
334
|
+
});
|
|
335
|
+
(0, debug_1.debugLog)("api-mode", "quota response", { status, bodyLength: body.length, preview: body.slice(0, 200) });
|
|
336
|
+
if (status < 200 || status >= 300) {
|
|
337
|
+
throw new Error(`额度接口返回 HTTP ${status}`);
|
|
338
|
+
}
|
|
339
|
+
if (body.trim() === "") {
|
|
340
|
+
throw new Error("额度接口返回空响应(可能被反爬虫拦截,尝试 `ccus api config --user-agent <浏览器UA>`)");
|
|
341
|
+
}
|
|
342
|
+
let json;
|
|
343
|
+
try {
|
|
344
|
+
json = JSON.parse(body);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
throw new Error(`额度响应不是合法 JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
348
|
+
}
|
|
349
|
+
const quota = prepared.extract(json);
|
|
350
|
+
if (!quota) {
|
|
351
|
+
throw new Error("额度响应无法解析出 5h/7d(字段路径不对或接口未返回额度数据)");
|
|
352
|
+
}
|
|
353
|
+
return quota;
|
|
354
|
+
}
|
|
355
|
+
/** 同步读取额度缓存;损坏/缺失返回 null。 */
|
|
356
|
+
function readApiQuotaCacheSync(dataDir) {
|
|
357
|
+
try {
|
|
358
|
+
const raw = node_fs_1.default.readFileSync((0, paths_1.getApiQuotaCachePath)(dataDir), "utf8");
|
|
359
|
+
const parsed = JSON.parse(raw);
|
|
360
|
+
if (typeof parsed.fetchedAt !== "string") {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
fetchedAt: parsed.fetchedAt,
|
|
365
|
+
fiveHour: typeof parsed.fiveHour === "number" ? parsed.fiveHour : null,
|
|
366
|
+
sevenDay: typeof parsed.sevenDay === "number" ? parsed.sevenDay : null,
|
|
367
|
+
level: typeof parsed.level === "string" && parsed.level.trim() !== "" ? parsed.level : null,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** 写入额度缓存;失败静默(不能因缓存写入失败影响 statusline 主流程)。 */
|
|
375
|
+
async function writeApiQuotaCache(dataDir, cache) {
|
|
376
|
+
try {
|
|
377
|
+
await promises_1.default.mkdir(dataDir, { recursive: true });
|
|
378
|
+
await promises_1.default.writeFile((0, paths_1.getApiQuotaCachePath)(dataDir), `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
(0, debug_1.debugLog)("api-mode", "failed to write quota cache", error instanceof Error ? error.message : String(error));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function isCacheFresh(cache, ttlMs, now) {
|
|
385
|
+
if (!cache) {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
const fetched = Date.parse(cache.fetchedAt);
|
|
389
|
+
if (!Number.isFinite(fetched)) {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
return now.getTime() - fetched < ttlMs;
|
|
393
|
+
}
|
|
394
|
+
function quotaFromCache(cache) {
|
|
395
|
+
return { fiveHour: cache.fiveHour, sevenDay: cache.sevenDay, level: cache.level };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* 缓存优先解析额度:新鲜直接返回;过期同步拉一次(带超时);拉取失败回退旧缓存,全失败返回 null。
|
|
399
|
+
*
|
|
400
|
+
* statusline 路径用,全程不抛错、不写 stdout。`options.fetcher` 供测试注入。
|
|
401
|
+
*/
|
|
402
|
+
async function resolveApiQuota(dataDir, config, env = process.env, options = {}) {
|
|
403
|
+
const now = options.now ?? new Date();
|
|
404
|
+
const fetcher = options.fetcher ?? fetchQuota;
|
|
405
|
+
const cached = readApiQuotaCacheSync(dataDir);
|
|
406
|
+
if (isCacheFresh(cached, config.cacheTtlMs, now) && cached) {
|
|
407
|
+
return quotaFromCache(cached);
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const quota = await fetcher(config, env);
|
|
411
|
+
await writeApiQuotaCache(dataDir, { ...quota, fetchedAt: now.toISOString() });
|
|
412
|
+
return quota;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
(0, debug_1.debugLog)("api-mode", "quota fetch failed, fallback to cache", error instanceof Error ? error.message : String(error));
|
|
416
|
+
if (cached) {
|
|
417
|
+
return quotaFromCache(cached);
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* 把额度合并写进 rawPayload.rate_limits:保留已有官方字段,补 five_hour / seven_day 的 used_percentage。
|
|
424
|
+
*
|
|
425
|
+
* 之后 appendEvent 落盘即带 rate_limits,computeStatuslineEvent 自动算出 usage;raw-first,零契约改动。
|
|
426
|
+
*/
|
|
427
|
+
function applyQuotaToPayload(rawPayload, quota) {
|
|
428
|
+
const existing = isRecord(rawPayload.rate_limits) ? rawPayload.rate_limits : {};
|
|
429
|
+
const existingFiveHour = isRecord(existing.five_hour) ? existing.five_hour : {};
|
|
430
|
+
const existingSevenDay = isRecord(existing.seven_day) ? existing.seven_day : {};
|
|
431
|
+
rawPayload.rate_limits = {
|
|
432
|
+
...existing,
|
|
433
|
+
five_hour: { ...existingFiveHour, used_percentage: quota.fiveHour },
|
|
434
|
+
seven_day: { ...existingSevenDay, used_percentage: quota.sevenDay },
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
//# sourceMappingURL=api-mode.js.map
|
package/dist/lib/chart-assets.js
CHANGED
|
@@ -324,6 +324,7 @@ function uplotBodyScripts() {
|
|
|
324
324
|
};
|
|
325
325
|
if (s.fill) ser.fill = s.fill;
|
|
326
326
|
if (s.dash) ser.dash = s.dash;
|
|
327
|
+
if (s.scale) ser.scale = s.scale;
|
|
327
328
|
if (spec.bars) {
|
|
328
329
|
ser.paths = uPlot.paths.bars({ size: [0.62, 48], align: 0 });
|
|
329
330
|
ser.points = { show: false };
|
|
@@ -379,12 +380,30 @@ function uplotBodyScripts() {
|
|
|
379
380
|
};
|
|
380
381
|
}
|
|
381
382
|
|
|
383
|
+
var axes = [xAxis, yAxis];
|
|
384
|
+
if (spec.y2) {
|
|
385
|
+
var y2unit = spec.y2.unit || "";
|
|
386
|
+
scales[spec.y2.scale] = spec.y2.range
|
|
387
|
+
? { range: spec.y2.range }
|
|
388
|
+
: (spec.y2.min0
|
|
389
|
+
? { range: function (u, min, max) { var hi = max == null || max <= 0 ? 1 : max; return [0, hi]; } }
|
|
390
|
+
: {});
|
|
391
|
+
axes.push({
|
|
392
|
+
scale: spec.y2.scale,
|
|
393
|
+
side: 1,
|
|
394
|
+
stroke: THEME.axis,
|
|
395
|
+
grid: { show: false },
|
|
396
|
+
ticks: { stroke: THEME.grid, width: 1 },
|
|
397
|
+
values: function (u, splits) { return splits.map(function (v) { return round1(v) + y2unit; }); },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
382
401
|
var opts = {
|
|
383
402
|
width: el.clientWidth || 920,
|
|
384
403
|
height: spec.height || 280,
|
|
385
404
|
scales: scales,
|
|
386
405
|
series: series,
|
|
387
|
-
axes:
|
|
406
|
+
axes: axes,
|
|
388
407
|
// 关掉 uPlot 原生底部 legend(每条 series 一行),改用 buildLegend 自绘「每人一项」图例。
|
|
389
408
|
legend: { show: false },
|
|
390
409
|
// dataIdx 2D 吸附:横向重合的尖峰按鼠标高度命中峰顶,稀疏区/缺失退回最近真实点。
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.summarizeEvents = summarizeEvents;
|
|
4
4
|
exports.bucketizeEvents = bucketizeEvents;
|
|
5
5
|
exports.buildDashboardHtml = buildDashboardHtml;
|
|
6
|
+
const aggregate_1 = require("./aggregate");
|
|
6
7
|
const chart_assets_1 = require("./chart-assets");
|
|
7
8
|
const time_1 = require("./time");
|
|
8
9
|
/** 所有插入到 HTML/SVG 的动态文本都先转义,避免本地页面被注入内容。 */
|
|
@@ -32,11 +33,14 @@ function summarizeEvents(events) {
|
|
|
32
33
|
const latestSevenDayUsagePct = [...events]
|
|
33
34
|
.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime())
|
|
34
35
|
.find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null;
|
|
36
|
+
// 7d 分区叠加累计:与 aggregate 同一套「去毛刺 + 分段峰谷和」,口径与团队看板一致。
|
|
37
|
+
const sevenDayCumulativeUsagePct = (0, aggregate_1.computeCumulativeSevenDay)((0, aggregate_1.buildSevenDayCurveFromEvents)(events));
|
|
35
38
|
return {
|
|
36
39
|
fiveHourLatestUsagePct: latestUsagePct,
|
|
37
40
|
fiveHourPeakUsagePct: usages.length > 0 ? (0, time_1.roundNumber)(Math.max(...usages), 1) : null,
|
|
38
41
|
sevenDayLatestUsagePct: sevenDayUsages.length > 0 ? latestSevenDayUsagePct : null,
|
|
39
42
|
sevenDayPeakUsagePct: sevenDayUsages.length > 0 ? (0, time_1.roundNumber)(Math.max(...sevenDayUsages), 1) : null,
|
|
43
|
+
sevenDayCumulativeUsagePct,
|
|
40
44
|
sampleCount: events.length,
|
|
41
45
|
uniqueSessions: new Set(events.map((event) => event.sessionId).filter(Boolean)).size,
|
|
42
46
|
uniqueWorkspaces: new Set(events.map((event) => event.workspaceDir).filter(Boolean)).size,
|
|
@@ -72,12 +76,24 @@ function bucketizeEvents(events, start, end, bucketMinutes = 5) {
|
|
|
72
76
|
slot.sevenDay.push(event.sevenDayUsagePct);
|
|
73
77
|
}
|
|
74
78
|
}
|
|
79
|
+
// 7d 累计曲线(去毛刺 + 分段峰谷和的逐点版本)映射到时间桶:曲线已按时间升序,
|
|
80
|
+
// 同桶内后写覆盖前写,最终每个桶留下落在其区间内最后一个累计点的值(单调,等于桶内最大累计)。
|
|
81
|
+
const cumulativeByBucket = new Map();
|
|
82
|
+
for (const point of (0, aggregate_1.computeCumulativeSevenDayCurve)((0, aggregate_1.buildSevenDayCurveFromEvents)(events))) {
|
|
83
|
+
const ts = new Date(point.timestamp).getTime();
|
|
84
|
+
if (ts < start.getTime() || ts > end.getTime()) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const bucketStart = start.getTime() + Math.floor((ts - start.getTime()) / bucketMs) * bucketMs;
|
|
88
|
+
cumulativeByBucket.set(bucketStart, point.cumulative);
|
|
89
|
+
}
|
|
75
90
|
return [...buckets.entries()].map(([bucketStart, slot]) => ({
|
|
76
91
|
bucketStart: new Date(bucketStart).toISOString(),
|
|
77
92
|
avgUsagePct: slot.fiveHour.length > 0 ? (0, time_1.roundNumber)(average(slot.fiveHour), 1) : null,
|
|
78
93
|
maxUsagePct: slot.fiveHour.length > 0 ? (0, time_1.roundNumber)(Math.max(...slot.fiveHour), 1) : null,
|
|
79
94
|
minUsagePct: slot.fiveHour.length > 0 ? (0, time_1.roundNumber)(Math.min(...slot.fiveHour), 1) : null,
|
|
80
95
|
avgSevenDayUsagePct: slot.sevenDay.length > 0 ? (0, time_1.roundNumber)(average(slot.sevenDay), 1) : null,
|
|
96
|
+
cumulativeSevenDayPct: cumulativeByBucket.has(bucketStart) ? cumulativeByBucket.get(bucketStart) : null,
|
|
81
97
|
sampleCount: slot.fiveHour.length,
|
|
82
98
|
}));
|
|
83
99
|
}
|
|
@@ -90,29 +106,34 @@ function bucketizeEvents(events, start, end, bucketMinutes = 5) {
|
|
|
90
106
|
* 提供十字线 + legend 跟随显示两条线当前值。
|
|
91
107
|
*/
|
|
92
108
|
function renderChart(buckets) {
|
|
93
|
-
// 每条曲线只连有数据的桶:取「5h
|
|
94
|
-
const valued = buckets.filter((bucket) => bucket.maxUsagePct !== null || bucket.avgSevenDayUsagePct !== null);
|
|
109
|
+
// 每条曲线只连有数据的桶:取「5h / 7d / 累计 任一非空」的桶作为统一 x 轴,缺失填 null。
|
|
110
|
+
const valued = buckets.filter((bucket) => bucket.maxUsagePct !== null || bucket.avgSevenDayUsagePct !== null || bucket.cumulativeSevenDayPct !== null);
|
|
95
111
|
const noData = valued.length === 0
|
|
96
112
|
? `<div class="empty-state">当前时间窗口里还没有可绘制的 Claude 使用率样本。</div>`
|
|
97
113
|
: "";
|
|
98
114
|
const xs = valued.map((bucket) => Math.floor(new Date(bucket.bucketStart).getTime() / 1000));
|
|
99
115
|
const fiveHour = valued.map((bucket) => bucket.maxUsagePct);
|
|
100
116
|
const sevenDay = valued.map((bucket) => bucket.avgSevenDayUsagePct);
|
|
117
|
+
// 7d 分区叠加累计:量纲可远超 0–100%,单独走右侧 Y 轴(y2 自适应),避免压扁固定 0–100 的 5h/7d 主轴。
|
|
118
|
+
const cumulative = valued.map((bucket) => bucket.cumulativeSevenDayPct);
|
|
101
119
|
const spec = {
|
|
102
120
|
height: 280,
|
|
103
121
|
xType: "time",
|
|
104
122
|
yUnit: "%",
|
|
105
123
|
yRange: [0, 100],
|
|
124
|
+
y2: { scale: "y2", unit: "%", min0: true },
|
|
106
125
|
series: [
|
|
107
126
|
{ label: "5 小时使用率", stroke: "#5eead4", fill: "rgba(94, 234, 212, 0.14)", width: 3 },
|
|
108
127
|
{ label: "7 天使用率", stroke: "#f59e0b", dash: [6, 4], width: 2 },
|
|
128
|
+
{ label: "7d 分区叠加累计", stroke: "#a855f7", width: 2, scale: "y2" },
|
|
109
129
|
],
|
|
110
130
|
legendGroups: [
|
|
111
131
|
{ label: "5 小时使用率", color: "#5eead4", seriesIdx: [1] },
|
|
112
132
|
{ label: "7 天使用率", color: "#f59e0b", seriesIdx: [2] },
|
|
133
|
+
{ label: "7d 分区叠加累计", color: "#a855f7", seriesIdx: [3] },
|
|
113
134
|
],
|
|
114
135
|
};
|
|
115
|
-
const chart = valued.length > 0 ? (0, chart_assets_1.renderUplotChart)("chart-usage", spec, [xs, fiveHour, sevenDay]) : "";
|
|
136
|
+
const chart = valued.length > 0 ? (0, chart_assets_1.renderUplotChart)("chart-usage", spec, [xs, fiveHour, sevenDay, cumulative]) : "";
|
|
116
137
|
return `
|
|
117
138
|
<section class="panel chart-panel">
|
|
118
139
|
<div class="panel-header">
|
|
@@ -120,7 +141,7 @@ function renderChart(buckets) {
|
|
|
120
141
|
<p class="eyebrow">Recent Trend</p>
|
|
121
142
|
<h2>Claude 使用率趋势</h2>
|
|
122
143
|
</div>
|
|
123
|
-
<p class="muted"
|
|
144
|
+
<p class="muted">按采样时间聚合:5h(实线)来自 rate_limits.five_hour,7d(虚线)来自 rate_limits.seven_day;紫色「7d 分区叠加累计」走右侧 Y 轴,是把 7d 锯齿波还原成的累计真实使用量(口径同团队看板)</p>
|
|
124
145
|
</div>
|
|
125
146
|
${noData}
|
|
126
147
|
${chart}
|
|
@@ -422,9 +443,9 @@ function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages =
|
|
|
422
443
|
<p class="stat-note">窗口内观测到的 5 小时使用率峰值</p>
|
|
423
444
|
</article>
|
|
424
445
|
<article class="panel stat-card">
|
|
425
|
-
<h2>
|
|
426
|
-
<p class="stat-value">${escapeHtml(statValue(summary.
|
|
427
|
-
<p class="stat-note"
|
|
446
|
+
<h2>7d 分区叠加累计</h2>
|
|
447
|
+
<p class="stat-value">${escapeHtml(statValue(summary.sevenDayCumulativeUsagePct))}</p>
|
|
448
|
+
<p class="stat-note">7 天额度分区叠加累计真实使用量 · 峰值 ${escapeHtml(statValue(summary.sevenDayPeakUsagePct))} · 最新 ${escapeHtml(statValue(summary.sevenDayLatestUsagePct))}</p>
|
|
428
449
|
</article>
|
|
429
450
|
<article class="panel stat-card">
|
|
430
451
|
<h2>用户消息数</h2>
|
package/dist/lib/paths.js
CHANGED
|
@@ -9,6 +9,8 @@ exports.getDashboardDir = getDashboardDir;
|
|
|
9
9
|
exports.getUpdateCachePath = getUpdateCachePath;
|
|
10
10
|
exports.getSyncConfigPath = getSyncConfigPath;
|
|
11
11
|
exports.getSyncStatePath = getSyncStatePath;
|
|
12
|
+
exports.getApiConfigPath = getApiConfigPath;
|
|
13
|
+
exports.getApiQuotaCachePath = getApiQuotaCachePath;
|
|
12
14
|
exports.getClaudeDataDir = getClaudeDataDir;
|
|
13
15
|
exports.getClaudeSettingsPath = getClaudeSettingsPath;
|
|
14
16
|
const node_os_1 = __importDefault(require("node:os"));
|
|
@@ -56,6 +58,14 @@ function getSyncConfigPath(dataDir) {
|
|
|
56
58
|
function getSyncStatePath(dataDir) {
|
|
57
59
|
return node_path_1.default.join(dataDir, "sync-state.json");
|
|
58
60
|
}
|
|
61
|
+
/** API 模式(第三方额度拉取)的用户配置文件,可手编。 */
|
|
62
|
+
function getApiConfigPath(dataDir) {
|
|
63
|
+
return node_path_1.default.join(dataDir, "api-config.json");
|
|
64
|
+
}
|
|
65
|
+
/** API 模式的额度缓存文件,记录上次抓取的额度与时间。 */
|
|
66
|
+
function getApiQuotaCachePath(dataDir) {
|
|
67
|
+
return node_path_1.default.join(dataDir, "api-quota-cache.json");
|
|
68
|
+
}
|
|
59
69
|
/** Claude Code 默认本地数据目录。 */
|
|
60
70
|
function getClaudeDataDir() {
|
|
61
71
|
const configured = process.env.CCUS_CLAUDE_DATA_DIR;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccus-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Claude Code statusline usage logger and dashboard CLI",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"build": "tsc -p tsconfig.json && node scripts/copy-vendor.js",
|
|
17
17
|
"prepublishOnly": "npm run build",
|
|
18
18
|
"test": "node --test dist/test",
|
|
19
|
-
"test:src": "node --import tsx --test src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts src/test/update-check.test.ts src/test/sync.test.ts src/test/scheduler.test.ts"
|
|
19
|
+
"test:src": "node --import tsx --test src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts src/test/update-check.test.ts src/test/sync.test.ts src/test/scheduler.test.ts src/test/api-mode.test.ts",
|
|
20
|
+
"smoke:api": "node scripts/smoke-api.mjs"
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|
|
22
23
|
"claude-code",
|