const state = { papers: [], total: 0, offset: 0, limit: 50, selectedId: null, selectedPaper: null, selectedMarkdown: "", topic: "", loading: false, health: null, }; const $ = (selector) => document.querySelector(selector); const elements = { healthLine: $("#healthLine"), searchInput: $("#searchInput"), yearSelect: $("#yearSelect"), statusSelect: $("#statusSelect"), sortSelect: $("#sortSelect"), clearTopicBtn: $("#clearTopicBtn"), topicChips: $("#topicChips"), resultTitle: $("#resultTitle"), resultCount: $("#resultCount"), resultsList: $("#resultsList"), loadMoreBtn: $("#loadMoreBtn"), detailMetaLine: $("#detailMetaLine"), detailTitle: $("#detailTitle"), detailTags: $("#detailTags"), arxivLink: $("#arxivLink"), paperPreview: $("#paperPreview"), abstractBtn: $("#abstractBtn"), summaryBtn: $("#summaryBtn"), translateBtn: $("#translateBtn"), deepBtn: $("#deepBtn"), modelStatus: $("#modelStatus"), aiStatus: $("#aiStatus"), abstractView: $("#abstractView"), aiView: $("#aiView"), }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function debounce(fn, delay = 250) { 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 queryParams(extra = {}) { const params = new URLSearchParams(); const q = elements.searchInput.value.trim(); if (q) params.set("q", q); if (state.topic) params.set("topic", state.topic); if (elements.yearSelect.value) params.set("year", elements.yearSelect.value); if (elements.statusSelect.value) params.set("status", elements.statusSelect.value); if (elements.sortSelect.value) params.set("sort", elements.sortSelect.value); params.set("limit", String(state.limit)); params.set("offset", String(state.offset)); Object.entries(extra).forEach(([key, value]) => params.set(key, String(value))); return params; } async function loadHealth() { try { const health = await api("/api/health"); state.health = health; elements.healthLine.textContent = `${health.paper_count} papers`; elements.modelStatus.textContent = health.ollama_ok ? `Ollama 已连接 · summary=${health.default_models.summary}` : "Ollama 未连接"; } catch (error) { elements.healthLine.textContent = "服务状态未知"; elements.modelStatus.textContent = error.message; } } async function loadTopics() { const payload = await api("/api/topics"); elements.topicChips.innerHTML = payload.topics .slice(0, 24) .map((item) => { const active = item.topic === state.topic ? " active" : ""; return ``; }) .join(""); } async function loadPapers({ reset = false } = {}) { if (state.loading) return; state.loading = true; if (reset) { state.offset = 0; state.papers = []; } elements.resultTitle.textContent = state.topic || "论文库"; elements.resultCount.textContent = "..."; try { const payload = await api(`/api/papers?${queryParams()}`); state.total = payload.total; state.papers = reset ? payload.items : [...state.papers, ...payload.items]; state.offset = state.papers.length; renderResults(); if (!state.selectedId && state.papers.length) { await selectPaper(state.papers[0].id); } } catch (error) { elements.resultsList.innerHTML = `
$1");
return value;
}
function renderMarkdown(markdown) {
const lines = stripFrontmatter(markdown).split(/\r?\n/);
const output = [];
let inList = false;
const closeList = () => {
if (inList) {
output.push("");
inList = false;
}
};
for (const raw of lines) {
const line = raw.trimEnd();
if (!line.trim()) {
closeList();
continue;
}
if (line.startsWith("### ")) {
closeList();
output.push(`${inlineMarkdown(line)}
`); } } closeList(); return output.join(""); } function renderDetail(paper, markdown) { state.selectedPaper = paper; state.selectedMarkdown = markdown; elements.detailMetaLine.textContent = `${paper.year || "unknown"} · ${paper.venue || paper.source || "paper"} · ${paper.status}`; elements.detailTitle.textContent = paper.title; elements.arxivLink.href = paper.url || "#"; elements.arxivLink.style.visibility = paper.url ? "visible" : "hidden"; elements.detailTags.innerHTML = [ `score ${escapeHtml(paper.collection_score)}`, `${escapeHtml(paper.relevance || "relevance")}`, ...paper.topics.map((topic) => `${escapeHtml(topic)}`), ].join(""); elements.paperPreview.innerHTML = renderMarkdown(markdown); elements.abstractView.innerHTML = `未加载摘要
`; elements.aiView.innerHTML = `未生成 AI 结果
`; elements.aiStatus.textContent = ""; renderResults(); } async function selectPaper(id) { state.selectedId = id; renderResults(); try { const payload = await api(`/api/paper?id=${encodeURIComponent(id)}`); renderDetail(payload.paper, payload.markdown); } catch (error) { elements.detailTitle.textContent = error.message; } } async function loadAbstract() { if (!state.selectedId) return; setBusy(true, "获取 arXiv 摘要"); try { const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedId)}`); const abstract = payload.abstract; if (!abstract) { elements.abstractView.innerHTML = `${escapeHtml(payload.message || "没有摘要")}
`; } else { elements.abstractView.innerHTML = `${escapeHtml(abstract.abstract)}
`; } activateTab("abstract"); } catch (error) { elements.abstractView.innerHTML = `${escapeHtml(error.message)}
`; } finally { setBusy(false); } } function setBusy(isBusy, label = "") { elements.abstractBtn.disabled = isBusy; elements.summaryBtn.disabled = isBusy; elements.translateBtn.disabled = isBusy; elements.deepBtn.disabled = isBusy; elements.aiStatus.textContent = isBusy ? label : ""; } async function runAi(mode) { if (!state.selectedId) return; if (mode === "deep") { const ok = window.confirm("深度研读会调用 large 模型,占用更多 GPU。继续?"); if (!ok) return; } const model = state.health?.default_models?.[mode] || (mode === "translate" ? "ChatGPT-5.6:light" : "ChatGPT-5.6:fast"); const label = mode === "translate" ? "翻译中" : mode === "deep" ? "深度研读中" : "摘要生成中"; setBusy(true, `${label} · ${model}`); activateTab("ai"); elements.aiView.innerHTML = `${escapeHtml(label)}
`; try { const payload = await api("/api/ai", { method: "POST", body: JSON.stringify({ id: state.selectedId, mode, model }), }); const cache = payload.cached ? "cached" : `${payload.duration_seconds}s`; elements.aiView.innerHTML = `${escapeHtml(error.message)}
`; } finally { setBusy(false); } } function activateTab(name) { document.querySelectorAll(".tab").forEach((tab) => { tab.classList.toggle("active", tab.dataset.tab === name); }); document.querySelectorAll(".tab-view").forEach((view) => { view.classList.toggle("active", view.id === `${name}View`); }); } function bindEvents() { const reload = debounce(() => loadPapers({ reset: true }), 260); elements.searchInput.addEventListener("input", reload); elements.yearSelect.addEventListener("change", reload); elements.statusSelect.addEventListener("change", reload); elements.sortSelect.addEventListener("change", reload); elements.clearTopicBtn.addEventListener("click", async () => { state.topic = ""; await loadTopics(); await loadPapers({ reset: true }); }); elements.topicChips.addEventListener("click", async (event) => { const button = event.target.closest("[data-topic]"); if (!button) return; state.topic = button.dataset.topic === state.topic ? "" : button.dataset.topic; await loadTopics(); await loadPapers({ reset: true }); }); elements.resultsList.addEventListener("click", (event) => { const button = event.target.closest("[data-id]"); if (button) selectPaper(button.dataset.id); }); elements.loadMoreBtn.addEventListener("click", () => loadPapers()); elements.abstractBtn.addEventListener("click", loadAbstract); elements.summaryBtn.addEventListener("click", () => runAi("summary")); elements.translateBtn.addEventListener("click", () => runAi("translate")); elements.deepBtn.addEventListener("click", () => runAi("deep")); document.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", () => activateTab(tab.dataset.tab)); }); } async function boot() { bindEvents(); await loadHealth(); await loadTopics(); await loadPapers({ reset: true }); } boot();