Files
2026-07-12 14:54:03 +08:00

1283 lines
42 KiB
JavaScript

const state = {
atlas: null,
health: null,
view: "atlas",
activeTopic: "",
topicFocus: null,
selectedPaper: null,
selectedMarkdown: "",
searchItems: [],
searchTotal: 0,
searchJobId: "",
searchResult: null,
searchPollTimer: null,
searchFollowups: [],
aiBusy: false,
atlasChart: null,
paperChatHistory: {},
};
const $ = (selector) => document.querySelector(selector);
const elements = {
app: $(".atlas-app"),
healthLine: $("#healthLine"),
searchInput: $("#searchInput"),
searchBtn: $("#searchBtn"),
mapTitle: $("#mapTitle"),
atlasChart: $("#atlasChart"),
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 loadChartEngine() {
if (window.echarts) {
if (state.atlas) renderAtlasMap();
return;
}
if (document.querySelector("script[data-chart-engine='echarts']")) return;
const script = document.createElement("script");
script.src = "/static/vendor/echarts.min.js?v=6.1.0";
script.async = true;
script.dataset.chartEngine = "echarts";
script.onload = () => {
if (state.atlas) renderAtlasMap();
};
script.onerror = () => {
console.warn("ECharts atlas enhancement unavailable; SVG fallback remains active.");
};
document.head.appendChild(script);
}
function topicLabel(topic) {
const found = state.atlas?.topics?.find((item) => item.id === topic);
return found?.label || topic.replaceAll("-", " ");
}
function colorFor(index) {
const colors = ["#0b7a75", "#c95745", "#b37a22", "#4d7f52", "#5f63b5", "#22699a", "#9a5d2f", "#7b4aa0"];
return colors[index % colors.length];
}
function hexToRgb(hex) {
const clean = String(hex || "").replace("#", "");
const value = Number.parseInt(clean.length === 3 ? clean.replace(/(.)/g, "$1$1") : clean, 16);
return {
r: (value >> 16) & 255,
g: (value >> 8) & 255,
b: value & 255,
};
}
function hexToRgba(hex, alpha) {
const { r, g, b } = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function mixColor(hex, target, amount) {
const base = hexToRgb(hex);
const next = hexToRgb(target);
const mix = (left, right) => Math.round(left + (right - left) * amount);
return `rgb(${mix(base.r, next.r)}, ${mix(base.g, next.g)}, ${mix(base.b, next.b)})`;
}
function chartGradient(color) {
if (!window.echarts?.graphic?.RadialGradient) return color;
return new window.echarts.graphic.RadialGradient(0.34, 0.28, 0.86, [
{ offset: 0, color: "#ffffff" },
{ offset: 0.58, color: mixColor(color, "#ffffff", 0.64) },
{ offset: 1, color },
]);
}
function setReaderMode(enabled) {
const changed = elements.app.classList.toggle("reader-mode", Boolean(enabled));
if (changed && state.atlas) {
window.requestAnimationFrame(() => renderAtlasMap());
}
}
async function loadHealth() {
try {
const health = await api("/api/health");
state.health = health;
const ollamaState = health.ollama_ready
? "Ollama ready"
: health.ollama_ok
? "Ollama model mismatch"
: "Ollama offline";
elements.healthLine.textContent = `${health.paper_count} papers · ${ollamaState}`;
} 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 atlasTopics() {
return state.atlas.topics.filter((topic) => topic.count >= 30).slice(0, 13);
}
function setAtlasRenderer(useChart) {
if (elements.atlasChart) elements.atlasChart.hidden = !useChart;
if (elements.atlasSvg) elements.atlasSvg.hidden = useChart;
}
function renderAtlasMap() {
if (window.echarts && elements.atlasChart) {
try {
renderEchartsAtlasMap();
return;
} catch (error) {
console.warn("ECharts atlas failed, falling back to SVG", error);
}
}
renderSvgAtlasMap();
}
function renderEchartsAtlasMap() {
const isMobile = window.matchMedia("(max-width: 760px)").matches;
const readerMode = elements.app.classList.contains("reader-mode");
let topics = atlasTopics();
if (readerMode && !isMobile) {
topics = topics.slice(0, 10);
}
const topicMap = new Map(topics.map((topic, index) => [topic.id, { ...topic, index }]));
const maxCount = Math.max(...topics.map((topic) => topic.count), 1);
setAtlasRenderer(true);
if (!state.atlasChart) {
state.atlasChart = window.echarts.init(elements.atlasChart, null, {
renderer: "canvas",
useDirtyRect: true,
});
}
const width = elements.atlasChart.clientWidth || (isMobile ? 360 : 780);
const height = elements.atlasChart.clientHeight || (isMobile ? 360 : 460);
const isCompact = isMobile || width < 690 || elements.app.classList.contains("reader-mode");
const padding = isCompact
? { left: 46, right: 86, top: 42, bottom: 54 }
: { left: 92, right: 92, top: 64, bottom: 74 };
const layoutX = (topic) => (isCompact ? topic.x - Math.max(0, topic.x - 64) * 0.65 : topic.x);
const xs = topics.map(layoutX);
const ys = topics.map((topic) => topic.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const xSpan = Math.max(maxX - minX, 1);
const ySpan = Math.max(maxY - minY, 1);
const plotWidth = Math.max(width - padding.left - padding.right, 260);
const plotHeight = Math.max(height - padding.top - padding.bottom, 230);
const sizeBase = isCompact ? (isMobile ? 24 : 36) : 48;
const sizeRange = isCompact ? (isMobile ? 30 : 42) : 62;
const nodes = topics.map((topic, index) => {
const color = colorFor(index);
const active = topic.id === state.activeTopic;
const symbolSize = sizeBase + sizeRange * Math.sqrt(topic.count / maxCount);
return {
id: topic.id,
name: topic.label,
value: topic.count,
x: padding.left + ((layoutX(topic) - minX) / xSpan) * plotWidth,
y: padding.top + ((topic.y - minY) / ySpan) * plotHeight,
symbolSize,
count: topic.count,
recent_count: topic.recent_count,
heat: topic.heat,
question: topic.question,
stance: topic.stance,
itemStyle: {
color: chartGradient(color),
borderColor: active ? "#17201c" : mixColor(color, "#101614", 0.08),
borderWidth: active ? 3 : 1.4,
shadowBlur: active ? 30 : 18,
shadowColor: hexToRgba(color, active ? 0.34 : 0.2),
},
label: {
color: active ? "#0f1714" : "#18211d",
},
};
});
const links = state.atlas.edges
.filter((edge) => topicMap.has(edge.source) && topicMap.has(edge.target))
.map((edge) => ({
source: edge.source,
target: edge.target,
value: edge.count,
lineStyle: {
width: Math.max(1, 1.2 + edge.strength * 5.4),
opacity: Math.min(0.58, 0.13 + edge.strength * 0.5),
color: hexToRgba(colorFor(topicMap.get(edge.source).index), 0.48),
curveness: 0.08,
},
}));
state.atlasChart.setOption(
{
backgroundColor: "transparent",
animationDuration: 850,
animationDurationUpdate: 450,
animationEasing: "cubicOut",
tooltip: {
trigger: "item",
confine: true,
appendToBody: true,
backgroundColor: "rgba(252, 253, 249, 0.97)",
borderColor: "rgba(31, 37, 33, 0.18)",
borderWidth: 1,
padding: [10, 12],
textStyle: {
color: "#151b18",
fontSize: 12,
lineHeight: 18,
},
extraCssText: "box-shadow:0 16px 42px rgba(24,31,28,.16);border-radius:8px;",
formatter(params) {
if (params.dataType === "edge") {
return `
<strong>${escapeHtml(topicLabel(params.data.source))}${escapeHtml(topicLabel(params.data.target))}</strong><br>
共现证据:${escapeHtml(params.data.value)}
`;
}
const item = params.data;
return `
<strong>${escapeHtml(item.name)}</strong><br>
${escapeHtml(item.count)} papers · recent +${escapeHtml(item.recent_count)}<br>
<span style="color:#68706b">${escapeHtml(item.question)}</span>
`;
},
},
series: [
{
type: "graph",
layout: "none",
data: nodes,
links,
roam: true,
draggable: true,
center: isCompact ? (isMobile ? ["80%", "50%"] : ["120%", "50%"]) : ["50%", "50%"],
zoom: isCompact ? (isMobile ? 0.64 : 0.72) : 1,
nodeScaleRatio: isCompact ? 0.24 : 0.5,
cursor: "pointer",
selectedMode: "single",
scaleLimit: { min: 0.45, max: 3.2 },
label: {
show: true,
position: "inside",
overflow: "truncate",
formatter(params) {
return `{name|${params.data.name}}\n{meta|${params.data.count} · +${params.data.recent_count}}`;
},
rich: {
name: {
fontSize: isCompact ? (isMobile ? 9 : 10) : 12,
fontWeight: 850,
lineHeight: isCompact ? (isMobile ? 12 : 13) : 16,
},
meta: {
color: "#59625d",
fontSize: isCompact ? (isMobile ? 8 : 9) : 10,
fontWeight: 700,
lineHeight: isCompact ? (isMobile ? 10 : 11) : 13,
},
},
},
lineStyle: {
color: "source",
cap: "round",
},
emphasis: {
focus: "adjacency",
scale: 1.08,
itemStyle: {
shadowBlur: 36,
},
lineStyle: {
opacity: 0.82,
width: 4,
},
label: {
color: "#0b1411",
},
},
blur: {
itemStyle: {
opacity: 0.28,
},
lineStyle: {
opacity: 0.06,
},
label: {
opacity: 0.35,
},
},
select: {
itemStyle: {
borderColor: "#111714",
borderWidth: 3,
shadowBlur: 34,
},
},
},
],
},
true,
);
state.atlasChart.off("click");
state.atlasChart.on("click", async (params) => {
if (params.dataType !== "node" || !params.data?.id) return;
await selectTopic(params.data.id);
scrollLensIntoView();
});
}
function renderSvgAtlasMap() {
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;
setAtlasRenderer(false);
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 `
<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 compactPaperList(items, limit = items.length) {
return paperList(items, limit);
}
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 searchActivity(steps = []) {
const current =
steps.find((step) => step.status === "running") ||
[...steps].reverse().find((step) => step.status === "done") ||
steps[0] ||
{};
const doneCount = steps.filter((step) => step.status === "done").length;
const currentIndex = Math.min(doneCount + (current.status === "running" ? 1 : 0), steps.length || 1);
const statusLabel = current.status === "running" ? "working" : current.status || "pending";
return `
<div class="agent-activity ${escapeHtml(current.status || "pending")}">
<span class="activity-dot"></span>
<div class="activity-copy">
<div class="activity-title">
<span>Search Agent</span>
<strong>${escapeHtml(current.label || "准备搜索")}</strong>
</div>
<div class="activity-detail">${escapeHtml(current.detail || "正在准备检索上下文")}</div>
</div>
<div class="activity-meta">${escapeHtml(statusLabel)} · ${currentIndex}/${steps.length || 1}</div>
</div>
`;
}
function searchAggregationView(aggregation) {
if (!aggregation) return "";
const topTopics = aggregation.topics || [];
const recentMonths = aggregation.recent_months || [];
return `
<section class="section">
${statGrid([
{ value: aggregation.total || 0, label: "matches" },
{ value: topTopics.length, label: "topics" },
{ value: aggregation.date_range?.end || "unknown", label: "latest" },
])}
<div class="mini-chart-row">
${recentMonths
.map((row) => `<span class="month-chip">${escapeHtml(row.month)} · ${escapeHtml(row.count)}</span>`)
.join("")}
</div>
<div class="chip-row search-topic-row">
${topTopics
.slice(0, 8)
.map(
(topic) => `
<button class="pill-button" type="button" data-topic="${escapeHtml(topic.topic)}">
${escapeHtml(topic.label)} · ${topic.count}
</button>
`,
)
.join("")}
</div>
</section>
`;
}
function searchPlanView(plan) {
if (!plan) return "";
const keywords = [...(plan.keywords_en || []), ...(plan.keywords_zh || [])].slice(0, 14);
return `
<section class="section compact-section">
<div class="eyebrow">Search Plan</div>
<p class="statement">${escapeHtml(plan.interpreted_query || "")}</p>
<p class="microcopy">${escapeHtml(plan.search_rationale || "")}</p>
<div class="chip-row">
${keywords.map((keyword) => `<span class="mini-badge">${escapeHtml(keyword)}</span>`).join("")}
</div>
</section>
`;
}
function renderSearchRunning(job) {
setReaderMode(false);
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search Agent";
elements.lensTitle.textContent = job?.query || elements.searchInput.value.trim() || "搜索";
elements.lensBody.innerHTML = `
<section class="section">
${searchActivity(job?.steps || [])}
</section>
`;
}
function renderSearchHome() {
setReaderMode(false);
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search Agent";
elements.lensTitle.textContent = "智能搜索";
elements.lensBody.innerHTML = `
<section class="section">
<p class="statement">输入主题、论文线索或研究问题。Search Agent 会理解意图、扩展中英文关键词、检索本地论文库,并生成一份简短结论。</p>
</section>
<section class="section">
<h3>可以这样搜</h3>
<div class="chip-row">
<button class="pill-button" type="button" data-search-example="强化学习">强化学习</button>
<button class="pill-button" type="button" data-search-example="最近的 agent evaluation benchmark">最近的 agent evaluation benchmark</button>
<button class="pill-button" type="button" data-search-example="长期记忆和 RAG 有什么关系">长期记忆和 RAG 有什么关系</button>
</div>
</section>
`;
}
function renderSearchError(jobOrError) {
const message = typeof jobOrError === "string" ? jobOrError : jobOrError?.error || "search failed";
elements.lensBody.innerHTML = `
<section class="section">
<div class="empty-state">${escapeHtml(message)}</div>
</section>
`;
}
function followupList() {
const rows = state.searchFollowups || [];
if (!rows.length) return "";
return `
<div class="dialogue-list">
${rows
.map(
(row) => `
<div class="dialogue-row ${escapeHtml(row.role)}">
<strong>${row.role === "user" ? "你" : "Search Agent"}</strong>
<div>${escapeHtml(row.content)}</div>
</div>
`,
)
.join("")}
</div>
`;
}
function renderSearchResult(result) {
setReaderMode(false);
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search Agent";
elements.lensTitle.textContent = result.query;
const summary = result.summary || {};
elements.lensBody.innerHTML = `
<section class="section">
<div class="ai-box search-brief">
<h3>${escapeHtml(summary.model || "Search Agent")}</h3>
<div class="ai-output">${escapeHtml(summary.text || "")}</div>
</div>
</section>
${searchAggregationView(result.aggregation)}
${searchPlanView(result.plan)}
<section class="section">
<h3>继续追问</h3>
${followupList()}
<div class="followup-row">
<input id="searchFollowupInput" type="text" placeholder="基于当前结果继续问,比如:只看最近的 / 给阅读顺序" />
<button class="tool-button primary" type="button" data-search-followup>Ask</button>
</div>
</section>
<section class="section">
<h3>相关论文</h3>
${compactPaperList(result.items || [], 60)}
</section>
`;
}
function renderOverview(aiHtml = "") {
setReaderMode(false);
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 = "") {
setReaderMode(false);
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;
setReaderMode(false);
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) {
renderSearchHome();
return;
}
const scopeTopic = state.topicFocus && state.view === "atlas" ? state.activeTopic : "";
setReaderMode(false);
state.view = "search";
setModeButtons("search");
state.searchFollowups = [];
state.searchResult = null;
const starter = {
query: q,
steps: [
{ label: "理解问题", status: "running", detail: "准备启动 Search Agent" },
{ label: "扩展关键词", status: "pending", detail: "" },
{ label: "检索论文库", status: "pending", detail: "" },
{ label: "统计主题/趋势", status: "pending", detail: "" },
{ label: "生成搜索结论", status: "pending", detail: "" },
],
};
renderSearchRunning(starter);
scrollLensIntoView();
try {
const payload = await api("/api/search/agent", {
method: "POST",
body: JSON.stringify({ query: q, topic: scopeTopic }),
});
state.searchJobId = payload.job.id;
renderSearchRunning(payload.job);
pollSearchJob(payload.job.id);
} catch (error) {
renderSearchError(error.message);
}
}
async function pollSearchJob(jobId) {
window.clearTimeout(state.searchPollTimer);
try {
const payload = await api(`/api/search/agent?id=${encodeURIComponent(jobId)}`);
const job = payload.job;
if (job.status === "done") {
state.searchResult = job.result;
state.searchItems = job.result.items || [];
state.searchTotal = job.result.total || 0;
renderSearchResult(job.result);
return;
}
if (job.status === "error") {
renderSearchError(job);
return;
}
renderSearchRunning(job);
state.searchPollTimer = window.setTimeout(() => pollSearchJob(jobId), 900);
} catch (error) {
renderSearchError(error.message);
}
}
async function runSearchFollowup() {
const input = $("#searchFollowupInput");
const question = input?.value.trim();
if (!question || !state.searchJobId) return;
state.searchFollowups.push({ role: "user", content: question });
state.searchFollowups.push({ role: "assistant", content: "thinking..." });
renderSearchResult(state.searchResult);
try {
const payload = await api("/api/search/followup", {
method: "POST",
body: JSON.stringify({ job_id: state.searchJobId, question }),
});
state.searchFollowups[state.searchFollowups.length - 1] = {
role: "assistant",
content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`,
};
} catch (error) {
state.searchFollowups[state.searchFollowups.length - 1] = {
role: "assistant",
content: error.message,
};
}
renderSearchResult(state.searchResult);
}
function renderReading() {
setReaderMode(false);
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) {
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;
setReaderMode(true);
const chatRows = state.paperChatHistory[paper.id] || [];
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-reader-action="map">回到图谱</button>
<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="dialogue-list paper-chat-list">
${chatRows
.map(
(row) => `
<div class="dialogue-row ${escapeHtml(row.role)}">
<strong>${row.role === "user" ? "你" : "Paper Agent"}</strong>
<div>${escapeHtml(row.content)}</div>
</div>
`,
)
.join("")}
</div>
<div class="followup-row">
<input id="paperChatInput" type="text" placeholder="继续问这篇论文,比如:实验设计有什么问题?和工程实践有什么关系?" />
<button class="tool-button primary" type="button" data-paper-chat>Ask</button>
</div>
</section>
<section class="section">
<h3>本地卡片</h3>
<div class="reader-box markdown-preview">${renderMarkdown(state.selectedMarkdown)}</div>
</section>
`;
}
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
? `<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) {
renderPaperReader("", `<div class="empty-state">${escapeHtml(error.message)}</div>`);
} finally {
setAiBusy(false);
}
}
async function runPaperChat() {
if (!state.selectedPaper) return;
const input = $("#paperChatInput");
const question = input?.value.trim();
if (!question) return;
const paperId = state.selectedPaper.id;
const history = state.paperChatHistory[paperId] || [];
history.push({ role: "user", content: question });
history.push({ role: "assistant", content: "thinking..." });
state.paperChatHistory[paperId] = history;
const aiHtml = $("#paperAi")?.innerHTML || "";
const abstractHtml = $("#paperAbstract")?.innerHTML || "";
renderPaperReader(aiHtml, abstractHtml);
try {
const payload = await api("/api/paper/chat", {
method: "POST",
body: JSON.stringify({
id: paperId,
question,
history: history.slice(0, -1),
}),
});
history[history.length - 1] = {
role: "assistant",
content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`,
};
} catch (error) {
history[history.length - 1] = { role: "assistant", content: error.message };
}
state.paperChatHistory[paperId] = history;
renderPaperReader($("#paperAi")?.innerHTML || aiHtml, $("#paperAbstract")?.innerHTML || abstractHtml);
}
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>`;
}
}
}
function isLargeModel(model) {
const configuredLarge = state.health?.default_models?.deep;
return model === configuredLarge || /:(large|sol)$/i.test(model);
}
async function runPaperAi(mode) {
if (!state.selectedPaper) return;
const model = state.health?.default_models?.[mode] || (mode === "translate" ? "ChatGPT-5.6:Luna" : "ChatGPT-5.6:Terra");
if (isLargeModel(model) && !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 = `<h3>${escapeHtml(payload.model)} · ${escapeHtml(cache)}</h3><div class="ai-output">${escapeHtml(payload.response)}</div>`;
renderPaperReader(html, $("#paperAbstract")?.innerHTML || "");
} catch (error) {
renderPaperReader(`<div class="empty-state">${escapeHtml(error.message)}</div>`, $("#paperAbstract")?.innerHTML || "");
} finally {
setAiBusy(false);
}
}
async function runAtlasAi(mode, topic = "", topicB = "") {
const model = state.health?.default_models?.[mode] || "ChatGPT-5.6:Terra";
if (isLargeModel(model) && !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);
});
}
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]");
const readerAction = event.target.closest("[data-reader-action]");
const searchFollowup = event.target.closest("[data-search-followup]");
const paperChat = event.target.closest("[data-paper-chat]");
const searchExample = event.target.closest("[data-search-example]");
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);
} else if (readerAction) {
if (state.topicFocus) renderTopic(state.topicFocus);
else renderOverview();
} else if (searchFollowup) {
await runSearchFollowup();
} else if (paperChat) {
await runPaperChat();
} else if (searchExample) {
elements.searchInput.value = searchExample.dataset.searchExample || "";
await runSearch();
}
});
elements.lensBody.addEventListener("keydown", async (event) => {
if (event.key === "Enter" && event.target.closest("#searchFollowupInput")) {
await runSearchFollowup();
}
if (event.key === "Enter" && event.target.closest("#paperChatInput")) {
await runPaperChat();
}
});
elements.searchBtn.addEventListener("click", runSearch);
elements.searchInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") 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();
});
});
window.addEventListener(
"resize",
debounce(() => {
if (state.atlas) renderAtlasMap();
}, 150),
);
}
async function boot() {
bindEvents();
await loadHealth();
await loadAtlas();
loadChartEngine();
}
boot();