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 = `
${escapeHtml(error.message)}
`; } finally { state.loading = false; } } function renderResults() { elements.resultCount.textContent = String(state.total); elements.loadMoreBtn.style.display = state.papers.length < state.total ? "block" : "none"; if (!state.papers.length) { elements.resultsList.innerHTML = `
没有匹配结果
`; return; } elements.resultsList.innerHTML = state.papers .map((paper) => { const active = paper.id === state.selectedId ? " active" : ""; const topics = paper.topics .slice(0, 4) .map((topic) => `${escapeHtml(topic)}`) .join(""); return ` `; }) .join(""); } 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 inlineMarkdown(text) { let value = escapeHtml(text); value = value.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1'); value = value.replace(/`([^`]+)`/g, "$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.slice(4))}

`); } else if (line.startsWith("## ")) { closeList(); output.push(`

${inlineMarkdown(line.slice(3))}

`); } else if (line.startsWith("# ")) { closeList(); output.push(`

${inlineMarkdown(line.slice(2))}

`); } else if (line.startsWith("- ")) { if (!inList) { output.push("