const path = require('path'); const fs = require('fs'); const { pathToFileURL } = require('url'); const express = require('express'); const { analyzeChatLogsFile, defaultReport, buildHookChatWindows } = require('./lib/analyzeChatLogs'); const { mergeAiEvaluation, loadAiEvaluationFile } = require('./lib/mergeAiEvaluation'); const { loadStudentRepoContext, attachRepoToHeuristicReport } = require('./lib/repoContext.cjs'); const { finalizeTaskAlignedEvidence } = require('./lib/taskAlignedAbility.cjs'); const { ensureStudentRepo } = require('./lib/studentRepo.cjs'); const { resolveChatLogPath } = require('./lib/resolveChatLogPath.cjs'); const { getDefaultPublicStudentGitUrl } = require('./lib/gitCloneUrl.cjs'); const { loadEvaluationDimensions, alignEvaluationAbilityToDimensions } = require('./lib/evaluationDimensions.cjs'); const ROOT = __dirname; const PORT = Number(process.env.PORT) || 53780; const CHAT_LOG = resolveChatLogPath(ROOT); /** 与 report-ui mergeQuestions 一致:跨会话合并提问卡片 */ function mergeHookQuestionsAcrossConversations(conversations) { const seen = new Set(); const out = []; for (const c of conversations || []) { const cid = String(c.conversation_id || c.id || c.session_id || '').trim(); for (const q of c.questions || []) { const k = q.title || q.detail; if (!k) continue; const dedupeKey = `${cid}\0${k}\0${String(q.time ?? '')}`; if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); out.push({ ...q, conversation_id: q.conversation_id || cid || null, }); } } return out; } /** 不含密码,用于 API 与页面展示;clone 时在 lib/studentRepo 内按需注入 DEFAULT_STUDENT_GIT_USER / DEFAULT_STUDENT_GIT_PASSWORD */ const DEFAULT_STUDENT_GIT_URL = getDefaultPublicStudentGitUrl(); const app = express(); app.use(express.json({ limit: '2mb' })); function isSafeHttpUrl(u) { try { const x = new URL(String(u).trim()); return x.protocol === 'https:' || x.protocol === 'http:'; } catch { return false; } } function pickGitUrl(req) { const raw = req.query.git; if (typeof raw === 'string' && raw.trim()) { try { const decoded = decodeURIComponent(raw.trim()); if (isSafeHttpUrl(decoded)) return decoded; } catch { if (isSafeHttpUrl(raw.trim())) return raw.trim(); } } return DEFAULT_STUDENT_GIT_URL; } /** * 从磁盘 chat_logs 注入:① 各会话完整 Hook 时间线(查看详情)② Hook 原文提问列表(与大模型摘要合并) */ function enrichReportWithHookTranscriptMap(report, chatLogPath) { if (!report || !report.ok) return; report.hook_transcript_source_file = path.basename(chatLogPath); if (!fs.existsSync(chatLogPath)) { report.hook_transcript_map_error = `未找到「${path.basename(chatLogPath)}」。可设置环境变量 CHAT_LOG_PATH 指向云端落盘路径。`; report.hook_transcript_by_conv_id = {}; report.hook_chat_windows = []; return; } try { const raw = JSON.parse(fs.readFileSync(chatLogPath, 'utf8')); const analysis = analyzeChatLogsFile(chatLogPath); if (!analysis.ok) { report.hook_transcript_map_error = analysis.error || 'chat_logs 解析失败'; report.hook_transcript_by_conv_id = {}; report.hook_chat_windows = []; return; } report.hook_excerpt_questions = mergeHookQuestionsAcrossConversations(analysis.conversations); report.hook_chat_windows = buildHookChatWindows(raw); const m = {}; for (const c of analysis.conversations || []) { const t = c.hook_transcript; if (typeof t !== 'string' || !t.trim() || !c.id) continue; const topId = String(c.id).trim(); m[topId] = t; const events = Array.isArray(raw[topId]) ? raw[topId] : null; if (!events) continue; for (const ev of events) { const hid = ev?.hook_input?.conversation_id; if (hid == null) continue; const inner = String(hid).trim(); if (inner && inner !== topId) m[inner] = t; } } report.hook_transcript_by_conv_id = m; report.hook_transcript_map_size = Object.keys(m).length; if (report.hook_transcript_map_size === 0) { report.hook_transcript_map_error = '日志已读入但未生成时间线,请确认 JSON 为「会话ID → Hook 事件数组」且事件含 hook_input。'; } else { delete report.hook_transcript_map_error; } } catch (e) { report.hook_transcript_map_error = String(e.message || e); report.hook_transcript_by_conv_id = {}; report.hook_chat_windows = []; // eslint-disable-next-line no-console console.warn('[api/report] hook_transcript_by_conv_id 构建失败:', e.message || e); } } function loadReportHeuristicOnly() { let report; if (!fs.existsSync(CHAT_LOG)) { report = defaultReport(); report.note = `未找到 ${path.basename(CHAT_LOG)},已返回占位报告。`; } else { try { report = analyzeChatLogsFile(CHAT_LOG); } catch (e) { return { ok: false, error: String(e.message || e) }; } } const heuristicEvalSnapshot = report.ok && report.evaluation ? JSON.parse(JSON.stringify(report.evaluation)) : null; const ai = loadAiEvaluationFile(ROOT); if (report.ok && ai) { report.evaluation = mergeAiEvaluation(report.evaluation, ai); } const dimList = loadEvaluationDimensions(ROOT); if (report.ok && report.evaluation && dimList.length) { alignEvaluationAbilityToDimensions(report.evaluation, dimList, heuristicEvalSnapshot); } return report; } const MAX_REPO_FILE_BYTES = 200000; const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.svg': 'image/svg+xml', }; app.get('/api/repo-file', (req, res) => { res.setHeader('Cache-Control', 'no-store'); const gitUrl = pickGitUrl(req); const relRaw = req.query.file; if (typeof relRaw !== 'string' || !relRaw.trim()) { return res.status(400).json({ ok: false, error: '缺少 file 参数(仓库内相对路径)' }); } let rel = relRaw.replace(/\\/g, '/').replace(/^\/+/, ''); if (rel.includes('..') || path.isAbsolute(rel)) { return res.status(400).json({ ok: false, error: '非法路径' }); } try { const repoPath = ensureStudentRepo(gitUrl, ROOT); const full = path.join(repoPath, rel); const resolved = path.resolve(full); const rootResolved = path.resolve(repoPath); if (!resolved.startsWith(rootResolved + path.sep) && resolved !== rootResolved) { return res.status(403).json({ ok: false, error: '禁止越权访问' }); } if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { return res.status(404).json({ ok: false, error: '文件不存在' }); } const ext = path.extname(resolved).toLowerCase(); const buf = fs.readFileSync(resolved); const mime = MIME[ext]; if (mime && !['.svg'].includes(ext)) { return res.json({ ok: true, path: rel, mode: 'binary', mime, base64: buf.toString('base64'), size: buf.length, }); } const text = buf.toString('utf8'); const truncated = buf.length > MAX_REPO_FILE_BYTES; return res.json({ ok: true, path: rel, mode: 'text', mime: mime || 'text/plain', truncated, content: truncated ? text.slice(0, MAX_REPO_FILE_BYTES) : text, size: buf.length, }); } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }); } }); app.get('/api/report', async (req, res) => { res.setHeader('Cache-Control', 'no-store'); const gitUrl = pickGitUrl(req); const qFast = String(req.query.fast || req.query.heuristic || req.query.nollm || '').toLowerCase(); const forceHeuristic = qFast === '1' || qFast === 'true' || qFast === 'yes'; const useLlm = !forceHeuristic && process.env.DISABLE_FULL_LLM_REPORT !== '1' && Boolean(process.env.CURSOR_API_KEY); const t0 = Date.now(); let ctx = null; try { ctx = loadStudentRepoContext(gitUrl, ROOT, CHAT_LOG); } catch (e) { console.error('[api/report] git clone/scan failed:', e.message || e); if (useLlm) { return res.status(502).json({ ok: false, error: `无法拉取或扫描远程仓库:${e.message || e}`, git_url: gitUrl, }); } } if (useLlm && ctx) { try { const modUrl = pathToFileURL(path.join(ROOT, 'lib/runFullPageAgent.mjs')).href; const { generateFullPageReport } = await import(modUrl); const data = await generateFullPageReport({ root: ROOT, gitUrl, chatLogPath: CHAT_LOG, repoContext: ctx, }); enrichReportWithHookTranscriptMap(data, CHAT_LOG); if (ctx) finalizeTaskAlignedEvidence(ctx, data); data.report_build_ms = Date.now() - t0; return res.json(data); } catch (e) { console.error('[api/report] full LLM report failed:', e.message || e); const fallback = loadReportHeuristicOnly(); if (fallback.ok) { fallback.source = 'heuristic_fallback'; fallback.git_url = gitUrl; fallback.llm_error = String(e.message || e); fallback.note = (fallback.note ? `${fallback.note} ` : '') + '大模型整页生成失败,已回退到启发式 + lab-eval-ai.json(若存在)。'; if (ctx) attachRepoToHeuristicReport(fallback, ctx, gitUrl); } else { fallback.llm_error = String(e.message || e); } if (fallback.ok) enrichReportWithHookTranscriptMap(fallback, CHAT_LOG); if (fallback.ok && ctx) finalizeTaskAlignedEvidence(ctx, fallback); fallback.report_build_ms = Date.now() - t0; return res.json(fallback); } } const data = loadReportHeuristicOnly(); if (data.ok) { data.source = forceHeuristic ? 'heuristic_fast' : 'heuristic_only'; data.git_url = gitUrl; data.note = (data.note ? `${data.note} ` : '') + (forceHeuristic ? '已使用 ?fast=1 跳过整页大模型生成(仅 git 扫描 + chat_logs 启发式),响应更快。' : '未设置 CURSOR_API_KEY 或已 DISABLE_FULL_LLM_REPORT=1,未调用大模型;已尽量从仓库拉取文件列表与评测表。'); if (ctx) attachRepoToHeuristicReport(data, ctx, gitUrl); } if (data.ok) enrichReportWithHookTranscriptMap(data, CHAT_LOG); if (data.ok && ctx) finalizeTaskAlignedEvidence(ctx, data); if (data.ok) data.report_build_ms = Date.now() - t0; return res.json(data); }); app.get('/api/health', (_req, res) => { res.json({ ok: true, service: 'chat-lab-report', port: PORT, default_git: DEFAULT_STUDENT_GIT_URL, llm_enabled: Boolean(process.env.CURSOR_API_KEY) && process.env.DISABLE_FULL_LLM_REPORT !== '1', }); }); app.get('/', (_req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); res.setHeader('Pragma', 'no-cache'); res.sendFile(path.join(ROOT, 'code.html')); }); app.get('/report-ui.js', (_req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); res.sendFile(path.join(ROOT, 'report-ui.js')); }); app.use(express.static(ROOT, { index: false })); const server = app.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`报告服务已启动: http://127.0.0.1:${PORT}/`); console.log(`API: http://127.0.0.1:${PORT}/api/report?git=<可选仓库URL>`); console.log(`快速模式(跳过整页大模型,仅 git+chat_logs): /api/report?fast=1&git=...`); console.log(`默认学员仓库: ${DEFAULT_STUDENT_GIT_URL}`); }); server.timeout = Number(process.env.REPORT_SERVER_TIMEOUT_MS) || 900000; server.headersTimeout = server.timeout + 10000;