// app.js — 视图路由、运行列表渲染、健康检查。整个控制台的入口编排。 // 会话保持:URL hash 路由(#runs / #new / #profiles / #report/{runId}) // + sessionStorage 兜底,F5 刷新 / 浏览器前进后退均可恢复。 const App = { currentRunId: null, activeView: null, views: ["runs", "new", "report", "profiles", "apidocs"], titles: { runs: "运行列表", new: "新建评估", report: "报告详情", profiles: "LLM 配置", apidocs: "API 文档" }, // 初始化:绑定导航、从 URL/sessionStorage 恢复上次位置、启动健康检查。 init() { document.querySelectorAll(".nav-item").forEach((btn) => { btn.addEventListener("click", () => App.navigate(btn.dataset.view)); }); document.getElementById("refresh-btn").addEventListener("click", () => App.refreshCurrent()); Runner.init(); Profiles.init(); // 恢复上次会话(优先 URL hash,其次 sessionStorage) App._restoreSession(); App.checkHealth(); setInterval(App.checkHealth, 15000); // 浏览器前进 / 后退按钮 window.addEventListener("popstate", () => App._restoreSession()); }, // ---------------------------------------------------------------- // 路由 —— 有历史记录的主动导航(更新 URL hash) // ---------------------------------------------------------------- navigate(view, runId) { if (runId !== undefined) App.currentRunId = runId; const hash = App._buildHash(view, App.currentRunId); if (location.hash !== `#${hash}`) { history.pushState({ view, runId: App.currentRunId }, "", `#${hash}`); } App._doSwitch(view); }, // 供内部调用(不产生历史记录),例如刷新同一视图 switchView(view) { App._doSwitch(view); }, // 刷新当前视图数据 refreshCurrent() { App._doSwitch(App.activeView || "runs"); }, // ---------------------------------------------------------------- // 内部:实际切换 DOM + 触发数据加载 // ---------------------------------------------------------------- _doSwitch(view) { App.views.forEach((name) => { const el = document.getElementById(`view-${name}`); if (el) el.hidden = name !== view; }); document.querySelectorAll(".nav-item").forEach((btn) => { btn.classList.toggle("active", btn.dataset.view === view); }); document.getElementById("view-title").textContent = App.titles[view] || view; App.activeView = view; // 持久化到 sessionStorage(URL 共享场景的备份) sessionStorage.setItem("rag_view", view); if (App.currentRunId) sessionStorage.setItem("rag_run_id", App.currentRunId); if (view === "runs") App.loadRuns(); if (view === "new") Runner.loadScenarios(); if (view === "report") Report.render(App.currentRunId); if (view === "profiles") Profiles.load(); }, // ---------------------------------------------------------------- // Hash 工具 // ---------------------------------------------------------------- _buildHash(view, runId) { if (view === "report" && runId) { return `report/${encodeURIComponent(runId)}`; } return view || "runs"; }, _parseHash() { const raw = location.hash.replace(/^#\/?/, ""); if (!raw) return { view: null, runId: null }; if (raw.startsWith("report/")) { const runId = decodeURIComponent(raw.slice("report/".length)); return { view: "report", runId }; } const view = App.views.includes(raw) ? raw : null; return { view, runId: null }; }, // 会话恢复:hash → sessionStorage → 默认 runs _restoreSession() { const { view: hView, runId: hRunId } = App._parseHash(); const view = hView || sessionStorage.getItem("rag_view") || "runs"; const runId = hRunId || sessionStorage.getItem("rag_run_id") || null; if (runId) { App.currentRunId = runId; App.enableReportNav(); } App._doSwitch(view); }, // ---------------------------------------------------------------- // 运行列表 // ---------------------------------------------------------------- async loadRuns() { const container = document.getElementById("runs-container"); const empty = document.getElementById("runs-empty"); container.innerHTML = '

加载中…

'; try { const data = await API.runs(); const runs = data.runs || []; if (runs.length === 0) { container.innerHTML = ""; empty.hidden = false; return; } empty.hidden = true; container.innerHTML = ""; runs.forEach((run) => container.appendChild(App.renderRunCard(run))); } catch (err) { container.innerHTML = `

加载失败:${App.escape(err.message)}

`; } }, renderRunCard(run) { const card = document.createElement("div"); card.className = "run-card" + (run.run_id === App.currentRunId ? " selected" : ""); card.addEventListener("click", () => { // 更新选中高亮 document.querySelectorAll(".run-card").forEach((c) => c.classList.remove("selected")); card.classList.add("selected"); App.enableReportNav(); App.navigate("report", run.run_id); }); const chips = (run.metrics || []) .map((m) => { const val = run.metric_means ? run.metric_means[m] : null; const cls = App.scoreClass(val); const text = val === null || val === undefined ? "n/a" : val.toFixed(2); return `${App.escape(App.shortMetric(m))} ${text}`; }) .join(""); card.innerHTML = `
${App.escape(run.scenario_name || run.run_id)}
${App.escape(run.mode || "—")} · judge: ${App.escape(run.judge_model || "—")}
${run.valid_samples} 有效 / ${run.invalid_samples} 无效 · ${App.escape(App.shortTime(run.finished_at))}
${chips}
`; return card; }, // ---------------------------------------------------------------- // 工具方法 // ---------------------------------------------------------------- enableReportNav() { const btn = document.querySelector('.nav-item[data-view="report"]'); if (btn) btn.disabled = false; }, scoreClass(value) { if (value === null || value === undefined) return "na"; if (value >= 0.8) return "good"; if (value >= 0.65) return "warn"; return "bad"; }, shortMetric(name) { const map = { faithfulness: "faith.", answer_relevancy: "ans.rel.", context_recall: "ctx.recall", context_precision: "ctx.prec.", noise_sensitivity: "noise.sens.", factual_correctness: "fact.corr.", semantic_similarity: "sem.sim.", }; return map[name] || name; }, shortTime(iso) { if (!iso) return "—"; return String(iso).replace("T", " ").slice(0, 16); }, escape(text) { const div = document.createElement("div"); div.textContent = text == null ? "" : String(text); return div.innerHTML; }, async checkHealth() { const dot = document.getElementById("health-dot"); const label = document.getElementById("health-text"); try { await API.health(); dot.className = "dot ok"; label.textContent = "服务正常"; } catch (_e) { dot.className = "dot bad"; label.textContent = "服务离线"; } }, }; document.addEventListener("DOMContentLoaded", App.init);