lumencode 1.0.0 → 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/lib/server.js CHANGED
@@ -1,523 +1,573 @@
1
- import { createServer } from 'http';
2
- import { readFileSync, existsSync } from 'fs';
3
- import { join, extname, resolve, sep } from 'path';
4
- import { fileURLToPath } from 'url';
5
- import { saveConfig } from './config.js';
6
- import { generateWorkReport, generateFeishuCard } from './report.js';
7
- import { collectAllRecords, filterRecordsByPeriod, groupBySessions, computeUsageStats, computeTrendData, computePrevPeriodRange } from './aggregate.js';
8
- import { normalizeProjectPath } from './aggregate.js';
9
- import { invalidateFileCache } from './cache.js';
10
- import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
11
- import { identifyBillingBlocks } from './blocks.js';
12
- import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
-
14
- // basename 提取,兼容不同路径格式
15
- function getProjectBaseName(p) {
16
- if (!p) return '';
17
- return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() || '';
18
- }
19
-
20
- const __dirname = fileURLToPath(new URL('..', import.meta.url));
21
-
22
- // 读取应用版本号(必须在 __dirname 定义之后)
23
- let appVersion = '0.0.0';
24
- try {
25
- const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
26
- appVersion = pkg.version || '0.0.0';
27
- } catch {}
28
-
29
- const MIME = {
30
- '.html': 'text/html',
31
- '.css': 'text/css',
32
- '.js': 'application/javascript; charset=utf-8',
33
- '.json': 'application/json',
34
- '.png': 'image/png',
35
- '.svg': 'image/svg+xml',
36
- '.ico': 'image/x-icon',
37
- };
38
-
39
- export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
40
- function computeIncludeProjects(cfg) {
41
- if (cfg.repos && cfg.repos.length > 0) {
42
- return cfg.repos.map(r => normalizeProjectPath(r));
43
- }
44
- return null;
45
- }
46
-
47
- const PORT = process.env.LUMENCODE_PORT || 4567;
48
-
49
- // ── 解析结果级缓存(避免同一秒内多次全量解析) ──
50
- let _parsedCache = null;
51
- let _parsedCacheKey = '';
52
- let _parsedCacheExpire = 0;
53
- const PARSED_CACHE_TTL = 5_000; // 5s
54
-
55
- function getCachedParse(config, includeProjects) {
56
- const key = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
57
- const now = Date.now();
58
- if (_parsedCache && _parsedCacheKey === key && now < _parsedCacheExpire) return _parsedCache;
59
- return null;
60
- }
61
-
62
- async function getOrParse(config, includeProjects) {
63
- const cached = getCachedParse(config, includeProjects);
64
- if (cached) return cached;
65
- const result = await parseAllEnabledTools(config, {
66
- excludeProjects: config.excludeProjects,
67
- includeProjects,
68
- });
69
- _parsedCache = result;
70
- _parsedCacheKey = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
71
- _parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
72
- return result;
73
- }
74
-
75
- const server = createServer(async (req, res) => {
76
- const url = new URL(req.url, `http://localhost:${PORT}`);
77
-
78
- // 安全响应头
79
- res.setHeader('X-Content-Type-Options', 'nosniff');
80
- res.setHeader('X-Frame-Options', 'DENY');
81
- res.setHeader('Referrer-Policy', 'no-referrer');
82
- res.setHeader('X-XSS-Protection', '1; mode=block');
83
- res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
84
-
85
- // API endpoint
86
- if (url.pathname === '/api/tools') {
87
- try {
88
- const tools = await detectAvailableTools(config);
89
- const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
90
- const result = {
91
- appName: 'LumenCode',
92
- appVersion: 'v' + appVersion,
93
- tools: tools.map(({ name, displayName, detected, version }) => ({
94
- name, displayName, detected, version,
95
- enabled: enabled.includes(name),
96
- })),
97
- };
98
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
99
- res.end(JSON.stringify(result));
100
- } catch (err) {
101
- res.writeHead(500, { 'Content-Type': 'application/json' });
102
- console.error('API error:', err.message);
103
- res.end(JSON.stringify({ error: '服务器内部错误' }));
104
- }
105
- return;
106
- }
107
-
108
- if (url.pathname === '/api/report') {
109
- const period = url.searchParams.get('period') || 'daily';
110
- const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
111
- const format = url.searchParams.get('format') || 'json';
112
- const tool = url.searchParams.get('tool') || 'all';
113
- const customStart = url.searchParams.get('start') || '';
114
- const customEnd = url.searchParams.get('end') || '';
115
-
116
- // Validate custom range
117
- if (period === 'custom') {
118
- if (!customStart || !customEnd || !/^\d{4}-\d{2}-\d{2}$/.test(customStart) || !/^\d{4}-\d{2}-\d{2}$/.test(customEnd)) {
119
- res.writeHead(400, { 'Content-Type': 'application/json' });
120
- res.end(JSON.stringify({ error: '自定义周期需要 start 和 end 参数 (YYYY-MM-DD)' }));
121
- return;
122
- }
123
- if (customStart > customEnd) {
124
- res.writeHead(400, { 'Content-Type': 'application/json' });
125
- res.end(JSON.stringify({ error: '起始日期不能晚于结束日期' }));
126
- return;
127
- }
128
- const spanMs = new Date(customEnd) - new Date(customStart);
129
- if (spanMs > 90 * 86400000) {
130
- res.writeHead(400, { 'Content-Type': 'application/json' });
131
- res.end(JSON.stringify({ error: '自定义周期最长 90 天' }));
132
- return;
133
- }
134
- }
135
-
136
- // 未配置时返回友好提示
137
- if (!config.claudeDir || !existsSync(config.claudeDir)) {
138
- res.writeHead(200, { 'Content-Type': 'application/json' });
139
- res.end(JSON.stringify({
140
- error: '未配置',
141
- hint: '尚未配置 Claude 日志目录,请在下方完成初始设置',
142
- }));
143
- return;
144
- }
145
-
146
- try {
147
- const parsed = await getOrParse(config, computeIncludeProjects(config));
148
- const data = await buildReportData(period, date, config, computeIncludeProjects(config), tool, parsed, { customStart, customEnd });
149
- if (!data) {
150
- res.writeHead(200, { 'Content-Type': 'application/json' });
151
- res.end(JSON.stringify({
152
- error: '未找到数据',
153
- hint: '请检查 Claude 日志目录配置是否正确,确认目录下有 projects/ 子目录',
154
- }));
155
- return;
156
- }
157
-
158
- if (format === 'work') {
159
- const platform = url.searchParams.get('platform') || 'default';
160
- const level = url.searchParams.get('level') || 'detailed';
161
- const feishuCard = url.searchParams.get('feishuCard') === 'true';
162
- const project = url.searchParams.get('project') || '';
163
-
164
- if (feishuCard) {
165
- const card = generateFeishuCard(data.usageStats, data.gitStats, period, data.start, data.end, tool);
166
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
167
- res.end(JSON.stringify(card));
168
- return;
169
- }
170
-
171
- // 单项目报告:过滤记录后重新计算统计
172
- let projUsageStats = data.usageStats;
173
- let projGitStats = data.gitStats;
174
- let projPrevStats = data.prevStats;
175
- let projectName = '';
176
-
177
- if (project) {
178
- const parsed = await getOrParse(config, computeIncludeProjects(config));
179
- const { records: allRecords } = parsed;
180
- const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
181
- // basename 匹配
182
- const projRecords = toolRecords.filter(r => {
183
- const base = (r.project || '').replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
184
- return base === project;
185
- });
186
- const { filtered: projFiltered, start: pStart, end: pEnd } = filterRecordsByPeriod(projRecords, period, date, { customStart, customEnd });
187
- projUsageStats = projFiltered.length > 0 ? computeUsageStats(projFiltered, config.scenarioKeywords, config.costMode) : { requestCount: 0, projects: {} };
188
- projectName = project;
189
-
190
- // 上一周期
191
- const prevRange = computePrevPeriodRange(period, date, { customStart, customEnd });
192
- const prevProjFiltered = projRecords.filter(r => {
193
- if (!r.timestamp) return false;
194
- const d = r.timestamp.slice(0, 10);
195
- return d >= prevRange.start && d <= prevRange.end;
196
- });
197
- projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
198
-
199
- // 单项目 Git 统计
200
- if (config.repos?.length > 0) {
201
- const matchedRepo = config.repos.find(r => r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() === project);
202
- if (matchedRepo) {
203
- try {
204
- const { getGitStatsAsync } = await import('./git.js');
205
- const sessions = groupBySessions(projFiltered);
206
- const { finalizeGitStats, getGitStatsForMultipleReposAsync } = await import('./git.js');
207
- let repoGit = await getGitStatsForMultipleReposAsync([matchedRepo], pStart, pEnd + 'T23:59:59');
208
- repoGit = finalizeGitStats(repoGit, sessions);
209
- projGitStats = repoGit;
210
- } catch {}
211
- }
212
- } else {
213
- projGitStats = null;
214
- }
215
- }
216
-
217
- const markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
218
- res.writeHead(200, {
219
- 'Content-Type': 'text/plain; charset=utf-8',
220
- 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
221
- });
222
- res.end(markdown);
223
- return;
224
- }
225
-
226
- // 按工具替换 aiContribution
227
- if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
228
- const toolAi = data.gitStats.aiContributionByTool[tool];
229
- if (toolAi) {
230
- data.gitStats.aiContribution = toolAi;
231
- }
232
- }
233
-
234
- // 添加费用分解
235
- if (data.usageStats?.models) {
236
- const modelEntries = Object.entries(data.usageStats.models)
237
- .sort((a, b) => b[1].cost - a[1].cost);
238
- const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
239
- // 缓存节省 = (inputTokens - cacheRead) * avgInputRate - 已通过 cacheRead 低价体现
240
- // 简化:缓存节省 = cacheRead * avgInputRate * (1 - cacheReadRate/inputRate)
241
- const cacheRead = data.usageStats.cacheRead || 0;
242
- const cacheCreate = data.usageStats.cacheCreate || 0;
243
- let cacheSaving = 0;
244
- if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
245
- const totalInput = data.usageStats.inputTokens || 1;
246
- const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
247
- cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
248
- }
249
- data.costBreakdown = {
250
- models: modelEntries.map(([name, d]) => ({
251
- name,
252
- cost: d.cost || 0,
253
- mode: d.costMode || 'unknown',
254
- requests: d.count,
255
- inputTokens: d.inputTokens,
256
- outputTokens: d.outputTokens,
257
- })),
258
- cacheSaving,
259
- total: Math.round(totalCost * 100) / 100,
260
- };
261
- }
262
-
263
- res.writeHead(200, {
264
- 'Content-Type': 'application/json',
265
- 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
266
- });
267
- res.end(JSON.stringify(data));
268
- } catch (err) {
269
- res.writeHead(500, { 'Content-Type': 'application/json' });
270
- console.error('API error:', err.message);
271
- res.end(JSON.stringify({ error: '服务器内部错误' }));
272
- }
273
- return;
274
- }
275
-
276
- if (url.pathname === '/api/sessions') {
277
- const period = url.searchParams.get('period') || 'daily';
278
- const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
279
- const project = url.searchParams.get('project') || '';
280
- const tool = url.searchParams.get('tool') || '';
281
- try {
282
- const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
283
- const { filtered, start, end } = filterRecordsByPeriod(allRecords, period, date);
284
- const tooledRecords = tool ? filtered.filter(r => r.tool === tool) : filtered;
285
- // basename 匹配,兼容不同工具的路径格式差异
286
- const projected = project ? tooledRecords.filter(r => getProjectBaseName(r.project) === project) : tooledRecords;
287
- const sessions = groupBySessions(projected);
288
-
289
- // 附加 commits 信息(若配置了 repos),按覆盖项目过滤,扩展窗口匹配跨天提交
290
- if (config.repos?.length) {
291
- try {
292
- const coveredBases = new Set(projected.map(r => {
293
- const p = r.project || '';
294
- return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
295
- }).filter(Boolean));
296
- const sessionRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
297
- if (sessionRepos.length > 0) {
298
- const extEnd = new Date(end);
299
- extEnd.setDate(extEnd.getDate() + 2);
300
- const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
301
- finalizeGitStats(gitStats, sessions);
302
- }
303
- } catch {}
304
- }
305
-
306
- // 精简返回字段,保留效率指标
307
- const slim = sessions.map(s => {
308
- const startMs = Date.parse(s.startTime);
309
- const endMs = Date.parse(s.endTime);
310
- const duration = Number.isFinite(startMs) && Number.isFinite(endMs) ? Math.round((endMs - startMs) / 1000) : 0;
311
- return {
312
- id: s.id,
313
- project: s.project,
314
- startTime: s.startTime,
315
- endTime: s.endTime,
316
- duration,
317
- requests: s.requests,
318
- userMessages: s.userMessages,
319
- inputTokens: s.inputTokens,
320
- outputTokens: s.outputTokens,
321
- models: s.models,
322
- primaryTool: s.primaryTool || null,
323
- touchedFileCount: (s.touchedFiles || []).length,
324
- toolSequence: (s.toolSequence || []).map(tc => tc.name),
325
- shellCommandCount: (s.shellCommands || []).length,
326
- commits: s.commits || [],
327
- };
328
- });
329
-
330
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
331
- res.end(JSON.stringify(slim));
332
- } catch (err) {
333
- res.writeHead(500, { 'Content-Type': 'application/json' });
334
- console.error('API error:', err.message);
335
- res.end(JSON.stringify({ error: '服务器内部错误' }));
336
- }
337
- return;
338
- }
339
-
340
- if (url.pathname === '/api/details') {
341
- const period = url.searchParams.get('period') || 'daily';
342
- const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
343
- const dimension = url.searchParams.get('dimension') || '';
344
- const key = url.searchParams.get('key') || '';
345
- try {
346
- const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
347
- const { filtered } = filterRecordsByPeriod(allRecords, period, date);
348
- let result = [];
349
- if (dimension === 'model') {
350
- const modelRecords = filtered.filter(r => {
351
- const isAssistant = r.metadata?.type === 'assistant' || (r.tool === 'codex') || (r.tool === 'opencode' && r.metadata?.role !== 'user') || (r.type === 'assistant' && !r.tool);
352
- return isAssistant && (r.model || '') === key;
353
- });
354
- const dailyMap = {};
355
- for (const r of modelRecords) {
356
- const d = r.timestamp.slice(0, 10);
357
- if (!dailyMap[d]) dailyMap[d] = { date: d, requests: 0, inputTokens: 0, outputTokens: 0 };
358
- dailyMap[d].requests++;
359
- dailyMap[d].inputTokens += r.inputTokens || r.tokens?.input || 0;
360
- dailyMap[d].outputTokens += r.outputTokens || r.tokens?.output || 0;
361
- }
362
- result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
363
- } else if (dimension === 'scenario') {
364
- const matched = filtered.filter(r => {
365
- const text = r.metadata?.text || r.text || '';
366
- const type = r.metadata?.type || r.type;
367
- return type === 'user' && text;
368
- });
369
- for (const r of matched) {
370
- const text = r.metadata?.text || r.text || '';
371
- const lower = text.toLowerCase();
372
- const keywords = config.scenarioKeywords?.[key] || [];
373
- for (const kw of keywords) {
374
- if (lower.includes(kw.toLowerCase())) {
375
- result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
376
- break;
377
- }
378
- }
379
- if (result.length >= 10) break;
380
- }
381
- }
382
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
383
- res.end(JSON.stringify(result));
384
- } catch (err) {
385
- res.writeHead(500, { 'Content-Type': 'application/json' });
386
- console.error('API error:', err.message);
387
- res.end(JSON.stringify({ error: '服务器内部错误' }));
388
- }
389
- return;
390
- }
391
-
392
- // Billing blocks endpoint
393
- if (url.pathname === '/api/blocks') {
394
- const period = url.searchParams.get('period') || 'daily';
395
- const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
396
- try {
397
- const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
398
- const { filtered } = filterRecordsByPeriod(allRecords, period, date);
399
- const blocks = identifyBillingBlocks(filtered, 5, config.costMode);
400
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
401
- res.end(JSON.stringify(blocks));
402
- } catch (err) {
403
- res.writeHead(500, { 'Content-Type': 'application/json' });
404
- console.error('API error:', err.message);
405
- res.end(JSON.stringify({ error: '服务器内部错误' }));
406
- }
407
- return;
408
- }
409
-
410
- // Config endpoint
411
- if (url.pathname === '/api/config') {
412
- if (req.method === 'GET') {
413
- res.writeHead(200, {
414
- 'Content-Type': 'application/json',
415
- 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
416
- });
417
- res.end(JSON.stringify({
418
- claudeDir: config.claudeDir,
419
- codexDir: config.codexDir || '',
420
- opencodeDir: config.opencodeDir || '',
421
- enabledTools: config.enabledTools || [],
422
- repos: config.repos || [],
423
- excludeProjects: config.excludeProjects || [],
424
- scenarioKeywords: config.scenarioKeywords || {},
425
- }));
426
- return;
427
- }
428
-
429
- if (req.method === 'POST') {
430
- let body = '';
431
- let bodySize = 0;
432
- const MAX_BODY = 1024 * 1024; // 1MB
433
- req.on('data', chunk => {
434
- bodySize += chunk.length;
435
- if (bodySize > MAX_BODY) { req.destroy(); return; }
436
- body += chunk;
437
- });
438
- req.on('end', () => {
439
- if (bodySize > MAX_BODY) {
440
- res.writeHead(413, { 'Content-Type': 'application/json' });
441
- res.end(JSON.stringify({ error: '请求体过大' }));
442
- return;
443
- }
444
- try {
445
- const newConfig = JSON.parse(body);
446
- // 路径字段验证:必须是字符串且路径存在或为空
447
- const validatePath = (v) => typeof v === 'string' && !v.includes('..') && !/[`$|;&<>!\n\r]/.test(v) && v.length < 500;
448
- 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; }
449
- 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; }
450
- 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; }
451
- if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
452
- if (newConfig.repos !== undefined) { if (!Array.isArray(newConfig.repos)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'repos 格式无效' })); return; } config.repos = newConfig.repos; }
453
- if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
454
- if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
455
- invalidateFileCache();
456
- invalidateGitCache();
457
- _parsedCache = null; // 配置变更后清除解析缓存
458
- saveConfig(config, configPath);
459
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
460
- res.end(JSON.stringify({ success: true }));
461
- } catch {
462
- res.writeHead(400, { 'Content-Type': 'application/json' });
463
- res.end(JSON.stringify({ error: 'JSON 解析失败' }));
464
- }
465
- });
466
- return;
467
- }
468
-
469
- res.writeHead(405, { 'Content-Type': 'application/json' });
470
- res.end(JSON.stringify({ error: 'Method not allowed' }));
471
- return;
472
- }
473
-
474
- // Favicon - 返回空响应避免 404 控制台报错
475
- if (url.pathname === '/favicon.ico') {
476
- res.writeHead(204);
477
- res.end();
478
- return;
479
- }
480
-
481
- // Static files
482
- let filePath = url.pathname === '/' ? '/index.html' : decodeURIComponent(url.pathname);
483
- // 防止路径遍历:normalize 后检查
484
- filePath = filePath.replace(/\.\./g, '').replace(/\\/g, '/');
485
- const resolved = resolve(__dirname, 'public', filePath.replace(/^\//, ''));
486
- const publicDir = resolve(__dirname, 'public');
487
-
488
- if (!resolved.startsWith(publicDir + sep) && resolved !== publicDir) {
489
- res.writeHead(403);
490
- res.end('Forbidden');
491
- return;
492
- }
493
-
494
- if (!existsSync(resolved)) {
495
- res.writeHead(404);
496
- res.end('Not Found');
497
- return;
498
- }
499
-
500
- const content = readFileSync(resolved);
501
- const type = MIME[extname(resolved)] || 'application/octet-stream';
502
- res.writeHead(200, { 'Content-Type': type });
503
- res.end(content);
504
- });
505
-
506
- // 防止未处理异常导致进程崩溃
507
- process.on('uncaughtException', (err) => {
508
- console.error('Uncaught exception:', err.message);
509
- });
510
- process.on('unhandledRejection', (reason) => {
511
- console.error('Unhandled rejection:', reason);
512
- });
513
-
514
- server.listen(PORT, '127.0.0.1', () => {
515
- console.log(`\n LumenCode server running at http://localhost:${PORT}\n`);
516
-
517
- // Auto-open browser
518
- const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
519
- import('child_process').then(({ exec }) => {
520
- exec(`${openCmd} http://localhost:${PORT}`, () => {});
521
- });
522
- });
523
- }
1
+ import { createServer } from 'http';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { join, extname, resolve, sep } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { saveConfig } from './config.js';
6
+ import { generateWorkReport, generateFeishuCard } from './report.js';
7
+ import { collectAllRecords, filterRecordsByPeriod, groupBySessions, computeUsageStats, computeTrendData, computePrevPeriodRange } from './aggregate.js';
8
+ import { normalizeProjectPath } from './aggregate.js';
9
+ import { invalidateFileCache } from './cache.js';
10
+ import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
11
+ import { identifyBillingBlocks } from './blocks.js';
12
+ import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
+ import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
14
+
15
+ // basename 提取,兼容不同路径格式
16
+ function getProjectBaseName(p) {
17
+ if (!p) return '';
18
+ return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() || '';
19
+ }
20
+
21
+ const __dirname = fileURLToPath(new URL('..', import.meta.url));
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
+
30
+ const MIME = {
31
+ '.html': 'text/html',
32
+ '.css': 'text/css',
33
+ '.js': 'application/javascript; charset=utf-8',
34
+ '.json': 'application/json',
35
+ '.png': 'image/png',
36
+ '.svg': 'image/svg+xml',
37
+ '.ico': 'image/x-icon',
38
+ };
39
+
40
+ export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
41
+ function computeIncludeProjects(cfg) {
42
+ if (cfg.repos && cfg.repos.length > 0) {
43
+ return cfg.repos.map(r => normalizeProjectPath(r));
44
+ }
45
+ return null;
46
+ }
47
+
48
+ const PORT = process.env.LUMENCODE_PORT || 4567;
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
+
105
+ const server = createServer(async (req, res) => {
106
+ const url = new URL(req.url, `http://localhost:${PORT}`);
107
+
108
+ // 安全响应头
109
+ res.setHeader('X-Content-Type-Options', 'nosniff');
110
+ res.setHeader('X-Frame-Options', 'DENY');
111
+ res.setHeader('Referrer-Policy', 'no-referrer');
112
+ res.setHeader('X-XSS-Protection', '1; mode=block');
113
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
114
+
115
+ // API endpoint
116
+ if (url.pathname === '/api/tools') {
117
+ try {
118
+ const tools = await detectAvailableTools(config);
119
+ const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
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
+ };
128
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
129
+ res.end(JSON.stringify(result));
130
+ } catch (err) {
131
+ res.writeHead(500, { 'Content-Type': 'application/json' });
132
+ console.error('API error:', err.message);
133
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (url.pathname === '/api/report') {
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;
147
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
148
+ const format = url.searchParams.get('format') || 'json';
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
+ }
173
+
174
+ // 未配置时返回友好提示
175
+ if (!config.claudeDir || !existsSync(config.claudeDir)) {
176
+ res.writeHead(200, { 'Content-Type': 'application/json' });
177
+ res.end(JSON.stringify({
178
+ error: '未配置',
179
+ hint: '尚未配置 Claude 日志目录,请在下方完成初始设置',
180
+ }));
181
+ return;
182
+ }
183
+
184
+ try {
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 });
200
+ if (!data) {
201
+ res.writeHead(200, { 'Content-Type': 'application/json' });
202
+ res.end(JSON.stringify({
203
+ error: '未找到数据',
204
+ hint: '请检查 Claude 日志目录配置是否正确,确认目录下有 projects/ 子目录',
205
+ }));
206
+ return;
207
+ }
208
+
209
+ if (format === 'work') {
210
+ const platform = url.searchParams.get('platform') || 'default';
211
+ const level = url.searchParams.get('level') || 'detailed';
212
+ const feishuCard = url.searchParams.get('feishuCard') === 'true';
213
+ const project = url.searchParams.get('project') || '';
214
+
215
+ if (feishuCard) {
216
+ const card = generateFeishuCard(data.usageStats, data.gitStats, period, data.start, data.end, tool);
217
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
218
+ res.end(JSON.stringify(card));
219
+ return;
220
+ }
221
+
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 });
267
+ res.writeHead(200, {
268
+ 'Content-Type': 'text/plain; charset=utf-8',
269
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
270
+ });
271
+ res.end(markdown);
272
+ return;
273
+ }
274
+
275
+ // 按工具替换 aiContribution
276
+ if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
277
+ const toolAi = data.gitStats.aiContributionByTool[tool];
278
+ if (toolAi) {
279
+ data.gitStats.aiContribution = toolAi;
280
+ }
281
+ }
282
+
283
+ // 添加费用分解
284
+ if (data.usageStats?.models) {
285
+ const modelEntries = Object.entries(data.usageStats.models)
286
+ .sort((a, b) => b[1].cost - a[1].cost);
287
+ const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
288
+ // 缓存节省 = (inputTokens - cacheRead) * avgInputRate - 已通过 cacheRead 低价体现
289
+ // 简化:缓存节省 = cacheRead * avgInputRate * (1 - cacheReadRate/inputRate)
290
+ const cacheRead = data.usageStats.cacheRead || 0;
291
+ const cacheCreate = data.usageStats.cacheCreate || 0;
292
+ let cacheSaving = 0;
293
+ if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
294
+ const totalInput = data.usageStats.inputTokens || 1;
295
+ const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
296
+ cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
297
+ }
298
+ data.costBreakdown = {
299
+ models: modelEntries.map(([name, d]) => ({
300
+ name,
301
+ cost: d.cost || 0,
302
+ mode: d.costMode || 'unknown',
303
+ requests: d.count,
304
+ inputTokens: d.inputTokens,
305
+ outputTokens: d.outputTokens,
306
+ })),
307
+ cacheSaving,
308
+ total: Math.round(totalCost * 100) / 100,
309
+ };
310
+ }
311
+
312
+ // 写入查询结果缓存
313
+ setCachedReport(reportCacheKey, data);
314
+
315
+ res.writeHead(200, {
316
+ 'Content-Type': 'application/json',
317
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
318
+ 'X-Cache': 'MISS',
319
+ });
320
+ res.end(JSON.stringify(data));
321
+ } catch (err) {
322
+ res.writeHead(500, { 'Content-Type': 'application/json' });
323
+ console.error('API error:', err.message);
324
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
325
+ }
326
+ return;
327
+ }
328
+
329
+ if (url.pathname === '/api/sessions') {
330
+ const period = url.searchParams.get('period') || 'daily';
331
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
332
+ const project = url.searchParams.get('project') || '';
333
+ const tool = url.searchParams.get('tool') || '';
334
+ try {
335
+ const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
336
+ const { filtered, start, end } = filterRecordsByPeriod(allRecords, period, date);
337
+ const tooledRecords = tool ? filtered.filter(r => r.tool === tool) : filtered;
338
+ // basename 匹配,兼容不同工具的路径格式差异
339
+ const projected = project ? tooledRecords.filter(r => getProjectBaseName(r.project) === project) : tooledRecords;
340
+ const sessions = groupBySessions(projected);
341
+
342
+ // 附加 commits 信息(若配置了 repos),按覆盖项目过滤,扩展窗口匹配跨天提交
343
+ if (config.repos?.length) {
344
+ try {
345
+ const coveredBases = new Set(projected.map(r => getProjectBaseName(r.project)).filter(Boolean));
346
+ const sessionRepos = config.repos.filter(r => coveredBases.has(getProjectBaseName(r)));
347
+ if (sessionRepos.length > 0) {
348
+ const extEnd = new Date(end);
349
+ extEnd.setDate(extEnd.getDate() + 2);
350
+ const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
351
+ finalizeGitStats(gitStats, sessions);
352
+ }
353
+ } catch {}
354
+ }
355
+
356
+ // 精简返回字段,保留效率指标
357
+ const slim = sessions.map(s => {
358
+ const startMs = Date.parse(s.startTime);
359
+ const endMs = Date.parse(s.endTime);
360
+ const duration = Number.isFinite(startMs) && Number.isFinite(endMs) ? Math.round((endMs - startMs) / 1000) : 0;
361
+ return {
362
+ id: s.id,
363
+ project: s.project,
364
+ startTime: s.startTime,
365
+ endTime: s.endTime,
366
+ duration,
367
+ requests: s.requests,
368
+ userMessages: s.userMessages,
369
+ inputTokens: s.inputTokens,
370
+ outputTokens: s.outputTokens,
371
+ models: s.models,
372
+ primaryTool: s.primaryTool || null,
373
+ touchedFileCount: (s.touchedFiles || []).length,
374
+ toolSequence: (s.toolSequence || []).map(tc => tc.name),
375
+ shellCommandCount: (s.shellCommands || []).length,
376
+ commits: s.commits || [],
377
+ };
378
+ });
379
+
380
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
381
+ res.end(JSON.stringify(slim));
382
+ } catch (err) {
383
+ res.writeHead(500, { 'Content-Type': 'application/json' });
384
+ console.error('API error:', err.message);
385
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
386
+ }
387
+ return;
388
+ }
389
+
390
+ if (url.pathname === '/api/details') {
391
+ const period = url.searchParams.get('period') || 'daily';
392
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
393
+ const dimension = url.searchParams.get('dimension') || '';
394
+ const key = url.searchParams.get('key') || '';
395
+ try {
396
+ const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
397
+ const { filtered } = filterRecordsByPeriod(allRecords, period, date);
398
+ let result = [];
399
+ if (dimension === 'model') {
400
+ const modelRecords = filtered.filter(r => {
401
+ return isAssistantRecord(r) && (r.model || '') === key;
402
+ });
403
+ const dailyMap = {};
404
+ for (const r of modelRecords) {
405
+ const d = r.timestamp.slice(0, 10);
406
+ if (!dailyMap[d]) dailyMap[d] = { date: d, requests: 0, inputTokens: 0, outputTokens: 0 };
407
+ dailyMap[d].requests++;
408
+ dailyMap[d].inputTokens += getInputTokens(r);
409
+ dailyMap[d].outputTokens += getOutputTokens(r);
410
+ }
411
+ result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
412
+ } else if (dimension === 'scenario') {
413
+ const matched = filtered.filter(r => {
414
+ const text = r.metadata?.text || r.text || '';
415
+ const type = r.metadata?.type || r.type;
416
+ return type === 'user' && text;
417
+ });
418
+ for (const r of matched) {
419
+ const text = r.metadata?.text || r.text || '';
420
+ const lower = text.toLowerCase();
421
+ const keywords = config.scenarioKeywords?.[key] || [];
422
+ for (const kw of keywords) {
423
+ if (lower.includes(kw.toLowerCase())) {
424
+ result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
425
+ break;
426
+ }
427
+ }
428
+ if (result.length >= 10) break;
429
+ }
430
+ }
431
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
432
+ res.end(JSON.stringify(result));
433
+ } catch (err) {
434
+ res.writeHead(500, { 'Content-Type': 'application/json' });
435
+ console.error('API error:', err.message);
436
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
437
+ }
438
+ return;
439
+ }
440
+
441
+ // Billing blocks endpoint
442
+ if (url.pathname === '/api/blocks') {
443
+ const period = url.searchParams.get('period') || 'daily';
444
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
445
+ try {
446
+ const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
447
+ const { filtered } = filterRecordsByPeriod(allRecords, period, date);
448
+ const blocks = identifyBillingBlocks(filtered, 5, config.costMode);
449
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
450
+ res.end(JSON.stringify(blocks));
451
+ } catch (err) {
452
+ res.writeHead(500, { 'Content-Type': 'application/json' });
453
+ console.error('API error:', err.message);
454
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
455
+ }
456
+ return;
457
+ }
458
+
459
+ // Config endpoint
460
+ if (url.pathname === '/api/config') {
461
+ if (req.method === 'GET') {
462
+ res.writeHead(200, {
463
+ 'Content-Type': 'application/json',
464
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
465
+ });
466
+ res.end(JSON.stringify({
467
+ claudeDir: config.claudeDir,
468
+ codexDir: config.codexDir || '',
469
+ opencodeDir: config.opencodeDir || '',
470
+ enabledTools: config.enabledTools || [],
471
+ repos: config.repos || [],
472
+ excludeProjects: config.excludeProjects || [],
473
+ scenarioKeywords: config.scenarioKeywords || {},
474
+ }));
475
+ return;
476
+ }
477
+
478
+ if (req.method === 'POST') {
479
+ let body = '';
480
+ let bodySize = 0;
481
+ const MAX_BODY = 1024 * 1024; // 1MB
482
+ req.on('data', chunk => {
483
+ bodySize += chunk.length;
484
+ if (bodySize > MAX_BODY) { req.destroy(); return; }
485
+ body += chunk;
486
+ });
487
+ req.on('end', () => {
488
+ if (bodySize > MAX_BODY) {
489
+ res.writeHead(413, { 'Content-Type': 'application/json' });
490
+ res.end(JSON.stringify({ error: '请求体过大' }));
491
+ return;
492
+ }
493
+ try {
494
+ const newConfig = JSON.parse(body);
495
+ // 路径字段验证:必须是字符串且路径存在或为空
496
+ const validatePath = (v) => typeof v === 'string' && !v.includes('..') && !/[`$|;&<>!\n\r]/.test(v) && v.length < 500;
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; }
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; }
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; }
500
+ if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
501
+ if (newConfig.repos !== undefined) { if (!Array.isArray(newConfig.repos)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'repos 格式无效' })); return; } config.repos = newConfig.repos; }
502
+ if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
503
+ if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
504
+ invalidateFileCache();
505
+ invalidateGitCache();
506
+ _parsedCache = null; // 配置变更后清除解析缓存
507
+ invalidateReportCache(); // 配置变更后清除查询结果缓存
508
+ saveConfig(config, configPath);
509
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
510
+ res.end(JSON.stringify({ success: true }));
511
+ } catch {
512
+ res.writeHead(400, { 'Content-Type': 'application/json' });
513
+ res.end(JSON.stringify({ error: 'JSON 解析失败' }));
514
+ }
515
+ });
516
+ return;
517
+ }
518
+
519
+ res.writeHead(405, { 'Content-Type': 'application/json' });
520
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
521
+ return;
522
+ }
523
+
524
+ // Favicon - 返回空响应避免 404 控制台报错
525
+ if (url.pathname === '/favicon.ico') {
526
+ res.writeHead(204);
527
+ res.end();
528
+ return;
529
+ }
530
+
531
+ // Static files
532
+ let filePath = url.pathname === '/' ? '/index.html' : decodeURIComponent(url.pathname);
533
+ // 防止路径遍历:normalize 后检查
534
+ filePath = filePath.replace(/\.\./g, '').replace(/\\/g, '/');
535
+ const resolved = resolve(__dirname, 'public', filePath.replace(/^\//, ''));
536
+ const publicDir = resolve(__dirname, 'public');
537
+
538
+ if (!resolved.startsWith(publicDir + sep) && resolved !== publicDir) {
539
+ res.writeHead(403);
540
+ res.end('Forbidden');
541
+ return;
542
+ }
543
+
544
+ if (!existsSync(resolved)) {
545
+ res.writeHead(404);
546
+ res.end('Not Found');
547
+ return;
548
+ }
549
+
550
+ const content = readFileSync(resolved);
551
+ const type = MIME[extname(resolved)] || 'application/octet-stream';
552
+ res.writeHead(200, { 'Content-Type': type });
553
+ res.end(content);
554
+ });
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
+
564
+ server.listen(PORT, '127.0.0.1', () => {
565
+ console.log(`\n LumenCode server running at http://localhost:${PORT}\n`);
566
+
567
+ // Auto-open browser
568
+ const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
569
+ import('child_process').then(({ exec }) => {
570
+ exec(`${openCmd} http://localhost:${PORT}`, () => {});
571
+ });
572
+ });
573
+ }