const state = { atlas: null, health: null, view: "atlas", activeTopic: "", topicFocus: null, selectedPaper: null, selectedMarkdown: "", searchItems: [], searchTotal: 0, searchJobId: "", searchResult: null, searchPollTimer: null, searchFollowups: [], aiBusy: false, atlasChart: null, paperChatHistory: {}, }; const $ = (selector) => document.querySelector(selector); const elements = { app: $(".atlas-app"), healthLine: $("#healthLine"), searchInput: $("#searchInput"), searchBtn: $("#searchBtn"), mapTitle: $("#mapTitle"), atlasChart: $("#atlasChart"), atlasSvg: $("#atlasSvg"), routeList: $("#routeList"), timeline: $("#timeline"), explainAtlasBtn: $("#explainAtlasBtn"), readingPathBtn: $("#readingPathBtn"), lensEyebrow: $("#lensEyebrow"), lensTitle: $("#lensTitle"), lensBody: $("#lensBody"), closePaperBtn: $("#closePaperBtn"), }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function debounce(fn, delay = 260) { let timer = null; return (...args) => { window.clearTimeout(timer); timer = window.setTimeout(() => fn(...args), delay); }; } async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}), }, }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } return payload; } function loadChartEngine() { if (window.echarts) { if (state.atlas) renderAtlasMap(); return; } if (document.querySelector("script[data-chart-engine='echarts']")) return; const script = document.createElement("script"); script.src = "/static/vendor/echarts.min.js?v=6.1.0"; script.async = true; script.dataset.chartEngine = "echarts"; script.onload = () => { if (state.atlas) renderAtlasMap(); }; script.onerror = () => { console.warn("ECharts atlas enhancement unavailable; SVG fallback remains active."); }; document.head.appendChild(script); } function topicLabel(topic) { const found = state.atlas?.topics?.find((item) => item.id === topic); return found?.label || topic.replaceAll("-", " "); } function colorFor(index) { const colors = ["#0b7a75", "#c95745", "#b37a22", "#4d7f52", "#5f63b5", "#22699a", "#9a5d2f", "#7b4aa0"]; return colors[index % colors.length]; } function hexToRgb(hex) { const clean = String(hex || "").replace("#", ""); const value = Number.parseInt(clean.length === 3 ? clean.replace(/(.)/g, "$1$1") : clean, 16); return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255, }; } function hexToRgba(hex, alpha) { const { r, g, b } = hexToRgb(hex); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function mixColor(hex, target, amount) { const base = hexToRgb(hex); const next = hexToRgb(target); const mix = (left, right) => Math.round(left + (right - left) * amount); return `rgb(${mix(base.r, next.r)}, ${mix(base.g, next.g)}, ${mix(base.b, next.b)})`; } function chartGradient(color) { if (!window.echarts?.graphic?.RadialGradient) return color; return new window.echarts.graphic.RadialGradient(0.34, 0.28, 0.86, [ { offset: 0, color: "#ffffff" }, { offset: 0.58, color: mixColor(color, "#ffffff", 0.64) }, { offset: 1, color }, ]); } function setReaderMode(enabled) { const changed = elements.app.classList.toggle("reader-mode", Boolean(enabled)); if (changed && state.atlas) { window.requestAnimationFrame(() => renderAtlasMap()); } } async function loadHealth() { try { const health = await api("/api/health"); state.health = health; elements.healthLine.textContent = `${health.paper_count} papers · ${health.ollama_ok ? "Ollama online" : "Ollama offline"}`; } catch (error) { elements.healthLine.textContent = error.message; } } async function loadAtlas() { state.atlas = await api("/api/atlas"); elements.mapTitle.textContent = `${state.atlas.paper_count} 篇论文构成的 Agent 版图`; renderAtlasMap(); renderRoutes(); renderTimeline(); renderOverview(); } function atlasTopics() { return state.atlas.topics.filter((topic) => topic.count >= 30).slice(0, 13); } function setAtlasRenderer(useChart) { if (elements.atlasChart) elements.atlasChart.hidden = !useChart; if (elements.atlasSvg) elements.atlasSvg.hidden = useChart; } function renderAtlasMap() { if (window.echarts && elements.atlasChart) { try { renderEchartsAtlasMap(); return; } catch (error) { console.warn("ECharts atlas failed, falling back to SVG", error); } } renderSvgAtlasMap(); } function renderEchartsAtlasMap() { const isMobile = window.matchMedia("(max-width: 760px)").matches; const readerMode = elements.app.classList.contains("reader-mode"); let topics = atlasTopics(); if (readerMode && !isMobile) { topics = topics.slice(0, 10); } const topicMap = new Map(topics.map((topic, index) => [topic.id, { ...topic, index }])); const maxCount = Math.max(...topics.map((topic) => topic.count), 1); setAtlasRenderer(true); if (!state.atlasChart) { state.atlasChart = window.echarts.init(elements.atlasChart, null, { renderer: "canvas", useDirtyRect: true, }); } const width = elements.atlasChart.clientWidth || (isMobile ? 360 : 780); const height = elements.atlasChart.clientHeight || (isMobile ? 360 : 460); const isCompact = isMobile || width < 690 || elements.app.classList.contains("reader-mode"); const padding = isCompact ? { left: 46, right: 86, top: 42, bottom: 54 } : { left: 92, right: 92, top: 64, bottom: 74 }; const layoutX = (topic) => (isCompact ? topic.x - Math.max(0, topic.x - 64) * 0.65 : topic.x); const xs = topics.map(layoutX); const ys = topics.map((topic) => topic.y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const xSpan = Math.max(maxX - minX, 1); const ySpan = Math.max(maxY - minY, 1); const plotWidth = Math.max(width - padding.left - padding.right, 260); const plotHeight = Math.max(height - padding.top - padding.bottom, 230); const sizeBase = isCompact ? (isMobile ? 24 : 36) : 48; const sizeRange = isCompact ? (isMobile ? 30 : 42) : 62; const nodes = topics.map((topic, index) => { const color = colorFor(index); const active = topic.id === state.activeTopic; const symbolSize = sizeBase + sizeRange * Math.sqrt(topic.count / maxCount); return { id: topic.id, name: topic.label, value: topic.count, x: padding.left + ((layoutX(topic) - minX) / xSpan) * plotWidth, y: padding.top + ((topic.y - minY) / ySpan) * plotHeight, symbolSize, count: topic.count, recent_count: topic.recent_count, heat: topic.heat, question: topic.question, stance: topic.stance, itemStyle: { color: chartGradient(color), borderColor: active ? "#17201c" : mixColor(color, "#101614", 0.08), borderWidth: active ? 3 : 1.4, shadowBlur: active ? 30 : 18, shadowColor: hexToRgba(color, active ? 0.34 : 0.2), }, label: { color: active ? "#0f1714" : "#18211d", }, }; }); const links = state.atlas.edges .filter((edge) => topicMap.has(edge.source) && topicMap.has(edge.target)) .map((edge) => ({ source: edge.source, target: edge.target, value: edge.count, lineStyle: { width: Math.max(1, 1.2 + edge.strength * 5.4), opacity: Math.min(0.58, 0.13 + edge.strength * 0.5), color: hexToRgba(colorFor(topicMap.get(edge.source).index), 0.48), curveness: 0.08, }, })); state.atlasChart.setOption( { backgroundColor: "transparent", animationDuration: 850, animationDurationUpdate: 450, animationEasing: "cubicOut", tooltip: { trigger: "item", confine: true, appendToBody: true, backgroundColor: "rgba(252, 253, 249, 0.97)", borderColor: "rgba(31, 37, 33, 0.18)", borderWidth: 1, padding: [10, 12], textStyle: { color: "#151b18", fontSize: 12, lineHeight: 18, }, extraCssText: "box-shadow:0 16px 42px rgba(24,31,28,.16);border-radius:8px;", formatter(params) { if (params.dataType === "edge") { return ` ${escapeHtml(topicLabel(params.data.source))} ↔ ${escapeHtml(topicLabel(params.data.target))}
共现证据:${escapeHtml(params.data.value)} `; } const item = params.data; return ` ${escapeHtml(item.name)}
${escapeHtml(item.count)} papers · recent +${escapeHtml(item.recent_count)}
${escapeHtml(item.question)} `; }, }, series: [ { type: "graph", layout: "none", data: nodes, links, roam: true, draggable: true, center: isCompact ? (isMobile ? ["80%", "50%"] : ["120%", "50%"]) : ["50%", "50%"], zoom: isCompact ? (isMobile ? 0.64 : 0.72) : 1, nodeScaleRatio: isCompact ? 0.24 : 0.5, cursor: "pointer", selectedMode: "single", scaleLimit: { min: 0.45, max: 3.2 }, label: { show: true, position: "inside", overflow: "truncate", formatter(params) { return `{name|${params.data.name}}\n{meta|${params.data.count} · +${params.data.recent_count}}`; }, rich: { name: { fontSize: isCompact ? (isMobile ? 9 : 10) : 12, fontWeight: 850, lineHeight: isCompact ? (isMobile ? 12 : 13) : 16, }, meta: { color: "#59625d", fontSize: isCompact ? (isMobile ? 8 : 9) : 10, fontWeight: 700, lineHeight: isCompact ? (isMobile ? 10 : 11) : 13, }, }, }, lineStyle: { color: "source", cap: "round", }, emphasis: { focus: "adjacency", scale: 1.08, itemStyle: { shadowBlur: 36, }, lineStyle: { opacity: 0.82, width: 4, }, label: { color: "#0b1411", }, }, blur: { itemStyle: { opacity: 0.28, }, lineStyle: { opacity: 0.06, }, label: { opacity: 0.35, }, }, select: { itemStyle: { borderColor: "#111714", borderWidth: 3, shadowBlur: 34, }, }, }, ], }, true, ); state.atlasChart.off("click"); state.atlasChart.on("click", async (params) => { if (params.dataType !== "node" || !params.data?.id) return; await selectTopic(params.data.id); scrollLensIntoView(); }); } function renderSvgAtlasMap() { const topics = state.atlas.topics.filter((topic) => topic.count >= 30).slice(0, 13); const topicMap = new Map(topics.map((topic, index) => [topic.id, { ...topic, index }])); const maxCount = Math.max(...topics.map((topic) => topic.count), 1); const isMobile = window.matchMedia("(max-width: 760px)").matches; setAtlasRenderer(false); elements.atlasSvg.setAttribute("viewBox", isMobile ? "16 -4 78 86" : "0 0 170 100"); const point = (topic) => ({ x: isMobile ? topic.x : 14 + topic.x * 1.58, y: -18 + topic.y * 0.93, }); const edges = state.atlas.edges .filter((edge) => topicMap.has(edge.source) && topicMap.has(edge.target)) .map((edge) => { const source = topicMap.get(edge.source); const target = topicMap.get(edge.target); const sourcePoint = point(source); const targetPoint = point(target); return ` ${escapeHtml(topicLabel(edge.source))} ↔ ${escapeHtml(topicLabel(edge.target))}: ${edge.count} `; }) .join(""); const nodes = topics .map((topic, index) => { const active = topic.id === state.activeTopic ? " active" : ""; const r = 3.6 + 5.6 * (topic.count / maxCount); const ring = r + 1.6 + topic.heat * 1.7; const color = colorFor(index); const p = point(topic); return ` ${escapeHtml(topic.label)} ${topic.count} · +${topic.recent_count} `; }) .join(""); elements.atlasSvg.innerHTML = `${edges}${nodes}`; } function renderRoutes() { elements.routeList.innerHTML = state.atlas.routes .map( (route) => ` `, ) .join(""); } function renderTimeline() { const rows = state.atlas.timeline.slice(-13); const maxTotal = Math.max(...rows.map((row) => row.total), 1); elements.timeline.innerHTML = rows .map((row) => { const height = 8 + 70 * (row.total / maxTotal); return `
${escapeHtml(row.month.slice(5))}
`; }) .join(""); } function statGrid(items) { return `
${items .map( (item) => `
${escapeHtml(item.value)} ${escapeHtml(item.label)}
`, ) .join("")}
`; } function paperList(items, limit = items.length) { if (!items?.length) { return `
暂无证据
`; } return `
${items .slice(0, limit) .map( (paper) => ` `, ) .join("")}
`; } function compactPaperList(items, limit = items.length) { return paperList(items, limit); } function routeButtons(routes) { return `
${routes .map( (route) => ` `, ) .join("")}
`; } function searchActivity(steps = []) { const current = steps.find((step) => step.status === "running") || [...steps].reverse().find((step) => step.status === "done") || steps[0] || {}; const doneCount = steps.filter((step) => step.status === "done").length; const currentIndex = Math.min(doneCount + (current.status === "running" ? 1 : 0), steps.length || 1); const statusLabel = current.status === "running" ? "working" : current.status || "pending"; return `
Search Agent ${escapeHtml(current.label || "准备搜索")}
${escapeHtml(current.detail || "正在准备检索上下文")}
${escapeHtml(statusLabel)} · ${currentIndex}/${steps.length || 1}
`; } function searchAggregationView(aggregation) { if (!aggregation) return ""; const topTopics = aggregation.topics || []; const recentMonths = aggregation.recent_months || []; return `
${statGrid([ { value: aggregation.total || 0, label: "matches" }, { value: topTopics.length, label: "topics" }, { value: aggregation.date_range?.end || "unknown", label: "latest" }, ])}
${recentMonths .map((row) => `${escapeHtml(row.month)} · ${escapeHtml(row.count)}`) .join("")}
${topTopics .slice(0, 8) .map( (topic) => ` `, ) .join("")}
`; } function searchPlanView(plan) { if (!plan) return ""; const keywords = [...(plan.keywords_en || []), ...(plan.keywords_zh || [])].slice(0, 14); return `
Search Plan

${escapeHtml(plan.interpreted_query || "")}

${escapeHtml(plan.search_rationale || "")}

${keywords.map((keyword) => `${escapeHtml(keyword)}`).join("")}
`; } function renderSearchRunning(job) { setReaderMode(false); state.view = "search"; setModeButtons("search"); elements.lensEyebrow.textContent = "Search Agent"; elements.lensTitle.textContent = job?.query || elements.searchInput.value.trim() || "搜索"; elements.lensBody.innerHTML = `
${searchActivity(job?.steps || [])}
`; } function renderSearchHome() { setReaderMode(false); state.view = "search"; setModeButtons("search"); elements.lensEyebrow.textContent = "Search Agent"; elements.lensTitle.textContent = "智能搜索"; elements.lensBody.innerHTML = `

输入主题、论文线索或研究问题。Search Agent 会理解意图、扩展中英文关键词、检索本地论文库,并生成一份简短结论。

可以这样搜

`; } function renderSearchError(jobOrError) { const message = typeof jobOrError === "string" ? jobOrError : jobOrError?.error || "search failed"; elements.lensBody.innerHTML = `
${escapeHtml(message)}
`; } function followupList() { const rows = state.searchFollowups || []; if (!rows.length) return ""; return `
${rows .map( (row) => `
${row.role === "user" ? "你" : "Search Agent"}
${escapeHtml(row.content)}
`, ) .join("")}
`; } function renderSearchResult(result) { setReaderMode(false); state.view = "search"; setModeButtons("search"); elements.lensEyebrow.textContent = "Search Agent"; elements.lensTitle.textContent = result.query; const summary = result.summary || {}; elements.lensBody.innerHTML = `

${escapeHtml(summary.model || "Search Agent")}

${escapeHtml(summary.text || "")}
${searchAggregationView(result.aggregation)} ${searchPlanView(result.plan)}

继续追问

${followupList()}

相关论文

${compactPaperList(result.items || [], 60)}
`; } function renderOverview(aiHtml = "") { setReaderMode(false); state.view = "atlas"; state.selectedPaper = null; elements.lensEyebrow.textContent = "Atlas"; elements.lensTitle.textContent = "整体脉络"; setModeButtons("atlas"); const topTopics = state.atlas.topics.slice(0, 7); elements.lensBody.innerHTML = `
${statGrid([ { value: state.atlas.paper_count, label: "papers" }, { value: state.atlas.topic_count, label: "topics" }, { value: state.atlas.recent_months.join(" / "), label: "recent window" }, ])}

这版 Atlas 把论文当作证据,把主题、路线和趋势当作入口。当前最强的主轴是 evaluation、tool use、RAG、reasoning、planning、safety、memory。

核心问题:Agent 如何从“能回答”走向“能长期、可观察、可治理地行动”?

主题入口

${topTopics .map( (topic) => ` `, ) .join("")}

路线

${routeButtons(state.atlas.routes)}

关键论文候选

${paperList(state.atlas.key_papers, 8)}

AI 研究编辑

${aiHtml}
`; } async function selectTopic(topic) { state.activeTopic = topic; state.selectedPaper = null; renderAtlasMap(); const focus = await api(`/api/topic?topic=${encodeURIComponent(topic)}`); state.topicFocus = focus; renderTopic(focus); } function renderTopic(focus, aiHtml = "") { setReaderMode(false); state.view = "atlas"; elements.lensEyebrow.textContent = "Topic"; elements.lensTitle.textContent = focus.label; setModeButtons("atlas"); const recentTotal = Object.values(focus.months).slice(-3).reduce((sum, value) => sum + Number(value || 0), 0); elements.lensBody.innerHTML = `
${statGrid([ { value: focus.count, label: "papers" }, { value: recentTotal, label: "recent" }, { value: focus.neighbors.length, label: "neighbors" }, ])}

${escapeHtml(focus.stance)}

${escapeHtml(focus.question)}

相邻主题

${focus.neighbors .slice(0, 8) .map( (item) => ` `, ) .join("")}

所在路线

${routeButtons(focus.routes)}

AI 研究编辑

${ focus.neighbors[0] ? `` : "" }
${aiHtml}

关键论文

${paperList(focus.key_papers, 10)}

最近新增

${paperList(focus.recent_papers, 8)}
`; } function renderRoute(routeId) { const route = state.atlas.routes.find((item) => item.id === routeId); if (!route) return; setReaderMode(false); state.selectedPaper = null; elements.lensEyebrow.textContent = "Route"; elements.lensTitle.textContent = route.title; elements.lensBody.innerHTML = `

${escapeHtml(route.summary)}

主题链路

${route.topics .map((topic) => ``) .join("")}

路线证据

${paperList( state.atlas.key_papers.filter((paper) => paper.topics.some((topic) => route.topics.includes(topic))), 12, )}
`; } async function runSearch() { const q = elements.searchInput.value.trim(); if (!q) { renderSearchHome(); return; } const scopeTopic = state.topicFocus && state.view === "atlas" ? state.activeTopic : ""; setReaderMode(false); state.view = "search"; setModeButtons("search"); state.searchFollowups = []; state.searchResult = null; const starter = { query: q, steps: [ { label: "理解问题", status: "running", detail: "准备启动 Search Agent" }, { label: "扩展关键词", status: "pending", detail: "" }, { label: "检索论文库", status: "pending", detail: "" }, { label: "统计主题/趋势", status: "pending", detail: "" }, { label: "生成搜索结论", status: "pending", detail: "" }, ], }; renderSearchRunning(starter); scrollLensIntoView(); try { const payload = await api("/api/search/agent", { method: "POST", body: JSON.stringify({ query: q, topic: scopeTopic }), }); state.searchJobId = payload.job.id; renderSearchRunning(payload.job); pollSearchJob(payload.job.id); } catch (error) { renderSearchError(error.message); } } async function pollSearchJob(jobId) { window.clearTimeout(state.searchPollTimer); try { const payload = await api(`/api/search/agent?id=${encodeURIComponent(jobId)}`); const job = payload.job; if (job.status === "done") { state.searchResult = job.result; state.searchItems = job.result.items || []; state.searchTotal = job.result.total || 0; renderSearchResult(job.result); return; } if (job.status === "error") { renderSearchError(job); return; } renderSearchRunning(job); state.searchPollTimer = window.setTimeout(() => pollSearchJob(jobId), 900); } catch (error) { renderSearchError(error.message); } } async function runSearchFollowup() { const input = $("#searchFollowupInput"); const question = input?.value.trim(); if (!question || !state.searchJobId) return; state.searchFollowups.push({ role: "user", content: question }); state.searchFollowups.push({ role: "assistant", content: "thinking..." }); renderSearchResult(state.searchResult); try { const payload = await api("/api/search/followup", { method: "POST", body: JSON.stringify({ job_id: state.searchJobId, question }), }); state.searchFollowups[state.searchFollowups.length - 1] = { role: "assistant", content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`, }; } catch (error) { state.searchFollowups[state.searchFollowups.length - 1] = { role: "assistant", content: error.message, }; } renderSearchResult(state.searchResult); } function renderReading() { setReaderMode(false); state.view = "reading"; setModeButtons("reading"); elements.lensEyebrow.textContent = "Reading"; elements.lensTitle.textContent = "阅读流"; elements.lensBody.innerHTML = `

先从路线理解问题,再进入主题,最后把论文作为证据读。当前版本把高分论文和最近新增放在这里,后续可以接入个人阅读队列。

高优先级

${paperList(state.atlas.key_papers, 12)}

最近新增

${paperList(state.atlas.recent_papers, 12)}
`; } function stripFrontmatter(markdown) { const text = String(markdown || ""); const match = text.match(/^# .+?\n\n---[\s\S]*?\n---\n\n?/); return match ? text.slice(match[0].length) : text; } function renderMarkdown(markdown) { return escapeHtml(stripFrontmatter(markdown)) .replace(/^### (.+)$/gm, "\n$1\n") .replace(/^## (.+)$/gm, "\n$1\n") .replace(/^# (.+)$/gm, "$1\n") .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, "$1 ($2)"); } async function selectPaper(id) { const payload = await api(`/api/paper?id=${encodeURIComponent(id)}`); state.selectedPaper = payload.paper; state.selectedMarkdown = payload.markdown; renderPaperReader(); } function renderPaperReader(aiHtml = "", abstractHtml = "") { const paper = state.selectedPaper; if (!paper) return; setReaderMode(true); const chatRows = state.paperChatHistory[paper.id] || []; elements.lensEyebrow.textContent = "Paper"; elements.lensTitle.textContent = "研读"; elements.lensBody.innerHTML = `

${escapeHtml(paper.title)}

${escapeHtml(paper.published_at || paper.year)} ${escapeHtml(paper.venue || paper.source)} score ${escapeHtml(paper.collection_score)}
${(paper.topics || []).map((topic) => ``).join("")}
arXiv
${abstractHtml}
${aiHtml}

继续研读

${chatRows .map( (row) => `
${row.role === "user" ? "你" : "Paper Agent"}
${escapeHtml(row.content)}
`, ) .join("")}

本地卡片

${renderMarkdown(state.selectedMarkdown)}
`; } async function loadAbstract() { if (!state.selectedPaper) return; setAiBusy(true, "摘要获取中"); try { const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedPaper.id)}`); const abstract = payload.abstract; const html = abstract ? `

arXiv Abstract

${escapeHtml(abstract.abstract)}
` : `
${escapeHtml(payload.message || "No abstract")}
`; renderPaperReader("", html); } catch (error) { renderPaperReader("", `
${escapeHtml(error.message)}
`); } finally { setAiBusy(false); } } async function runPaperChat() { if (!state.selectedPaper) return; const input = $("#paperChatInput"); const question = input?.value.trim(); if (!question) return; const paperId = state.selectedPaper.id; const history = state.paperChatHistory[paperId] || []; history.push({ role: "user", content: question }); history.push({ role: "assistant", content: "thinking..." }); state.paperChatHistory[paperId] = history; const aiHtml = $("#paperAi")?.innerHTML || ""; const abstractHtml = $("#paperAbstract")?.innerHTML || ""; renderPaperReader(aiHtml, abstractHtml); try { const payload = await api("/api/paper/chat", { method: "POST", body: JSON.stringify({ id: paperId, question, history: history.slice(0, -1), }), }); history[history.length - 1] = { role: "assistant", content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`, }; } catch (error) { history[history.length - 1] = { role: "assistant", content: error.message }; } state.paperChatHistory[paperId] = history; renderPaperReader($("#paperAi")?.innerHTML || aiHtml, $("#paperAbstract")?.innerHTML || abstractHtml); } function setAiBusy(isBusy, label = "") { state.aiBusy = isBusy; document.querySelectorAll(".tool-button, .quiet-button").forEach((button) => { button.disabled = isBusy; }); if (isBusy) { const box = $("#aiResult") || $("#paperAi"); if (box) { box.style.display = ""; box.innerHTML = `
${escapeHtml(label)}
`; } } } async function runPaperAi(mode) { if (!state.selectedPaper) return; const model = state.health?.default_models?.[mode] || (mode === "translate" ? "ChatGPT-5.6:light" : "ChatGPT-5.6:fast"); if (model.includes(":large") && !window.confirm("将调用 large 模型,占用更多 GPU。继续?")) return; setAiBusy(true, `${mode} · ${model}`); try { const payload = await api("/api/ai", { method: "POST", body: JSON.stringify({ id: state.selectedPaper.id, mode, model }), }); const cache = payload.cached ? "cached" : `${payload.duration_seconds}s`; const html = `

${escapeHtml(payload.model)} · ${escapeHtml(cache)}

${escapeHtml(payload.response)}
`; renderPaperReader(html, $("#paperAbstract")?.innerHTML || ""); } catch (error) { renderPaperReader(`
${escapeHtml(error.message)}
`, $("#paperAbstract")?.innerHTML || ""); } finally { setAiBusy(false); } } async function runAtlasAi(mode, topic = "", topicB = "") { const model = state.health?.default_models?.[mode] || "ChatGPT-5.6:fast"; if (model.includes(":large") && !window.confirm("将调用 large 模型做高维综合,占用更多 GPU。继续?")) return; setAiBusy(true, `${mode} · ${model}`); try { const payload = await api("/api/atlas/ai", { method: "POST", body: JSON.stringify({ mode, topic, topic_b: topicB, model }), }); const cache = payload.cached ? "cached" : `${payload.duration_seconds}s`; const html = `

${escapeHtml(payload.model)} · ${escapeHtml(cache)}

${escapeHtml(payload.response)}
`; if (mode === "topic" || (mode === "path" && state.topicFocus)) { renderTopic(state.topicFocus, html); } else { renderOverview(html); } } catch (error) { const html = `
${escapeHtml(error.message)}
`; if (state.topicFocus) renderTopic(state.topicFocus, html); else renderOverview(html); } finally { setAiBusy(false); } } function setModeButtons(view) { document.querySelectorAll("[data-view]").forEach((button) => { button.classList.toggle("active", button.dataset.view === view); }); } function scrollLensIntoView() { if (window.matchMedia("(max-width: 760px)").matches) { document.querySelector(".lens-panel").scrollIntoView({ behavior: "smooth", block: "start" }); } } function bindEvents() { elements.atlasSvg.addEventListener("click", async (event) => { const node = event.target.closest("[data-topic]"); if (!node) return; await selectTopic(node.dataset.topic); scrollLensIntoView(); }); elements.routeList.addEventListener("click", (event) => { const button = event.target.closest("[data-route]"); if (button) { renderRoute(button.dataset.route); scrollLensIntoView(); } }); elements.lensBody.addEventListener("click", async (event) => { const topicButton = event.target.closest("[data-topic]"); const routeButton = event.target.closest("[data-route]"); const paperButton = event.target.closest("[data-paper]"); const atlasAi = event.target.closest("[data-atlas-ai]"); const topicAi = event.target.closest("[data-topic-ai]"); const compareButton = event.target.closest("[data-compare-topic]"); const paperAction = event.target.closest("[data-paper-action]"); const readerAction = event.target.closest("[data-reader-action]"); const searchFollowup = event.target.closest("[data-search-followup]"); const paperChat = event.target.closest("[data-paper-chat]"); const searchExample = event.target.closest("[data-search-example]"); if (topicButton) { await selectTopic(topicButton.dataset.topic); scrollLensIntoView(); } else if (routeButton) { renderRoute(routeButton.dataset.route); } else if (paperButton) { await selectPaper(paperButton.dataset.paper); scrollLensIntoView(); } else if (atlasAi) { await runAtlasAi(atlasAi.dataset.atlasAi, state.activeTopic); } else if (topicAi) { await runAtlasAi(topicAi.dataset.topicAi, state.activeTopic); } else if (compareButton) { await runAtlasAi("compare", state.activeTopic, compareButton.dataset.compareTopic); } else if (paperAction) { const action = paperAction.dataset.paperAction; if (action === "abstract") await loadAbstract(); else await runPaperAi(action); } else if (readerAction) { if (state.topicFocus) renderTopic(state.topicFocus); else renderOverview(); } else if (searchFollowup) { await runSearchFollowup(); } else if (paperChat) { await runPaperChat(); } else if (searchExample) { elements.searchInput.value = searchExample.dataset.searchExample || ""; await runSearch(); } }); elements.lensBody.addEventListener("keydown", async (event) => { if (event.key === "Enter" && event.target.closest("#searchFollowupInput")) { await runSearchFollowup(); } if (event.key === "Enter" && event.target.closest("#paperChatInput")) { await runPaperChat(); } }); elements.searchBtn.addEventListener("click", runSearch); elements.searchInput.addEventListener("keydown", (event) => { if (event.key === "Enter") runSearch(); }); elements.explainAtlasBtn.addEventListener("click", () => runAtlasAi("atlas", state.activeTopic)); elements.readingPathBtn.addEventListener("click", () => runAtlasAi("path", state.activeTopic)); elements.closePaperBtn.addEventListener("click", () => { if (state.topicFocus) renderTopic(state.topicFocus); else renderOverview(); }); document.querySelectorAll("[data-view]").forEach((button) => { button.addEventListener("click", async () => { const view = button.dataset.view; if (view === "atlas") { state.activeTopic ? await selectTopic(state.activeTopic) : renderOverview(); } else if (view === "search") { await runSearch(); } else { renderReading(); } scrollLensIntoView(); }); }); window.addEventListener( "resize", debounce(() => { if (state.atlas) renderAtlasMap(); }, 150), ); } async function boot() { bindEvents(); await loadHealth(); await loadAtlas(); loadChartEngine(); } boot();