Evolve paper browser into knowledge atlas
This commit is contained in:
+569
-256
@@ -1,14 +1,14 @@
|
||||
const state = {
|
||||
papers: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
selectedId: null,
|
||||
atlas: null,
|
||||
health: null,
|
||||
view: "atlas",
|
||||
activeTopic: "",
|
||||
topicFocus: null,
|
||||
selectedPaper: null,
|
||||
selectedMarkdown: "",
|
||||
topic: "",
|
||||
loading: false,
|
||||
health: null,
|
||||
searchItems: [],
|
||||
searchTotal: 0,
|
||||
aiBusy: false,
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -16,28 +16,17 @@ 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"),
|
||||
searchBtn: $("#searchBtn"),
|
||||
mapTitle: $("#mapTitle"),
|
||||
atlasSvg: $("#atlasSvg"),
|
||||
routeList: $("#routeList"),
|
||||
timeline: $("#timeline"),
|
||||
explainAtlasBtn: $("#explainAtlasBtn"),
|
||||
readingPathBtn: $("#readingPathBtn"),
|
||||
lensEyebrow: $("#lensEyebrow"),
|
||||
lensTitle: $("#lensTitle"),
|
||||
lensBody: $("#lensBody"),
|
||||
closePaperBtn: $("#closePaperBtn"),
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -49,7 +38,7 @@ function escapeHtml(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function debounce(fn, delay = 250) {
|
||||
function debounce(fn, delay = 260) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
window.clearTimeout(timer);
|
||||
@@ -72,98 +61,384 @@ async function api(path, options = {}) {
|
||||
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;
|
||||
function topicLabel(topic) {
|
||||
const found = state.atlas?.topics?.find((item) => item.id === topic);
|
||||
return found?.label || topic.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
function colorFor(index) {
|
||||
const colors = ["#0d766f", "#ba5644", "#aa7419", "#527b46", "#665d9f", "#236092", "#8a5a25"];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
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 未连接";
|
||||
elements.healthLine.textContent = `${health.paper_count} papers · ${health.ollama_ok ? "Ollama online" : "Ollama offline"}`;
|
||||
} catch (error) {
|
||||
elements.healthLine.textContent = "服务状态未知";
|
||||
elements.modelStatus.textContent = error.message;
|
||||
elements.healthLine.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 loadAtlas() {
|
||||
state.atlas = await api("/api/atlas");
|
||||
elements.mapTitle.textContent = `${state.atlas.paper_count} 篇论文构成的 Agent 版图`;
|
||||
renderAtlasMap();
|
||||
renderRoutes();
|
||||
renderTimeline();
|
||||
renderOverview();
|
||||
}
|
||||
|
||||
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 renderAtlasMap() {
|
||||
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;
|
||||
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,
|
||||
});
|
||||
|
||||
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("");
|
||||
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 `
|
||||
<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>
|
||||
<line class="atlas-edge"
|
||||
x1="${sourcePoint.x}" y1="${sourcePoint.y}"
|
||||
x2="${targetPoint.x}" y2="${targetPoint.y}"
|
||||
stroke-width="${Math.max(0.25, 1.8 * edge.strength).toFixed(2)}">
|
||||
<title>${escapeHtml(topicLabel(edge.source))} ↔ ${escapeHtml(topicLabel(edge.target))}: ${edge.count}</title>
|
||||
</line>
|
||||
`;
|
||||
})
|
||||
.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 `
|
||||
<g class="atlas-node${active}" data-topic="${escapeHtml(topic.id)}" transform="translate(${p.x.toFixed(2)} ${p.y.toFixed(2)})">
|
||||
<circle class="heat-ring" r="${ring.toFixed(2)}" style="stroke:${color}; opacity:${0.2 + topic.heat * 0.65}"></circle>
|
||||
<circle r="${r.toFixed(2)}" style="stroke:${color};"></circle>
|
||||
<text y="-0.4">${escapeHtml(topic.label)}</text>
|
||||
<text class="node-count" y="3.0">${topic.count} · +${topic.recent_count}</text>
|
||||
</g>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
elements.atlasSvg.innerHTML = `${edges}${nodes}`;
|
||||
}
|
||||
|
||||
function renderRoutes() {
|
||||
elements.routeList.innerHTML = state.atlas.routes
|
||||
.map(
|
||||
(route) => `
|
||||
<button class="route-item" type="button" data-route="${escapeHtml(route.id)}">
|
||||
<strong>${escapeHtml(route.title)}</strong>
|
||||
<span>${escapeHtml(route.summary)}</span>
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.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 `
|
||||
<div class="month-cell" title="${escapeHtml(row.month)} · ${row.total}">
|
||||
<div class="month-bar" style="height:${height.toFixed(1)}px"></div>
|
||||
<div class="month-label">${escapeHtml(row.month.slice(5))}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function statGrid(items) {
|
||||
return `
|
||||
<div class="stat-grid">
|
||||
${items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="stat">
|
||||
<strong>${escapeHtml(item.value)}</strong>
|
||||
<span>${escapeHtml(item.label)}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function paperList(items, limit = items.length) {
|
||||
if (!items?.length) {
|
||||
return `<div class="empty-state">暂无证据</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="paper-list">
|
||||
${items
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
(paper) => `
|
||||
<button class="paper-item" type="button" data-paper="${escapeHtml(paper.id)}">
|
||||
<div class="paper-title">${escapeHtml(paper.title)}</div>
|
||||
<div class="paper-meta">
|
||||
<span>${escapeHtml(paper.published_at || paper.year || "unknown")}</span>
|
||||
<span>score ${escapeHtml(paper.collection_score)}</span>
|
||||
${(paper.topics || [])
|
||||
.slice(0, 4)
|
||||
.map((topic) => `<span class="mini-badge">${escapeHtml(topicLabel(topic))}</span>`)
|
||||
.join("")}
|
||||
</div>
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function routeButtons(routes) {
|
||||
return `
|
||||
<div class="chip-row">
|
||||
${routes
|
||||
.map(
|
||||
(route) => `
|
||||
<button class="pill-button" type="button" data-route="${escapeHtml(route.id)}">${escapeHtml(route.title)}</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderOverview(aiHtml = "") {
|
||||
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 = `
|
||||
<section class="section">
|
||||
${statGrid([
|
||||
{ value: state.atlas.paper_count, label: "papers" },
|
||||
{ value: state.atlas.topic_count, label: "topics" },
|
||||
{ value: state.atlas.recent_months.join(" / "), label: "recent window" },
|
||||
])}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="statement">这版 Atlas 把论文当作证据,把主题、路线和趋势当作入口。当前最强的主轴是 evaluation、tool use、RAG、reasoning、planning、safety、memory。</p>
|
||||
<p class="question">核心问题:Agent 如何从“能回答”走向“能长期、可观察、可治理地行动”?</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>主题入口</h3>
|
||||
<div class="chip-row">
|
||||
${topTopics
|
||||
.map(
|
||||
(topic) => `
|
||||
<button class="pill-button" type="button" data-topic="${escapeHtml(topic.id)}">
|
||||
${escapeHtml(topic.label)} · ${topic.count}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>路线</h3>
|
||||
${routeButtons(state.atlas.routes)}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>关键论文候选</h3>
|
||||
${paperList(state.atlas.key_papers, 8)}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>AI 研究编辑</h3>
|
||||
<div class="tool-row">
|
||||
<button class="tool-button primary" type="button" data-atlas-ai="atlas">解释地图</button>
|
||||
<button class="tool-button" type="button" data-atlas-ai="path">阅读路径</button>
|
||||
</div>
|
||||
<div id="aiResult" class="ai-box" style="${aiHtml ? "" : "display:none"}">${aiHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = "") {
|
||||
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 = `
|
||||
<section class="section">
|
||||
${statGrid([
|
||||
{ value: focus.count, label: "papers" },
|
||||
{ value: recentTotal, label: "recent" },
|
||||
{ value: focus.neighbors.length, label: "neighbors" },
|
||||
])}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="statement">${escapeHtml(focus.stance)}</p>
|
||||
<p class="question">${escapeHtml(focus.question)}</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>相邻主题</h3>
|
||||
<div class="chip-row">
|
||||
${focus.neighbors
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(item) => `
|
||||
<button class="pill-button" type="button" data-topic="${escapeHtml(item.topic)}">
|
||||
${escapeHtml(item.label)} · ${item.count}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>所在路线</h3>
|
||||
${routeButtons(focus.routes)}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>AI 研究编辑</h3>
|
||||
<div class="tool-row">
|
||||
<button class="tool-button primary" type="button" data-topic-ai="topic">生成主题导读</button>
|
||||
<button class="tool-button" type="button" data-topic-ai="path">生成阅读路径</button>
|
||||
${
|
||||
focus.neighbors[0]
|
||||
? `<button class="tool-button" type="button" data-compare-topic="${escapeHtml(focus.neighbors[0].topic)}">对比 ${escapeHtml(focus.neighbors[0].label)}</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<div id="aiResult" class="ai-box" style="${aiHtml ? "" : "display:none"}">${aiHtml}</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>关键论文</h3>
|
||||
${paperList(focus.key_papers, 10)}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>最近新增</h3>
|
||||
${paperList(focus.recent_papers, 8)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRoute(routeId) {
|
||||
const route = state.atlas.routes.find((item) => item.id === routeId);
|
||||
if (!route) return;
|
||||
state.selectedPaper = null;
|
||||
elements.lensEyebrow.textContent = "Route";
|
||||
elements.lensTitle.textContent = route.title;
|
||||
elements.lensBody.innerHTML = `
|
||||
<section class="section">
|
||||
<p class="statement">${escapeHtml(route.summary)}</p>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h3>主题链路</h3>
|
||||
<div class="chip-row">
|
||||
${route.topics
|
||||
.map((topic) => `<button class="pill-button" type="button" data-topic="${escapeHtml(topic)}">${escapeHtml(topicLabel(topic))}</button>`)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h3>路线证据</h3>
|
||||
${paperList(
|
||||
state.atlas.key_papers.filter((paper) => paper.topics.some((topic) => route.topics.includes(topic))),
|
||||
12,
|
||||
)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const q = elements.searchInput.value.trim();
|
||||
if (!q) {
|
||||
renderOverview();
|
||||
return;
|
||||
}
|
||||
state.view = "search";
|
||||
setModeButtons("search");
|
||||
elements.lensEyebrow.textContent = "Search";
|
||||
elements.lensTitle.textContent = q;
|
||||
elements.lensBody.innerHTML = `<div class="empty-state">searching</div>`;
|
||||
const params = new URLSearchParams({ q, limit: "60", sort: "score" });
|
||||
if (state.activeTopic) params.set("topic", state.activeTopic);
|
||||
const payload = await api(`/api/papers?${params.toString()}`);
|
||||
state.searchItems = payload.items;
|
||||
state.searchTotal = payload.total;
|
||||
elements.lensBody.innerHTML = `
|
||||
<section class="section">
|
||||
${statGrid([
|
||||
{ value: payload.total, label: "matches" },
|
||||
{ value: state.activeTopic ? topicLabel(state.activeTopic) : "all", label: "scope" },
|
||||
{ value: "score", label: "sort" },
|
||||
])}
|
||||
</section>
|
||||
<section class="section">
|
||||
${paperList(payload.items, payload.items.length)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderReading() {
|
||||
state.view = "reading";
|
||||
setModeButtons("reading");
|
||||
elements.lensEyebrow.textContent = "Reading";
|
||||
elements.lensTitle.textContent = "阅读流";
|
||||
elements.lensBody.innerHTML = `
|
||||
<section class="section">
|
||||
<p class="statement">先从路线理解问题,再进入主题,最后把论文作为证据读。当前版本把高分论文和最近新增放在这里,后续可以接入个人阅读队列。</p>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h3>高优先级</h3>
|
||||
${paperList(state.atlas.key_papers, 12)}
|
||||
</section>
|
||||
<section class="section">
|
||||
<h3>最近新增</h3>
|
||||
${paperList(state.atlas.recent_papers, 12)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function stripFrontmatter(markdown) {
|
||||
@@ -172,196 +447,234 @@ function stripFrontmatter(markdown) {
|
||||
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();
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
elements.lensEyebrow.textContent = "Paper";
|
||||
elements.lensTitle.textContent = "研读";
|
||||
elements.lensBody.innerHTML = `
|
||||
<section class="section">
|
||||
<h3 class="reader-title">${escapeHtml(paper.title)}</h3>
|
||||
<div class="paper-meta">
|
||||
<span>${escapeHtml(paper.published_at || paper.year)}</span>
|
||||
<span>${escapeHtml(paper.venue || paper.source)}</span>
|
||||
<span>score ${escapeHtml(paper.collection_score)}</span>
|
||||
</div>
|
||||
<div class="chip-row" style="margin-top:10px">
|
||||
${(paper.topics || []).map((topic) => `<button class="pill-button" type="button" data-topic="${escapeHtml(topic)}">${escapeHtml(topicLabel(topic))}</button>`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="tool-row">
|
||||
<a class="paper-link" href="${escapeHtml(paper.url)}" target="_blank" rel="noreferrer">arXiv</a>
|
||||
<button class="tool-button" type="button" data-paper-action="abstract">摘要</button>
|
||||
<button class="tool-button primary" type="button" data-paper-action="summary">中文摘要</button>
|
||||
<button class="tool-button" type="button" data-paper-action="translate">翻译</button>
|
||||
<button class="tool-button" type="button" data-paper-action="deep">深度研读</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="paperAbstract" style="${abstractHtml ? "" : "display:none"}">${abstractHtml}</section>
|
||||
<section class="section" id="paperAi" style="${aiHtml ? "" : "display:none"}">${aiHtml}</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>本地卡片</h3>
|
||||
<div class="reader-box markdown-preview">${renderMarkdown(state.selectedMarkdown)}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAbstract() {
|
||||
if (!state.selectedId) return;
|
||||
setBusy(true, "获取 arXiv 摘要");
|
||||
if (!state.selectedPaper) return;
|
||||
setAiBusy(true, "摘要获取中");
|
||||
try {
|
||||
const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedId)}`);
|
||||
const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedPaper.id)}`);
|
||||
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");
|
||||
const html = abstract
|
||||
? `<h3>arXiv Abstract</h3><div class="abstract-text">${escapeHtml(abstract.abstract)}</div>`
|
||||
: `<div class="empty-state">${escapeHtml(payload.message || "No abstract")}</div>`;
|
||||
renderPaperReader("", html);
|
||||
} catch (error) {
|
||||
elements.abstractView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
|
||||
renderPaperReader("", `<div class="empty-state">${escapeHtml(error.message)}</div>`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setAiBusy(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 : "";
|
||||
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 = `<div class="empty-state">${escapeHtml(label)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAi(mode) {
|
||||
if (!state.selectedId) return;
|
||||
if (mode === "deep") {
|
||||
const ok = window.confirm("深度研读会调用 large 模型,占用更多 GPU。继续?");
|
||||
if (!ok) return;
|
||||
}
|
||||
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");
|
||||
const label = mode === "translate" ? "翻译中" : mode === "deep" ? "深度研读中" : "摘要生成中";
|
||||
setBusy(true, `${label} · ${model}`);
|
||||
activateTab("ai");
|
||||
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(label)}</p>`;
|
||||
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.selectedId, mode, model }),
|
||||
body: JSON.stringify({ id: state.selectedPaper.id, 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>
|
||||
`;
|
||||
const html = `<h3>${escapeHtml(payload.model)} · ${escapeHtml(cache)}</h3><div class="ai-output">${escapeHtml(payload.response)}</div>`;
|
||||
renderPaperReader(html, $("#paperAbstract")?.innerHTML || "");
|
||||
} catch (error) {
|
||||
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
|
||||
renderPaperReader(`<div class="empty-state">${escapeHtml(error.message)}</div>`, $("#paperAbstract")?.innerHTML || "");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setAiBusy(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`);
|
||||
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 = `<h3>${escapeHtml(payload.model)} · ${escapeHtml(cache)}</h3><div class="ai-output">${escapeHtml(payload.response)}</div>`;
|
||||
if (mode === "topic" || (mode === "path" && state.topicFocus)) {
|
||||
renderTopic(state.topicFocus, html);
|
||||
} else {
|
||||
renderOverview(html);
|
||||
}
|
||||
} catch (error) {
|
||||
const html = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
|
||||
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);
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
debounce(() => {
|
||||
if (state.atlas) renderAtlasMap();
|
||||
}, 150),
|
||||
);
|
||||
}
|
||||
|
||||
function scrollLensIntoView() {
|
||||
if (window.matchMedia("(max-width: 760px)").matches) {
|
||||
document.querySelector(".lens-panel").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
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.atlasSvg.addEventListener("click", async (event) => {
|
||||
const node = event.target.closest("[data-topic]");
|
||||
if (!node) return;
|
||||
await selectTopic(node.dataset.topic);
|
||||
scrollLensIntoView();
|
||||
});
|
||||
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.routeList.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-route]");
|
||||
if (button) {
|
||||
renderRoute(button.dataset.route);
|
||||
scrollLensIntoView();
|
||||
}
|
||||
});
|
||||
elements.resultsList.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-id]");
|
||||
if (button) selectPaper(button.dataset.id);
|
||||
|
||||
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]");
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
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));
|
||||
|
||||
elements.searchBtn.addEventListener("click", runSearch);
|
||||
elements.searchInput.addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
if (elements.searchInput.value.trim()) 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
bindEvents();
|
||||
await loadHealth();
|
||||
await loadTopics();
|
||||
await loadPapers({ reset: true });
|
||||
await loadAtlas();
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in New Issue
Block a user