|
|
/**
|
|
|
* 拉取 /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, '<')
|
|
|
.replace(/>/g, '>')
|
|
|
.replace(/"/g, '"');
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
}
|
|
|
})();
|