368 lines
12 KiB
JavaScript
368 lines
12 KiB
JavaScript
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 `<button class="topic-chip${active}" type="button" data-topic="${escapeHtml(item.topic)}">${escapeHtml(item.topic)} ${item.count}</button>`;
|
|
})
|
|
.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 = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
|
|
} 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 = `<div class="empty-state">没有匹配结果</div>`;
|
|
return;
|
|
}
|
|
elements.resultsList.innerHTML = state.papers
|
|
.map((paper) => {
|
|
const active = paper.id === state.selectedId ? " active" : "";
|
|
const topics = paper.topics
|
|
.slice(0, 4)
|
|
.map((topic) => `<span class="mini-badge">${escapeHtml(topic)}</span>`)
|
|
.join("");
|
|
return `
|
|
<button class="paper-row${active}" type="button" data-id="${paper.id}">
|
|
<div class="paper-title">${escapeHtml(paper.title)}</div>
|
|
<div class="paper-meta">
|
|
<span>${escapeHtml(paper.year || "unknown")}</span>
|
|
<span>${escapeHtml(paper.venue || paper.source || "paper")}</span>
|
|
<span>score ${escapeHtml(paper.collection_score)}</span>
|
|
<span>${escapeHtml(paper.status)}</span>
|
|
</div>
|
|
<div class="paper-topics">${topics}</div>
|
|
</button>
|
|
`;
|
|
})
|
|
.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, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
|
value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
return value;
|
|
}
|
|
|
|
function renderMarkdown(markdown) {
|
|
const lines = stripFrontmatter(markdown).split(/\r?\n/);
|
|
const output = [];
|
|
let inList = false;
|
|
const closeList = () => {
|
|
if (inList) {
|
|
output.push("</ul>");
|
|
inList = false;
|
|
}
|
|
};
|
|
|
|
for (const raw of lines) {
|
|
const line = raw.trimEnd();
|
|
if (!line.trim()) {
|
|
closeList();
|
|
continue;
|
|
}
|
|
if (line.startsWith("### ")) {
|
|
closeList();
|
|
output.push(`<h3>${inlineMarkdown(line.slice(4))}</h3>`);
|
|
} else if (line.startsWith("## ")) {
|
|
closeList();
|
|
output.push(`<h2>${inlineMarkdown(line.slice(3))}</h2>`);
|
|
} else if (line.startsWith("# ")) {
|
|
closeList();
|
|
output.push(`<h1>${inlineMarkdown(line.slice(2))}</h1>`);
|
|
} else if (line.startsWith("- ")) {
|
|
if (!inList) {
|
|
output.push("<ul>");
|
|
inList = true;
|
|
}
|
|
output.push(`<li>${inlineMarkdown(line.slice(2))}</li>`);
|
|
} else {
|
|
closeList();
|
|
output.push(`<p>${inlineMarkdown(line)}</p>`);
|
|
}
|
|
}
|
|
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 = [
|
|
`<span class="tag score">score ${escapeHtml(paper.collection_score)}</span>`,
|
|
`<span class="tag">${escapeHtml(paper.relevance || "relevance")}</span>`,
|
|
...paper.topics.map((topic) => `<span class="tag">${escapeHtml(topic)}</span>`),
|
|
].join("");
|
|
elements.paperPreview.innerHTML = renderMarkdown(markdown);
|
|
elements.abstractView.innerHTML = `<p class="empty-state">未加载摘要</p>`;
|
|
elements.aiView.innerHTML = `<p class="empty-state">未生成 AI 结果</p>`;
|
|
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 = `<p class="empty-state">${escapeHtml(payload.message || "没有摘要")}</p>`;
|
|
} else {
|
|
elements.abstractView.innerHTML = `
|
|
<h3>${escapeHtml(abstract.title)}</h3>
|
|
<p>${escapeHtml(abstract.abstract)}</p>
|
|
<div class="paper-meta">
|
|
<span>${escapeHtml(abstract.published)}</span>
|
|
<span>${escapeHtml((abstract.categories || []).join(", "))}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
activateTab("abstract");
|
|
} catch (error) {
|
|
elements.abstractView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
|
|
} 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 = `<p class="empty-state">${escapeHtml(label)}</p>`;
|
|
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 = `
|
|
<div class="paper-meta">
|
|
<span>${escapeHtml(payload.model)}</span>
|
|
<span>${escapeHtml(payload.mode)}</span>
|
|
<span>${escapeHtml(cache)}</span>
|
|
</div>
|
|
<div class="ai-output">${escapeHtml(payload.response)}</div>
|
|
`;
|
|
} catch (error) {
|
|
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
|
|
} 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();
|