You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1547 lines
66 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 拉取 /api/report支持 ?git= 与页面 ?git=)并填充 code.html
*/
(function () {
const RUBRIC_EVAL_HINT = {
prep: '平台评测会检查 scores.csv请结合仓库内是否真实存在该文件表述。',
s1: '步骤一多为本地操作可依据对话是否提及项目路径、Cursor 使用等。',
s2: '应对齐评测score_analysis.py 中含 read_csv、mean 等统计逻辑。',
s3: '对应报错粘贴与修复;依据对话或仓库脚本质量推断。',
s4: '应对齐评测matplotlib 与 score_chart.png以仓库扫描为准。',
s5: '对应请 AI 解释代码;依据对话关键词。',
s6: '对应保存脚本;以仓库中 score_analysis.py 是否存在及内容为准。',
};
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function getMarkedParse() {
const m = typeof marked !== 'undefined' ? marked : null;
if (!m) return null;
if (typeof m.parse === 'function') return m.parse.bind(m);
if (typeof m === 'function') return m;
return null;
}
function configureMarkedOnce() {
if (configureMarkedOnce._done) return;
configureMarkedOnce._done = true;
if (typeof marked === 'undefined' || !marked.setOptions) return;
try {
marked.setOptions({ gfm: true, breaks: true });
} catch (_) {
/* ignore */
}
}
/** Markdown → 安全 HTML对话与提问、Hook 详情);依赖页面引入 marked + DOMPurify */
function mdToSafeHtml(raw) {
const s = raw == null ? '' : String(raw);
if (!String(s).trim()) return '';
const parse = getMarkedParse();
if (!parse || typeof DOMPurify === 'undefined') {
return `<p class="whitespace-pre-wrap">${esc(s)}</p>`;
}
configureMarkedOnce();
let html;
try {
html = parse(s);
} catch (_) {
return `<p class="whitespace-pre-wrap">${esc(s)}</p>`;
}
if (typeof html !== 'string') html = String(html ?? '');
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
function highlightMdInRoot(root) {
if (!root || typeof hljs === 'undefined' || !hljs.highlightElement) return;
root.querySelectorAll('pre code').forEach((block) => {
try {
hljs.highlightElement(block);
} catch (_) {
/* ignore */
}
});
}
/** chat_logs 汇总的 token与 evaluation.meta.tokens 一致) */
function sumChatLogTokens(tok) {
if (!tok || typeof tok !== 'object') return null;
const input = Number(tok.input_tokens) || 0;
const output = Number(tok.output_tokens) || 0;
const cacheRead = Number(tok.cache_read_tokens) || 0;
const cacheWrite = Number(tok.cache_write_tokens) || 0;
return { input, output, cacheRead, cacheWrite, sum: input + output + cacheRead + cacheWrite };
}
function formatTokenBreakdownLine(tok) {
const t = sumChatLogTokens(tok);
if (!t || t.sum <= 0) return 'chat_logs 中无TOKENS或未累计到消耗';
const cw =
t.cacheWrite > 0 ? ` · 缓存写入 ${t.cacheWrite.toLocaleString('zh-CN')}` : '';
return `输入 ${t.input.toLocaleString('zh-CN')} · 输出 ${t.output.toLocaleString('zh-CN')} · 缓存读取 ${t.cacheRead.toLocaleString('zh-CN')}${cw}`;
}
/** 单行展示本会话 token 汇总(用于列表 / 弹窗,纯文本后由 esc 输出) */
function formatConvTokensBlockPlain(tok) {
const t = sumChatLogTokens(tok);
if (!t || t.sum <= 0) return null;
const cw = t.cacheWrite > 0 ? ` · 缓存写入 ${t.cacheWrite.toLocaleString('zh-CN')}` : '';
return `累计 ${t.sum.toLocaleString('zh-CN')} · 输入 ${t.input.toLocaleString('zh-CN')} · 输出 ${t.output.toLocaleString('zh-CN')} · 缓存读取 ${t.cacheRead.toLocaleString('zh-CN')}${cw}`;
}
/** 与会话时间线相同:按会话 ID 多键索引 tokens 对象 */
function mergeConversationTokensByConvId(report) {
const o = {};
for (const c of (report && report.conversations) || []) {
const tok = c.tokens;
const t = sumChatLogTokens(tok);
if (!t || t.sum <= 0) continue;
for (const k of convRecordKeys(c)) o[k] = tok;
}
return o;
}
/** 总体评价支持换行;避免 innerHTML 以防御 XSS */
function setEvalOverallText(el, text) {
if (!el) return;
const t = text == null ? '' : String(text).trim();
el.style.whiteSpace = 'pre-wrap';
el.textContent = t || '(暂无总体评价)';
}
let lastModalTranscriptRaw = '';
let lastTranscriptByConvId = {};
let lastTokensByConvId = {};
let lastConvDisplayByConvId = {};
let lastHookTranscriptMapError = '';
let lastHookTranscriptSourceFile = '';
/** renderHookChatWindows 用于「复制本窗口」按钮 */
let lastHookChatWindowsCache = [];
function splitHookSections(body) {
if (!body || !String(body).trim()) return [];
const b = String(body).trim();
const parts = b.split(/(?=【(?:末条用户消息|近期用户消息|模型回复文本|元数据)】)/);
return parts
.map((part) => {
const p = part.trim();
if (!p) return null;
const m = p.match(/^【([^】]+)】\s*\n?([\s\S]*)$/);
if (m) {
const label = m[1];
const content = (m[2] || '').trim();
let type = 'meta';
if (/用户/.test(label)) type = 'user';
else if (/模型/.test(label)) type = 'model';
return { label, content, type };
}
return { label: '', content: p, type: 'plain' };
})
.filter(Boolean);
}
function formatHookSection(s) {
if (!s || s.type === 'plain') {
const t = (s && s.content) || '';
const inner = mdToSafeHtml(t) || '<p class="text-sm text-slate-400">(空)</p>';
return `<div class="rounded-xl border border-slate-200 bg-slate-50/90 p-4 shadow-sm">
<div class="hook-modal-md max-w-none">${inner}</div>
</div>`;
}
const labelDisplay = s.label || '片段';
const cfg =
s.type === 'user'
? {
wrap: 'border-l-[5px] border-emerald-500 bg-gradient-to-br from-emerald-50/95 to-teal-50/50',
pill: 'bg-emerald-600 text-white',
pillText: '用户',
sub: 'text-emerald-900/90',
}
: s.type === 'model'
? {
wrap: 'border-l-[5px] border-violet-500 bg-gradient-to-br from-violet-50/95 to-indigo-50/40',
pill: 'bg-violet-600 text-white',
pillText: '模型',
sub: 'text-violet-900/90',
}
: {
wrap: 'border-l-[5px] border-slate-300 bg-slate-100/90',
pill: 'bg-slate-600 text-white',
pillText: '元数据',
sub: 'text-slate-700',
};
return `<section class="rounded-xl ${cfg.wrap} p-4 shadow-sm ring-1 ring-slate-900/[0.04]">
<div class="mb-3 flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full ${cfg.pill} px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide">${esc(cfg.pillText)}</span>
<span class="text-xs font-semibold ${cfg.sub}">${esc(`${labelDisplay}`)}</span>
</div>
<div class="hook-modal-md max-w-none rounded-lg border border-white/70 bg-white/75 p-3.5 text-[13px] leading-relaxed text-slate-800 shadow-inner">${mdToSafeHtml(s.content) || '<p class="text-slate-400">(空)</p>'}</div>
</section>`;
}
function formatHookEventChunk(chunk) {
const c = String(chunk || '').trim();
const lines = c.split('\n');
let bodyStart = 0;
const h0 = lines[0] || '';
if (/^═{10,}$/.test(h0.trim())) {
bodyStart = 1;
}
const h1 = lines[bodyStart] || '';
if (/^#\d+\s*·\s*.+/.test(h1.trim())) {
bodyStart += 1;
}
if (lines[bodyStart] && /^事件:\s*.+?\s*·\s*model:\s*.+$/i.test(lines[bodyStart].trim())) {
bodyStart += 1;
}
if (lines[bodyStart] && /^─{10,}$/.test((lines[bodyStart] || '').trim())) bodyStart += 1;
const body = lines.slice(bodyStart).join('\n').trim();
const sections = splitHookSections(body);
const sectionsHtml = sections.length
? sections.map(formatHookSection).join('')
: `<p class="text-sm text-slate-500">(本条无分段正文)</p>`;
return `<div class="space-y-4 rounded-2xl border border-slate-200/90 bg-white px-4 py-5 shadow-sm ring-1 ring-slate-900/[0.04] sm:px-5">${sectionsHtml}</div>`;
}
function formatHookTranscriptHtml(raw) {
const text = String(raw || '').trim();
if (!text) return '<p class="py-12 text-center text-sm text-slate-500">(无内容)</p>';
if (!/\n═{20,}\n/.test(text)) {
const inner = mdToSafeHtml(text) || '<p class="text-sm text-slate-500">(无内容)</p>';
return `<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="hook-modal-md max-w-none">${inner}</div>
</div>`;
}
const chunks = text.split(/\n═{20,}\n/).map((x) => x.trim()).filter(Boolean);
if (!chunks.length) {
const inner = mdToSafeHtml(text) || '<p class="text-sm text-slate-500">(无内容)</p>';
return `<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="hook-modal-md max-w-none">${inner}</div>
</div>`;
}
return `<div class="flex flex-col gap-5">${chunks.map(formatHookEventChunk).join('')}</div>`;
}
function formatHookTimelineEmptyHtml(conversationId) {
if (lastHookTranscriptMapError) {
const hint = lastHookTranscriptSourceFile ? `(文件:${lastHookTranscriptSourceFile}` : '';
return `<div class="rounded-2xl border border-rose-200/90 bg-gradient-to-br from-rose-50 to-orange-50/40 p-6 text-sm leading-relaxed text-rose-950 shadow-sm">${esc(
`${lastHookTranscriptMapError} ${hint}`.trim()
)}</div>`;
}
const n = Object.keys(lastTranscriptByConvId).length;
const msg =
n > 0
? `服务端已解析 ${n} 个会话的钩子时间线,但当前卡片会话 ID「${conversationId || '(空)'}」未命中索引。请核对 conversation_id 与 chat_logs 顶层键是否一致。`
: '未收到可用的钩子时间线索引。请确认 chat_logs.json 已由本服务读取且格式正确。';
const hint = lastHookTranscriptSourceFile ? `(文件:${lastHookTranscriptSourceFile}` : '';
return `<div class="rounded-2xl border border-amber-200/90 bg-gradient-to-br from-amber-50 to-orange-50/50 p-6 text-sm leading-relaxed text-amber-950 shadow-sm">${esc(
`${msg} ${hint}`.trim()
)}</div>`;
}
function getGitQuery() {
const p = new URLSearchParams(window.location.search);
const g = p.get('git');
if (g && g.trim()) return g.trim();
return '';
}
function buildReportApiUrl() {
const p = new URLSearchParams(window.location.search);
const git = getGitQuery();
const parts = [];
parts.push(`_=${Date.now()}`);
if (git) parts.push(`git=${encodeURIComponent(git)}`);
const fast = (p.get('fast') || p.get('heuristic') || p.get('nollm') || '').trim().toLowerCase();
if (fast === '1' || fast === 'true' || fast === 'yes') parts.push('fast=1');
const q = `?${parts.join('&')}`;
return `/api/report${q}`;
}
function setRingPercent(p) {
const ring = document.getElementById('score-ring');
const deg = Math.max(0, Math.min(100, Number(p) || 0)) * 3.6;
if (ring) ring.style.setProperty('--percentage', `${deg}deg`);
}
function setAlignBadge(text) {
const el = document.getElementById('align-label-badge');
if (!el) return;
const t = text || '—';
el.textContent = t;
el.className = 'px-3 py-1 text-xs rounded-full ';
if (/待加强|不及|较差|差/.test(t)) el.className += 'bg-red-100 text-red-800';
else if (/及格|中等|一般/.test(t)) el.className += 'bg-amber-100 text-amber-900';
else el.className += 'bg-green-100 text-green-800';
}
function applyBanner(data) {
const b = document.getElementById('report-banner');
if (!b) return;
if (data.source === 'ai_full') {
b.classList.add('hidden');
b.textContent = '';
return;
}
b.classList.remove('hidden');
const parts = [];
if (data.source === 'heuristic_fallback') {
parts.push('大模型整页生成失败,已回退到启发式报告。');
if (data.llm_error) parts.push(`原因:${data.llm_error}`);
} else if (data.source === 'heuristic_only') {
parts.push('当前未调用大模型整页生成(未配置 CURSOR_API_KEY 或已禁用)。');
} else if (data.source === 'heuristic_fast') {
parts.push('已使用 ?fast=1 快速模式:跳过整页大模型,仅 git 扫描 + chat_logs 启发式(响应更快)。');
}
if (data.git_url_requested || data.git_url) {
parts.push(`仓库:${data.git_url_requested || data.git_url}`);
}
b.textContent = parts.join(' ');
}
function applyUi(ui) {
if (!ui || typeof ui !== 'object') return;
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val != null && val !== '') el.textContent = String(val);
};
set('report-date', ui.report_date);
set('student-name', ui.student_name);
set('student-meta', ui.student_meta);
set('tag-course', ui.tag_course);
set('tag-exp', ui.tag_exp);
set('questions-section-title', ui.tab_questions_title);
set('rubric-section-title', ui.tab_rubric_title);
const rf = document.getElementById('rubric-footer-hint');
if (rf && ui.rubric_footer) rf.textContent = ui.rubric_footer;
const qfoot = document.getElementById('questions-footer-hint');
if (qfoot && ui.questions_footer) qfoot.textContent = ui.questions_footer;
const learnHint = document.getElementById('learning-section-hint');
if (learnHint && ui.learning_section_hint) learnHint.textContent = ui.learning_section_hint;
const abHint = document.getElementById('ability-section-hint');
if (abHint && ui.ability_section_hint) abHint.textContent = ui.ability_section_hint;
const av = document.getElementById('student-avatar');
if (av && ui.avatar_url && /^https?:\/\//i.test(ui.avatar_url)) {
av.src = ui.avatar_url;
}
if (ui.stat_lab_progress != null) {
const el = document.getElementById('stat-lab-progress');
if (el) el.textContent = String(ui.stat_lab_progress);
setRingPercent(ui.stat_lab_progress);
}
setAlignBadge(ui.align_badge);
if (ui.stat_hook_main != null) {
const c = document.getElementById('stat-hook-count');
if (c) c.textContent = String(ui.stat_hook_main);
}
set('stat-hook-unit', ui.stat_hook_unit);
const sub = document.getElementById('stat-token-hint');
if (sub && ui.stat_hook_sub) sub.textContent = ui.stat_hook_sub;
if (ui.stat_questions_count != null) {
const q = document.getElementById('stat-question-count');
if (q) q.textContent = String(ui.stat_questions_count);
}
const badges = document.getElementById('stat-question-badges');
if (badges && (ui.stat_badge_left || ui.stat_badge_right)) {
const l = esc(ui.stat_badge_left || '');
const r = esc(ui.stat_badge_right || '');
badges.innerHTML = `<span class="px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded-full">${l}</span>
<span class="px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded-full">${r}</span>`;
}
set('stat-coverage-a-label', ui.stat_coverage_a_label);
set('stat-coverage-a', ui.stat_coverage_a);
const ab = document.getElementById('stat-coverage-a-bar');
if (ab && ui.stat_coverage_a_bar_pct != null) {
ab.style.width = `${Math.max(0, Math.min(100, Number(ui.stat_coverage_a_bar_pct)))}%`;
}
set('stat-coverage-b-label', ui.stat_coverage_b_label);
set('stat-coverage-b', ui.stat_coverage_b);
const bb = document.getElementById('stat-coverage-b-bar');
if (bb && ui.stat_coverage_b_bar_pct != null) {
bb.style.width = `${Math.max(0, Math.min(100, Number(ui.stat_coverage_b_bar_pct)))}%`;
}
}
function mergeQuestions(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;
}
/** 大模型摘要条目 + chat_logs 原文摘录条目,去重后优先展示 Hook 原文 */
function mergeHookAndLlmQuestions(llmList, hookList) {
const seen = new Set();
const out = [];
const pushQ = (q) => {
const cid = String(q.conversation_id || '').trim();
const k = q.title || q.detail;
if (!k) return;
const dedupeKey = `${cid}\0${k}\0${String(q.time ?? '')}`;
if (seen.has(dedupeKey)) return;
seen.add(dedupeKey);
out.push(q);
};
for (const q of hookList || []) pushQ(q);
for (const q of llmList || []) pushQ(q);
return out;
}
function convRecordKeys(c) {
const s = new Set();
for (const f of ['id', 'conversation_id', 'session_id']) {
const v = c[f];
if (v != null && String(v).trim()) s.add(String(v).trim());
}
return [...s];
}
/** 从报告中的会话对象推导展示用「会话名称」(供弹窗标题等) */
function deriveConversationDisplayName(c) {
if (!c || typeof c !== 'object') return '会话';
const pick = (...vals) => {
for (const v of vals) {
const s = String(v ?? '').trim();
if (s) return s;
}
return '';
};
let name = pick(c.display_name, c.conversation_title, c.session_name, c.title, c.name);
if (name) {
name = name.replace(/\s+/g, ' ');
return name.length > 42 ? `${name.slice(0, 42)}` : name;
}
const topic = String(c.topic_preview || '').trim();
if (topic) return topic.length > 48 ? `${topic.slice(0, 48)}` : topic;
const qs = c.questions;
if (Array.isArray(qs) && qs.length) {
const raw = String(qs[0].title || qs[0].detail || '')
.trim()
.replace(/\s+/g, ' ');
if (raw) {
const oneLine = raw.split('\n')[0].trim();
return oneLine.length > 36 ? `${oneLine.slice(0, 36)}` : oneLine;
}
}
const email = String(c.user_email || '').trim();
if (email.includes('@')) {
const local = email.split('@')[0].trim();
if (local) return local;
}
const idRaw = pick(c.id, c.conversation_id, c.session_id);
if (idRaw) {
const id = String(idRaw);
return id.length > 12 ? `会话 ${id.slice(0, 8)}` : `会话 ${id}`;
}
return '会话';
}
/** 与会话时间线相同:按会话 ID 多键索引展示名 */
function mergeConversationDisplayNameByConvId(report) {
const o = {};
for (const c of (report && report.conversations) || []) {
const name = deriveConversationDisplayName(c);
for (const k of convRecordKeys(c)) o[k] = name;
}
return o;
}
/** 合并服务端从 chat_logs.json 注入的 map + 报告里各会话自带的 hook_transcript */
function mergeHookTranscriptLookup(report) {
const o = {};
const map = report && report.hook_transcript_by_conv_id;
if (map && typeof map === 'object') {
for (const [k, v] of Object.entries(map)) {
const key = String(k).trim();
if (key && typeof v === 'string' && v.trim()) o[key] = v;
}
}
for (const c of (report && report.conversations) || []) {
const t = c.hook_transcript;
if (typeof t !== 'string' || !t.trim()) continue;
for (const k of convRecordKeys(c)) o[k] = t;
}
return o;
}
function lookupTokensForConvId(convId) {
if (convId == null || convId === '') return null;
const s = String(convId).trim();
if (!s) return null;
return lastTokensByConvId[s] || null;
}
/** 从 Hook 时间线正文中取首个「末条用户消息」块,作标题兜底 */
function topicFromTranscript(raw) {
const t = String(raw || '');
const m = t.match(/【末条用户消息】\s*\n([^\n【]+)/);
if (!m) return '';
return String(m[1] || '')
.replace(/\s+/g, ' ')
.trim()
.split('\n')[0]
.trim()
.slice(0, 48);
}
function lookupConvDisplayName(convId, transcriptOpt) {
if (convId == null || convId === '') return '会话';
const s = String(convId).trim();
if (!s) return '会话';
if (lastConvDisplayByConvId[s]) return lastConvDisplayByConvId[s];
const fromTx = transcriptOpt && topicFromTranscript(transcriptOpt);
if (fromTx) return fromTx.length > 48 ? `${fromTx.slice(0, 48)}` : fromTx;
return s.length > 12 ? `会话 ${s.slice(0, 8)}` : `会话 ${s}`;
}
function wrapModalBodyWithTokens(convId, innerRestHtml, tokenOverride) {
const fromWindow = tokenOverride && typeof tokenOverride === 'object';
const tok = fromWindow ? tokenOverride : lookupTokensForConvId(convId);
const line = tok ? formatConvTokensBlockPlain(tok) : null;
const bannerTitle = fromWindow ? '本窗口Tokens累计' : '本会话TOKENS消耗';
const banner = line
? `<div class="mb-4 rounded-xl border border-indigo-100 bg-indigo-50/90 px-4 py-3 text-slate-800 ring-1 ring-indigo-900/[0.04]">
<div class="text-[11px] font-bold uppercase tracking-wide text-indigo-700">${bannerTitle}</div>
<p class="mt-1.5 font-mono text-xs leading-relaxed text-slate-700">${esc(line)}</p>
</div>`
: '';
return `${banner}${innerRestHtml}`;
}
/**
* 与「聊天窗口」列表同源:按 turns 渲染,保证弹窗与折叠列表一致(仅 last_user_text + hook 模型正文)。
*/
function formatHookWindowFromListHtml(w) {
if (!w || !Array.isArray(w.turns) || !w.turns.length) {
return '<p class="py-12 text-center text-sm text-slate-500">(无内容)</p>';
}
const tp = typeof w.transcript_path === 'string' ? w.transcript_path.trim() : '';
const pathBlock = tp
? `<div class="mb-4 rounded-lg border border-slate-100 bg-slate-50/80 px-3 py-2 text-[11px] font-mono text-slate-600 break-all">transcript_path${esc(
tp
)}</div>`
: '';
const turns = w.turns
.map((t) => {
const idx = t.index != null ? t.index : '';
const userHtml = mdToSafeHtml(t.user) || '<p class="text-gray-400 text-sm">(无用户文案)</p>';
const modelHtml = mdToSafeHtml(t.model) || '<p class="text-gray-400 text-sm">(无模型回复)</p>';
return `<div class="mb-6 last:mb-0 pb-6 last:pb-0 border-b border-slate-200 last:border-b-0">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-1">提问 ${esc(String(idx))}</p>
<div class="hook-modal-md max-w-none text-slate-900">${userHtml}</div>
<p class="text-xs font-semibold text-indigo-800 mt-4 mb-1">模型回答</p>
<div class="hook-modal-md max-w-none text-slate-800">${modelHtml}</div>
</div>`;
})
.join('');
return `<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm ring-1 ring-slate-900/[0.04]">
${pathBlock}
<div class="flex flex-col gap-0">${turns}</div>
</div>`;
}
function hookWindowTitleParts(w, wi) {
const titleNum = wi + 1;
const firstTurn = (w.turns && w.turns[0]) || {};
const firstUser = typeof firstTurn.user === 'string' ? firstTurn.user.trim() : '';
const previewRaw = firstUser
? firstUser.replace(/\s+/g, ' ')
: typeof w.transcript_path === 'string'
? w.transcript_path.replace(/^.*\//, '').replace(/\.jsonl$/i, '') || `窗口 ${titleNum}`
: `窗口 ${titleNum}`;
const preview = previewRaw.length > 56 ? `${previewRaw.slice(0, 56)}` : previewRaw;
return { titleNum, preview };
}
/** @param {object} [options] @param {string} [options.sessionLabelOverride] 弹窗标题中的会话名 */
/** @param {string} [options.htmlBody] 已拼好的 HTML与列表同源时使用 */
/** @param {string} [options.copyTextOverride] 弹窗「复制」所用的纯文本 */
/** @param {object} [options.hookWindowTokens] 与列表折叠块一致的本窗口累计 token */
function openQuestionChatModal(conversationId, transcript, options) {
const opts = options && typeof options === 'object' ? options : {};
const modal = document.getElementById('question-chat-modal');
const body = document.getElementById('question-chat-modal-body');
const idEl = document.getElementById('question-chat-modal-conv-id');
const titleEl = document.getElementById('question-chat-modal-title');
const panel = document.getElementById('question-chat-modal-panel');
if (!modal || !body) return;
const trimmed = transcript && String(transcript).trim() ? String(transcript).trim() : '';
const copyOverride =
opts.copyTextOverride != null && String(opts.copyTextOverride).trim()
? String(opts.copyTextOverride).trim()
: '';
const sessionLabel =
opts.sessionLabelOverride && String(opts.sessionLabelOverride).trim()
? String(opts.sessionLabelOverride).trim()
: lookupConvDisplayName(conversationId, trimmed || copyOverride);
if (titleEl) titleEl.textContent = `${sessionLabel} -对话详情`;
if (idEl) {
idEl.textContent = conversationId || '—';
idEl.title = conversationId || '';
}
const copyPlain = copyOverride || trimmed;
const tokOverride = opts.hookWindowTokens;
lastModalTranscriptRaw = copyPlain;
if (opts.htmlBody) {
body.innerHTML = wrapModalBodyWithTokens(conversationId, opts.htmlBody, tokOverride);
highlightMdInRoot(body);
} else if (trimmed) {
body.innerHTML = wrapModalBodyWithTokens(conversationId, formatHookTranscriptHtml(trimmed));
highlightMdInRoot(body);
} else {
body.innerHTML = wrapModalBodyWithTokens(conversationId, formatHookTimelineEmptyHtml(conversationId));
}
if (panel) {
panel.classList.remove('hook-modal-panel-animate');
void panel.offsetWidth;
panel.classList.add('hook-modal-panel-animate');
}
const btnCopy = document.getElementById('question-chat-modal-copy');
if (btnCopy) {
btnCopy.disabled = !copyPlain;
btnCopy.classList.toggle('opacity-40', !copyPlain);
btnCopy.classList.toggle('pointer-events-none', !copyPlain);
}
modal.classList.remove('hidden');
modal.classList.add('flex');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeQuestionChatModal() {
const modal = document.getElementById('question-chat-modal');
const body = document.getElementById('question-chat-modal-body');
const titleEl = document.getElementById('question-chat-modal-title');
if (titleEl) titleEl.textContent = '对话详情';
if (body) body.innerHTML = '';
lastModalTranscriptRaw = '';
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function wireQuestionChatModalOnce() {
if (wireQuestionChatModalOnce._done) return;
wireQuestionChatModalOnce._done = true;
const modal = document.getElementById('question-chat-modal');
const backdrop = document.getElementById('question-chat-modal-backdrop');
const btnClose = document.getElementById('question-chat-modal-close');
const btnCopy = document.getElementById('question-chat-modal-copy');
if (backdrop) backdrop.addEventListener('click', closeQuestionChatModal);
if (btnClose) btnClose.addEventListener('click', closeQuestionChatModal);
if (btnCopy) {
btnCopy.addEventListener('click', async () => {
const text = lastModalTranscriptRaw || '';
if (!text) return;
try {
await navigator.clipboard.writeText(text);
const prev = btnCopy.textContent;
btnCopy.textContent = '已复制';
setTimeout(() => {
btnCopy.textContent = prev;
}, 1800);
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
const prev = btnCopy.textContent;
btnCopy.textContent = '已复制';
setTimeout(() => {
btnCopy.textContent = prev;
}, 1800);
} catch {
window.alert('复制失败,请手动全选正文复制。');
}
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal && !modal.classList.contains('hidden')) closeQuestionChatModal();
});
}
function buildHookWindowFullPlainText(w) {
if (!w || typeof w !== 'object') return '';
const lines = [];
const tp = typeof w.transcript_path === 'string' ? w.transcript_path.trim() : '';
if (tp) {
lines.push(`transcript_path: ${tp}`, '');
}
for (const t of w.turns || []) {
const n = t.index != null ? t.index : '';
lines.push(`提问${n}`, (t.user && String(t.user)) || '(无)', '', '模型回答:', (t.model && String(t.model)) || '(无)', '');
}
return lines.join('\n').trim();
}
function wireHookWindowActions(container) {
if (!container) return;
container.querySelectorAll('.hook-window-copy-btn').forEach((copyBtn) => {
copyBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const idx = Number(copyBtn.getAttribute('data-window-index'));
const w = lastHookChatWindowsCache[idx];
const text = buildHookWindowFullPlainText(w);
if (!text) return;
const doOk = () => {
const prev = copyBtn.textContent;
copyBtn.textContent = '已复制';
setTimeout(() => {
copyBtn.textContent = prev;
}, 1800);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(doOk).catch(() => {
window.alert('复制失败,请手动全选复制。');
});
} else {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
doOk();
} catch {
window.alert('复制失败,请手动全选复制。');
}
}
});
});
container.querySelectorAll('.hook-window-detail-btn').forEach((detBtn) => {
detBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const wi = Number(detBtn.getAttribute('data-window-index'));
const w = lastHookChatWindowsCache[wi];
if (!w) return;
const enc = detBtn.getAttribute('data-conversation-id') || '';
let id = '';
try {
id = enc ? decodeURIComponent(enc) : '';
} catch {
id = enc;
}
const { titleNum, preview } = hookWindowTitleParts(w, wi);
const sessionLabel = `聊天 ${titleNum}${preview}`;
openQuestionChatModal(id, '', {
sessionLabelOverride: sessionLabel,
htmlBody: formatHookWindowFromListHtml(w),
copyTextOverride: buildHookWindowFullPlainText(w),
hookWindowTokens: w.tokens,
});
});
});
}
function renderHookChatWindows(container, windows, report) {
if (!container) return;
lastHookTranscriptMapError = (report && report.hook_transcript_map_error) || '';
lastHookTranscriptSourceFile = (report && report.hook_transcript_source_file) || '';
lastTranscriptByConvId = mergeHookTranscriptLookup(report || {});
lastTokensByConvId = mergeConversationTokensByConvId(report || {});
lastConvDisplayByConvId = mergeConversationDisplayNameByConvId(report || {});
lastHookChatWindowsCache = Array.isArray(windows) ? windows : [];
wireQuestionChatModalOnce();
if (!lastHookChatWindowsCache.length) {
container.innerHTML =
'<div class="border border-dashed border-gray-200 rounded-xl p-6 text-center text-sm text-gray-500">暂无按 transcript_path 合并的钩子对话窗口。</div>';
return;
}
container.innerHTML = lastHookChatWindowsCache
.map((w, wi) => {
const { titleNum, preview } = hookWindowTitleParts(w, wi);
const metaBits = [
`${Number(w.turn_count) || 0}`,
w.last_activity ? `最后 ${w.last_activity}` : null,
].filter(Boolean);
const metaLine = metaBits.join(' · ');
const tokLine = w.tokens ? formatConvTokensBlockPlain(w.tokens) : null;
const tokenBlock = tokLine
? `<div class="mt-3 rounded-lg border border-indigo-100 bg-indigo-50/70 px-2.5 py-2 ring-1 ring-indigo-900/[0.03]">
<p class="text-[11px] font-semibold text-indigo-800">本窗口Tokens累计</p>
<p class="mt-1 font-mono text-xs leading-relaxed text-slate-700">${esc(tokLine)}</p>
</div>`
: '';
const tp = typeof w.transcript_path === 'string' ? w.transcript_path.trim() : '';
const pathRow = '';
const rawConvId = w.conversation_id != null ? String(w.conversation_id).trim() : '';
const attrConvId = rawConvId ? encodeURIComponent(rawConvId) : '';
const detailBtn = `<button type="button" class="hook-window-detail-btn shrink-0 px-3 py-1.5 text-xs font-medium text-toge-primary border border-indigo-200 rounded-lg hover:bg-indigo-50 transition-colors" data-window-index="${wi}"${
attrConvId ? ` data-conversation-id="${attrConvId}"` : ''
}>查看时间线原文</button>`;
const turnsHtml = (w.turns || [])
.map((t) => {
const idx = t.index != null ? t.index : '';
const userHtml = mdToSafeHtml(t.user) || '<p class="text-gray-400 text-sm">(无用户文案)</p>';
const modelHtml = mdToSafeHtml(t.model) || '<p class="text-gray-400 text-sm">(无模型回复)</p>';
return `<div class="mb-6 last:mb-0 pb-6 last:pb-0 border-b border-gray-100 last:border-b-0">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">提问 ${idx}</p>
<div class="question-tab-md max-w-none text-gray-900">${userHtml}</div>
<p class="text-xs font-semibold text-indigo-800 mt-4 mb-1">模型回答</p>
<div class="question-tab-md max-w-none text-gray-800">${modelHtml}</div>
</div>`;
})
.join('');
return `<details open class="hook-window-details group border border-gray-100 rounded-xl overflow-hidden bg-white mb-3 shadow-sm hover:shadow-md transition-shadow">
<summary class="list-none cursor-pointer select-none px-4 py-3 bg-gray-50/90 hover:bg-gray-50 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2 [&::-webkit-details-marker]:hidden">
<div class="min-w-0 flex-1">
<div class="font-medium text-gray-900"><span class="text-indigo-700">聊天 ${titleNum}</span>${esc(preview)}</div>
<div class="text-xs text-gray-500 mt-1">${esc(metaLine)}</div>
</div>
<span class="text-xs text-gray-400 shrink-0">点击标题可收起</span>
</summary>
<div class="p-4">
${pathRow}
${tokenBlock}
<div class="mt-4">${turnsHtml}</div>
<div class="mt-4 flex flex-wrap gap-2 pt-3 border-t border-gray-100">
<button type="button" class="hook-window-copy-btn px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 bg-white hover:bg-gray-50 text-gray-800" data-window-index="${wi}">复制本窗口全文</button>
${detailBtn}
</div>
</div>
</details>`;
})
.join('');
wireHookWindowActions(container);
highlightMdInRoot(container);
}
function renderQuestionsOrWindows(container, items, report) {
const wins = report && Array.isArray(report.hook_chat_windows) ? report.hook_chat_windows : [];
if (wins.length) {
renderHookChatWindows(container, wins, report);
return;
}
renderQuestions(container, items, report);
}
function renderQuestions(container, items, report) {
if (!container) return;
lastHookTranscriptMapError = (report && report.hook_transcript_map_error) || '';
lastHookTranscriptSourceFile = (report && report.hook_transcript_source_file) || '';
lastTranscriptByConvId = mergeHookTranscriptLookup(report || {});
lastTokensByConvId = mergeConversationTokensByConvId(report || {});
lastConvDisplayByConvId = mergeConversationDisplayNameByConvId(report || {});
if (!items.length) {
container.innerHTML =
'<div class="border border-dashed border-gray-200 rounded-xl p-6 text-center text-sm text-gray-500">暂无用户提问记录(大模型可依据对话摘要留空说明)。</div>';
return;
}
container.innerHTML = items
.map((q) => {
const ok = q.status !== 'open';
const iconBg = ok ? 'bg-green-100' : 'bg-amber-100';
const iconPath = ok
? 'M5 13l4 4L19 7'
: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z';
const badge = ok
? '<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">一般对话 / 已闭环</span>'
: '<span class="px-2 py-1 bg-amber-100 text-amber-800 text-xs rounded-full">疑似排错 / 待跟进</span>';
const tags = (q.tags || [])
.map((t) => `<span class="px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded-full">${esc(t)}</span>`)
.join(' ');
const rawConvId = q.conversation_id != null ? String(q.conversation_id).trim() : '';
const attrConvId = rawConvId ? encodeURIComponent(rawConvId) : '';
const tokForRow = rawConvId ? lookupTokensForConvId(rawConvId) : null;
const tokLine = tokForRow ? formatConvTokensBlockPlain(tokForRow) : null;
const tokenBlock = tokLine
? `<div class="mt-2 rounded-lg border border-indigo-100 bg-indigo-50/70 px-2.5 py-2 ring-1 ring-indigo-900/[0.03]">
<p class="text-[11px] font-semibold text-indigo-800">本会话TOKENS</p>
<p class="mt-1 font-mono text-xs leading-relaxed text-slate-700">${esc(tokLine)}</p>
</div>`
: '';
const detailBtn = attrConvId
? `<button type="button" class="question-detail-btn shrink-0 px-3 py-1.5 text-xs font-medium text-toge-primary border border-indigo-200 rounded-lg hover:bg-indigo-50 transition-colors" data-conversation-id="${attrConvId}">查看详情</button>`
: '';
return `<div class="border border-gray-100 rounded-xl overflow-hidden hover:shadow-md transition-shadow">
<div class="p-4 bg-white">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start space-x-3 min-w-0 flex-1">
<div class="w-8 h-8 rounded-full ${iconBg} flex items-center justify-center mt-1 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ${ok ? 'text-green-600' : 'text-amber-600'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${iconPath}" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="question-tab-md max-w-none font-medium text-gray-900">${mdToSafeHtml(q.title) || '<p class="text-gray-400">(无标题)</p>'}</div>
<div class="question-tab-md max-w-none text-sm text-gray-600 mt-1">${mdToSafeHtml(q.detail) || ''}</div>
${tokenBlock}
<div class="flex flex-wrap items-center gap-2 mt-3">${badge}${tags}<span class="text-xs text-gray-500">${esc(
q.time || ''
)}</span>${detailBtn}</div>
</div>
</div>
</div>
</div>
</div>`;
})
.join('');
wireQuestionChatModalOnce();
container.querySelectorAll('.question-detail-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const enc = btn.getAttribute('data-conversation-id') || '';
let id = '';
try {
id = enc ? decodeURIComponent(enc) : '';
} catch {
id = enc;
}
const t = (id && lastTranscriptByConvId[id]) || (id && lastTranscriptByConvId[String(id).trim()]) || '';
openQuestionChatModal(id, t);
});
});
highlightMdInRoot(container);
}
function renderRubric(tbody, steps) {
if (!tbody) return;
const rows = (steps || []).map((s) => {
const hit = s.done ? '已体现' : '未体现';
const badge = s.done
? '<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">已体现</span>'
: '<span class="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full">未体现</span>';
const hint =
typeof s.eval_note === 'string' && s.eval_note.trim() ? s.eval_note.trim() : RUBRIC_EVAL_HINT[s.id] || '';
return `<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${esc(s.id)}</td>
<td class="px-6 py-4 text-sm text-gray-700">${esc(s.label)}</td>
<td class="px-6 py-4 whitespace-nowrap">${badge}<span class="sr-only">${esc(hit)}</span></td>
<td class="px-6 py-4 text-sm text-gray-600">${esc(hint)}</td>
</tr>`;
});
tbody.innerHTML = rows.join('');
}
function renderDeliverablesFromSteps(container, steps) {
if (!container) return;
const done = (id) => !!(steps || []).find((x) => x.id === id)?.done;
const items = [
{ name: 'scores.csv', sub: '准备数据 · 全班成绩表', badge: done('prep') ? '已满足' : '待完成', ok: done('prep') },
{ name: 'score_analysis.py', sub: '主程序', badge: done('s2') ? '已满足' : '待完成', ok: done('s2') },
{ name: 'score_chart.png', sub: 'matplotlib 导出', badge: done('s4') ? '已满足' : '待完成', ok: done('s4') },
];
container.innerHTML = items
.map((f) => {
const cls = f.ok ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
return `<div class="bg-white border border-gray-200 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-gray-900">${esc(f.name)}</h4>
<span class="px-2 py-1 ${cls} text-xs rounded-full">${esc(f.badge)}</span>
</div>
<p class="text-xs text-gray-500">${esc(f.sub)}</p>
</div>`;
})
.join('');
}
function renderDeliverablesFromAi(container, list) {
if (!container) return;
if (!list || !list.length) {
container.innerHTML = '<div class="text-sm text-gray-500 border border-dashed rounded-xl p-4">无交付物列表</div>';
return;
}
container.innerHTML = list
.map((f) => {
const ok = /完成|通过|已|有|✓|OK/i.test(f.badge || '');
const cls = ok ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
return `<div class="bg-white border border-gray-200 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-gray-900">${esc(f.name)}</h4>
<span class="px-2 py-1 ${cls} text-xs rounded-full">${esc(f.badge || '—')}</span>
</div>
<p class="text-xs text-gray-500">${esc(f.sub || '')}</p>
</div>`;
})
.join('');
}
function renderAbilities(container, abilities) {
if (!container) return;
if (!abilities || !abilities.length) {
container.innerHTML = '<p class="text-sm text-gray-500">暂无能力评估数据。</p>';
return;
}
const colors = ['bg-toge-primary', 'bg-toge-secondary', 'bg-toge-accent', 'bg-toge-success', 'bg-toge-warning'];
container.innerHTML = abilities
.map((a, i) => {
const w = Math.max(0, Math.min(100, Number(a.value) || 0));
const bar = colors[i % colors.length];
const comment =
typeof a.comment === 'string' && a.comment.trim()
? `<p class="text-xs text-gray-500 mt-1 whitespace-pre-wrap">${esc(a.comment)}</p>`
: '';
const label = a.id
? `<span class="text-sm font-medium text-gray-700">${esc(a.name)} <span class="text-gray-400 font-normal">(${esc(
a.id
)})</span></span>`
: `<span class="text-sm font-medium text-gray-700">${esc(a.name)}</span>`;
return `<div>
<div class="flex items-center justify-between mb-1">
${label}
<span class="text-sm font-medium text-toge-primary">${w}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="${bar} h-2 rounded-full progress-bar" style="width:${w}%"></div>
</div>
${comment}
</div>`;
})
.join('');
}
const DEFAULT_RESOURCES = [
{ title: 'Pandas read_csv', subtitle: '成绩表读取', url: 'https://pandas.pydata.org/docs/reference/api/pandas.read_csv.html' },
{ title: 'Matplotlib 柱状图', subtitle: '步骤四', url: 'https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.bar.html' },
{ title: 'Cursor 文档', subtitle: 'Agent', url: 'https://cursor.com/docs' },
];
const BOOK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>`;
function renderResources(container, resources, source) {
if (!container) return;
const list = resources && resources.length ? resources : DEFAULT_RESOURCES;
const hint = document.getElementById('resources-source-hint');
if (hint) {
hint.textContent =
source === 'ai_full'
? '以下由大模型根据实训与仓库情况推荐。'
: '以下为默认参考链接;完整大模型模式将覆盖。';
}
const wraps = ['bg-indigo-50', 'bg-violet-50', 'bg-cyan-50'];
container.innerHTML = list
.map((r, idx) => {
const u = esc(r.url);
const wrap = wraps[idx % wraps.length];
return `<a href="${u}" target="_blank" rel="noopener noreferrer" class="block p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors border border-transparent hover:border-gray-200">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg ${wrap} flex items-center justify-center">${BOOK_SVG}</div>
<div>
<h4 class="text-sm font-medium text-gray-900">${esc(r.title)}</h4>
<p class="text-xs text-gray-500">${esc(r.subtitle || '')}</p>
</div>
</div>
</a>`;
})
.join('');
}
function applyClassRank(rank) {
const p = document.getElementById('class-rank-place');
const l2 = document.getElementById('class-rank-line2');
const note = document.getElementById('class-rank-note');
if (!rank) {
if (p) p.textContent = '—';
if (l2) l2.textContent = '需教务数据';
if (note) note.textContent = '可由大模型 class_rank 填充说明。';
return;
}
if (rank.place != null && !Number.isNaN(Number(rank.place))) {
if (p) p.textContent = String(rank.place);
} else if (p) p.textContent = '—';
if (rank.total != null && !Number.isNaN(Number(rank.total))) {
if (l2) l2.textContent = `/ ${rank.total}`;
} else if (l2) l2.textContent = '名次需教务数据';
if (note) note.textContent = rank.note || '';
}
function renderIssues(container, issues) {
if (!container) return;
if (!issues || !issues.length) {
container.innerHTML = '<p class="text-sm text-gray-500">暂无问题分析条目。</p>';
return;
}
container.innerHTML = issues
.map(
(it) => `<div class="ai-message bg-gray-50 rounded-lg p-4">
<div class="flex items-start space-x-3">
<div class="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center mt-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h4 class="font-medium text-gray-900">${esc(it.title)}</h4>
<p class="text-sm text-gray-600 mt-1">${esc(it.body)}</p>
</div>
</div>
</div>`
)
.join('');
}
function renderLearning(container, tips) {
if (!container) return;
container.innerHTML = (tips || [])
.map(
(t, i) => `<div class="flex items-start space-x-3">
<div class="w-6 h-6 rounded-full bg-toge-primary text-white flex items-center justify-center text-xs mt-0.5">${
i + 1
}</div>
<p class="text-sm text-gray-700">${esc(t)}</p>
</div>`
)
.join('');
}
function formatBytes(n) {
const b = Number(n) || 0;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
/** highlight.js 语言名;未收录的扩展名返回 null走 plaintext */
function extToHljsLang(ext) {
const e = String(ext || '')
.toLowerCase()
.replace(/^\./, '');
const map = {
py: 'python',
js: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
ts: 'typescript',
tsx: 'typescript',
jsx: 'javascript',
json: 'json',
html: 'xml',
htm: 'xml',
xml: 'xml',
svg: 'xml',
vue: 'xml',
css: 'css',
scss: 'scss',
less: 'less',
md: 'markdown',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yaml: 'yaml',
yml: 'yaml',
sql: 'sql',
c: 'c',
cpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
h: 'c',
hpp: 'cpp',
cs: 'csharp',
java: 'java',
go: 'go',
rs: 'rust',
rb: 'ruby',
php: 'php',
swift: 'swift',
kt: 'kotlin',
gradle: 'gradle',
properties: 'properties',
toml: 'ini',
ini: 'ini',
dockerfile: 'dockerfile',
r: 'r',
csv: 'plaintext',
txt: 'plaintext',
log: 'plaintext',
};
return map[e] || null;
}
function pickHighlightLang(filenameForHighlight) {
if (!filenameForHighlight || typeof filenameForHighlight !== 'string') return 'plaintext';
const base = filenameForHighlight.split('/').pop() || '';
const lower = base.toLowerCase();
if (lower === 'dockerfile' || lower.startsWith('dockerfile.')) {
return typeof hljs !== 'undefined' && hljs.getLanguage('dockerfile') ? 'dockerfile' : 'plaintext';
}
const dot = base.lastIndexOf('.');
if (dot <= 0 || dot === base.length - 1) return 'plaintext';
const ext = base.slice(dot + 1);
const mapped = extToHljsLang(ext);
if (!mapped || mapped === 'plaintext') return 'plaintext';
if (typeof hljs !== 'undefined' && hljs.getLanguage(mapped)) return mapped;
return 'plaintext';
}
function applySyntaxHighlight(codeEl, text, filenameForHighlight) {
if (!codeEl) return;
const lang = pickHighlightLang(filenameForHighlight);
codeEl.textContent = text;
codeEl.className = 'hljs block w-full whitespace-pre-wrap text-[13px] leading-relaxed';
codeEl.classList.add(`language-${lang}`);
codeEl.removeAttribute('data-highlighted');
if (typeof hljs !== 'undefined') {
try {
hljs.highlightElement(codeEl);
} catch {
codeEl.textContent = text;
}
}
}
/** @param {string} text @param {string} [filenameForHighlight] 仓库相对路径或带扩展名的文件名,用于选择高亮语言 */
function showTextPreview(text, filenameForHighlight) {
const code = document.getElementById('dynamic-code-snippet');
const wrap = document.getElementById('repo-text-preview-wrap');
const img = document.getElementById('repo-preview-image');
if (img) {
img.classList.add('hidden');
img.removeAttribute('src');
}
if (wrap) wrap.classList.remove('hidden');
if (code) {
code.classList.remove('hidden');
applySyntaxHighlight(code, text, filenameForHighlight);
}
}
function showImagePreview(base64, mime) {
const code = document.getElementById('dynamic-code-snippet');
const wrap = document.getElementById('repo-text-preview-wrap');
const img = document.getElementById('repo-preview-image');
if (wrap) wrap.classList.add('hidden');
if (code) code.classList.add('hidden');
if (img) {
img.classList.remove('hidden');
img.src = `data:${mime || 'image/png'};base64,${base64}`;
}
}
function initRepoFilesBrowser(data) {
const listEl = document.getElementById('dynamic-file-list');
if (!listEl || !data.student_repo?.files?.length) return;
const gitEnc = encodeURIComponent(data.git_url || '');
const files = data.student_repo.files;
listEl.innerHTML = files
.map((f) => {
const safeAttr = encodeURIComponent(f.path);
return `<button type="button" class="repo-file-row w-full text-left bg-white border border-gray-200 rounded-xl p-3 hover:border-toge-primary transition-colors" data-repo-path="${safeAttr}">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs text-gray-900 truncate">${esc(f.path)}</span>
<span class="text-xs text-gray-500 shrink-0">${formatBytes(f.size)}</span>
</div>
</button>`;
})
.join('');
const pf = document.getElementById('preview-filename');
if (pf) pf.textContent = '选择仓库内文件以预览';
listEl.querySelectorAll('.repo-file-row').forEach((btn) => {
btn.addEventListener('click', async () => {
const rel = decodeURIComponent(btn.getAttribute('data-repo-path') || '');
if (pf) pf.textContent = rel;
const sm = document.getElementById('snippet-meta-text');
if (sm) sm.textContent = '加载中…';
try {
const res = await fetch(`/api/repo-file?git=${gitEnc}&file=${encodeURIComponent(rel)}`, {
cache: 'no-store',
});
const j = await res.json();
if (!j.ok) throw new Error(j.error || '加载失败');
if (j.mode === 'binary' && j.base64) {
showImagePreview(j.base64, j.mime);
if (sm) sm.textContent = `${rel} · ${formatBytes(j.size)} · 二进制`;
} else {
showTextPreview(
(j.content || '') + (j.truncated ? '\n\n…(服务端已截断长文件)…' : ''),
rel
);
if (sm) sm.textContent = `${rel} · ${formatBytes(j.size)}${j.truncated ? ' · 已截断' : ''}`;
}
} catch (e) {
showTextPreview(`加载失败:${e.message || e}`, null);
if (sm) sm.textContent = String(e.message || e);
}
});
});
const head = document.getElementById('snippet-meta-heading');
if (head) head.textContent = '文件信息';
const asg = document.getElementById('assignment-section-title');
if (asg) asg.textContent = '学员仓库文件';
}
function fillFromHeuristic(data) {
const now = new Date();
const reportDate = document.getElementById('report-date');
if (reportDate) {
reportDate.textContent = `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`;
}
const pct = data.summary?.lab_progress_percent ?? 0;
const elProg = document.getElementById('stat-lab-progress');
if (elProg) elProg.textContent = String(pct);
setRingPercent(pct);
setAlignBadge(pct >= 85 ? '优秀' : pct >= 60 ? '良好' : pct >= 40 ? '中等' : '待加强');
const hookN = data.summary?.hook_event_count ?? 0;
const elHook = document.getElementById('stat-hook-count');
const u = document.getElementById('stat-hook-unit');
const th = document.getElementById('stat-token-hint');
const tok = data.evaluation?.meta?.tokens;
const agg = sumChatLogTokens(tok);
if (elHook) elHook.textContent = agg && agg.sum > 0 ? agg.sum.toLocaleString('zh-CN') : '0';
if (u) u.textContent = 'TOKENS';
if (th) {
const line = formatTokenBreakdownLine(tok);
th.textContent =
hookN > 0 ? `${line} · 钩子事件 ${hookN}` : line;
}
const convs = data.conversations || [];
const cc = convs.length;
const elA = document.getElementById('stat-coverage-a');
const elAbar = document.getElementById('stat-coverage-a-bar');
if (elA) elA.textContent = String(cc);
if (elAbar) elAbar.style.width = `${Math.min(100, cc * 25)}%`;
const latestModel = convs[0]?.last_model || '—';
const elB = document.getElementById('stat-coverage-b');
const elBbar = document.getElementById('stat-coverage-b-bar');
if (elB) elB.textContent = latestModel;
if (elBbar) elBbar.style.width = `${Math.min(100, pct)}%`;
const email = data.evaluation?.meta?.user_hint;
const local = email && email.includes('@') ? email.split('@')[0] : null;
const nameEl = document.getElementById('student-name');
if (nameEl) nameEl.textContent = local ? `学员(${local}` : '学员';
const metaEl = document.getElementById('student-meta');
if (metaEl) {
metaEl.textContent = email
? `对话数 ${cc} · 钩子事件 ${hookN}`
: `对话数 ${cc} · 钩子事件 ${hookN} 条 · 报告生成于 ${data.generated_at || ''}`;
}
const qMerged = mergeQuestions(convs);
const qn = qMerged.length;
const sq = document.getElementById('stat-question-count');
if (sq) sq.textContent = String(qn);
const openC = qMerged.filter((q) => q.status === 'open').length;
const badges = document.getElementById('stat-question-badges');
if (badges) {
badges.innerHTML = `<span class="px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded-full">共 ${qn} 条摘录</span>
<span class="px-2 py-1 ${openC ? 'bg-amber-100 text-amber-900' : 'bg-green-100 text-green-800'} text-xs rounded-full">疑似排错 ${openC} 条</span>`;
}
const wins = Array.isArray(data.hook_chat_windows) ? data.hook_chat_windows : [];
const totalHookTurns = wins.reduce((s, w) => s + (Number(w.turn_count) || 0), 0);
const qh = document.getElementById('questions-pager-hint');
if (qh) {
if (wins.length) {
qh.textContent = `${wins.length} 个聊天窗口(按最近活动排序,新的在前)· ${totalHookTurns} 轮钩子对话 · 默认展开`;
} else {
qh.textContent = `${qn} 条(去重后展示,最多 20 条)`;
}
}
renderQuestionsOrWindows(document.getElementById('dynamic-questions'), qMerged, data);
renderRubric(document.getElementById('dynamic-rubric-body'), data.summary?.rubric_steps);
if (data.student_repo?.files?.length) {
initRepoFilesBrowser(data);
showTextPreview('点击左侧仓库中的文件可在此预览源码或图片。');
const sm0 = document.getElementById('snippet-meta-text');
if (sm0) sm0.textContent = `${data.student_repo.file_count} 个文件 · ${data.git_url || ''}`;
} else {
renderDeliverablesFromSteps(document.getElementById('dynamic-file-list'), data.summary?.rubric_steps);
const snippet = convs[0]?.ai_snippets?.[0];
showTextPreview(snippet?.full || '(暂无 Agent 回复文本。)', 'snippet.md');
const sm = document.getElementById('snippet-meta-text');
if (sm) {
sm.textContent = snippet
? `模型:${snippet.model || '—'} · 时间:${snippet.at || '—'}`
: '无可用摘录。';
}
}
const rfh = document.getElementById('rubric-footer-hint');
if (rfh && data.rubric_footer_auto) rfh.textContent = data.rubric_footer_auto;
setEvalOverallText(document.getElementById('eval-overall-text'), data.evaluation?.overall);
const genAt = document.getElementById('eval-generated-at');
const metaAi = data.evaluation?.meta?.ai_evaluation_at;
const src = data.evaluation?.meta?.ai_evaluation_source;
if (genAt) {
const t = metaAi || data.generated_at || '—';
const extra = src ? ` · 来源: ${src}` : '';
genAt.textContent = `生成时间: ${t}${extra}`;
}
renderAbilities(document.getElementById('eval-ability-container'), data.evaluation?.ability);
renderIssues(document.getElementById('dynamic-eval-issues'), data.evaluation?.issues);
renderLearning(document.getElementById('dynamic-learning-list'), data.evaluation?.learning);
renderResources(document.getElementById('dynamic-resources'), data.evaluation?.resources, data.source);
applyClassRank(data.evaluation?.class_rank);
const learnHintH = document.getElementById('learning-section-hint');
if (learnHintH && data.ui?.learning_section_hint) learnHintH.textContent = data.ui.learning_section_hint;
const abHintH = document.getElementById('ability-section-hint');
if (abHintH && data.ui?.ability_section_hint) abHintH.textContent = data.ui.ability_section_hint;
}
async function load() {
const reportDate = document.getElementById('report-date');
if (reportDate) reportDate.textContent = '加载中…';
let data;
try {
const res = await fetch(buildReportApiUrl(), {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
});
data = await res.json();
} catch (e) {
setEvalOverallText(document.getElementById('eval-overall-text'), '无法连接报告服务,请确认已运行 npm start。');
return;
}
if (!data.ok) {
const msg = data.error || '报告生成失败';
setEvalOverallText(document.getElementById('eval-overall-text'), msg);
applyBanner({ source: 'error', llm_error: msg });
return;
}
applyBanner(data);
if (data.source !== 'ai_full') {
fillFromHeuristic(data);
return;
}
if (data.ui) applyUi(data.ui);
const pctFull = data.ui?.stat_lab_progress ?? data.summary?.lab_progress_percent ?? 0;
setRingPercent(pctFull);
const convs = data.conversations || [];
const llmQs = mergeQuestions(convs);
const hookQs = Array.isArray(data.hook_excerpt_questions) ? data.hook_excerpt_questions : [];
const qMerged = mergeHookAndLlmQuestions(llmQs, hookQs);
const qn = qMerged.length;
if (!data.ui?.stat_questions_count) {
const sq = document.getElementById('stat-question-count');
if (sq) sq.textContent = String(qn);
}
const wins = Array.isArray(data.hook_chat_windows) ? data.hook_chat_windows : [];
const totalHookTurns = wins.reduce((s, w) => s + (Number(w.turn_count) || 0), 0);
const qh = document.getElementById('questions-pager-hint');
if (qh) {
if (wins.length) {
qh.textContent = `${wins.length} 个聊天窗口 · ${totalHookTurns}transcript_path 合并)· 新的在前 · 默认展开`;
} else {
const hn = hookQs.length;
const ln = llmQs.length;
qh.textContent =
hn > 0
? `${qn} 条(含 chat_logs 钩子原文 ${hn} 条 + 大模型摘要 ${ln} 条,已去重合并)`
: `${qn} 条(大模型整理)`;
}
}
renderQuestionsOrWindows(document.getElementById('dynamic-questions'), qMerged, data);
renderRubric(document.getElementById('dynamic-rubric-body'), data.summary?.rubric_steps);
if (data.student_repo?.files?.length) {
initRepoFilesBrowser(data);
showTextPreview('点击左侧仓库中的文件可在此预览。对话摘录可在上方「对话与提问」查看。');
const smA = document.getElementById('snippet-meta-text');
if (smA) smA.textContent = `${data.student_repo.file_count} 个文件 · 仓库已扫描`;
} else if (data.ui && Array.isArray(data.ui.deliverables) && data.ui.deliverables.length) {
renderDeliverablesFromAi(document.getElementById('dynamic-file-list'), data.ui.deliverables);
} else {
renderDeliverablesFromSteps(document.getElementById('dynamic-file-list'), data.summary?.rubric_steps);
}
const snippet = convs[0]?.ai_snippets?.[0];
if (!data.student_repo?.files?.length) {
showTextPreview(snippet?.full || data.ui?.code_preview || '(无代码/对话摘录。)', 'snippet.md');
const sm = document.getElementById('snippet-meta-text');
if (sm) {
sm.textContent = snippet
? `模型:${snippet.model || '—'} · 时间:${snippet.at || '—'} · 仓库:${esc(data.git_url || '')}`
: `仓库:${esc(data.git_url || '')}`;
}
}
const rfh2 = document.getElementById('rubric-footer-hint');
if (rfh2 && data.rubric_footer_auto) rfh2.textContent = data.rubric_footer_auto;
setEvalOverallText(document.getElementById('eval-overall-text'), data.evaluation?.overall);
const genAt = document.getElementById('eval-generated-at');
if (genAt) {
genAt.textContent = `生成时间: ${data.generated_at || '—'} · 来源: Cursor Agent每次刷新重新生成`;
}
renderAbilities(document.getElementById('eval-ability-container'), data.evaluation?.ability);
renderIssues(document.getElementById('dynamic-eval-issues'), data.evaluation?.issues);
renderLearning(document.getElementById('dynamic-learning-list'), data.evaluation?.learning);
renderResources(document.getElementById('dynamic-resources'), data.evaluation?.resources, data.source);
applyClassRank(data.evaluation?.class_rank);
}
function wireRefresh() {
const btn = document.getElementById('btn-refresh-report');
if (btn) {
btn.addEventListener('click', () => {
window.location.reload();
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
wireRefresh();
load();
});
} else {
wireRefresh();
load();
}
})();