const state = {
atlas: null,
health: null,
view: "atlas",
activeTopic: "",
topicFocus: null,
selectedPaper: null,
selectedMarkdown: "",
searchItems: [],
searchTotal: 0,
aiBusy: false,
};
const $ = (selector) => document.querySelector(selector);
const elements = {
healthLine: $("#healthLine"),
searchInput: $("#searchInput"),
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) {
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 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 · ${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 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,
});
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 routeButtons(routes) {
return `
${routes
.map(
(route) => `
`,
)
.join("")}
`;
}
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 = `
${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 = "") {
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;
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) {
renderOverview();
return;
}
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search";
elements.lensTitle.textContent = q;
elements.lensBody.innerHTML = `searching
`;
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 = `
${statGrid([
{ value: payload.total, label: "matches" },
{ value: state.activeTopic ? topicLabel(state.activeTopic) : "all", label: "scope" },
{ value: "score", label: "sort" },
])}
${paperList(payload.items, payload.items.length)}
`;
}
function renderReading() {
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;
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("")}
本地卡片
${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);
}
}
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);
});
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() {
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]");
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.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 loadAtlas();
}
boot();