lumencode 0.4.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -38
- package/index.js +128 -48
- package/lib/aggregate.js +40 -6
- package/lib/cache.js +8 -0
- package/lib/git.js +75 -20
- package/lib/parsers/claude.js +321 -316
- package/lib/parsers/codex.js +360 -316
- package/lib/parsers/opencode.js +236 -216
- package/lib/record-utils.js +36 -35
- package/lib/report.js +53 -16
- package/lib/server.js +191 -30
- package/package.json +1 -1
- package/public/api.js +6 -0
- package/public/app.js +827 -636
- package/public/charts.js +285 -95
- package/public/config.js +22 -21
- package/public/git-insights.js +39 -113
- package/public/index.html +728 -341
- package/public/style.css +829 -1701
- package/public/ui-state.js +8 -67
- package/public/utils.js +10 -0
- package/public/work-report.js +1 -22
package/lib/report.js
CHANGED
|
@@ -144,7 +144,17 @@ function buildGitNarrative(git, periodName) {
|
|
|
144
144
|
const totalLines = ai.totalLinesChanged || 1;
|
|
145
145
|
const aiLinePct = Math.round((ai.aiLinesChanged / totalLines) * 100);
|
|
146
146
|
const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / git.commits)) * 100);
|
|
147
|
-
|
|
147
|
+
const possibleCommitPct = ai.possibleAICommits > 0 ? Math.round((ai.possibleAICommits / git.commits) * 100) : 0;
|
|
148
|
+
const weightedPct = Math.round((ai.weightedAILineRatio || 0) * 100);
|
|
149
|
+
line += ` 高/中置信 AI 提交 ${ai.aiCommits}/${git.commits} (${commitPct}%),`;
|
|
150
|
+
if (ai.possibleAICommits > 0) {
|
|
151
|
+
line += `可能 AI 提交 ${ai.possibleAICommits} (${possibleCommitPct}%),`;
|
|
152
|
+
}
|
|
153
|
+
line += `AI 代码改写占比 ${aiLinePct}%`;
|
|
154
|
+
if (weightedPct > aiLinePct) {
|
|
155
|
+
line += `,加权影响力 ${weightedPct}%`;
|
|
156
|
+
}
|
|
157
|
+
line += '。';
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
return line;
|
|
@@ -433,10 +443,12 @@ function buildGitInsight(git) {
|
|
|
433
443
|
const ai = git.aiContribution;
|
|
434
444
|
const ratio = ai.aiCommitRatio ?? (ai.aiCommits ? ai.aiCommits / git.commits : 0);
|
|
435
445
|
const commitPct = Math.round(ratio * 100);
|
|
446
|
+
const possiblePct = Math.round((ai.possibleAICommitRatio || 0) * 100);
|
|
436
447
|
if (!isNaN(commitPct)) {
|
|
437
448
|
if (commitPct > 80) insights.push('AI 参与度极高,核心代码产出几乎全程 AI 辅助');
|
|
438
449
|
else if (commitPct > 50) insights.push('AI 参与度较高,超过半数提交有 AI 辅助');
|
|
439
|
-
else if (commitPct > 0) insights.push(
|
|
450
|
+
else if (commitPct > 0) insights.push(`高/中置信 AI 参与 ${commitPct}% 的提交,人机协作比例适中`);
|
|
451
|
+
else if (possiblePct > 0) insights.push(`无高/中置信 AI 提交,但 ${possiblePct}% 提交可能受 AI 影响`);
|
|
440
452
|
}
|
|
441
453
|
}
|
|
442
454
|
const netLines = git.linesAdded - git.linesDeleted;
|
|
@@ -602,6 +614,10 @@ export function generateReport(usageData, gitData, period, startDate, endDate) {
|
|
|
602
614
|
gitTable.addRow(['高置信提交', String(ai.highConfidenceCommits), '']);
|
|
603
615
|
gitTable.addRow(['AI 命中文件新增行', String(ai.aiFileLinesAdded), '']);
|
|
604
616
|
gitTable.addRow(['AI 命中文件删除行', String(ai.aiFileLinesDeleted), '']);
|
|
617
|
+
if (ai.possibleAICommits > 0) {
|
|
618
|
+
const possiblePct = Math.round((ai.possibleAICommits / gitData.commits) * 100);
|
|
619
|
+
gitTable.addRow(['可能 AI 提交', `${ai.possibleAICommits}/${gitData.commits}`, `${possiblePct}%`]);
|
|
620
|
+
}
|
|
605
621
|
gitTable.addRow(['低置信关联提交', String(ai.lowConfidenceCommits), '']);
|
|
606
622
|
}
|
|
607
623
|
lines.push(gitTable.render());
|
|
@@ -900,7 +916,11 @@ export function generateFeishuCard(usageData, gitData, period, startDate, endDat
|
|
|
900
916
|
if (gitData.aiContribution) {
|
|
901
917
|
const ai = gitData.aiContribution;
|
|
902
918
|
const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / gitData.commits)) * 100);
|
|
903
|
-
fields.push({ is_short: true, text: { tag: 'lark_md', content: `**AI
|
|
919
|
+
fields.push({ is_short: true, text: { tag: 'lark_md', content: `**AI 提交**\n${ai.aiCommits}/${gitData.commits} (${commitPct}%)` } });
|
|
920
|
+
if (ai.possibleAICommits > 0) {
|
|
921
|
+
const possiblePct = Math.round((ai.possibleAICommits / gitData.commits) * 100);
|
|
922
|
+
fields.push({ is_short: true, text: { tag: 'lark_md', content: `**可能 AI**\n${ai.possibleAICommits} (${possiblePct}%)` } });
|
|
923
|
+
}
|
|
904
924
|
}
|
|
905
925
|
}
|
|
906
926
|
if (usageData.estimatedCost) {
|
|
@@ -1024,7 +1044,16 @@ export function generateBriefReport(usageData, gitData, period, startDate, endDa
|
|
|
1024
1044
|
const totalLines = ai.totalLinesChanged || 1;
|
|
1025
1045
|
const aiLinePct = Math.round((ai.aiLinesChanged / totalLines) * 100);
|
|
1026
1046
|
const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / gitData.commits)) * 100);
|
|
1027
|
-
|
|
1047
|
+
const possibleCommitPct = ai.possibleAICommits > 0 ? Math.round((ai.possibleAICommits / gitData.commits) * 100) : 0;
|
|
1048
|
+
const weightedPct = Math.round((ai.weightedAILineRatio || 0) * 100);
|
|
1049
|
+
let line = `${aiLinePct}% 代码变更有 AI 参与,${ai.aiCommits}/${gitData.commits} 提交使用 AI (${commitPct}%)`;
|
|
1050
|
+
if (ai.possibleAICommits > 0) {
|
|
1051
|
+
line += `,可能 AI 提交 ${ai.possibleAICommits} (${possibleCommitPct}%)`;
|
|
1052
|
+
}
|
|
1053
|
+
if (weightedPct > aiLinePct) {
|
|
1054
|
+
line += `,加权影响力 ${weightedPct}%`;
|
|
1055
|
+
}
|
|
1056
|
+
lines.push(bullet(line));
|
|
1028
1057
|
}
|
|
1029
1058
|
|
|
1030
1059
|
if (gitData.commitList?.length) {
|
|
@@ -1146,16 +1175,17 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
|
|
|
1146
1175
|
const opts = typeof options === 'string'
|
|
1147
1176
|
? { level: 'detailed', platform: options }
|
|
1148
1177
|
: { level: 'detailed', platform: 'default', ...options };
|
|
1149
|
-
const { level, platform: fmt, tool } = opts;
|
|
1150
|
-
const
|
|
1178
|
+
const { level, platform: fmt, tool, projectName } = opts;
|
|
1179
|
+
const toolLabel = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
|
|
1180
|
+
const titlePrefix = projectName ? `${projectName} · ${toolLabel}` : toolLabel;
|
|
1151
1181
|
|
|
1152
1182
|
// 简报路由
|
|
1153
1183
|
if (level === 'brief') {
|
|
1154
1184
|
return generateBriefReport(usageData, gitData, period, startDate, endDate, prevData, fmt, tool);
|
|
1155
1185
|
}
|
|
1156
1186
|
const lines = [];
|
|
1157
|
-
const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : '月报';
|
|
1158
|
-
const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === '
|
|
1187
|
+
const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : period === 'monthly' ? '月报' : '自定义';
|
|
1188
|
+
const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'daily' ? startDate : `${startDate} ~ ${endDate}`;
|
|
1159
1189
|
|
|
1160
1190
|
lines.push(`# ${titlePrefix} 工作${periodLabel} - ${dateLabel}`);
|
|
1161
1191
|
lines.push('');
|
|
@@ -1291,18 +1321,25 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
|
|
|
1291
1321
|
const aiLinePct = Math.round(((gitData.aiContribution?.aiLineRatio ?? gitData.aiContribution?.aiRatio) || 0) * 100);
|
|
1292
1322
|
sectionLines.push(`- 高/中置信 AI 提交 **${totalAI}/${totalCommits}**(${aiLinePct}%),涉及 +${formatInt(aiDetail.totalAIFileAdded)}/-${formatInt(aiDetail.totalAIFileDeleted)} 行`);
|
|
1293
1323
|
|
|
1294
|
-
if (
|
|
1295
|
-
|
|
1324
|
+
if (gitData.aiContribution?.possibleAICommits > 0) {
|
|
1325
|
+
const possiblePct = Math.round((gitData.aiContribution.possibleAICommits / totalCommits) * 100);
|
|
1326
|
+
sectionLines.push(`- 可能 AI 提交 **${gitData.aiContribution.possibleAICommits}/${totalCommits}**(${possiblePct}%)`);
|
|
1296
1327
|
}
|
|
1297
|
-
if (
|
|
1298
|
-
sectionLines.push(`-
|
|
1328
|
+
if (gitData.aiContribution?.weightedAILineRatio > 0) {
|
|
1329
|
+
sectionLines.push(`- 加权 AI 影响力 **${Math.round(gitData.aiContribution.weightedAILineRatio * 100)}%**`);
|
|
1299
1330
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1331
|
+
|
|
1332
|
+
// 汇总统计(不列出具体 commit subject,工作汇报中无阅读价值)
|
|
1333
|
+
const parts = [];
|
|
1334
|
+
if (aiDetail.explicit.length > 0) parts.push(`显式标记 ${aiDetail.explicit.length} 项`);
|
|
1335
|
+
if (aiDetail.sessionStrong.length > 0) parts.push(`会话强关联 ${aiDetail.sessionStrong.length} 项`);
|
|
1336
|
+
if (aiDetail.fileOverlap.length > 0) parts.push(`文件重叠 ${aiDetail.fileOverlap.length} 项`);
|
|
1337
|
+
if (parts.length > 0) {
|
|
1338
|
+
sectionLines.push(`- 归因方式:${parts.join('、')}`);
|
|
1302
1339
|
}
|
|
1303
1340
|
if (aiDetail.aiFiles.length > 0) {
|
|
1304
|
-
const topFiles = aiDetail.aiFiles.slice(0,
|
|
1305
|
-
const overflow = aiDetail.aiFiles.length >
|
|
1341
|
+
const topFiles = aiDetail.aiFiles.slice(0, 5).join('、');
|
|
1342
|
+
const overflow = aiDetail.aiFiles.length > 5 ? ` 等 ${aiDetail.aiFiles.length} 个` : '';
|
|
1306
1343
|
sectionLines.push(`- **AI 涉及文件**:${topFiles}${overflow}`);
|
|
1307
1344
|
}
|
|
1308
1345
|
sectionLines.push('');
|
package/lib/server.js
CHANGED
|
@@ -4,12 +4,13 @@ import { join, extname, resolve, sep } from 'path';
|
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { saveConfig } from './config.js';
|
|
6
6
|
import { generateWorkReport, generateFeishuCard } from './report.js';
|
|
7
|
-
import { collectAllRecords, filterRecordsByPeriod, groupBySessions } from './aggregate.js';
|
|
7
|
+
import { collectAllRecords, filterRecordsByPeriod, groupBySessions, computeUsageStats, computeTrendData, computePrevPeriodRange } from './aggregate.js';
|
|
8
8
|
import { normalizeProjectPath } from './aggregate.js';
|
|
9
9
|
import { invalidateFileCache } from './cache.js';
|
|
10
10
|
import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
|
|
11
11
|
import { identifyBillingBlocks } from './blocks.js';
|
|
12
12
|
import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
|
|
13
|
+
import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
|
|
13
14
|
|
|
14
15
|
// basename 提取,兼容不同路径格式
|
|
15
16
|
function getProjectBaseName(p) {
|
|
@@ -19,6 +20,13 @@ function getProjectBaseName(p) {
|
|
|
19
20
|
|
|
20
21
|
const __dirname = fileURLToPath(new URL('..', import.meta.url));
|
|
21
22
|
|
|
23
|
+
// 读取应用版本号(必须在 __dirname 定义之后)
|
|
24
|
+
let appVersion = '0.0.0';
|
|
25
|
+
try {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
|
|
27
|
+
appVersion = pkg.version || '0.0.0';
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
22
30
|
const MIME = {
|
|
23
31
|
'.html': 'text/html',
|
|
24
32
|
'.css': 'text/css',
|
|
@@ -39,6 +47,61 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
39
47
|
|
|
40
48
|
const PORT = process.env.LUMENCODE_PORT || 4567;
|
|
41
49
|
|
|
50
|
+
// ── 解析结果级缓存(避免同一秒内多次全量解析) ──
|
|
51
|
+
let _parsedCache = null;
|
|
52
|
+
let _parsedCacheKey = '';
|
|
53
|
+
let _parsedCacheExpire = 0;
|
|
54
|
+
const PARSED_CACHE_TTL = 30_000; // 30s
|
|
55
|
+
|
|
56
|
+
// ── 查询结果缓存(按查询条件缓存 buildReportData 结果) ──
|
|
57
|
+
const _reportCache = new Map();
|
|
58
|
+
const REPORT_CACHE_TTL = 30_000; // 30s
|
|
59
|
+
const REPORT_CACHE_MAX_SIZE = 50;
|
|
60
|
+
|
|
61
|
+
function getReportCacheKey(period, date, tool, customStart, customEnd, format) {
|
|
62
|
+
return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}|${format || 'json'}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getCachedReport(cacheKey) {
|
|
66
|
+
const cached = _reportCache.get(cacheKey);
|
|
67
|
+
if (cached && Date.now() < cached.expire) return cached.data;
|
|
68
|
+
_reportCache.delete(cacheKey);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setCachedReport(cacheKey, data) {
|
|
73
|
+
_reportCache.set(cacheKey, { data, expire: Date.now() + REPORT_CACHE_TTL });
|
|
74
|
+
// LRU: 超出限制时删除最早的条目
|
|
75
|
+
while (_reportCache.size > REPORT_CACHE_MAX_SIZE) {
|
|
76
|
+
const oldest = _reportCache.keys().next().value;
|
|
77
|
+
_reportCache.delete(oldest);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function invalidateReportCache() {
|
|
82
|
+
_reportCache.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getCachedParse(config, includeProjects) {
|
|
86
|
+
const key = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
if (_parsedCache && _parsedCacheKey === key && now < _parsedCacheExpire) return _parsedCache;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function getOrParse(config, includeProjects) {
|
|
93
|
+
const cached = getCachedParse(config, includeProjects);
|
|
94
|
+
if (cached) return cached;
|
|
95
|
+
const result = await parseAllEnabledTools(config, {
|
|
96
|
+
excludeProjects: config.excludeProjects,
|
|
97
|
+
includeProjects,
|
|
98
|
+
});
|
|
99
|
+
_parsedCache = result;
|
|
100
|
+
_parsedCacheKey = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
|
|
101
|
+
_parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
42
105
|
const server = createServer(async (req, res) => {
|
|
43
106
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
44
107
|
|
|
@@ -46,16 +109,22 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
46
109
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
47
110
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
48
111
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
112
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
113
|
+
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
49
114
|
|
|
50
115
|
// API endpoint
|
|
51
116
|
if (url.pathname === '/api/tools') {
|
|
52
117
|
try {
|
|
53
118
|
const tools = await detectAvailableTools(config);
|
|
54
119
|
const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
|
|
55
|
-
const result =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
120
|
+
const result = {
|
|
121
|
+
appName: 'LumenCode',
|
|
122
|
+
appVersion: 'v' + appVersion,
|
|
123
|
+
tools: tools.map(({ name, displayName, detected, version }) => ({
|
|
124
|
+
name, displayName, detected, version,
|
|
125
|
+
enabled: enabled.includes(name),
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
59
128
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
|
|
60
129
|
res.end(JSON.stringify(result));
|
|
61
130
|
} catch (err) {
|
|
@@ -67,10 +136,40 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
67
136
|
}
|
|
68
137
|
|
|
69
138
|
if (url.pathname === '/api/report') {
|
|
70
|
-
const
|
|
139
|
+
const VALID_PERIODS = ['daily', 'weekly', 'monthly', 'custom'];
|
|
140
|
+
const rawPeriod = url.searchParams.get('period') || 'daily';
|
|
141
|
+
if (!VALID_PERIODS.includes(rawPeriod)) {
|
|
142
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
143
|
+
res.end(JSON.stringify({ error: `无效的 period 参数,可选值:${VALID_PERIODS.join('/')}` }));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const period = rawPeriod;
|
|
71
147
|
const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
|
|
72
148
|
const format = url.searchParams.get('format') || 'json';
|
|
73
149
|
const tool = url.searchParams.get('tool') || 'all';
|
|
150
|
+
const customStart = url.searchParams.get('start') || '';
|
|
151
|
+
const customEnd = url.searchParams.get('end') || '';
|
|
152
|
+
const includeProjects = computeIncludeProjects(config);
|
|
153
|
+
|
|
154
|
+
// Validate custom range
|
|
155
|
+
if (period === 'custom') {
|
|
156
|
+
if (!customStart || !customEnd || !/^\d{4}-\d{2}-\d{2}$/.test(customStart) || !/^\d{4}-\d{2}-\d{2}$/.test(customEnd)) {
|
|
157
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
158
|
+
res.end(JSON.stringify({ error: '自定义周期需要 start 和 end 参数 (YYYY-MM-DD)' }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (customStart > customEnd) {
|
|
162
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
163
|
+
res.end(JSON.stringify({ error: '起始日期不能晚于结束日期' }));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const spanMs = new Date(customEnd) - new Date(customStart);
|
|
167
|
+
if (spanMs > 90 * 86400000) {
|
|
168
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
169
|
+
res.end(JSON.stringify({ error: '自定义周期最长 90 天' }));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
74
173
|
|
|
75
174
|
// 未配置时返回友好提示
|
|
76
175
|
if (!config.claudeDir || !existsSync(config.claudeDir)) {
|
|
@@ -83,7 +182,21 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
83
182
|
}
|
|
84
183
|
|
|
85
184
|
try {
|
|
86
|
-
|
|
185
|
+
// 查询结果缓存:相同条件直接返回缓存
|
|
186
|
+
const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd, format);
|
|
187
|
+
let data = getCachedReport(reportCacheKey);
|
|
188
|
+
if (data) {
|
|
189
|
+
res.writeHead(200, {
|
|
190
|
+
'Content-Type': 'application/json',
|
|
191
|
+
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
192
|
+
'X-Cache': 'HIT',
|
|
193
|
+
});
|
|
194
|
+
res.end(JSON.stringify(data));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const parsed = await getOrParse(config, includeProjects);
|
|
199
|
+
data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
|
|
87
200
|
if (!data) {
|
|
88
201
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
89
202
|
res.end(JSON.stringify({
|
|
@@ -97,6 +210,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
97
210
|
const platform = url.searchParams.get('platform') || 'default';
|
|
98
211
|
const level = url.searchParams.get('level') || 'detailed';
|
|
99
212
|
const feishuCard = url.searchParams.get('feishuCard') === 'true';
|
|
213
|
+
const project = url.searchParams.get('project') || '';
|
|
100
214
|
|
|
101
215
|
if (feishuCard) {
|
|
102
216
|
const card = generateFeishuCard(data.usageStats, data.gitStats, period, data.start, data.end, tool);
|
|
@@ -105,7 +219,51 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
105
219
|
return;
|
|
106
220
|
}
|
|
107
221
|
|
|
108
|
-
|
|
222
|
+
// 单项目报告:过滤记录后重新计算统计
|
|
223
|
+
let projUsageStats = data.usageStats;
|
|
224
|
+
let projGitStats = data.gitStats;
|
|
225
|
+
let projPrevStats = data.prevStats;
|
|
226
|
+
let projectName = '';
|
|
227
|
+
|
|
228
|
+
if (project) {
|
|
229
|
+
const { records: allRecords } = parsed;
|
|
230
|
+
const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
|
|
231
|
+
// basename 匹配
|
|
232
|
+
const projRecords = toolRecords.filter(r => {
|
|
233
|
+
return getProjectBaseName(r.project) === project;
|
|
234
|
+
});
|
|
235
|
+
const { filtered: projFiltered, start: pStart, end: pEnd } = filterRecordsByPeriod(projRecords, period, date, { customStart, customEnd });
|
|
236
|
+
projUsageStats = projFiltered.length > 0 ? computeUsageStats(projFiltered, config.scenarioKeywords, config.costMode) : { requestCount: 0, projects: {} };
|
|
237
|
+
projectName = project;
|
|
238
|
+
|
|
239
|
+
// 上一周期
|
|
240
|
+
const prevRange = computePrevPeriodRange(period, date, { customStart, customEnd });
|
|
241
|
+
const prevProjFiltered = projRecords.filter(r => {
|
|
242
|
+
if (!r.timestamp) return false;
|
|
243
|
+
const d = r.timestamp.slice(0, 10);
|
|
244
|
+
return d >= prevRange.start && d <= prevRange.end;
|
|
245
|
+
});
|
|
246
|
+
projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
|
|
247
|
+
|
|
248
|
+
// 单项目 Git 统计
|
|
249
|
+
if (config.repos?.length > 0) {
|
|
250
|
+
const matchedRepo = config.repos.find(r => getProjectBaseName(r) === project);
|
|
251
|
+
if (matchedRepo) {
|
|
252
|
+
try {
|
|
253
|
+
const { getGitStatsAsync } = await import('./git.js');
|
|
254
|
+
const sessions = groupBySessions(projFiltered);
|
|
255
|
+
const { finalizeGitStats, getGitStatsForMultipleReposAsync } = await import('./git.js');
|
|
256
|
+
let repoGit = await getGitStatsForMultipleReposAsync([matchedRepo], pStart, pEnd + 'T23:59:59');
|
|
257
|
+
repoGit = finalizeGitStats(repoGit, sessions);
|
|
258
|
+
projGitStats = repoGit;
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
projGitStats = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
|
|
109
267
|
res.writeHead(200, {
|
|
110
268
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
111
269
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
@@ -151,9 +309,13 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
151
309
|
};
|
|
152
310
|
}
|
|
153
311
|
|
|
312
|
+
// 写入查询结果缓存
|
|
313
|
+
setCachedReport(reportCacheKey, data);
|
|
314
|
+
|
|
154
315
|
res.writeHead(200, {
|
|
155
316
|
'Content-Type': 'application/json',
|
|
156
317
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
318
|
+
'X-Cache': 'MISS',
|
|
157
319
|
});
|
|
158
320
|
res.end(JSON.stringify(data));
|
|
159
321
|
} catch (err) {
|
|
@@ -170,10 +332,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
170
332
|
const project = url.searchParams.get('project') || '';
|
|
171
333
|
const tool = url.searchParams.get('tool') || '';
|
|
172
334
|
try {
|
|
173
|
-
const { records: allRecords } = await
|
|
174
|
-
excludeProjects: config.excludeProjects,
|
|
175
|
-
includeProjects: computeIncludeProjects(config),
|
|
176
|
-
});
|
|
335
|
+
const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
|
|
177
336
|
const { filtered, start, end } = filterRecordsByPeriod(allRecords, period, date);
|
|
178
337
|
const tooledRecords = tool ? filtered.filter(r => r.tool === tool) : filtered;
|
|
179
338
|
// basename 匹配,兼容不同工具的路径格式差异
|
|
@@ -183,11 +342,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
183
342
|
// 附加 commits 信息(若配置了 repos),按覆盖项目过滤,扩展窗口匹配跨天提交
|
|
184
343
|
if (config.repos?.length) {
|
|
185
344
|
try {
|
|
186
|
-
const coveredBases = new Set(projected.map(r =>
|
|
187
|
-
|
|
188
|
-
return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
|
|
189
|
-
}).filter(Boolean));
|
|
190
|
-
const sessionRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
|
|
345
|
+
const coveredBases = new Set(projected.map(r => getProjectBaseName(r.project)).filter(Boolean));
|
|
346
|
+
const sessionRepos = config.repos.filter(r => coveredBases.has(getProjectBaseName(r)));
|
|
191
347
|
if (sessionRepos.length > 0) {
|
|
192
348
|
const extEnd = new Date(end);
|
|
193
349
|
extEnd.setDate(extEnd.getDate() + 2);
|
|
@@ -237,24 +393,20 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
237
393
|
const dimension = url.searchParams.get('dimension') || '';
|
|
238
394
|
const key = url.searchParams.get('key') || '';
|
|
239
395
|
try {
|
|
240
|
-
const { records: allRecords } = await
|
|
241
|
-
excludeProjects: config.excludeProjects,
|
|
242
|
-
includeProjects: computeIncludeProjects(config),
|
|
243
|
-
});
|
|
396
|
+
const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
|
|
244
397
|
const { filtered } = filterRecordsByPeriod(allRecords, period, date);
|
|
245
398
|
let result = [];
|
|
246
399
|
if (dimension === 'model') {
|
|
247
400
|
const modelRecords = filtered.filter(r => {
|
|
248
|
-
|
|
249
|
-
return isAssistant && (r.model || '') === key;
|
|
401
|
+
return isAssistantRecord(r) && (r.model || '') === key;
|
|
250
402
|
});
|
|
251
403
|
const dailyMap = {};
|
|
252
404
|
for (const r of modelRecords) {
|
|
253
405
|
const d = r.timestamp.slice(0, 10);
|
|
254
406
|
if (!dailyMap[d]) dailyMap[d] = { date: d, requests: 0, inputTokens: 0, outputTokens: 0 };
|
|
255
407
|
dailyMap[d].requests++;
|
|
256
|
-
dailyMap[d].inputTokens += r
|
|
257
|
-
dailyMap[d].outputTokens += r
|
|
408
|
+
dailyMap[d].inputTokens += getInputTokens(r);
|
|
409
|
+
dailyMap[d].outputTokens += getOutputTokens(r);
|
|
258
410
|
}
|
|
259
411
|
result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
|
|
260
412
|
} else if (dimension === 'scenario') {
|
|
@@ -291,10 +443,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
291
443
|
const period = url.searchParams.get('period') || 'daily';
|
|
292
444
|
const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
|
|
293
445
|
try {
|
|
294
|
-
const { records: allRecords } = await
|
|
295
|
-
excludeProjects: config.excludeProjects,
|
|
296
|
-
includeProjects: computeIncludeProjects(config),
|
|
297
|
-
});
|
|
446
|
+
const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
|
|
298
447
|
const { filtered } = filterRecordsByPeriod(allRecords, period, date);
|
|
299
448
|
const blocks = identifyBillingBlocks(filtered, 5, config.costMode);
|
|
300
449
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
|
|
@@ -344,7 +493,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
344
493
|
try {
|
|
345
494
|
const newConfig = JSON.parse(body);
|
|
346
495
|
// 路径字段验证:必须是字符串且路径存在或为空
|
|
347
|
-
const validatePath = (v) => typeof v === 'string';
|
|
496
|
+
const validatePath = (v) => typeof v === 'string' && !v.includes('..') && !/[`$|;&<>!\n\r]/.test(v) && v.length < 500;
|
|
348
497
|
if (newConfig.claudeDir !== undefined) { if (!validatePath(newConfig.claudeDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'claudeDir 格式无效' })); return; } config.claudeDir = newConfig.claudeDir; }
|
|
349
498
|
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; }
|
|
350
499
|
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; }
|
|
@@ -354,6 +503,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
354
503
|
if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
|
|
355
504
|
invalidateFileCache();
|
|
356
505
|
invalidateGitCache();
|
|
506
|
+
_parsedCache = null; // 配置变更后清除解析缓存
|
|
507
|
+
invalidateReportCache(); // 配置变更后清除查询结果缓存
|
|
357
508
|
saveConfig(config, configPath);
|
|
358
509
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
|
|
359
510
|
res.end(JSON.stringify({ success: true }));
|
|
@@ -379,6 +530,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
379
530
|
|
|
380
531
|
// Static files
|
|
381
532
|
let filePath = url.pathname === '/' ? '/index.html' : decodeURIComponent(url.pathname);
|
|
533
|
+
// 防止路径遍历:normalize 后检查
|
|
534
|
+
filePath = filePath.replace(/\.\./g, '').replace(/\\/g, '/');
|
|
382
535
|
const resolved = resolve(__dirname, 'public', filePath.replace(/^\//, ''));
|
|
383
536
|
const publicDir = resolve(__dirname, 'public');
|
|
384
537
|
|
|
@@ -400,6 +553,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
400
553
|
res.end(content);
|
|
401
554
|
});
|
|
402
555
|
|
|
556
|
+
// 防止未处理异常导致进程崩溃
|
|
557
|
+
process.on('uncaughtException', (err) => {
|
|
558
|
+
console.error('Uncaught exception:', err.message);
|
|
559
|
+
});
|
|
560
|
+
process.on('unhandledRejection', (reason) => {
|
|
561
|
+
console.error('Unhandled rejection:', reason);
|
|
562
|
+
});
|
|
563
|
+
|
|
403
564
|
server.listen(PORT, '127.0.0.1', () => {
|
|
404
565
|
console.log(`\n LumenCode server running at http://localhost:${PORT}\n`);
|
|
405
566
|
|
package/package.json
CHANGED
package/public/api.js
CHANGED
|
@@ -27,6 +27,7 @@ export function createLatestRequestGuard() {
|
|
|
27
27
|
const cache = new Map();
|
|
28
28
|
const pending = new Map();
|
|
29
29
|
const DEFAULT_TTL = 30_000;
|
|
30
|
+
const CACHE_MAX_SIZE = 50;
|
|
30
31
|
|
|
31
32
|
export function clearApiCache() {
|
|
32
33
|
cache.clear();
|
|
@@ -49,6 +50,11 @@ export async function cachedFetch(url, options = {}, ttl = DEFAULT_TTL) {
|
|
|
49
50
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
50
51
|
const data = await res.json();
|
|
51
52
|
cache.set(key, { data, expire: Date.now() + ttl });
|
|
53
|
+
// LRU eviction
|
|
54
|
+
while (cache.size > CACHE_MAX_SIZE) {
|
|
55
|
+
const oldest = cache.keys().next().value;
|
|
56
|
+
cache.delete(oldest);
|
|
57
|
+
}
|
|
52
58
|
return data;
|
|
53
59
|
} finally {
|
|
54
60
|
pending.delete(key);
|