coding-tool-x 3.2.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/CHANGELOG.md +599 -0
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/bin/ctx.js +8 -0
- package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
- package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
- package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
- package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
- package/dist/web/assets/Home-38JTUlYt.js +1 -0
- package/dist/web/assets/Home-CjupSEWE.css +1 -0
- package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
- package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
- package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
- package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
- package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
- package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
- package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
- package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
- package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
- package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
- package/dist/web/assets/icons-DRrXwWZi.js +1 -0
- package/dist/web/assets/index-CetESrXw.css +1 -0
- package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
- package/dist/web/assets/markdown-BfC0goYb.css +10 -0
- package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
- package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
- package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
- package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/index.html +20 -0
- package/dist/web/logo.png +0 -0
- package/docs/bannel.png +0 -0
- package/docs/home.png +0 -0
- package/docs/logo.png +0 -0
- package/docs/model-redirection.md +251 -0
- package/docs/multi-channel-load-balancing.md +249 -0
- package/package.json +80 -0
- package/src/commands/channels.js +551 -0
- package/src/commands/cli-type.js +101 -0
- package/src/commands/daemon.js +365 -0
- package/src/commands/doctor.js +333 -0
- package/src/commands/export-config.js +205 -0
- package/src/commands/list.js +222 -0
- package/src/commands/logs.js +261 -0
- package/src/commands/plugin.js +585 -0
- package/src/commands/port-config.js +135 -0
- package/src/commands/proxy-control.js +264 -0
- package/src/commands/proxy.js +152 -0
- package/src/commands/resume.js +137 -0
- package/src/commands/search.js +190 -0
- package/src/commands/security.js +37 -0
- package/src/commands/stats.js +398 -0
- package/src/commands/switch.js +48 -0
- package/src/commands/toggle-proxy.js +247 -0
- package/src/commands/ui.js +99 -0
- package/src/commands/update.js +97 -0
- package/src/commands/workspace.js +454 -0
- package/src/config/default.js +69 -0
- package/src/config/loader.js +149 -0
- package/src/config/model-metadata.js +167 -0
- package/src/config/model-metadata.json +125 -0
- package/src/config/model-pricing.js +35 -0
- package/src/config/paths.js +190 -0
- package/src/index.js +680 -0
- package/src/plugins/constants.js +15 -0
- package/src/plugins/event-bus.js +54 -0
- package/src/plugins/manifest-validator.js +129 -0
- package/src/plugins/plugin-api.js +128 -0
- package/src/plugins/plugin-installer.js +601 -0
- package/src/plugins/plugin-loader.js +229 -0
- package/src/plugins/plugin-manager.js +170 -0
- package/src/plugins/registry.js +152 -0
- package/src/plugins/schema/plugin-manifest.json +115 -0
- package/src/reset-config.js +94 -0
- package/src/server/api/agents.js +826 -0
- package/src/server/api/aliases.js +36 -0
- package/src/server/api/channels.js +368 -0
- package/src/server/api/claude-hooks.js +480 -0
- package/src/server/api/codex-channels.js +417 -0
- package/src/server/api/codex-projects.js +104 -0
- package/src/server/api/codex-proxy.js +195 -0
- package/src/server/api/codex-sessions.js +483 -0
- package/src/server/api/codex-statistics.js +57 -0
- package/src/server/api/commands.js +482 -0
- package/src/server/api/config-export.js +212 -0
- package/src/server/api/config-registry.js +357 -0
- package/src/server/api/config-sync.js +155 -0
- package/src/server/api/config-templates.js +248 -0
- package/src/server/api/config.js +521 -0
- package/src/server/api/convert.js +260 -0
- package/src/server/api/dashboard.js +142 -0
- package/src/server/api/env.js +144 -0
- package/src/server/api/favorites.js +77 -0
- package/src/server/api/gemini-channels.js +366 -0
- package/src/server/api/gemini-projects.js +91 -0
- package/src/server/api/gemini-proxy.js +173 -0
- package/src/server/api/gemini-sessions.js +376 -0
- package/src/server/api/gemini-statistics.js +57 -0
- package/src/server/api/health-check.js +31 -0
- package/src/server/api/mcp.js +399 -0
- package/src/server/api/opencode-channels.js +419 -0
- package/src/server/api/opencode-projects.js +99 -0
- package/src/server/api/opencode-proxy.js +207 -0
- package/src/server/api/opencode-sessions.js +327 -0
- package/src/server/api/opencode-statistics.js +57 -0
- package/src/server/api/plugins.js +463 -0
- package/src/server/api/pm2-autostart.js +269 -0
- package/src/server/api/projects.js +124 -0
- package/src/server/api/prompts.js +279 -0
- package/src/server/api/proxy.js +306 -0
- package/src/server/api/security.js +53 -0
- package/src/server/api/sessions.js +514 -0
- package/src/server/api/settings.js +142 -0
- package/src/server/api/skills.js +570 -0
- package/src/server/api/statistics.js +238 -0
- package/src/server/api/ui-config.js +64 -0
- package/src/server/api/workspaces.js +456 -0
- package/src/server/codex-proxy-server.js +681 -0
- package/src/server/dev-server.js +26 -0
- package/src/server/gemini-proxy-server.js +610 -0
- package/src/server/index.js +422 -0
- package/src/server/opencode-proxy-server.js +4771 -0
- package/src/server/proxy-server.js +669 -0
- package/src/server/services/agents-service.js +1137 -0
- package/src/server/services/alias.js +71 -0
- package/src/server/services/channel-health.js +234 -0
- package/src/server/services/channel-scheduler.js +240 -0
- package/src/server/services/channels.js +447 -0
- package/src/server/services/codex-channels.js +705 -0
- package/src/server/services/codex-config.js +90 -0
- package/src/server/services/codex-parser.js +322 -0
- package/src/server/services/codex-sessions.js +936 -0
- package/src/server/services/codex-settings-manager.js +619 -0
- package/src/server/services/codex-speed-test-template.json +24 -0
- package/src/server/services/codex-statistics-service.js +161 -0
- package/src/server/services/commands-service.js +574 -0
- package/src/server/services/config-export-service.js +1165 -0
- package/src/server/services/config-registry-service.js +828 -0
- package/src/server/services/config-sync-manager.js +941 -0
- package/src/server/services/config-sync-service.js +504 -0
- package/src/server/services/config-templates-service.js +913 -0
- package/src/server/services/enhanced-cache.js +196 -0
- package/src/server/services/env-checker.js +409 -0
- package/src/server/services/env-manager.js +436 -0
- package/src/server/services/favorites.js +165 -0
- package/src/server/services/format-converter.js +620 -0
- package/src/server/services/gemini-channels.js +459 -0
- package/src/server/services/gemini-config.js +73 -0
- package/src/server/services/gemini-sessions.js +689 -0
- package/src/server/services/gemini-settings-manager.js +263 -0
- package/src/server/services/gemini-statistics-service.js +157 -0
- package/src/server/services/health-check.js +85 -0
- package/src/server/services/mcp-client.js +790 -0
- package/src/server/services/mcp-service.js +1732 -0
- package/src/server/services/model-detector.js +1245 -0
- package/src/server/services/network-access.js +80 -0
- package/src/server/services/opencode-channels.js +366 -0
- package/src/server/services/opencode-gateway-adapters.js +1168 -0
- package/src/server/services/opencode-gateway-converter.js +639 -0
- package/src/server/services/opencode-sessions.js +931 -0
- package/src/server/services/opencode-settings-manager.js +478 -0
- package/src/server/services/opencode-statistics-service.js +161 -0
- package/src/server/services/plugins-service.js +1268 -0
- package/src/server/services/prompts-service.js +534 -0
- package/src/server/services/proxy-runtime.js +79 -0
- package/src/server/services/repo-scanner-base.js +708 -0
- package/src/server/services/request-logger.js +130 -0
- package/src/server/services/response-decoder.js +21 -0
- package/src/server/services/security-config.js +131 -0
- package/src/server/services/session-cache.js +127 -0
- package/src/server/services/session-converter.js +577 -0
- package/src/server/services/sessions.js +900 -0
- package/src/server/services/settings-manager.js +163 -0
- package/src/server/services/skill-service.js +1482 -0
- package/src/server/services/speed-test.js +1146 -0
- package/src/server/services/statistics-service.js +1043 -0
- package/src/server/services/ui-config.js +132 -0
- package/src/server/services/workspace-service.js +830 -0
- package/src/server/utils/pricing.js +73 -0
- package/src/server/websocket-server.js +513 -0
- package/src/ui/menu.js +139 -0
- package/src/ui/prompts.js +100 -0
- package/src/utils/format.js +43 -0
- package/src/utils/port-helper.js +108 -0
- package/src/utils/session.js +240 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
// 北京时间辅助(UTC+8),统一所有时间计算
|
|
6
|
+
const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function toCSTDate(ts) {
|
|
9
|
+
// 返回以北京时间解释的 Date 对象各字段(通过偏移 UTC)
|
|
10
|
+
return new Date(new Date(ts).getTime() + CST_OFFSET_MS);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getCSTDateStr(ts) {
|
|
14
|
+
// 返回北京时间日期字符串 YYYY-MM-DD
|
|
15
|
+
const d = toCSTDate(ts);
|
|
16
|
+
return d.toISOString().split('T')[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getCSTHour(ts) {
|
|
20
|
+
// 返回北京时间小时 (0-23)
|
|
21
|
+
return toCSTDate(ts).getUTCHours();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 统计服务 - 数据采集和存储
|
|
26
|
+
*
|
|
27
|
+
* 文件结构:
|
|
28
|
+
* ~/.cc-tool/
|
|
29
|
+
* ├── statistics.json # 总体统计(实时更新)
|
|
30
|
+
* ├── daily-stats/
|
|
31
|
+
* │ ├── 2025-11-22.json # 每日汇总统计
|
|
32
|
+
* │ └── 2025-11-23.json
|
|
33
|
+
* └── request-logs/
|
|
34
|
+
* ├── 2025-11/
|
|
35
|
+
* │ ├── 22.jsonl # 每日详细日志(JSONL格式)
|
|
36
|
+
* │ └── 23.jsonl
|
|
37
|
+
* └── 2025-12/
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// 获取基础目录
|
|
41
|
+
function getBaseDir() {
|
|
42
|
+
const dir = path.join(os.homedir(), '.cc-tool');
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 获取每日统计目录
|
|
50
|
+
function getDailyStatsDir() {
|
|
51
|
+
const dir = path.join(getBaseDir(), 'daily-stats');
|
|
52
|
+
if (!fs.existsSync(dir)) {
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 获取请求日志目录
|
|
59
|
+
function getRequestLogsDir(year, month) {
|
|
60
|
+
const baseDir = path.join(getBaseDir(), 'request-logs', `${year}-${month.toString().padStart(2, '0')}`);
|
|
61
|
+
if (!fs.existsSync(baseDir)) {
|
|
62
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
return baseDir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 获取统计文件路径
|
|
68
|
+
function getStatisticsFilePath() {
|
|
69
|
+
return path.join(getBaseDir(), 'statistics.json');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 获取每日统计文件路径
|
|
73
|
+
function getDailyStatsFilePath(date) {
|
|
74
|
+
// date 格式: YYYY-MM-DD
|
|
75
|
+
return path.join(getDailyStatsDir(), `${date}.json`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 获取请求日志文件路径
|
|
79
|
+
function getRequestLogFilePath(year, month, day) {
|
|
80
|
+
const dir = getRequestLogsDir(year, month);
|
|
81
|
+
return path.join(dir, `${day.toString().padStart(2, '0')}.jsonl`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getProxyLogsFilePath() {
|
|
85
|
+
return path.join(getBaseDir(), 'proxy-logs.json');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 加载总体统计
|
|
89
|
+
function loadStatistics() {
|
|
90
|
+
const filePath = getStatisticsFilePath();
|
|
91
|
+
try {
|
|
92
|
+
if (fs.existsSync(filePath)) {
|
|
93
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
94
|
+
return JSON.parse(data);
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('Failed to load statistics:', err);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 返回默认结构
|
|
101
|
+
return {
|
|
102
|
+
version: '2.0',
|
|
103
|
+
lastUpdated: new Date().toISOString(),
|
|
104
|
+
global: {
|
|
105
|
+
totalRequests: 0,
|
|
106
|
+
totalTokens: 0,
|
|
107
|
+
totalCost: 0
|
|
108
|
+
},
|
|
109
|
+
byToolType: {},
|
|
110
|
+
byChannel: {},
|
|
111
|
+
byModel: {}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 保存总体统计
|
|
116
|
+
function saveStatistics(stats) {
|
|
117
|
+
const filePath = getStatisticsFilePath();
|
|
118
|
+
stats.lastUpdated = new Date().toISOString();
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf8');
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error('Failed to save statistics:', err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 加载每日统计
|
|
128
|
+
function loadDailyStats(date) {
|
|
129
|
+
const filePath = getDailyStatsFilePath(date);
|
|
130
|
+
try {
|
|
131
|
+
if (fs.existsSync(filePath)) {
|
|
132
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
133
|
+
return JSON.parse(data);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error('Failed to load daily stats:', err);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 返回默认结构
|
|
140
|
+
return {
|
|
141
|
+
date: date,
|
|
142
|
+
summary: {
|
|
143
|
+
requests: 0,
|
|
144
|
+
tokens: 0,
|
|
145
|
+
cost: 0
|
|
146
|
+
},
|
|
147
|
+
hourly: {}, // 按小时统计
|
|
148
|
+
byToolType: {},
|
|
149
|
+
byChannel: {},
|
|
150
|
+
byModel: {}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 保存每日统计
|
|
155
|
+
function saveDailyStats(date, stats) {
|
|
156
|
+
const filePath = getDailyStatsFilePath(date);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf8');
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('Failed to save daily stats:', err);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 追加请求日志(JSONL格式)
|
|
166
|
+
function appendRequestLog(logEntry) {
|
|
167
|
+
const cst = toCSTDate(logEntry.timestamp);
|
|
168
|
+
const year = cst.getUTCFullYear();
|
|
169
|
+
const month = cst.getUTCMonth() + 1;
|
|
170
|
+
const day = cst.getUTCDate();
|
|
171
|
+
|
|
172
|
+
const filePath = getRequestLogFilePath(year, month, day);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
// JSONL 格式:每行一个 JSON 对象
|
|
176
|
+
const line = JSON.stringify(logEntry) + '\n';
|
|
177
|
+
fs.appendFileSync(filePath, line, 'utf8');
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error('Failed to append request log:', err);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 初始化统计对象
|
|
184
|
+
function initStatsObject() {
|
|
185
|
+
return {
|
|
186
|
+
requests: 0,
|
|
187
|
+
tokens: {
|
|
188
|
+
input: 0,
|
|
189
|
+
output: 0,
|
|
190
|
+
cacheCreation: 0,
|
|
191
|
+
cacheRead: 0,
|
|
192
|
+
total: 0
|
|
193
|
+
},
|
|
194
|
+
cost: 0
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 更新统计数据
|
|
199
|
+
function updateStats(stats, tokens, cost) {
|
|
200
|
+
stats.requests += 1;
|
|
201
|
+
stats.tokens.input += tokens.input || 0;
|
|
202
|
+
stats.tokens.output += tokens.output || 0;
|
|
203
|
+
stats.tokens.cacheCreation += tokens.cacheCreation || 0;
|
|
204
|
+
stats.tokens.cacheRead += tokens.cacheRead || 0;
|
|
205
|
+
stats.tokens.total += tokens.total || 0;
|
|
206
|
+
stats.cost += cost || 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 记录一次请求
|
|
211
|
+
* @param {Object} requestData - 请求数据
|
|
212
|
+
*/
|
|
213
|
+
function recordRequest(requestData) {
|
|
214
|
+
try {
|
|
215
|
+
const {
|
|
216
|
+
id,
|
|
217
|
+
timestamp,
|
|
218
|
+
toolType = 'claude-code',
|
|
219
|
+
channel,
|
|
220
|
+
channelId,
|
|
221
|
+
model,
|
|
222
|
+
tokens,
|
|
223
|
+
duration,
|
|
224
|
+
success,
|
|
225
|
+
cost = 0,
|
|
226
|
+
session,
|
|
227
|
+
project
|
|
228
|
+
} = requestData;
|
|
229
|
+
|
|
230
|
+
// 1. 写入详细日志
|
|
231
|
+
const logEntry = {
|
|
232
|
+
id,
|
|
233
|
+
timestamp,
|
|
234
|
+
toolType,
|
|
235
|
+
channel,
|
|
236
|
+
channelId,
|
|
237
|
+
model,
|
|
238
|
+
tokens,
|
|
239
|
+
duration,
|
|
240
|
+
success,
|
|
241
|
+
cost,
|
|
242
|
+
session,
|
|
243
|
+
project
|
|
244
|
+
};
|
|
245
|
+
appendRequestLog(logEntry);
|
|
246
|
+
|
|
247
|
+
// 2. 更新总体统计
|
|
248
|
+
const globalStats = loadStatistics();
|
|
249
|
+
|
|
250
|
+
// 更新全局统计
|
|
251
|
+
globalStats.global.totalRequests += 1;
|
|
252
|
+
globalStats.global.totalTokens += tokens.total || 0;
|
|
253
|
+
globalStats.global.totalCost += cost || 0;
|
|
254
|
+
|
|
255
|
+
// 按工具类型统计
|
|
256
|
+
if (!globalStats.byToolType[toolType]) {
|
|
257
|
+
globalStats.byToolType[toolType] = {
|
|
258
|
+
...initStatsObject(),
|
|
259
|
+
channels: {},
|
|
260
|
+
models: {}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
updateStats(globalStats.byToolType[toolType], tokens, cost);
|
|
264
|
+
|
|
265
|
+
// 按工具类型 -> 渠道统计
|
|
266
|
+
if (!globalStats.byToolType[toolType].channels[channelId]) {
|
|
267
|
+
globalStats.byToolType[toolType].channels[channelId] = {
|
|
268
|
+
name: channel,
|
|
269
|
+
...initStatsObject(),
|
|
270
|
+
firstUsed: timestamp,
|
|
271
|
+
lastUsed: timestamp
|
|
272
|
+
};
|
|
273
|
+
} else {
|
|
274
|
+
globalStats.byToolType[toolType].channels[channelId].lastUsed = timestamp;
|
|
275
|
+
}
|
|
276
|
+
updateStats(globalStats.byToolType[toolType].channels[channelId], tokens, cost);
|
|
277
|
+
|
|
278
|
+
// 按工具类型 -> 模型统计
|
|
279
|
+
if (!globalStats.byToolType[toolType].models[model]) {
|
|
280
|
+
globalStats.byToolType[toolType].models[model] = initStatsObject();
|
|
281
|
+
}
|
|
282
|
+
updateStats(globalStats.byToolType[toolType].models[model], tokens, cost);
|
|
283
|
+
|
|
284
|
+
// 按渠道统计(跨工具)
|
|
285
|
+
if (!globalStats.byChannel[channelId]) {
|
|
286
|
+
globalStats.byChannel[channelId] = {
|
|
287
|
+
toolType,
|
|
288
|
+
name: channel,
|
|
289
|
+
...initStatsObject(),
|
|
290
|
+
firstUsed: timestamp,
|
|
291
|
+
lastUsed: timestamp
|
|
292
|
+
};
|
|
293
|
+
} else {
|
|
294
|
+
globalStats.byChannel[channelId].lastUsed = timestamp;
|
|
295
|
+
}
|
|
296
|
+
updateStats(globalStats.byChannel[channelId], tokens, cost);
|
|
297
|
+
|
|
298
|
+
// 按模型统计(跨工具)
|
|
299
|
+
if (!globalStats.byModel[model]) {
|
|
300
|
+
globalStats.byModel[model] = {
|
|
301
|
+
toolType,
|
|
302
|
+
...initStatsObject()
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
updateStats(globalStats.byModel[model], tokens, cost);
|
|
306
|
+
|
|
307
|
+
saveStatistics(globalStats);
|
|
308
|
+
|
|
309
|
+
// 3. 更新每日统计(使用北京时间)
|
|
310
|
+
const date = getCSTDateStr(timestamp); // YYYY-MM-DD (CST)
|
|
311
|
+
const hour = getCSTHour(timestamp).toString().padStart(2, '0'); // HH (CST)
|
|
312
|
+
|
|
313
|
+
const dailyStats = loadDailyStats(date);
|
|
314
|
+
|
|
315
|
+
// 更新每日汇总
|
|
316
|
+
dailyStats.summary.requests += 1;
|
|
317
|
+
dailyStats.summary.tokens += tokens.total || 0;
|
|
318
|
+
dailyStats.summary.cost += cost || 0;
|
|
319
|
+
|
|
320
|
+
// 按小时统计
|
|
321
|
+
if (!dailyStats.hourly[hour]) {
|
|
322
|
+
dailyStats.hourly[hour] = {
|
|
323
|
+
...initStatsObject(),
|
|
324
|
+
byToolType: {}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
updateStats(dailyStats.hourly[hour], tokens, cost);
|
|
328
|
+
|
|
329
|
+
// 按小时 -> 工具类型
|
|
330
|
+
if (!dailyStats.hourly[hour].byToolType[toolType]) {
|
|
331
|
+
dailyStats.hourly[hour].byToolType[toolType] = initStatsObject();
|
|
332
|
+
}
|
|
333
|
+
updateStats(dailyStats.hourly[hour].byToolType[toolType], tokens, cost);
|
|
334
|
+
|
|
335
|
+
// 按工具类型统计
|
|
336
|
+
if (!dailyStats.byToolType[toolType]) {
|
|
337
|
+
dailyStats.byToolType[toolType] = {
|
|
338
|
+
...initStatsObject(),
|
|
339
|
+
channels: {},
|
|
340
|
+
models: {}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
updateStats(dailyStats.byToolType[toolType], tokens, cost);
|
|
344
|
+
|
|
345
|
+
// 按工具类型 -> 渠道
|
|
346
|
+
if (!dailyStats.byToolType[toolType].channels) {
|
|
347
|
+
dailyStats.byToolType[toolType].channels = {};
|
|
348
|
+
}
|
|
349
|
+
if (!dailyStats.byToolType[toolType].channels[channelId]) {
|
|
350
|
+
dailyStats.byToolType[toolType].channels[channelId] = {
|
|
351
|
+
name: channel,
|
|
352
|
+
...initStatsObject()
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
updateStats(dailyStats.byToolType[toolType].channels[channelId], tokens, cost);
|
|
356
|
+
|
|
357
|
+
// 按渠道统计
|
|
358
|
+
if (!dailyStats.byChannel[channelId]) {
|
|
359
|
+
dailyStats.byChannel[channelId] = {
|
|
360
|
+
toolType,
|
|
361
|
+
name: channel,
|
|
362
|
+
...initStatsObject()
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
updateStats(dailyStats.byChannel[channelId], tokens, cost);
|
|
366
|
+
|
|
367
|
+
// 按模型统计
|
|
368
|
+
if (!dailyStats.byModel[model]) {
|
|
369
|
+
dailyStats.byModel[model] = {
|
|
370
|
+
toolType,
|
|
371
|
+
...initStatsObject()
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
updateStats(dailyStats.byModel[model], tokens, cost);
|
|
375
|
+
|
|
376
|
+
saveDailyStats(date, dailyStats);
|
|
377
|
+
|
|
378
|
+
// Invalidate cached trend results that cover this date
|
|
379
|
+
invalidateTrendCacheForDate(date);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error('[Statistics] Failed to record request:', err);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 获取统计数据
|
|
387
|
+
*/
|
|
388
|
+
function getStatistics() {
|
|
389
|
+
return loadStatistics();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 获取每日统计
|
|
394
|
+
*/
|
|
395
|
+
function getDailyStatistics(date) {
|
|
396
|
+
return aggregateDailyStatistics(date);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 获取今日统计
|
|
401
|
+
*/
|
|
402
|
+
function getTodayStatistics() {
|
|
403
|
+
const today = getCSTDateStr(Date.now());
|
|
404
|
+
return aggregateDailyStatistics(today);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 从统计对象中提取指定指标值
|
|
409
|
+
*/
|
|
410
|
+
function extractMetric(stats, metric) {
|
|
411
|
+
if (!stats) return 0;
|
|
412
|
+
if (metric === 'tokens') return stats.tokens?.total || stats.tokens || 0;
|
|
413
|
+
if (metric === 'cost') return stats.cost || 0;
|
|
414
|
+
if (metric === 'requests') return stats.requests || 0;
|
|
415
|
+
return 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 从 JSONL 日志文件读取指定日期+小时的数据(按 model 或 channel 聚合)
|
|
420
|
+
* @param {number} year
|
|
421
|
+
* @param {number} month
|
|
422
|
+
* @param {number} day
|
|
423
|
+
* @param {number} hour
|
|
424
|
+
* @param {string} groupBy
|
|
425
|
+
* @param {Object} [filters] - optional { toolType, channel, model }
|
|
426
|
+
*/
|
|
427
|
+
function readJsonlForHour(year, month, day, hour, groupBy, filters) {
|
|
428
|
+
const filePath = getRequestLogFilePath(year, month, day);
|
|
429
|
+
const result = {};
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
if (!fs.existsSync(filePath)) return result;
|
|
433
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
434
|
+
|
|
435
|
+
for (const line of lines) {
|
|
436
|
+
if (!line.trim()) continue;
|
|
437
|
+
let entry;
|
|
438
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
439
|
+
|
|
440
|
+
const ts = new Date(entry.timestamp);
|
|
441
|
+
if (getCSTHour(ts) !== hour) continue;
|
|
442
|
+
|
|
443
|
+
// Apply filters
|
|
444
|
+
if (filters) {
|
|
445
|
+
if (filters.toolType && entry.toolType !== filters.toolType) continue;
|
|
446
|
+
if (filters.channel && entry.channel !== filters.channel) continue;
|
|
447
|
+
if (filters.model && entry.model !== filters.model) continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let key;
|
|
451
|
+
if (groupBy === 'model') key = entry.model || 'unknown';
|
|
452
|
+
else if (groupBy === 'channel') key = entry.channel || entry.channelId || 'unknown';
|
|
453
|
+
else if (groupBy === 'toolType') key = entry.toolType || 'claude-code';
|
|
454
|
+
else continue;
|
|
455
|
+
|
|
456
|
+
if (!result[key]) result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
|
|
457
|
+
result[key].tokens.total += entry.tokens?.total || 0;
|
|
458
|
+
result[key].cost += entry.cost || 0;
|
|
459
|
+
result[key].requests += 1;
|
|
460
|
+
}
|
|
461
|
+
} catch (err) {
|
|
462
|
+
console.error('Failed to read JSONL for hour:', err);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 从 JSONL 日志文件读取整天的数据(应用过滤器后按维度聚合)
|
|
470
|
+
* @param {number} year
|
|
471
|
+
* @param {number} month
|
|
472
|
+
* @param {number} day
|
|
473
|
+
* @param {string} groupBy
|
|
474
|
+
* @param {Object} [filters] - optional { toolType, channel, model }
|
|
475
|
+
*/
|
|
476
|
+
function readJsonlForDay(year, month, day, groupBy, filters) {
|
|
477
|
+
const filePath = getRequestLogFilePath(year, month, day);
|
|
478
|
+
const result = {};
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
if (!fs.existsSync(filePath)) return result;
|
|
482
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
483
|
+
|
|
484
|
+
for (const line of lines) {
|
|
485
|
+
if (!line.trim()) continue;
|
|
486
|
+
let entry;
|
|
487
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
488
|
+
|
|
489
|
+
// Apply filters
|
|
490
|
+
if (filters) {
|
|
491
|
+
if (filters.toolType && entry.toolType !== filters.toolType) continue;
|
|
492
|
+
if (filters.channel && entry.channel !== filters.channel) continue;
|
|
493
|
+
if (filters.model && entry.model !== filters.model) continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let key;
|
|
497
|
+
if (groupBy === 'model') key = entry.model || 'unknown';
|
|
498
|
+
else if (groupBy === 'channel') key = entry.channel || entry.channelId || 'unknown';
|
|
499
|
+
else if (groupBy === 'toolType') key = entry.toolType || 'claude-code';
|
|
500
|
+
else continue;
|
|
501
|
+
|
|
502
|
+
if (!result[key]) result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
|
|
503
|
+
result[key].tokens.total += entry.tokens?.total || 0;
|
|
504
|
+
result[key].cost += entry.cost || 0;
|
|
505
|
+
result[key].requests += 1;
|
|
506
|
+
}
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.error('Failed to read JSONL for day:', err);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function mapSourceToToolType(source) {
|
|
515
|
+
if (source === 'codex') return 'codex';
|
|
516
|
+
if (source === 'gemini') return 'gemini';
|
|
517
|
+
if (source === 'opencode') return 'opencode';
|
|
518
|
+
return 'claude-code';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function loadProxyLogs() {
|
|
522
|
+
const filePath = getProxyLogsFilePath();
|
|
523
|
+
try {
|
|
524
|
+
if (!fs.existsSync(filePath)) return [];
|
|
525
|
+
const logs = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
526
|
+
return Array.isArray(logs) ? logs : [];
|
|
527
|
+
} catch (err) {
|
|
528
|
+
console.error('Failed to load proxy logs:', err);
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function filterProxyLogsForHour(logs, dateStr, hour, groupBy) {
|
|
534
|
+
const result = {};
|
|
535
|
+
|
|
536
|
+
for (const entry of logs) {
|
|
537
|
+
if (!entry || entry.type === 'action') continue;
|
|
538
|
+
|
|
539
|
+
const ts = new Date(entry.timestamp || Date.now());
|
|
540
|
+
if (Number.isNaN(ts.getTime())) continue;
|
|
541
|
+
|
|
542
|
+
const entryDate = getCSTDateStr(ts);
|
|
543
|
+
if (entryDate !== dateStr || getCSTHour(ts) !== hour) continue;
|
|
544
|
+
|
|
545
|
+
let key;
|
|
546
|
+
if (groupBy === 'toolType') {
|
|
547
|
+
key = mapSourceToToolType(entry.source);
|
|
548
|
+
} else if (groupBy === 'model') {
|
|
549
|
+
key = entry.model || 'unknown';
|
|
550
|
+
} else if (groupBy === 'channel') {
|
|
551
|
+
key = entry.channel || 'unknown';
|
|
552
|
+
} else {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!result[key]) {
|
|
557
|
+
result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const totalTokens = entry.totalTokens ||
|
|
561
|
+
entry.tokens?.total ||
|
|
562
|
+
(entry.inputTokens || 0) + (entry.outputTokens || 0) + (entry.reasoningTokens || 0);
|
|
563
|
+
|
|
564
|
+
result[key].tokens.total += totalTokens || 0;
|
|
565
|
+
result[key].cost += entry.cost || 0;
|
|
566
|
+
result[key].requests += 1;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function readProxyLogsForHour(dateStr, hour, groupBy) {
|
|
573
|
+
return filterProxyLogsForHour(loadProxyLogs(), dateStr, hour, groupBy);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 获取趋势统计数据
|
|
578
|
+
* @param {Object} options
|
|
579
|
+
* @param {string} options.startDate - YYYY-MM-DD
|
|
580
|
+
* @param {string} options.endDate - YYYY-MM-DD
|
|
581
|
+
* @param {string} options.granularity - 'day' | 'hour'
|
|
582
|
+
* @param {string} options.groupBy - 'model' | 'channel' | 'toolType'
|
|
583
|
+
* @param {string} options.metric - 'tokens' | 'cost' | 'requests'
|
|
584
|
+
*/
|
|
585
|
+
|
|
586
|
+
// 工具类型到 daily-stats 目录前缀的映射
|
|
587
|
+
const TOOL_PREFIXES = {
|
|
588
|
+
'claude-code': '',
|
|
589
|
+
'codex': 'codex-',
|
|
590
|
+
'gemini': 'gemini-',
|
|
591
|
+
'opencode': 'opencode-'
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
function getTokenTotal(tokens) {
|
|
595
|
+
if (typeof tokens === 'number') return tokens;
|
|
596
|
+
if (tokens && typeof tokens === 'object') {
|
|
597
|
+
if (typeof tokens.total === 'number') return tokens.total;
|
|
598
|
+
return Object.entries(tokens).reduce((sum, [key, value]) => {
|
|
599
|
+
if (key === 'total') return sum;
|
|
600
|
+
return typeof value === 'number' ? sum + value : sum;
|
|
601
|
+
}, 0);
|
|
602
|
+
}
|
|
603
|
+
return 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function normalizeTokens(tokens) {
|
|
607
|
+
if (typeof tokens === 'number') {
|
|
608
|
+
return { total: tokens };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const normalized = { total: 0 };
|
|
612
|
+
if (tokens && typeof tokens === 'object') {
|
|
613
|
+
for (const [key, value] of Object.entries(tokens)) {
|
|
614
|
+
if (typeof value === 'number') {
|
|
615
|
+
normalized[key] = value;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
normalized.total = getTokenTotal(tokens);
|
|
621
|
+
return normalized;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function mergeStatsEntry(target, source) {
|
|
625
|
+
if (!source) return;
|
|
626
|
+
|
|
627
|
+
target.requests += source.requests || 0;
|
|
628
|
+
target.cost += source.cost || 0;
|
|
629
|
+
|
|
630
|
+
const sourceTokens = normalizeTokens(source.tokens);
|
|
631
|
+
for (const [key, value] of Object.entries(sourceTokens)) {
|
|
632
|
+
target.tokens[key] = (target.tokens[key] || 0) + value;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function createEmptyEntry(toolType, name) {
|
|
637
|
+
const entry = {
|
|
638
|
+
requests: 0,
|
|
639
|
+
tokens: { total: 0 },
|
|
640
|
+
cost: 0
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
if (toolType) entry.toolType = toolType;
|
|
644
|
+
if (name) entry.name = name;
|
|
645
|
+
return entry;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getScopedKey(container, baseKey, toolType) {
|
|
649
|
+
if (!container[baseKey] || container[baseKey].toolType === toolType) {
|
|
650
|
+
return baseKey;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let index = 1;
|
|
654
|
+
let scopedKey = `${toolType}:${baseKey}`;
|
|
655
|
+
while (container[scopedKey] && container[scopedKey].toolType !== toolType) {
|
|
656
|
+
scopedKey = `${toolType}:${baseKey}:${index}`;
|
|
657
|
+
index += 1;
|
|
658
|
+
}
|
|
659
|
+
return scopedKey;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function mergeHourlyStats(targetHourly, sourceHourly = {}) {
|
|
663
|
+
for (const [hour, hourStats] of Object.entries(sourceHourly)) {
|
|
664
|
+
if (!targetHourly[hour]) {
|
|
665
|
+
targetHourly[hour] = {
|
|
666
|
+
requests: 0,
|
|
667
|
+
tokens: { total: 0 },
|
|
668
|
+
cost: 0,
|
|
669
|
+
byToolType: {}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
mergeStatsEntry(targetHourly[hour], hourStats);
|
|
674
|
+
|
|
675
|
+
if (hourStats.byToolType && typeof hourStats.byToolType === 'object') {
|
|
676
|
+
for (const [toolType, toolStats] of Object.entries(hourStats.byToolType)) {
|
|
677
|
+
if (!targetHourly[hour].byToolType[toolType]) {
|
|
678
|
+
targetHourly[hour].byToolType[toolType] = createEmptyEntry();
|
|
679
|
+
}
|
|
680
|
+
mergeStatsEntry(targetHourly[hour].byToolType[toolType], toolStats);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function hasStatsData(stats = {}) {
|
|
687
|
+
const requests = Number(stats.requests || 0);
|
|
688
|
+
const cost = Number(stats.cost || 0);
|
|
689
|
+
const totalTokens = getTokenTotal(stats.tokens);
|
|
690
|
+
return requests > 0 || cost > 0 || totalTokens > 0;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function aggregateDailyStatistics(dateStr) {
|
|
694
|
+
const aggregated = {
|
|
695
|
+
date: dateStr,
|
|
696
|
+
summary: {
|
|
697
|
+
requests: 0,
|
|
698
|
+
tokens: 0,
|
|
699
|
+
cost: 0
|
|
700
|
+
},
|
|
701
|
+
hourly: {},
|
|
702
|
+
byToolType: {},
|
|
703
|
+
byChannel: {},
|
|
704
|
+
byModel: {}
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const sharedStats = loadDailyStats(dateStr);
|
|
708
|
+
const sharedSummaryEntry = createEmptyEntry();
|
|
709
|
+
mergeStatsEntry(sharedSummaryEntry, {
|
|
710
|
+
requests: sharedStats.summary?.requests || 0,
|
|
711
|
+
tokens: sharedStats.summary?.tokens || 0,
|
|
712
|
+
cost: sharedStats.summary?.cost || 0
|
|
713
|
+
});
|
|
714
|
+
aggregated.summary.requests += sharedSummaryEntry.requests;
|
|
715
|
+
aggregated.summary.tokens += sharedSummaryEntry.tokens.total || 0;
|
|
716
|
+
aggregated.summary.cost += sharedSummaryEntry.cost;
|
|
717
|
+
mergeHourlyStats(aggregated.hourly, sharedStats.hourly);
|
|
718
|
+
|
|
719
|
+
for (const toolType of Object.keys(TOOL_PREFIXES)) {
|
|
720
|
+
const toolStats = sharedStats.byToolType?.[toolType];
|
|
721
|
+
if (!aggregated.byToolType[toolType]) {
|
|
722
|
+
aggregated.byToolType[toolType] = createEmptyEntry();
|
|
723
|
+
}
|
|
724
|
+
if (!toolStats) continue;
|
|
725
|
+
|
|
726
|
+
mergeStatsEntry(aggregated.byToolType[toolType], toolStats);
|
|
727
|
+
|
|
728
|
+
for (const [channelId, channelStats] of Object.entries(toolStats.channels || {})) {
|
|
729
|
+
const key = getScopedKey(aggregated.byChannel, channelId, toolType);
|
|
730
|
+
if (!aggregated.byChannel[key]) {
|
|
731
|
+
aggregated.byChannel[key] = createEmptyEntry(toolType, channelStats.name || channelId);
|
|
732
|
+
}
|
|
733
|
+
mergeStatsEntry(aggregated.byChannel[key], channelStats);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
for (const [modelName, modelStats] of Object.entries(toolStats.models || {})) {
|
|
737
|
+
const key = getScopedKey(aggregated.byModel, modelName, toolType);
|
|
738
|
+
if (!aggregated.byModel[key]) {
|
|
739
|
+
aggregated.byModel[key] = createEmptyEntry(toolType);
|
|
740
|
+
}
|
|
741
|
+
mergeStatsEntry(aggregated.byModel[key], modelStats);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Fallback: if byModel is still empty (older daily-stats files store model data
|
|
746
|
+
// directly in byModel rather than byToolType[toolType].models), merge it directly.
|
|
747
|
+
if (Object.keys(aggregated.byModel).length === 0 && sharedStats.byModel) {
|
|
748
|
+
for (const [modelName, modelStats] of Object.entries(sharedStats.byModel)) {
|
|
749
|
+
const toolType = modelStats.toolType || 'claude-code';
|
|
750
|
+
if (!aggregated.byModel[modelName]) {
|
|
751
|
+
aggregated.byModel[modelName] = createEmptyEntry(toolType);
|
|
752
|
+
}
|
|
753
|
+
mergeStatsEntry(aggregated.byModel[modelName], modelStats);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Fallback: if byChannel is still empty, merge from sharedStats.byChannel directly.
|
|
758
|
+
if (Object.keys(aggregated.byChannel).length === 0 && sharedStats.byChannel) {
|
|
759
|
+
for (const [channelId, channelStats] of Object.entries(sharedStats.byChannel)) {
|
|
760
|
+
const toolType = channelStats.toolType || 'claude-code';
|
|
761
|
+
if (!aggregated.byChannel[channelId]) {
|
|
762
|
+
aggregated.byChannel[channelId] = createEmptyEntry(toolType, channelStats.name || channelId);
|
|
763
|
+
}
|
|
764
|
+
mergeStatsEntry(aggregated.byChannel[channelId], channelStats);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// 保证前端可用的工具键始终存在
|
|
769
|
+
for (const toolType of Object.keys(TOOL_PREFIXES)) {
|
|
770
|
+
if (!aggregated.byToolType[toolType]) {
|
|
771
|
+
aggregated.byToolType[toolType] = createEmptyEntry();
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return aggregated;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// 合并某天共享 daily-stats,groupBy 决定合并维度
|
|
779
|
+
function mergeAllToolsDailyStats(dateStr, groupBy) {
|
|
780
|
+
const merged = {};
|
|
781
|
+
|
|
782
|
+
const aggregated = aggregateDailyStatistics(dateStr);
|
|
783
|
+
if (!aggregated) return merged;
|
|
784
|
+
|
|
785
|
+
if (groupBy === 'toolType') {
|
|
786
|
+
for (const toolType of Object.keys(TOOL_PREFIXES)) {
|
|
787
|
+
const toolStats = aggregated.byToolType?.[toolType];
|
|
788
|
+
if (!toolStats || !hasStatsData(toolStats)) continue;
|
|
789
|
+
merged[toolType] = {
|
|
790
|
+
requests: toolStats.requests || 0,
|
|
791
|
+
tokens: { total: getTokenTotal(toolStats.tokens) },
|
|
792
|
+
cost: toolStats.cost || 0
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
return merged;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (groupBy === 'model') {
|
|
799
|
+
for (const [modelName, modelStats] of Object.entries(aggregated.byModel || {})) {
|
|
800
|
+
if (!merged[modelName]) merged[modelName] = { requests: 0, tokens: { total: 0 }, cost: 0 };
|
|
801
|
+
merged[modelName].requests += modelStats.requests || 0;
|
|
802
|
+
merged[modelName].tokens.total += getTokenTotal(modelStats.tokens);
|
|
803
|
+
merged[modelName].cost += modelStats.cost || 0;
|
|
804
|
+
}
|
|
805
|
+
return merged;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (groupBy === 'channel') {
|
|
809
|
+
for (const [channelId, channelStats] of Object.entries(aggregated.byChannel || {})) {
|
|
810
|
+
const key = channelStats.name || channelId;
|
|
811
|
+
if (!merged[key]) merged[key] = { requests: 0, tokens: { total: 0 }, cost: 0 };
|
|
812
|
+
merged[key].requests += channelStats.requests || 0;
|
|
813
|
+
merged[key].tokens.total += getTokenTotal(channelStats.tokens);
|
|
814
|
+
merged[key].cost += channelStats.cost || 0;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return merged;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* 扫描日期范围内的 JSONL 文件,返回可用的过滤器选项
|
|
823
|
+
* @param {string} startDate - YYYY-MM-DD
|
|
824
|
+
* @param {string} endDate - YYYY-MM-DD
|
|
825
|
+
* @returns {{ toolTypes: string[], channels: string[], models: string[] }}
|
|
826
|
+
*/
|
|
827
|
+
function getAvailableFilters(startDate, endDate) {
|
|
828
|
+
const toolTypes = new Set();
|
|
829
|
+
const channels = new Set();
|
|
830
|
+
const models = new Set();
|
|
831
|
+
|
|
832
|
+
const start = new Date(startDate + 'T00:00:00');
|
|
833
|
+
const end = new Date(endDate + 'T00:00:00');
|
|
834
|
+
|
|
835
|
+
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
836
|
+
const year = d.getFullYear();
|
|
837
|
+
const month = d.getMonth() + 1;
|
|
838
|
+
const day = d.getDate();
|
|
839
|
+
const filePath = getRequestLogFilePath(year, month, day);
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
if (!fs.existsSync(filePath)) continue;
|
|
843
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
844
|
+
for (const line of lines) {
|
|
845
|
+
if (!line.trim()) continue;
|
|
846
|
+
let entry;
|
|
847
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
848
|
+
if (entry.toolType) toolTypes.add(entry.toolType);
|
|
849
|
+
if (entry.channel) channels.add(entry.channel);
|
|
850
|
+
if (entry.model) models.add(entry.model);
|
|
851
|
+
}
|
|
852
|
+
} catch (err) {
|
|
853
|
+
console.error('Failed to scan JSONL for filters:', err);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return {
|
|
858
|
+
toolTypes: Array.from(toolTypes).sort(),
|
|
859
|
+
channels: Array.from(channels).sort(),
|
|
860
|
+
models: Array.from(models).sort()
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ─── Trend statistics in-memory cache ───────────────────────────────────────
|
|
865
|
+
// Key: JSON-serialized params, Value: { result, expiresAt }
|
|
866
|
+
const trendCache = new Map();
|
|
867
|
+
const TREND_CACHE_TTL_MS = 30 * 1000; // 30 seconds
|
|
868
|
+
|
|
869
|
+
function getTrendCacheKey(params) {
|
|
870
|
+
return JSON.stringify(params);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function invalidateTrendCache() {
|
|
874
|
+
trendCache.clear();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Called by recordRequest so fresh data is visible immediately after a new request
|
|
878
|
+
function invalidateTrendCacheForDate(dateStr) {
|
|
879
|
+
for (const key of trendCache.keys()) {
|
|
880
|
+
try {
|
|
881
|
+
const p = JSON.parse(key);
|
|
882
|
+
if (p.startDate <= dateStr && dateStr <= p.endDate) {
|
|
883
|
+
trendCache.delete(key);
|
|
884
|
+
}
|
|
885
|
+
} catch { trendCache.delete(key); }
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
889
|
+
|
|
890
|
+
async function getTrendStatistics({ startDate, endDate, granularity = 'day', step = 1, groupBy = 'model', metric = 'tokens', filters }) {
|
|
891
|
+
step = parseInt(step) || 1;
|
|
892
|
+
|
|
893
|
+
// Normalize filters: treat empty string as no filter
|
|
894
|
+
const activeFilters = filters && (filters.toolType || filters.channel || filters.model) ? filters : null;
|
|
895
|
+
|
|
896
|
+
// Check cache first
|
|
897
|
+
const cacheKey = getTrendCacheKey({ startDate, endDate, granularity, step: String(step), groupBy, metric, filters: activeFilters || null });
|
|
898
|
+
const cached = trendCache.get(cacheKey);
|
|
899
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
900
|
+
return cached.result;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const labels = [];
|
|
904
|
+
const seriesMap = {}; // { dimensionName: number[] }
|
|
905
|
+
const totals = {};
|
|
906
|
+
|
|
907
|
+
const start = new Date(startDate + 'T00:00:00');
|
|
908
|
+
const end = new Date(endDate + 'T00:00:00');
|
|
909
|
+
|
|
910
|
+
// Load proxy-logs once upfront (only needed for hour granularity) to avoid
|
|
911
|
+
// re-reading the large file on every iteration of the inner loop.
|
|
912
|
+
const cachedProxyLogs = granularity === 'hour' ? loadProxyLogs() : [];
|
|
913
|
+
|
|
914
|
+
// Iterate each day
|
|
915
|
+
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
916
|
+
const year = d.getFullYear();
|
|
917
|
+
const month = d.getMonth() + 1;
|
|
918
|
+
const day = d.getDate();
|
|
919
|
+
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
|
920
|
+
|
|
921
|
+
if (granularity === 'day') {
|
|
922
|
+
labels.push(dateStr);
|
|
923
|
+
const byDimension = activeFilters
|
|
924
|
+
? readJsonlForDay(year, month, day, groupBy, activeFilters)
|
|
925
|
+
: mergeAllToolsDailyStats(dateStr, groupBy);
|
|
926
|
+
|
|
927
|
+
// Accumulate dimensions seen so far with 0 for this label position
|
|
928
|
+
const labelIdx = labels.length - 1;
|
|
929
|
+
|
|
930
|
+
// Fill existing series with 0 for this position first
|
|
931
|
+
for (const key of Object.keys(seriesMap)) {
|
|
932
|
+
seriesMap[key].push(0);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
for (const [key, stats] of Object.entries(byDimension)) {
|
|
936
|
+
const val = extractMetric(stats, metric);
|
|
937
|
+
if (!seriesMap[key]) {
|
|
938
|
+
// New dimension: backfill with zeros for previous labels
|
|
939
|
+
seriesMap[key] = new Array(labelIdx).fill(0);
|
|
940
|
+
seriesMap[key].push(val);
|
|
941
|
+
} else {
|
|
942
|
+
// Already pushed 0 above, replace last element
|
|
943
|
+
seriesMap[key][labelIdx] = val;
|
|
944
|
+
}
|
|
945
|
+
totals[key] = (totals[key] || 0) + val;
|
|
946
|
+
}
|
|
947
|
+
} else {
|
|
948
|
+
// granularity === 'hour'
|
|
949
|
+
for (let h = 0; h < 24; h += step) {
|
|
950
|
+
const hourEnd = Math.min(h + step, 24);
|
|
951
|
+
const hourStr = h.toString().padStart(2, '0');
|
|
952
|
+
const label = step === 1
|
|
953
|
+
? `${dateStr} ${hourStr}:00`
|
|
954
|
+
: `${dateStr} ${hourStr}:00-${String(hourEnd).padStart(2, '0')}:00`;
|
|
955
|
+
labels.push(label);
|
|
956
|
+
const labelIdx = labels.length - 1;
|
|
957
|
+
|
|
958
|
+
// Fill existing series with 0 for this label
|
|
959
|
+
for (const key of Object.keys(seriesMap)) {
|
|
960
|
+
seriesMap[key].push(0);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Accumulate all hours in this step bucket
|
|
964
|
+
for (let hh = h; hh < hourEnd; hh++) {
|
|
965
|
+
const hhStr = hh.toString().padStart(2, '0');
|
|
966
|
+
let byDimension = {};
|
|
967
|
+
|
|
968
|
+
// 小时粒度优先使用 proxy-logs(含 codex/gemini/opencode 实时数据),
|
|
969
|
+
// 若该小时没有 proxy-logs 再回退到历史统计文件/JSONL。
|
|
970
|
+
byDimension = filterProxyLogsForHour(cachedProxyLogs, dateStr, hh, groupBy);
|
|
971
|
+
|
|
972
|
+
if (Object.keys(byDimension).length === 0 || activeFilters) {
|
|
973
|
+
if (activeFilters) {
|
|
974
|
+
byDimension = readJsonlForHour(year, month, day, hh, groupBy, activeFilters);
|
|
975
|
+
} else if (groupBy === 'toolType') {
|
|
976
|
+
const dailyStats = aggregateDailyStatistics(dateStr);
|
|
977
|
+
const hourData = dailyStats?.hourly?.[hhStr];
|
|
978
|
+
for (const [toolType, toolStats] of Object.entries(hourData?.byToolType || {})) {
|
|
979
|
+
if (!hasStatsData(toolStats)) continue;
|
|
980
|
+
byDimension[toolType] = {
|
|
981
|
+
requests: toolStats.requests || 0,
|
|
982
|
+
tokens: { total: getTokenTotal(toolStats.tokens) },
|
|
983
|
+
cost: toolStats.cost || 0
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
} else {
|
|
987
|
+
byDimension = readJsonlForHour(year, month, day, hh, groupBy);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
for (const [key, stats] of Object.entries(byDimension)) {
|
|
992
|
+
const val = extractMetric(stats, metric);
|
|
993
|
+
if (!seriesMap[key]) {
|
|
994
|
+
seriesMap[key] = new Array(labelIdx).fill(0);
|
|
995
|
+
seriesMap[key].push(val);
|
|
996
|
+
} else {
|
|
997
|
+
if (seriesMap[key].length <= labelIdx) seriesMap[key].push(0);
|
|
998
|
+
seriesMap[key][labelIdx] = (seriesMap[key][labelIdx] || 0) + val;
|
|
999
|
+
}
|
|
1000
|
+
totals[key] = (totals[key] || 0) + val;
|
|
1001
|
+
}
|
|
1002
|
+
} // end hh loop
|
|
1003
|
+
} // end h loop
|
|
1004
|
+
} // end else (hour granularity)
|
|
1005
|
+
} // end for day loop
|
|
1006
|
+
|
|
1007
|
+
// Sort series by total desc, keep top 10, merge rest into 'Other'
|
|
1008
|
+
const sorted = Object.entries(totals).sort((a, b) => b[1] - a[1]);
|
|
1009
|
+
const top10 = sorted.slice(0, 10);
|
|
1010
|
+
const rest = sorted.slice(10);
|
|
1011
|
+
|
|
1012
|
+
const series = top10.map(([name]) => ({
|
|
1013
|
+
name,
|
|
1014
|
+
data: seriesMap[name] || []
|
|
1015
|
+
}));
|
|
1016
|
+
|
|
1017
|
+
if (rest.length > 0) {
|
|
1018
|
+
const otherData = labels.map((_, i) =>
|
|
1019
|
+
rest.reduce((sum, [name]) => sum + (seriesMap[name]?.[i] || 0), 0)
|
|
1020
|
+
);
|
|
1021
|
+
const otherTotal = rest.reduce((sum, [, total]) => sum + total, 0);
|
|
1022
|
+
series.push({ name: 'Other', data: otherData });
|
|
1023
|
+
totals['Other'] = otherTotal;
|
|
1024
|
+
// Remove merged keys from totals
|
|
1025
|
+
for (const [name] of rest) delete totals[name];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const result = { labels, series, totals };
|
|
1029
|
+
|
|
1030
|
+
// Store in cache
|
|
1031
|
+
trendCache.set(cacheKey, { result, expiresAt: Date.now() + TREND_CACHE_TTL_MS });
|
|
1032
|
+
|
|
1033
|
+
return result;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
module.exports = {
|
|
1037
|
+
recordRequest,
|
|
1038
|
+
getStatistics,
|
|
1039
|
+
getDailyStatistics,
|
|
1040
|
+
getTodayStatistics,
|
|
1041
|
+
getTrendStatistics,
|
|
1042
|
+
getAvailableFilters
|
|
1043
|
+
};
|