lumencode 1.3.3 → 1.3.4
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 +12 -0
- package/lib/server.js +361 -29
- package/lib/smart-report-store.js +98 -0
- package/lib/smart-report.js +339 -0
- package/package.json +1 -1
- package/public/api.js +25 -0
- package/public/app.js +315 -2
- package/public/config.js +4 -0
- package/public/index.html +86 -3
- package/public/style.css +202 -0
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ npx lumencode serve
|
|
|
66
66
|
| 🎯 **行级 AI 归因** | 通过 hook 步骤追踪,精确识别每一行代码的 AI 参与度。不是"这个提交 AI 帮忙了",而是"这行代码是 AI 写的" |
|
|
67
67
|
| 🌐 **三工具统一** | Claude Code / Codex / OpenCode 数据全自动汇总,左侧标签一键切换 |
|
|
68
68
|
| 📝 **自然语言工作汇报** | 详报/简报一键生成,支持标准 Markdown / 飞书 / 钉钉三种格式,每个板块附诊断解读 |
|
|
69
|
+
| 🤖 **智能报告生成** | 连接本地 OpenCode CLI,对受限统计上下文和原始工作汇报做 AI 分析,支持默认风格与面向领导汇报的「牛马」风格 |
|
|
69
70
|
| 📂 **按项目独立汇报** | 右侧面板选择项目,生成该项目的独立工作汇报(commits + AI 交互量 + 热点文件) |
|
|
70
71
|
| 📅 **自定义时间范围** | 除日/周/月外,支持选择任意起止日期,方便对齐 Sprint 周期 |
|
|
71
72
|
| 💰 **精确费用估算** | 600+ 模型本地定价(含 GLM/Kimi/Qwen/DeepSeek)+ Portkey API 兜底,未知模型不计费而非乱算 |
|
|
@@ -119,6 +120,9 @@ npx lumencode serve
|
|
|
119
120
|
|
|
120
121
|
- **详报** —— 完整数据 + 洞察解读 + 板块编号,适合周报、月报
|
|
121
122
|
- **简报** —— 3-5 句话核心摘要,适合日报或群消息
|
|
123
|
+
- **智能报告** —— 页面内调用本地 OpenCode CLI 生成 AI 分析报告,补充数据摘要、工作亮点分析、关键洞察、风险与建议
|
|
124
|
+
- **风格选择** —— 生成前可选择默认风格,或「牛马」风格输出更适合向领导汇报的表达倾向
|
|
125
|
+
- **持久化与更新提醒** —— 智能报告会按周期、项目、报告层级和风格保存;统计数据变化后提示重新生成
|
|
122
126
|
- **多平台格式** —— 标准 Markdown / 飞书 / 钉钉,一键切换
|
|
123
127
|
- **按项目生成** —— 右侧面板选择项目,生成该项目的独立汇报
|
|
124
128
|
|
|
@@ -250,6 +254,14 @@ node index.js hooks disable
|
|
|
250
254
|
|
|
251
255
|
## 更新日志
|
|
252
256
|
|
|
257
|
+
### v1.3.4 (2026-06-05) — 智能报告风格与工作亮点分析
|
|
258
|
+
|
|
259
|
+
- **智能报告风格选择** — 生成智能报告前弹窗选择「默认风格」或「牛马」风格,后者面向领导汇报口径,突出投入、产出、风险兜底和下一步计划
|
|
260
|
+
- **工作亮点分析** — AI 智能报告新增「工作亮点分析」章节要求,让报告不止复述统计数据,而是提炼可汇报的亮点与依据
|
|
261
|
+
- **Boss 汇报迁移** — 移除普通工作汇报里的「汇报 Boss」层级,将其能力迁移为智能报告的一种风格,减少普通报告入口复杂度
|
|
262
|
+
- **报告持久化增强** — 智能报告按风格独立保存,默认风格兼容旧记录,刷新或切换页面后不会丢失
|
|
263
|
+
- **后台生成体验** — 智能报告改为后台任务生成,支持刷新后恢复进度,并用渐进进度条降低长时间等待的不确定感
|
|
264
|
+
|
|
253
265
|
### v1.3.0 (2026-05-28) — 行级 AI 归因 & 交互式 Hooks 管理
|
|
254
266
|
|
|
255
267
|
- **行级 AI 归因** — 通过 hook 步骤追踪系统,将归因粒度从提交级细化到行级,精确识别每一行代码的 AI 参与度
|
package/lib/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { createServer } from 'http';
|
|
2
|
-
import { readFileSync, existsSync } from 'fs';
|
|
3
|
-
import { join, extname, resolve, sep } from 'path';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
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 { randomUUID } from 'crypto';
|
|
5
6
|
import { saveConfig } from './config.js';
|
|
6
7
|
import { parseRepoPaths } from './path-utils.js';
|
|
7
8
|
import { generateWorkReport, generateFeishuCard, generateBossReport } from './report.js';
|
|
@@ -14,7 +15,9 @@ import { identifyBillingBlocks } from './blocks.js';
|
|
|
14
15
|
import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
|
|
15
16
|
import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
|
|
16
17
|
import { StepTracker } from './step-tracker.js';
|
|
17
|
-
import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
|
|
18
|
+
import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
|
|
19
|
+
import { SMART_REPORT_PROMPT_MARKER, buildSmartReportContext, createSmartReport, detectSmartReportAgents } from './smart-report.js';
|
|
20
|
+
import { buildSmartReportKey, buildSourceHash, getSmartReportStoreDir, readSmartReportRecord, saveSmartReportRecord } from './smart-report-store.js';
|
|
18
21
|
|
|
19
22
|
// basename 提取,兼容不同路径格式
|
|
20
23
|
function getProjectBaseName(p) {
|
|
@@ -87,9 +90,11 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
87
90
|
const PARSED_CACHE_TTL = 30_000; // 30s
|
|
88
91
|
|
|
89
92
|
// ── 查询结果缓存(按查询条件缓存 buildReportData 结果) ──
|
|
90
|
-
const _reportCache = new Map();
|
|
91
|
-
const REPORT_CACHE_TTL = 30_000; // 30s
|
|
92
|
-
const REPORT_CACHE_MAX_SIZE = 50;
|
|
93
|
+
const _reportCache = new Map();
|
|
94
|
+
const REPORT_CACHE_TTL = 30_000; // 30s
|
|
95
|
+
const REPORT_CACHE_MAX_SIZE = 50;
|
|
96
|
+
const smartReportJobs = new Map();
|
|
97
|
+
const SMART_REPORT_JOB_KEEP_MS = 30 * 60_000;
|
|
93
98
|
|
|
94
99
|
function getReportCacheKey(period, date, tool, customStart, customEnd) {
|
|
95
100
|
return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}`;
|
|
@@ -115,13 +120,38 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
115
120
|
_reportCache.clear();
|
|
116
121
|
}
|
|
117
122
|
|
|
118
|
-
function writeJson(res, statusCode, data) {
|
|
123
|
+
function writeJson(res, statusCode, data) {
|
|
119
124
|
res.writeHead(statusCode, {
|
|
120
125
|
'Content-Type': 'application/json',
|
|
121
126
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
122
127
|
});
|
|
123
|
-
res.end(JSON.stringify(data));
|
|
124
|
-
}
|
|
128
|
+
res.end(JSON.stringify(data));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function publicSmartReportJob(job) {
|
|
132
|
+
if (!job) return null;
|
|
133
|
+
return {
|
|
134
|
+
id: job.id,
|
|
135
|
+
reportKey: job.reportKey,
|
|
136
|
+
status: job.status,
|
|
137
|
+
error: job.error || '',
|
|
138
|
+
startedAt: job.startedAt,
|
|
139
|
+
updatedAt: job.updatedAt,
|
|
140
|
+
completedAt: job.completedAt || '',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rememberSmartReportJob(job) {
|
|
145
|
+
smartReportJobs.set(job.reportKey, job);
|
|
146
|
+
return job;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function forgetSmartReportJobLater(reportKey) {
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
const job = smartReportJobs.get(reportKey);
|
|
152
|
+
if (job && job.status !== 'running') smartReportJobs.delete(reportKey);
|
|
153
|
+
}, SMART_REPORT_JOB_KEEP_MS).unref?.();
|
|
154
|
+
}
|
|
125
155
|
|
|
126
156
|
function parseHookTools(value) {
|
|
127
157
|
if (!value) return [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE];
|
|
@@ -181,7 +211,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
181
211
|
return result;
|
|
182
212
|
}
|
|
183
213
|
|
|
184
|
-
function deriveProjectGitStats(gitStats, project, start, end, attributionOptions) {
|
|
214
|
+
function deriveProjectGitStats(gitStats, project, start, end, attributionOptions) {
|
|
185
215
|
if (!gitStats?.commitList?.length || !project) return null;
|
|
186
216
|
const windowEnd = end + 'T23:59:59';
|
|
187
217
|
const commitList = gitStats.commitList.filter(c => {
|
|
@@ -199,10 +229,178 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
199
229
|
fileHotspots: computeFileHotspots(commitList, 10),
|
|
200
230
|
aiContribution: computeAIContribution(commitList, null, attributionOptions),
|
|
201
231
|
attributionSummary: gitStats.attributionSummary,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseSmartReportParams(input = {}) {
|
|
236
|
+
return {
|
|
237
|
+
agent: input.agent || '',
|
|
238
|
+
period: input.period || 'daily',
|
|
239
|
+
date: input.date || new Date().toISOString().slice(0, 10),
|
|
240
|
+
start: input.start || '',
|
|
241
|
+
end: input.end || '',
|
|
242
|
+
tool: input.tool || 'all',
|
|
243
|
+
project: input.project || '',
|
|
244
|
+
level: input.level || 'detailed',
|
|
245
|
+
style: input.style || 'default',
|
|
246
|
+
platform: input.platform || 'default',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function validateSmartReportParams(params) {
|
|
251
|
+
const validPeriods = ['daily', 'weekly', 'monthly', 'custom'];
|
|
252
|
+
if (!validPeriods.includes(params.period)) {
|
|
253
|
+
return `无效的 period 参数,可选值:${validPeriods.join('/')}`;
|
|
254
|
+
}
|
|
255
|
+
if (params.period === 'custom') {
|
|
256
|
+
if (!params.start || !params.end || !/^\d{4}-\d{2}-\d{2}$/.test(params.start) || !/^\d{4}-\d{2}-\d{2}$/.test(params.end)) {
|
|
257
|
+
return '自定义周期需要 start 和 end 参数 (YYYY-MM-DD)';
|
|
258
|
+
}
|
|
259
|
+
if (params.start > params.end) return '起始日期不能晚于结束日期';
|
|
260
|
+
}
|
|
261
|
+
const validStyles = ['default', 'workhorse'];
|
|
262
|
+
if (!validStyles.includes(params.style)) {
|
|
263
|
+
return `无效的 style 参数,可选值:${validStyles.join('/')}`;
|
|
264
|
+
}
|
|
265
|
+
return '';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildSmartReportRecordIdentity(params, data) {
|
|
269
|
+
return {
|
|
270
|
+
agent: params.agent,
|
|
271
|
+
period: params.period,
|
|
272
|
+
date: params.period === 'daily' ? data.start : '',
|
|
273
|
+
start: data.start,
|
|
274
|
+
end: data.end,
|
|
275
|
+
tool: params.tool,
|
|
276
|
+
project: params.project,
|
|
277
|
+
level: params.level,
|
|
278
|
+
style: params.style,
|
|
279
|
+
platform: params.platform,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isSmartReportInternalRecord(record) {
|
|
284
|
+
const text = String(record?.metadata?.text || record?.text || '');
|
|
285
|
+
return text.includes(SMART_REPORT_PROMPT_MARKER)
|
|
286
|
+
|| text.includes('LumenCode 的智能报告分析器');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function filterInternalSmartReportParsed(parsed) {
|
|
290
|
+
if (!parsed?.records?.length) return parsed;
|
|
291
|
+
const internalSessions = new Set(
|
|
292
|
+
parsed.records
|
|
293
|
+
.filter(isSmartReportInternalRecord)
|
|
294
|
+
.map(r => r.sessionId)
|
|
295
|
+
.filter(Boolean)
|
|
296
|
+
);
|
|
297
|
+
if (internalSessions.size === 0) return parsed;
|
|
298
|
+
|
|
299
|
+
const records = parsed.records.filter(r => !internalSessions.has(r.sessionId));
|
|
300
|
+
const toolBreakdown = {};
|
|
301
|
+
for (const [tool, base] of Object.entries(parsed.toolBreakdown || {})) {
|
|
302
|
+
toolBreakdown[tool] = { recordCount: 0, sessionCount: 0, error: base.error };
|
|
303
|
+
}
|
|
304
|
+
const groups = {};
|
|
305
|
+
for (const record of records) {
|
|
306
|
+
const tool = record.tool || 'claude';
|
|
307
|
+
if (!groups[tool]) groups[tool] = { recordCount: 0, sessions: new Set() };
|
|
308
|
+
groups[tool].recordCount++;
|
|
309
|
+
if (record.sessionId) groups[tool].sessions.add(record.sessionId);
|
|
310
|
+
}
|
|
311
|
+
for (const [tool, group] of Object.entries(groups)) {
|
|
312
|
+
toolBreakdown[tool] = {
|
|
313
|
+
recordCount: group.recordCount,
|
|
314
|
+
sessionCount: group.sessions.size,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return { ...parsed, records, toolBreakdown };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function sourceReportHashesMatch(record, source) {
|
|
321
|
+
const saved = record?.sourceReports || {};
|
|
322
|
+
const current = source?.sourceHashes || {};
|
|
323
|
+
if (!saved.detailedHash && !saved.briefHash && !saved.bossHash) return false;
|
|
324
|
+
return ['detailedHash', 'briefHash', 'bossHash'].every(key => (saved[key] || '') === (current[key] || ''));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function smartReportNeedsUpdate(record, source) {
|
|
328
|
+
if (!record?.sourceHash) return false;
|
|
329
|
+
if (record.sourceHash === source.sourceHash) return false;
|
|
330
|
+
if ((record.sourceHashVersion || 1) < 2 && sourceReportHashesMatch(record, source)) return false;
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function buildSmartReportSource(params) {
|
|
335
|
+
const includeProjects = computeIncludeProjects(config);
|
|
336
|
+
const parsed = filterInternalSmartReportParsed(await getOrParse(config, includeProjects));
|
|
337
|
+
const data = await buildReportData(params.period, params.date, config, includeProjects, params.tool, parsed, {
|
|
338
|
+
customStart: params.start,
|
|
339
|
+
customEnd: params.end,
|
|
340
|
+
});
|
|
341
|
+
if (!data) return null;
|
|
342
|
+
|
|
343
|
+
let reportData = data;
|
|
344
|
+
let workUsageStats = data.usageStats;
|
|
345
|
+
let workGitStats = data.gitStats;
|
|
346
|
+
let workPrevStats = data.prevStats;
|
|
347
|
+
let projectName = '';
|
|
348
|
+
|
|
349
|
+
if (params.project) {
|
|
350
|
+
const { records: allRecords } = parsed;
|
|
351
|
+
const toolRecords = params.tool !== 'all' ? allRecords.filter(r => r.tool === params.tool) : allRecords;
|
|
352
|
+
const projRecords = toolRecords.filter(r => getProjectBaseName(r.project) === params.project);
|
|
353
|
+
const { filtered: projFiltered, start: pStart, end: pEnd } = filterRecordsByPeriod(projRecords, params.period, params.date, { customStart: params.start, customEnd: params.end });
|
|
354
|
+
workUsageStats = projFiltered.length > 0 ? computeUsageStats(projFiltered, config.scenarioKeywords, config.costMode) : { requestCount: 0, projects: {} };
|
|
355
|
+
workPrevStats = null;
|
|
356
|
+
workGitStats = deriveProjectGitStats(data.gitStats, params.project, pStart, pEnd, config.aiAttribution);
|
|
357
|
+
projectName = params.project;
|
|
358
|
+
reportData = {
|
|
359
|
+
...data,
|
|
360
|
+
usageStats: workUsageStats,
|
|
361
|
+
gitStats: workGitStats,
|
|
362
|
+
prevStats: workPrevStats,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const detailedMarkdown = generateWorkReport(workUsageStats, workGitStats, params.period, data.start, data.end, workPrevStats, { level: 'detailed', platform: params.platform, tool: params.tool, projectName });
|
|
367
|
+
const briefMarkdown = generateWorkReport(workUsageStats, workGitStats, params.period, data.start, data.end, workPrevStats, { level: 'brief', platform: params.platform, tool: params.tool, projectName });
|
|
368
|
+
const bossMarkdown = generateBossReport(workUsageStats, workGitStats, params.period, data.start, data.end, workPrevStats, params.platform);
|
|
369
|
+
const workMarkdown = params.level === 'brief' ? briefMarkdown : detailedMarkdown;
|
|
370
|
+
const includeBossSource = params.style === 'workhorse';
|
|
371
|
+
const sourceReports = {
|
|
372
|
+
detailedMarkdown,
|
|
373
|
+
briefMarkdown,
|
|
374
|
+
bossMarkdown: includeBossSource ? bossMarkdown : '',
|
|
375
|
+
};
|
|
376
|
+
const sourceHashes = {
|
|
377
|
+
detailedHash: buildSourceHash(detailedMarkdown),
|
|
378
|
+
briefHash: buildSourceHash(briefMarkdown),
|
|
379
|
+
bossHash: includeBossSource ? buildSourceHash(bossMarkdown) : '',
|
|
380
|
+
};
|
|
381
|
+
const sourceHash = buildSourceHash(buildSmartReportContext(reportData, workMarkdown, {
|
|
382
|
+
period: params.period,
|
|
383
|
+
date: params.date,
|
|
384
|
+
tool: params.tool,
|
|
385
|
+
project: params.project,
|
|
386
|
+
level: params.level,
|
|
387
|
+
style: params.style,
|
|
388
|
+
platform: params.platform,
|
|
389
|
+
sourceReports,
|
|
390
|
+
}));
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
data,
|
|
394
|
+
reportData,
|
|
395
|
+
workMarkdown,
|
|
396
|
+
sourceReports,
|
|
397
|
+
sourceHashes,
|
|
398
|
+
sourceHash,
|
|
399
|
+
identity: buildSmartReportRecordIdentity(params, data),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function prepareJsonReportData(baseData, tool) {
|
|
206
404
|
const data = { ...baseData };
|
|
207
405
|
|
|
208
406
|
if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
|
|
@@ -251,8 +449,146 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
251
449
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
252
450
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
253
451
|
|
|
254
|
-
// API endpoint
|
|
255
|
-
if (url.pathname === '/api/
|
|
452
|
+
// API endpoint
|
|
453
|
+
if (url.pathname === '/api/smart-report/tools') {
|
|
454
|
+
try {
|
|
455
|
+
const tools = await detectSmartReportAgents();
|
|
456
|
+
writeJson(res, 200, { tools });
|
|
457
|
+
} catch (err) {
|
|
458
|
+
console.error('API error:', err.message);
|
|
459
|
+
writeJson(res, 500, { error: err.message || '服务端内部错误' });
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (url.pathname === '/api/smart-report') {
|
|
465
|
+
if (req.method === 'GET') {
|
|
466
|
+
const params = parseSmartReportParams({
|
|
467
|
+
agent: url.searchParams.get('agent') || '',
|
|
468
|
+
period: url.searchParams.get('period') || 'daily',
|
|
469
|
+
date: url.searchParams.get('date') || '',
|
|
470
|
+
start: url.searchParams.get('start') || '',
|
|
471
|
+
end: url.searchParams.get('end') || '',
|
|
472
|
+
tool: url.searchParams.get('tool') || 'all',
|
|
473
|
+
project: url.searchParams.get('project') || '',
|
|
474
|
+
level: url.searchParams.get('level') || 'detailed',
|
|
475
|
+
style: url.searchParams.get('style') || 'default',
|
|
476
|
+
platform: url.searchParams.get('platform') || 'default',
|
|
477
|
+
});
|
|
478
|
+
const validationError = validateSmartReportParams(params);
|
|
479
|
+
if (validationError) {
|
|
480
|
+
writeJson(res, 400, { error: validationError });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const source = await buildSmartReportSource(params);
|
|
485
|
+
if (!source) {
|
|
486
|
+
writeJson(res, 200, { record: null, needsUpdate: false });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const storeDir = getSmartReportStoreDir(configPath);
|
|
491
|
+
let record = readSmartReportRecord(storeDir, buildSmartReportKey(source.identity));
|
|
492
|
+
if (!record) {
|
|
493
|
+
record = readSmartReportRecord(storeDir, buildSmartReportKey(params));
|
|
494
|
+
}
|
|
495
|
+
const reportKey = buildSmartReportKey(source.identity);
|
|
496
|
+
const job = smartReportJobs.get(reportKey) || null;
|
|
497
|
+
const needsUpdate = smartReportNeedsUpdate(record, source);
|
|
498
|
+
writeJson(res, 200, {
|
|
499
|
+
record,
|
|
500
|
+
job: publicSmartReportJob(job),
|
|
501
|
+
needsUpdate,
|
|
502
|
+
currentSourceHash: source.sourceHash,
|
|
503
|
+
range: { start: source.data.start, end: source.data.end },
|
|
504
|
+
});
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (req.method !== 'POST') {
|
|
509
|
+
writeJson(res, 405, { error: 'Method not allowed' });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
readJsonBody(req, res, async (body) => {
|
|
514
|
+
const params = parseSmartReportParams(body);
|
|
515
|
+
const validationError = validateSmartReportParams(params);
|
|
516
|
+
if (validationError) {
|
|
517
|
+
writeJson(res, 400, { error: validationError });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const source = await buildSmartReportSource(params);
|
|
522
|
+
if (!source) {
|
|
523
|
+
writeJson(res, 404, { error: '未找到可用于智能报告的数据' });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const storeDir = getSmartReportStoreDir(configPath);
|
|
528
|
+
const reportKey = buildSmartReportKey(source.identity);
|
|
529
|
+
const existingJob = smartReportJobs.get(reportKey);
|
|
530
|
+
if (existingJob?.status === 'running') {
|
|
531
|
+
const record = readSmartReportRecord(storeDir, reportKey);
|
|
532
|
+
writeJson(res, 202, {
|
|
533
|
+
record,
|
|
534
|
+
job: publicSmartReportJob(existingJob),
|
|
535
|
+
needsUpdate: smartReportNeedsUpdate(record, source),
|
|
536
|
+
currentSourceHash: source.sourceHash,
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const job = rememberSmartReportJob({
|
|
542
|
+
id: randomUUID(),
|
|
543
|
+
reportKey,
|
|
544
|
+
status: 'running',
|
|
545
|
+
error: '',
|
|
546
|
+
startedAt: new Date().toISOString(),
|
|
547
|
+
updatedAt: new Date().toISOString(),
|
|
548
|
+
completedAt: '',
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
Promise.resolve().then(async () => {
|
|
552
|
+
try {
|
|
553
|
+
const markdown = await createSmartReport({
|
|
554
|
+
agent: params.agent,
|
|
555
|
+
reportData: source.reportData,
|
|
556
|
+
workMarkdown: source.workMarkdown,
|
|
557
|
+
options: { period: params.period, date: params.date, tool: params.tool, project: params.project, level: params.level, style: params.style, platform: params.platform, sourceReports: source.sourceReports },
|
|
558
|
+
requireAvailable: true,
|
|
559
|
+
});
|
|
560
|
+
const record = saveSmartReportRecord(storeDir, {
|
|
561
|
+
...source.identity,
|
|
562
|
+
markdown,
|
|
563
|
+
sourceHash: source.sourceHash,
|
|
564
|
+
sourceHashVersion: 2,
|
|
565
|
+
sourceReports: source.sourceHashes,
|
|
566
|
+
});
|
|
567
|
+
job.status = 'completed';
|
|
568
|
+
job.record = record;
|
|
569
|
+
job.completedAt = new Date().toISOString();
|
|
570
|
+
job.updatedAt = job.completedAt;
|
|
571
|
+
} catch (err) {
|
|
572
|
+
job.status = 'failed';
|
|
573
|
+
job.error = err.message || '智能报告生成失败';
|
|
574
|
+
job.updatedAt = new Date().toISOString();
|
|
575
|
+
} finally {
|
|
576
|
+
forgetSmartReportJobLater(reportKey);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const record = readSmartReportRecord(storeDir, reportKey);
|
|
581
|
+
writeJson(res, 202, {
|
|
582
|
+
record,
|
|
583
|
+
job: publicSmartReportJob(job),
|
|
584
|
+
needsUpdate: smartReportNeedsUpdate(record, source),
|
|
585
|
+
currentSourceHash: source.sourceHash,
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (url.pathname === '/api/hooks') {
|
|
256
592
|
try {
|
|
257
593
|
if (req.method === 'GET') {
|
|
258
594
|
writeJson(res, 200, getConfiguredHooksStatus(config));
|
|
@@ -402,11 +738,12 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
402
738
|
return;
|
|
403
739
|
}
|
|
404
740
|
|
|
405
|
-
if (format === 'work') {
|
|
406
|
-
const platform = url.searchParams.get('platform') || 'default';
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
const
|
|
741
|
+
if (format === 'work') {
|
|
742
|
+
const platform = url.searchParams.get('platform') || 'default';
|
|
743
|
+
const requestedLevel = url.searchParams.get('level') || 'detailed';
|
|
744
|
+
const level = requestedLevel === 'brief' ? 'brief' : 'detailed';
|
|
745
|
+
const feishuCard = url.searchParams.get('feishuCard') === 'true';
|
|
746
|
+
const project = url.searchParams.get('project') || '';
|
|
410
747
|
|
|
411
748
|
if (feishuCard) {
|
|
412
749
|
const card = generateFeishuCard(data.usageStats, data.gitStats, period, data.start, data.end, tool);
|
|
@@ -446,12 +783,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
446
783
|
projGitStats = deriveProjectGitStats(data.gitStats, project, pStart, pEnd, config.aiAttribution);
|
|
447
784
|
}
|
|
448
785
|
|
|
449
|
-
|
|
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
|
-
}
|
|
786
|
+
const markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
|
|
455
787
|
res.writeHead(200, {
|
|
456
788
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
457
789
|
'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
|
|
5
|
+
const STORE_FILE = 'records.json';
|
|
6
|
+
|
|
7
|
+
function stableStringify(value) {
|
|
8
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
9
|
+
if (value && typeof value === 'object') {
|
|
10
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
11
|
+
}
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function hashText(text) {
|
|
16
|
+
return createHash('sha256').update(String(text)).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getSmartReportStoreDir(configPath) {
|
|
20
|
+
return join(dirname(configPath || join(process.cwd(), 'config.json')), 'smart-reports');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildSmartReportKey(input = {}) {
|
|
24
|
+
return hashText(stableStringify({
|
|
25
|
+
agent: input.agent || '',
|
|
26
|
+
period: input.period || '',
|
|
27
|
+
date: input.date || '',
|
|
28
|
+
start: input.start || '',
|
|
29
|
+
end: input.end || '',
|
|
30
|
+
tool: input.tool || 'all',
|
|
31
|
+
project: input.project || '',
|
|
32
|
+
level: input.level || 'detailed',
|
|
33
|
+
style: input.style && input.style !== 'default' ? input.style : '',
|
|
34
|
+
platform: input.platform || 'default',
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildSourceHash(source = {}) {
|
|
39
|
+
return hashText(stableStringify(source));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getStorePath(storeDir) {
|
|
43
|
+
return join(storeDir, STORE_FILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readStore(storeDir) {
|
|
47
|
+
const file = getStorePath(storeDir);
|
|
48
|
+
if (!existsSync(file)) return { records: {} };
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
51
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.records) return { records: {} };
|
|
52
|
+
return parsed;
|
|
53
|
+
} catch {
|
|
54
|
+
return { records: {} };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeStore(storeDir, store) {
|
|
59
|
+
if (!existsSync(storeDir)) mkdirSync(storeDir, { recursive: true });
|
|
60
|
+
writeFileSync(getStorePath(storeDir), JSON.stringify(store, null, 2), 'utf8');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readSmartReportRecord(storeDir, reportKey) {
|
|
64
|
+
const store = readStore(storeDir);
|
|
65
|
+
return store.records[reportKey] || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveSmartReportRecord(storeDir, input) {
|
|
69
|
+
const store = readStore(storeDir);
|
|
70
|
+
const reportKey = buildSmartReportKey(input);
|
|
71
|
+
const now = new Date().toISOString();
|
|
72
|
+
const existing = store.records[reportKey] || null;
|
|
73
|
+
const record = {
|
|
74
|
+
id: existing?.id || reportKey,
|
|
75
|
+
reportKey,
|
|
76
|
+
agent: input.agent || '',
|
|
77
|
+
period: input.period || '',
|
|
78
|
+
date: input.date || '',
|
|
79
|
+
start: input.start || '',
|
|
80
|
+
end: input.end || '',
|
|
81
|
+
tool: input.tool || 'all',
|
|
82
|
+
project: input.project || '',
|
|
83
|
+
level: input.level || 'detailed',
|
|
84
|
+
style: input.style || 'default',
|
|
85
|
+
platform: input.platform || 'default',
|
|
86
|
+
markdown: input.markdown || '',
|
|
87
|
+
sourceHash: input.sourceHash || '',
|
|
88
|
+
sourceHashVersion: input.sourceHashVersion || 1,
|
|
89
|
+
sourceReports: input.sourceReports || {},
|
|
90
|
+
createdAt: existing?.createdAt || now,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
generatedCount: (existing?.generatedCount || 0) + 1,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
store.records[reportKey] = record;
|
|
96
|
+
writeStore(storeDir, store);
|
|
97
|
+
return record;
|
|
98
|
+
}
|