lumencode 1.3.0 → 1.3.2
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 +8 -8
- package/index.js +348 -343
- package/lib/aggregate.js +58 -3
- package/lib/config.js +21 -8
- package/lib/path-utils.js +18 -0
- package/lib/report.js +969 -53
- package/lib/scenario.js +29 -4
- package/lib/server.js +304 -278
- package/package.json +1 -1
- package/public/app.js +232 -17
- package/public/config.js +1 -0
- package/public/export.js +11 -7
- package/public/index.html +77 -16
- package/public/style.css +248 -1
- package/public/utils.js +218 -0
package/lib/server.js
CHANGED
|
@@ -3,16 +3,18 @@ import { readFileSync, existsSync } from 'fs';
|
|
|
3
3
|
import { join, extname, resolve, sep } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { saveConfig } from './config.js';
|
|
6
|
-
import {
|
|
6
|
+
import { parseRepoPaths } from './path-utils.js';
|
|
7
|
+
import { generateWorkReport, generateFeishuCard, generateBossReport } from './report.js';
|
|
8
|
+
import { classifyRecord } from './scenario.js';
|
|
7
9
|
import { collectAllRecords, filterRecordsByPeriod, groupBySessions, computeUsageStats, computeTrendData, computePrevPeriodRange } from './aggregate.js';
|
|
8
10
|
import { normalizeProjectPath } from './aggregate.js';
|
|
9
11
|
import { invalidateFileCache } from './cache.js';
|
|
10
|
-
import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
|
|
12
|
+
import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats, computeAIContribution, computeCommitTypes, computeFileHotspots } from './git.js';
|
|
11
13
|
import { identifyBillingBlocks } from './blocks.js';
|
|
12
|
-
import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
|
|
13
|
-
import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
|
|
14
|
-
import { StepTracker } from './step-tracker.js';
|
|
15
|
-
import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
|
|
14
|
+
import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
|
|
15
|
+
import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
|
|
16
|
+
import { StepTracker } from './step-tracker.js';
|
|
17
|
+
import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
|
|
16
18
|
|
|
17
19
|
// basename 提取,兼容不同路径格式
|
|
18
20
|
function getProjectBaseName(p) {
|
|
@@ -39,42 +41,42 @@ const MIME = {
|
|
|
39
41
|
'.ico': 'image/x-icon',
|
|
40
42
|
};
|
|
41
43
|
|
|
42
|
-
export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
|
|
43
|
-
function computeIncludeProjects(cfg) {
|
|
44
|
-
if (cfg.repos && cfg.repos.length > 0) {
|
|
45
|
-
return cfg.repos.map(r => normalizeProjectPath(r));
|
|
46
|
-
}
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function getHookProjectRoots(cfg) {
|
|
51
|
-
if (!Array.isArray(cfg.repos)) return [];
|
|
52
|
-
return [...new Set(cfg.repos.map(r => normalizeProjectPath(String(r || '').trim())).filter(Boolean))];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function getConfiguredHooksStatus(cfg) {
|
|
56
|
-
const projectRoots = getHookProjectRoots(cfg);
|
|
57
|
-
const projects = projectRoots.map(root => getHooksStatus(root));
|
|
58
|
-
const total = projects.length;
|
|
59
|
-
const enabledCount = (tool) => projects.filter(p => p[tool]?.enabled).length;
|
|
60
|
-
const stepsReadyCount = projects.filter(p => p.stepsInitialized).length;
|
|
61
|
-
const toolStatus = (tool) => ({
|
|
62
|
-
enabled: total > 0 && enabledCount(tool) === total,
|
|
63
|
-
enabledCount: enabledCount(tool),
|
|
64
|
-
total,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
targetMode: 'configured-projects',
|
|
69
|
-
projectCount: total,
|
|
70
|
-
projects,
|
|
71
|
-
stepsInitialized: total > 0 && stepsReadyCount === total,
|
|
72
|
-
stepsReadyCount,
|
|
73
|
-
claude: toolStatus(HOOK_TOOLS.CLAUDE),
|
|
74
|
-
codex: toolStatus(HOOK_TOOLS.CODEX),
|
|
75
|
-
opencode: toolStatus(HOOK_TOOLS.OPENCODE),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
44
|
+
export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
|
|
45
|
+
function computeIncludeProjects(cfg) {
|
|
46
|
+
if (cfg.repos && cfg.repos.length > 0) {
|
|
47
|
+
return cfg.repos.map(r => normalizeProjectPath(r));
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getHookProjectRoots(cfg) {
|
|
53
|
+
if (!Array.isArray(cfg.repos)) return [];
|
|
54
|
+
return [...new Set(cfg.repos.map(r => normalizeProjectPath(String(r || '').trim())).filter(Boolean))];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getConfiguredHooksStatus(cfg) {
|
|
58
|
+
const projectRoots = getHookProjectRoots(cfg);
|
|
59
|
+
const projects = projectRoots.map(root => getHooksStatus(root));
|
|
60
|
+
const total = projects.length;
|
|
61
|
+
const enabledCount = (tool) => projects.filter(p => p[tool]?.enabled).length;
|
|
62
|
+
const stepsReadyCount = projects.filter(p => p.stepsInitialized).length;
|
|
63
|
+
const toolStatus = (tool) => ({
|
|
64
|
+
enabled: total > 0 && enabledCount(tool) === total,
|
|
65
|
+
enabledCount: enabledCount(tool),
|
|
66
|
+
total,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
targetMode: 'configured-projects',
|
|
71
|
+
projectCount: total,
|
|
72
|
+
projects,
|
|
73
|
+
stepsInitialized: total > 0 && stepsReadyCount === total,
|
|
74
|
+
stepsReadyCount,
|
|
75
|
+
claude: toolStatus(HOOK_TOOLS.CLAUDE),
|
|
76
|
+
codex: toolStatus(HOOK_TOOLS.CODEX),
|
|
77
|
+
opencode: toolStatus(HOOK_TOOLS.OPENCODE),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
78
80
|
|
|
79
81
|
const PORT = process.env.LUMENCODE_PORT || 4567;
|
|
80
82
|
|
|
@@ -89,8 +91,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
89
91
|
const REPORT_CACHE_TTL = 30_000; // 30s
|
|
90
92
|
const REPORT_CACHE_MAX_SIZE = 50;
|
|
91
93
|
|
|
92
|
-
function getReportCacheKey(period, date, tool, customStart, customEnd
|
|
93
|
-
return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}
|
|
94
|
+
function getReportCacheKey(period, date, tool, customStart, customEnd) {
|
|
95
|
+
return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}`;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
function getCachedReport(cacheKey) {
|
|
@@ -109,55 +111,55 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
function invalidateReportCache() {
|
|
113
|
-
_reportCache.clear();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function writeJson(res, statusCode, data) {
|
|
117
|
-
res.writeHead(statusCode, {
|
|
118
|
-
'Content-Type': 'application/json',
|
|
119
|
-
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
120
|
-
});
|
|
121
|
-
res.end(JSON.stringify(data));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function parseHookTools(value) {
|
|
125
|
-
if (!value) return [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE];
|
|
126
|
-
const tools = [];
|
|
127
|
-
for (const raw of String(value).split(',')) {
|
|
128
|
-
const tool = raw.trim().toLowerCase();
|
|
129
|
-
if (!tool) continue;
|
|
130
|
-
if (tool === 'claude' || tool === 'claude-code') tools.push(HOOK_TOOLS.CLAUDE);
|
|
131
|
-
else if (tool === 'codex') tools.push(HOOK_TOOLS.CODEX);
|
|
132
|
-
else if (tool === 'opencode' || tool === 'open-code') tools.push(HOOK_TOOLS.OPENCODE);
|
|
133
|
-
}
|
|
134
|
-
return [...new Set(tools)];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function readJsonBody(req, res, callback) {
|
|
138
|
-
let body = '';
|
|
139
|
-
let bodySize = 0;
|
|
140
|
-
const MAX_BODY = 1024 * 1024; // 1MB
|
|
141
|
-
req.on('data', chunk => {
|
|
142
|
-
bodySize += chunk.length;
|
|
143
|
-
if (bodySize > MAX_BODY) { req.destroy(); return; }
|
|
144
|
-
body += chunk;
|
|
145
|
-
});
|
|
146
|
-
req.on('end', () => {
|
|
147
|
-
if (bodySize > MAX_BODY) {
|
|
148
|
-
writeJson(res, 413, { error: '请求体过大' });
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
Promise.resolve(callback(body ? JSON.parse(body) : {})).catch(err => {
|
|
153
|
-
console.error('API error:', err.message);
|
|
154
|
-
writeJson(res, 500, { error: err.message || '服务器内部错误' });
|
|
155
|
-
});
|
|
156
|
-
} catch {
|
|
157
|
-
writeJson(res, 400, { error: 'JSON 解析失败' });
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
}
|
|
114
|
+
function invalidateReportCache() {
|
|
115
|
+
_reportCache.clear();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeJson(res, statusCode, data) {
|
|
119
|
+
res.writeHead(statusCode, {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
122
|
+
});
|
|
123
|
+
res.end(JSON.stringify(data));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseHookTools(value) {
|
|
127
|
+
if (!value) return [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE];
|
|
128
|
+
const tools = [];
|
|
129
|
+
for (const raw of String(value).split(',')) {
|
|
130
|
+
const tool = raw.trim().toLowerCase();
|
|
131
|
+
if (!tool) continue;
|
|
132
|
+
if (tool === 'claude' || tool === 'claude-code') tools.push(HOOK_TOOLS.CLAUDE);
|
|
133
|
+
else if (tool === 'codex') tools.push(HOOK_TOOLS.CODEX);
|
|
134
|
+
else if (tool === 'opencode' || tool === 'open-code') tools.push(HOOK_TOOLS.OPENCODE);
|
|
135
|
+
}
|
|
136
|
+
return [...new Set(tools)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readJsonBody(req, res, callback) {
|
|
140
|
+
let body = '';
|
|
141
|
+
let bodySize = 0;
|
|
142
|
+
const MAX_BODY = 1024 * 1024; // 1MB
|
|
143
|
+
req.on('data', chunk => {
|
|
144
|
+
bodySize += chunk.length;
|
|
145
|
+
if (bodySize > MAX_BODY) { req.destroy(); return; }
|
|
146
|
+
body += chunk;
|
|
147
|
+
});
|
|
148
|
+
req.on('end', () => {
|
|
149
|
+
if (bodySize > MAX_BODY) {
|
|
150
|
+
writeJson(res, 413, { error: '请求体过大' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
Promise.resolve(callback(body ? JSON.parse(body) : {})).catch(err => {
|
|
155
|
+
console.error('API error:', err.message);
|
|
156
|
+
writeJson(res, 500, { error: err.message || '服务器内部错误' });
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
writeJson(res, 400, { error: 'JSON 解析失败' });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
161
163
|
|
|
162
164
|
function getCachedParse(config, includeProjects) {
|
|
163
165
|
const key = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
|
|
@@ -179,6 +181,66 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
179
181
|
return result;
|
|
180
182
|
}
|
|
181
183
|
|
|
184
|
+
function deriveProjectGitStats(gitStats, project, start, end, attributionOptions) {
|
|
185
|
+
if (!gitStats?.commitList?.length || !project) return null;
|
|
186
|
+
const windowEnd = end + 'T23:59:59';
|
|
187
|
+
const commitList = gitStats.commitList.filter(c => {
|
|
188
|
+
return getProjectBaseName(c.repo) === project && (c.date || '') >= start && (c.date || '') <= windowEnd;
|
|
189
|
+
});
|
|
190
|
+
if (commitList.length === 0) return null;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
commits: commitList.length,
|
|
194
|
+
filesChanged: new Set(commitList.flatMap(c => (c.files || []).map(f => f.path))).size,
|
|
195
|
+
linesAdded: commitList.reduce((s, c) => s + (c.linesAdded || 0), 0),
|
|
196
|
+
linesDeleted: commitList.reduce((s, c) => s + (c.linesDeleted || 0), 0),
|
|
197
|
+
commitList,
|
|
198
|
+
commitTypes: computeCommitTypes(commitList),
|
|
199
|
+
fileHotspots: computeFileHotspots(commitList, 10),
|
|
200
|
+
aiContribution: computeAIContribution(commitList, null, attributionOptions),
|
|
201
|
+
attributionSummary: gitStats.attributionSummary,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function prepareJsonReportData(baseData, tool) {
|
|
206
|
+
const data = { ...baseData };
|
|
207
|
+
|
|
208
|
+
if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
|
|
209
|
+
const toolAi = data.gitStats.aiContributionByTool[tool];
|
|
210
|
+
if (toolAi) {
|
|
211
|
+
data.gitStats = { ...data.gitStats, aiContribution: toolAi };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (data.usageStats?.models) {
|
|
216
|
+
const modelEntries = Object.entries(data.usageStats.models)
|
|
217
|
+
.sort((a, b) => b[1].cost - a[1].cost);
|
|
218
|
+
const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
|
|
219
|
+
const cacheRead = data.usageStats.cacheRead || 0;
|
|
220
|
+
const cacheCreate = data.usageStats.cacheCreate || 0;
|
|
221
|
+
let cacheSaving = 0;
|
|
222
|
+
if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
|
|
223
|
+
const totalInput = data.usageStats.inputTokens || 1;
|
|
224
|
+
const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
|
|
225
|
+
cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
|
|
226
|
+
}
|
|
227
|
+
data.costBreakdown = {
|
|
228
|
+
models: modelEntries.map(([name, d]) => ({
|
|
229
|
+
name,
|
|
230
|
+
cost: d.cost || 0,
|
|
231
|
+
mode: d.costMode || 'unknown',
|
|
232
|
+
requests: d.count,
|
|
233
|
+
inputTokens: d.inputTokens,
|
|
234
|
+
outputTokens: d.outputTokens,
|
|
235
|
+
})),
|
|
236
|
+
cacheSaving,
|
|
237
|
+
total: Math.round(totalCost * 100) / 100,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return data;
|
|
242
|
+
}
|
|
243
|
+
|
|
182
244
|
const server = createServer(async (req, res) => {
|
|
183
245
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
184
246
|
|
|
@@ -189,60 +251,60 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
189
251
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
190
252
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
191
253
|
|
|
192
|
-
// API endpoint
|
|
193
|
-
if (url.pathname === '/api/hooks') {
|
|
194
|
-
try {
|
|
195
|
-
if (req.method === 'GET') {
|
|
196
|
-
writeJson(res, 200, getConfiguredHooksStatus(config));
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (req.method === 'POST') {
|
|
201
|
-
readJsonBody(req, res, async (body) => {
|
|
202
|
-
const action = body.action || 'enable';
|
|
203
|
-
const tools = parseHookTools(body.tools || url.searchParams.get('tools'));
|
|
204
|
-
if (tools.length === 0) {
|
|
205
|
-
writeJson(res, 400, { error: '未选择支持的 hooks 工具' });
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const projectRoots = getHookProjectRoots(config);
|
|
209
|
-
if (projectRoots.length === 0) {
|
|
210
|
-
writeJson(res, 400, { error: '请先在设置中添加项目路径,页面开启 hooks 只作用于设置内配置的项目。' });
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
const stepTracking = [];
|
|
214
|
-
const results = [];
|
|
215
|
-
for (const projectRoot of projectRoots) {
|
|
216
|
-
let projectStepTracking = null;
|
|
217
|
-
if (action !== 'disable') {
|
|
218
|
-
projectStepTracking = await initStepTracking(projectRoot);
|
|
219
|
-
stepTracking.push({ projectRoot, ...projectStepTracking });
|
|
220
|
-
}
|
|
221
|
-
const projectResults = action === 'disable'
|
|
222
|
-
? disableHooks(projectRoot, tools, { backup: true })
|
|
223
|
-
: enableHooks(projectRoot, tools, { backup: true });
|
|
224
|
-
results.push({ projectRoot, stepTracking: projectStepTracking, results: projectResults });
|
|
225
|
-
}
|
|
226
|
-
writeJson(res, 200, {
|
|
227
|
-
success: true,
|
|
228
|
-
action,
|
|
229
|
-
stepTracking,
|
|
230
|
-
results,
|
|
231
|
-
status: getConfiguredHooksStatus(config),
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
writeJson(res, 405, { error: 'Method not allowed' });
|
|
238
|
-
} catch (err) {
|
|
239
|
-
console.error('API error:', err.message);
|
|
240
|
-
writeJson(res, 500, { error: err.message || '服务器内部错误' });
|
|
241
|
-
}
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (url.pathname === '/api/tools') {
|
|
254
|
+
// API endpoint
|
|
255
|
+
if (url.pathname === '/api/hooks') {
|
|
256
|
+
try {
|
|
257
|
+
if (req.method === 'GET') {
|
|
258
|
+
writeJson(res, 200, getConfiguredHooksStatus(config));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (req.method === 'POST') {
|
|
263
|
+
readJsonBody(req, res, async (body) => {
|
|
264
|
+
const action = body.action || 'enable';
|
|
265
|
+
const tools = parseHookTools(body.tools || url.searchParams.get('tools'));
|
|
266
|
+
if (tools.length === 0) {
|
|
267
|
+
writeJson(res, 400, { error: '未选择支持的 hooks 工具' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const projectRoots = getHookProjectRoots(config);
|
|
271
|
+
if (projectRoots.length === 0) {
|
|
272
|
+
writeJson(res, 400, { error: '请先在设置中添加项目路径,页面开启 hooks 只作用于设置内配置的项目。' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const stepTracking = [];
|
|
276
|
+
const results = [];
|
|
277
|
+
for (const projectRoot of projectRoots) {
|
|
278
|
+
let projectStepTracking = null;
|
|
279
|
+
if (action !== 'disable') {
|
|
280
|
+
projectStepTracking = await initStepTracking(projectRoot);
|
|
281
|
+
stepTracking.push({ projectRoot, ...projectStepTracking });
|
|
282
|
+
}
|
|
283
|
+
const projectResults = action === 'disable'
|
|
284
|
+
? disableHooks(projectRoot, tools, { backup: true })
|
|
285
|
+
: enableHooks(projectRoot, tools, { backup: true });
|
|
286
|
+
results.push({ projectRoot, stepTracking: projectStepTracking, results: projectResults });
|
|
287
|
+
}
|
|
288
|
+
writeJson(res, 200, {
|
|
289
|
+
success: true,
|
|
290
|
+
action,
|
|
291
|
+
stepTracking,
|
|
292
|
+
results,
|
|
293
|
+
status: getConfiguredHooksStatus(config),
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
writeJson(res, 405, { error: 'Method not allowed' });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error('API error:', err.message);
|
|
302
|
+
writeJson(res, 500, { error: err.message || '服务器内部错误' });
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (url.pathname === '/api/tools') {
|
|
246
308
|
try {
|
|
247
309
|
const tools = await detectAvailableTools(config);
|
|
248
310
|
const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
|
|
@@ -312,20 +374,25 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
312
374
|
|
|
313
375
|
try {
|
|
314
376
|
// 查询结果缓存:相同条件直接返回缓存
|
|
315
|
-
const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd
|
|
377
|
+
const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd);
|
|
316
378
|
let data = getCachedReport(reportCacheKey);
|
|
317
|
-
if (data) {
|
|
379
|
+
if (data && format !== 'work') {
|
|
380
|
+
const responseData = prepareJsonReportData(data, tool);
|
|
318
381
|
res.writeHead(200, {
|
|
319
382
|
'Content-Type': 'application/json',
|
|
320
383
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
321
384
|
'X-Cache': 'HIT',
|
|
322
385
|
});
|
|
323
|
-
res.end(JSON.stringify(
|
|
386
|
+
res.end(JSON.stringify(responseData));
|
|
324
387
|
return;
|
|
325
388
|
}
|
|
326
389
|
|
|
327
|
-
|
|
328
|
-
|
|
390
|
+
let parsed = null;
|
|
391
|
+
if (!data) {
|
|
392
|
+
parsed = await getOrParse(config, includeProjects);
|
|
393
|
+
data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
|
|
394
|
+
if (data) setCachedReport(reportCacheKey, data);
|
|
395
|
+
}
|
|
329
396
|
if (!data) {
|
|
330
397
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
331
398
|
res.end(JSON.stringify({
|
|
@@ -355,6 +422,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
355
422
|
let projectName = '';
|
|
356
423
|
|
|
357
424
|
if (project) {
|
|
425
|
+
if (!parsed) parsed = await getOrParse(config, includeProjects);
|
|
358
426
|
const { records: allRecords } = parsed;
|
|
359
427
|
const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
|
|
360
428
|
// basename 匹配
|
|
@@ -375,27 +443,15 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
375
443
|
projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
|
|
376
444
|
|
|
377
445
|
// 单项目 Git 统计
|
|
378
|
-
|
|
379
|
-
const matchedRepo = config.repos.find(r => getProjectBaseName(r) === project);
|
|
380
|
-
if (matchedRepo) {
|
|
381
|
-
try {
|
|
382
|
-
const { getGitStatsAsync } = await import('./git.js');
|
|
383
|
-
const sessions = groupBySessions(projFiltered);
|
|
384
|
-
const { finalizeGitStats, getGitStatsForMultipleReposAsync } = await import('./git.js');
|
|
385
|
-
let repoGit = await getGitStatsForMultipleReposAsync([matchedRepo], pStart, pEnd + 'T23:59:59');
|
|
386
|
-
repoGit = await finalizeGitStats(repoGit, sessions, {
|
|
387
|
-
attribution: config.aiAttribution,
|
|
388
|
-
stepTracking: config.stepTracking,
|
|
389
|
-
});
|
|
390
|
-
projGitStats = repoGit;
|
|
391
|
-
} catch (e) { console.warn("[server] error", e.message); }
|
|
392
|
-
}
|
|
393
|
-
} else {
|
|
394
|
-
projGitStats = null;
|
|
395
|
-
}
|
|
446
|
+
projGitStats = deriveProjectGitStats(data.gitStats, project, pStart, pEnd, config.aiAttribution);
|
|
396
447
|
}
|
|
397
448
|
|
|
398
|
-
|
|
449
|
+
let markdown;
|
|
450
|
+
if (level === 'boss') {
|
|
451
|
+
markdown = generateBossReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, platform);
|
|
452
|
+
} else {
|
|
453
|
+
markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
|
|
454
|
+
}
|
|
399
455
|
res.writeHead(200, {
|
|
400
456
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
401
457
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
@@ -404,52 +460,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
404
460
|
return;
|
|
405
461
|
}
|
|
406
462
|
|
|
407
|
-
|
|
408
|
-
if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
|
|
409
|
-
const toolAi = data.gitStats.aiContributionByTool[tool];
|
|
410
|
-
if (toolAi) {
|
|
411
|
-
data.gitStats.aiContribution = toolAi;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// 添加费用分解
|
|
416
|
-
if (data.usageStats?.models) {
|
|
417
|
-
const modelEntries = Object.entries(data.usageStats.models)
|
|
418
|
-
.sort((a, b) => b[1].cost - a[1].cost);
|
|
419
|
-
const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
|
|
420
|
-
// 缓存节省 = (inputTokens - cacheRead) * avgInputRate - 已通过 cacheRead 低价体现
|
|
421
|
-
// 简化:缓存节省 = cacheRead * avgInputRate * (1 - cacheReadRate/inputRate)
|
|
422
|
-
const cacheRead = data.usageStats.cacheRead || 0;
|
|
423
|
-
const cacheCreate = data.usageStats.cacheCreate || 0;
|
|
424
|
-
let cacheSaving = 0;
|
|
425
|
-
if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
|
|
426
|
-
const totalInput = data.usageStats.inputTokens || 1;
|
|
427
|
-
const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
|
|
428
|
-
cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
|
|
429
|
-
}
|
|
430
|
-
data.costBreakdown = {
|
|
431
|
-
models: modelEntries.map(([name, d]) => ({
|
|
432
|
-
name,
|
|
433
|
-
cost: d.cost || 0,
|
|
434
|
-
mode: d.costMode || 'unknown',
|
|
435
|
-
requests: d.count,
|
|
436
|
-
inputTokens: d.inputTokens,
|
|
437
|
-
outputTokens: d.outputTokens,
|
|
438
|
-
})),
|
|
439
|
-
cacheSaving,
|
|
440
|
-
total: Math.round(totalCost * 100) / 100,
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// 写入查询结果缓存
|
|
445
|
-
setCachedReport(reportCacheKey, data);
|
|
463
|
+
const responseData = prepareJsonReportData(data, tool);
|
|
446
464
|
|
|
447
465
|
res.writeHead(200, {
|
|
448
466
|
'Content-Type': 'application/json',
|
|
449
467
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
450
468
|
'X-Cache': 'MISS',
|
|
451
469
|
});
|
|
452
|
-
res.end(JSON.stringify(
|
|
470
|
+
res.end(JSON.stringify(responseData));
|
|
453
471
|
} catch (err) {
|
|
454
472
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
455
473
|
console.error('API error:', err.message);
|
|
@@ -480,10 +498,10 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
480
498
|
const extEnd = new Date(end);
|
|
481
499
|
extEnd.setDate(extEnd.getDate() + 2);
|
|
482
500
|
const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
|
|
483
|
-
await finalizeGitStats(gitStats, sessions, {
|
|
484
|
-
attribution: config.aiAttribution,
|
|
485
|
-
stepTracking: config.stepTracking,
|
|
486
|
-
});
|
|
501
|
+
await finalizeGitStats(gitStats, sessions, {
|
|
502
|
+
attribution: config.aiAttribution,
|
|
503
|
+
stepTracking: config.stepTracking,
|
|
504
|
+
});
|
|
487
505
|
}
|
|
488
506
|
} catch (e) { console.warn("[server] error", e.message); }
|
|
489
507
|
}
|
|
@@ -545,21 +563,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
545
563
|
}
|
|
546
564
|
result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
|
|
547
565
|
} else if (dimension === 'scenario') {
|
|
566
|
+
// 复用 classifyRecord 确保与统计逻辑一致
|
|
548
567
|
const matched = filtered.filter(r => {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
return type === 'user' && text;
|
|
568
|
+
const classified = classifyRecord(r, config.scenarioKeywords);
|
|
569
|
+
return !!classified[key];
|
|
552
570
|
});
|
|
553
571
|
for (const r of matched) {
|
|
554
572
|
const text = r.metadata?.text || r.text || '';
|
|
555
|
-
|
|
556
|
-
const keywords = config.scenarioKeywords?.[key] || [];
|
|
557
|
-
for (const kw of keywords) {
|
|
558
|
-
if (lower.includes(kw.toLowerCase())) {
|
|
559
|
-
result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
|
|
560
|
-
break;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
573
|
+
result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
|
|
563
574
|
if (result.length >= 10) break;
|
|
564
575
|
}
|
|
565
576
|
}
|
|
@@ -633,10 +644,25 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
633
644
|
if (newConfig.codexDir !== undefined) { if (!validatePath(newConfig.codexDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'codexDir 格式无效' })); return; } config.codexDir = newConfig.codexDir; }
|
|
634
645
|
if (newConfig.opencodeDir !== undefined) { if (!validatePath(newConfig.opencodeDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'opencodeDir 格式无效' })); return; } config.opencodeDir = newConfig.opencodeDir; }
|
|
635
646
|
if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
|
|
636
|
-
if (newConfig.repos !== undefined)
|
|
637
|
-
if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
|
|
638
|
-
if (newConfig.scenarioKeywords !== undefined)
|
|
639
|
-
|
|
647
|
+
if (newConfig.repos !== undefined) config.repos = parseRepoPaths(newConfig.repos);
|
|
648
|
+
if (newConfig.excludeProjects !== undefined) config.excludeProjects = parseRepoPaths(newConfig.excludeProjects);
|
|
649
|
+
if (newConfig.scenarioKeywords !== undefined) {
|
|
650
|
+
// 服务端校验:限制关键词长度和数量
|
|
651
|
+
const sk = newConfig.scenarioKeywords;
|
|
652
|
+
if (typeof sk === 'object' && sk !== null) {
|
|
653
|
+
const sanitized = {};
|
|
654
|
+
for (const [scene, words] of Object.entries(sk)) {
|
|
655
|
+
if (!Array.isArray(words)) continue;
|
|
656
|
+
sanitized[scene] = words
|
|
657
|
+
.map(w => String(w).trim())
|
|
658
|
+
.filter(w => w.length > 0 && w.length <= 100)
|
|
659
|
+
.filter(w => !/[\x00-\x1f\x7f]/.test(w))
|
|
660
|
+
.slice(0, 50);
|
|
661
|
+
}
|
|
662
|
+
config.scenarioKeywords = sanitized;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (newConfig.stepTracking !== undefined) config.stepTracking = newConfig.stepTracking;
|
|
640
666
|
invalidateFileCache();
|
|
641
667
|
invalidateGitCache();
|
|
642
668
|
_parsedCache = null; // 配置变更后清除解析缓存
|
|
@@ -658,21 +684,21 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
658
684
|
}
|
|
659
685
|
|
|
660
686
|
// Step blame stats API
|
|
661
|
-
if (url.pathname === '/api/step-stats') {
|
|
662
|
-
let stepStats = { stepCount: 0, sessionCount: 0, available: false };
|
|
663
|
-
try {
|
|
664
|
-
if (config.stepTracking?.enabled !== false) {
|
|
665
|
-
for (const repo of config.repos || []) {
|
|
666
|
-
const tracker = new StepTracker(repo, { dbPath: config.stepTracking?.dbPath });
|
|
667
|
-
if (await tracker.isAvailableAsync()) {
|
|
668
|
-
await tracker.open();
|
|
669
|
-
stepStats = { ...tracker.getStats(), available: true };
|
|
670
|
-
tracker.close();
|
|
671
|
-
break;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
} catch { /* step tracking not available */ }
|
|
687
|
+
if (url.pathname === '/api/step-stats') {
|
|
688
|
+
let stepStats = { stepCount: 0, sessionCount: 0, available: false };
|
|
689
|
+
try {
|
|
690
|
+
if (config.stepTracking?.enabled !== false) {
|
|
691
|
+
for (const repo of config.repos || []) {
|
|
692
|
+
const tracker = new StepTracker(repo, { dbPath: config.stepTracking?.dbPath });
|
|
693
|
+
if (await tracker.isAvailableAsync()) {
|
|
694
|
+
await tracker.open();
|
|
695
|
+
stepStats = { ...tracker.getStats(), available: true };
|
|
696
|
+
tracker.close();
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
} catch { /* step tracking not available */ }
|
|
676
702
|
res.writeHead(200, {
|
|
677
703
|
'Content-Type': 'application/json',
|
|
678
704
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
@@ -727,9 +753,9 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
727
753
|
const cyan = '\x1b[96m';
|
|
728
754
|
const green = '\x1b[92m';
|
|
729
755
|
const yellow = '\x1b[93m';
|
|
730
|
-
const blue = '\x1b[94m';
|
|
731
|
-
const dim = '\x1b[2m';
|
|
732
|
-
const actualPort = server.address()?.port || PORT;
|
|
756
|
+
const blue = '\x1b[94m';
|
|
757
|
+
const dim = '\x1b[2m';
|
|
758
|
+
const actualPort = server.address()?.port || PORT;
|
|
733
759
|
|
|
734
760
|
const banner = [
|
|
735
761
|
'',
|
|
@@ -752,35 +778,35 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
752
778
|
if (configPath) {
|
|
753
779
|
process.stdout.write(` ${dim}●${R} ${B}Config${R} ${configPath}\n`);
|
|
754
780
|
}
|
|
755
|
-
const repoCount = config.repos?.length || 0;
|
|
756
|
-
if (repoCount > 0) {
|
|
757
|
-
process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
|
|
758
|
-
}
|
|
759
|
-
const hookStatus = getConfiguredHooksStatus(config);
|
|
760
|
-
const hookParts = [
|
|
761
|
-
`Claude ${hookStatus.claude.enabledCount}/${hookStatus.projectCount}`,
|
|
762
|
-
`Codex ${hookStatus.codex.enabledCount}/${hookStatus.projectCount}`,
|
|
763
|
-
`OpenCode ${hookStatus.opencode.enabledCount}/${hookStatus.projectCount}`,
|
|
764
|
-
`steps ${hookStatus.stepsReadyCount}/${hookStatus.projectCount}`,
|
|
765
|
-
];
|
|
766
|
-
process.stdout.write(` ${dim}●${R} ${B}Hooks${R} ${hookParts.join(' / ')}\n`);
|
|
767
|
-
if (hookStatus.projectCount === 0) {
|
|
768
|
-
process.stdout.write(` ${yellow}${B}!${R} 未配置项目路径:请先在页面设置中添加项目,页面开启 hooks 只作用于设置内项目。\n`);
|
|
769
|
-
} else if (!hookStatus.claude.enabled || !hookStatus.codex.enabled || !hookStatus.opencode.enabled || !hookStatus.stepsInitialized) {
|
|
770
|
-
process.stdout.write(` ${yellow}${B}!${R} 行级归因未完整开启:在页面中开启,或进入项目目录运行 ${B}npx lumencode hooks enable${R}。\n`);
|
|
771
|
-
}
|
|
772
|
-
process.stdout.write('\n');
|
|
773
|
-
process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${actualPort}${R}\n`);
|
|
781
|
+
const repoCount = config.repos?.length || 0;
|
|
782
|
+
if (repoCount > 0) {
|
|
783
|
+
process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
|
|
784
|
+
}
|
|
785
|
+
const hookStatus = getConfiguredHooksStatus(config);
|
|
786
|
+
const hookParts = [
|
|
787
|
+
`Claude ${hookStatus.claude.enabledCount}/${hookStatus.projectCount}`,
|
|
788
|
+
`Codex ${hookStatus.codex.enabledCount}/${hookStatus.projectCount}`,
|
|
789
|
+
`OpenCode ${hookStatus.opencode.enabledCount}/${hookStatus.projectCount}`,
|
|
790
|
+
`steps ${hookStatus.stepsReadyCount}/${hookStatus.projectCount}`,
|
|
791
|
+
];
|
|
792
|
+
process.stdout.write(` ${dim}●${R} ${B}Hooks${R} ${hookParts.join(' / ')}\n`);
|
|
793
|
+
if (hookStatus.projectCount === 0) {
|
|
794
|
+
process.stdout.write(` ${yellow}${B}!${R} 未配置项目路径:请先在页面设置中添加项目,页面开启 hooks 只作用于设置内项目。\n`);
|
|
795
|
+
} else if (!hookStatus.claude.enabled || !hookStatus.codex.enabled || !hookStatus.opencode.enabled || !hookStatus.stepsInitialized) {
|
|
796
|
+
process.stdout.write(` ${yellow}${B}!${R} 行级归因未完整开启:在页面中开启,或进入项目目录运行 ${B}npx lumencode hooks enable${R}。\n`);
|
|
797
|
+
}
|
|
798
|
+
process.stdout.write('\n');
|
|
799
|
+
process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${actualPort}${R}\n`);
|
|
774
800
|
process.stdout.write('\n');
|
|
775
801
|
|
|
776
|
-
// Auto-open browser
|
|
777
|
-
if (process.env.LUMENCODE_NO_OPEN !== '1') {
|
|
778
|
-
const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
779
|
-
import('child_process').then(({ exec }) => {
|
|
780
|
-
exec(`${openCmd} http://localhost:${actualPort}`, () => {});
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
return server;
|
|
786
|
-
}
|
|
802
|
+
// Auto-open browser
|
|
803
|
+
if (process.env.LUMENCODE_NO_OPEN !== '1') {
|
|
804
|
+
const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
805
|
+
import('child_process').then(({ exec }) => {
|
|
806
|
+
exec(`${openCmd} http://localhost:${actualPort}`, () => {});
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
return server;
|
|
812
|
+
}
|