cc-viewer 1.3.0 → 1.3.1
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/package.json +2 -1
- package/stats-worker.js +236 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Claude Code Logger visualization management tool",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"interceptor.js",
|
|
49
49
|
"i18n.js",
|
|
50
50
|
"findcc.js",
|
|
51
|
+
"stats-worker.js",
|
|
51
52
|
"locales/",
|
|
52
53
|
"concepts/"
|
|
53
54
|
],
|
package/stats-worker.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Stats Worker — 后台线程,扫描 JSONL 日志生成项目级统计 JSON
|
|
2
|
+
import { parentPort } from 'node:worker_threads';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { join, basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 解析单个 JSONL 文件,提取模型使用次数和 token 统计
|
|
8
|
+
* @param {string} filePath JSONL 文件绝对路径
|
|
9
|
+
* @returns {{ models: Object, summary: Object }}
|
|
10
|
+
*/
|
|
11
|
+
function parseJsonlFile(filePath) {
|
|
12
|
+
const models = {};
|
|
13
|
+
let requestCount = 0;
|
|
14
|
+
let totalInput = 0;
|
|
15
|
+
let totalOutput = 0;
|
|
16
|
+
let totalCacheRead = 0;
|
|
17
|
+
let totalCacheCreation = 0;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
21
|
+
if (!content.trim()) return { models, summary: { requestCount: 0, input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } };
|
|
22
|
+
|
|
23
|
+
const entries = content.split('\n---\n').filter(p => p.trim());
|
|
24
|
+
for (const raw of entries) {
|
|
25
|
+
try {
|
|
26
|
+
const entry = JSON.parse(raw);
|
|
27
|
+
requestCount++;
|
|
28
|
+
|
|
29
|
+
// 提取模型名:优先 body.model,其次 response.body.model
|
|
30
|
+
const model = entry.body?.model || entry.response?.body?.model;
|
|
31
|
+
if (!model) continue;
|
|
32
|
+
|
|
33
|
+
if (!models[model]) {
|
|
34
|
+
models[model] = { count: 0, input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
35
|
+
}
|
|
36
|
+
models[model].count++;
|
|
37
|
+
|
|
38
|
+
// 提取 usage — 可能在 response.body.usage
|
|
39
|
+
const usage = entry.response?.body?.usage;
|
|
40
|
+
if (usage) {
|
|
41
|
+
const inp = usage.input_tokens || 0;
|
|
42
|
+
const out = usage.output_tokens || 0;
|
|
43
|
+
const cacheRead = usage.cache_read_input_tokens || usage.cache_creation_input_tokens ? (usage.cache_read_input_tokens || 0) : 0;
|
|
44
|
+
const cacheCreate = usage.cache_creation_input_tokens || 0;
|
|
45
|
+
|
|
46
|
+
models[model].input_tokens += inp;
|
|
47
|
+
models[model].output_tokens += out;
|
|
48
|
+
models[model].cache_read_input_tokens += cacheRead;
|
|
49
|
+
models[model].cache_creation_input_tokens += cacheCreate;
|
|
50
|
+
|
|
51
|
+
totalInput += inp;
|
|
52
|
+
totalOutput += out;
|
|
53
|
+
totalCacheRead += cacheRead;
|
|
54
|
+
totalCacheCreation += cacheCreate;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// 跳过无法解析的条目
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// 文件读取失败
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
models,
|
|
66
|
+
summary: {
|
|
67
|
+
requestCount,
|
|
68
|
+
input_tokens: totalInput,
|
|
69
|
+
output_tokens: totalOutput,
|
|
70
|
+
cache_read_input_tokens: totalCacheRead,
|
|
71
|
+
cache_creation_input_tokens: totalCacheCreation,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 为单个项目生成或增量更新统计 JSON
|
|
78
|
+
* @param {string} projectDir 项目日志目录
|
|
79
|
+
* @param {string} projectName 项目名
|
|
80
|
+
* @param {string|null} onlyFile 仅更新此文件(增量),null 表示智能增量
|
|
81
|
+
*/
|
|
82
|
+
function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
83
|
+
const statsFile = join(projectDir, `${projectName}.json`);
|
|
84
|
+
|
|
85
|
+
// 读取已有统计(用于增量更新)
|
|
86
|
+
let existing = null;
|
|
87
|
+
try {
|
|
88
|
+
if (existsSync(statsFile)) {
|
|
89
|
+
existing = JSON.parse(readFileSync(statsFile, 'utf-8'));
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
existing = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 列出所有 JSONL 文件(排除 _temp.jsonl)
|
|
96
|
+
let jsonlFiles;
|
|
97
|
+
try {
|
|
98
|
+
jsonlFiles = readdirSync(projectDir)
|
|
99
|
+
.filter(f => f.endsWith('.jsonl') && !f.endsWith('_temp.jsonl'))
|
|
100
|
+
.sort();
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (jsonlFiles.length === 0) return;
|
|
106
|
+
|
|
107
|
+
const filesStats = {};
|
|
108
|
+
const topModels = {};
|
|
109
|
+
|
|
110
|
+
for (const f of jsonlFiles) {
|
|
111
|
+
const filePath = join(projectDir, f);
|
|
112
|
+
let stat;
|
|
113
|
+
try {
|
|
114
|
+
stat = statSync(filePath);
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const size = stat.size;
|
|
120
|
+
const lastModified = stat.mtime.toISOString();
|
|
121
|
+
|
|
122
|
+
// 增量优化:如果有已有统计且文件未变化,直接复用
|
|
123
|
+
if (existing?.files?.[f] && existing.files[f].size === size && existing.files[f].lastModified === lastModified) {
|
|
124
|
+
// 如果指定了 onlyFile 且不是此文件,跳过重新解析
|
|
125
|
+
if (!onlyFile || onlyFile !== f) {
|
|
126
|
+
filesStats[f] = existing.files[f];
|
|
127
|
+
// 汇总模型
|
|
128
|
+
if (filesStats[f].models) {
|
|
129
|
+
for (const [model, data] of Object.entries(filesStats[f].models)) {
|
|
130
|
+
if (!topModels[model]) topModels[model] = 0;
|
|
131
|
+
topModels[model] += data.count;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 需要重新解析
|
|
139
|
+
const parsed = parseJsonlFile(filePath);
|
|
140
|
+
filesStats[f] = {
|
|
141
|
+
models: parsed.models,
|
|
142
|
+
summary: parsed.summary,
|
|
143
|
+
size,
|
|
144
|
+
lastModified,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// 汇总模型使用次数
|
|
148
|
+
for (const [model, data] of Object.entries(parsed.models)) {
|
|
149
|
+
if (!topModels[model]) topModels[model] = 0;
|
|
150
|
+
topModels[model] += data.count;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 计算全局汇总
|
|
155
|
+
let totalRequests = 0;
|
|
156
|
+
let totalInput = 0;
|
|
157
|
+
let totalOutput = 0;
|
|
158
|
+
let totalCacheRead = 0;
|
|
159
|
+
let totalCacheCreation = 0;
|
|
160
|
+
|
|
161
|
+
for (const f of Object.values(filesStats)) {
|
|
162
|
+
totalRequests += f.summary.requestCount;
|
|
163
|
+
totalInput += f.summary.input_tokens;
|
|
164
|
+
totalOutput += f.summary.output_tokens;
|
|
165
|
+
totalCacheRead += f.summary.cache_read_input_tokens;
|
|
166
|
+
totalCacheCreation += f.summary.cache_creation_input_tokens;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const stats = {
|
|
170
|
+
project: projectName,
|
|
171
|
+
updatedAt: new Date().toISOString(),
|
|
172
|
+
models: topModels,
|
|
173
|
+
files: filesStats,
|
|
174
|
+
summary: {
|
|
175
|
+
requestCount: totalRequests,
|
|
176
|
+
fileCount: jsonlFiles.length,
|
|
177
|
+
input_tokens: totalInput,
|
|
178
|
+
output_tokens: totalOutput,
|
|
179
|
+
cache_read_input_tokens: totalCacheRead,
|
|
180
|
+
cache_creation_input_tokens: totalCacheCreation,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
writeFileSync(statsFile, JSON.stringify(stats, null, 2));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
parentPort?.postMessage({ type: 'error', message: `Failed to write stats: ${err.message}` });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 扫描 logDir 下所有项目目录,逐个生成统计
|
|
193
|
+
*/
|
|
194
|
+
function scanAllProjects(logDir) {
|
|
195
|
+
try {
|
|
196
|
+
const entries = readdirSync(logDir, { withFileTypes: true });
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (!entry.isDirectory()) continue;
|
|
199
|
+
const projectDir = join(logDir, entry.name);
|
|
200
|
+
generateProjectStats(projectDir, entry.name, null);
|
|
201
|
+
}
|
|
202
|
+
parentPort?.postMessage({ type: 'scan-all-done' });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
parentPort?.postMessage({ type: 'error', message: `scan-all failed: ${err.message}` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Worker 消息处理
|
|
209
|
+
parentPort?.on('message', (msg) => {
|
|
210
|
+
switch (msg.type) {
|
|
211
|
+
case 'init': {
|
|
212
|
+
const { logDir, projectName } = msg;
|
|
213
|
+
const projectDir = join(logDir, projectName);
|
|
214
|
+
if (existsSync(projectDir)) {
|
|
215
|
+
generateProjectStats(projectDir, projectName, null);
|
|
216
|
+
parentPort?.postMessage({ type: 'init-done', projectName });
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 'update': {
|
|
221
|
+
const { logDir, projectName, logFile } = msg;
|
|
222
|
+
const projectDir = join(logDir, projectName);
|
|
223
|
+
const fileName = basename(logFile);
|
|
224
|
+
if (existsSync(projectDir)) {
|
|
225
|
+
generateProjectStats(projectDir, projectName, fileName);
|
|
226
|
+
parentPort?.postMessage({ type: 'update-done', projectName, logFile: fileName });
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'scan-all': {
|
|
231
|
+
const { logDir } = msg;
|
|
232
|
+
scanAllProjects(logDir);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|