Add agentic paper search and chat

This commit is contained in:
wuyang
2026-07-08 14:02:03 +08:00
parent cda6f1aabe
commit 894c914e26
5 changed files with 1129 additions and 29 deletions
+321 -27
View File
@@ -8,8 +8,13 @@ const state = {
selectedMarkdown: "",
searchItems: [],
searchTotal: 0,
searchJobId: "",
searchResult: null,
searchPollTimer: null,
searchFollowups: [],
aiBusy: false,
atlasChart: null,
paperChatHistory: {},
};
const $ = (selector) => document.querySelector(selector);
@@ -498,6 +503,10 @@ function paperList(items, limit = items.length) {
`;
}
function compactPaperList(items, limit = items.length) {
return paperList(items, limit);
}
function routeButtons(routes) {
return `
<div class="chip-row">
@@ -512,6 +521,169 @@ function routeButtons(routes) {
`;
}
function searchSteps(steps = []) {
return `
<div class="agent-steps">
${steps
.map(
(step) => `
<div class="agent-step ${escapeHtml(step.status)}">
<span class="step-dot"></span>
<div>
<strong>${escapeHtml(step.label)}</strong>
<span>${escapeHtml(step.detail || step.status)}</span>
</div>
</div>
`,
)
.join("")}
</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">
<p class="statement">Search Agent 正在理解问题、扩展关键词并检索本地论文库。</p>
</section>
<section class="section">
${searchSteps(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";
@@ -681,32 +853,85 @@ function renderRoute(routeId) {
async function runSearch() {
const q = elements.searchInput.value.trim();
if (!q) {
renderOverview();
renderSearchHome();
return;
}
const scopeTopic = state.topicFocus && state.view === "atlas" ? state.activeTopic : "";
setReaderMode(false);
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>
`;
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);
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() {
@@ -755,6 +980,7 @@ 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 = `
@@ -784,6 +1010,26 @@ function renderPaperReader(aiHtml = "", abstractHtml = "") {
<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>
@@ -808,6 +1054,39 @@ async function loadAbstract() {
}
}
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) => {
@@ -904,6 +1183,9 @@ function bindEvents() {
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);
@@ -926,16 +1208,28 @@ function bindEvents() {
} 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(
"input",
debounce(() => {
if (elements.searchInput.value.trim()) 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));
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Knowledge Atlas</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23cfe8e3'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-size='24' font-family='Arial' font-weight='700' fill='%23064c47'%3EAK%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="/static/styles.css?v=atlas-20260708-8" />
<link rel="stylesheet" href="/static/styles.css?v=atlas-20260708-10" />
</head>
<body>
<main class="atlas-app">
@@ -79,6 +79,6 @@
</footer>
</main>
<script src="/static/app.js?v=atlas-20260708-8"></script>
<script src="/static/app.js?v=atlas-20260708-10"></script>
</body>
</html>
+126
View File
@@ -521,6 +521,132 @@ button {
padding: 3px 6px;
}
.agent-steps {
display: grid;
gap: 10px;
}
.agent-step {
display: grid;
grid-template-columns: 13px 1fr;
gap: 10px;
align-items: start;
color: var(--muted);
}
.step-dot {
width: 9px;
height: 9px;
margin-top: 6px;
border: 1px solid #aab3ad;
border-radius: 50%;
background: #ffffff;
}
.agent-step strong {
display: block;
color: #17201d;
font-size: 13px;
}
.agent-step span:last-child {
display: block;
margin-top: 2px;
font-size: 12px;
line-height: 1.42;
}
.agent-step.running .step-dot {
border-color: var(--teal);
background: var(--teal);
box-shadow: 0 0 0 5px rgba(13, 118, 111, 0.12);
}
.agent-step.done .step-dot {
border-color: var(--green);
background: var(--green);
}
.agent-step.error .step-dot {
border-color: var(--coral);
background: var(--coral);
}
.search-brief h3 {
margin: 0 0 8px;
font-size: 14px;
}
.compact-section {
display: grid;
gap: 8px;
}
.mini-chart-row,
.search-topic-row {
margin-top: 10px;
}
.month-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
margin: 0 6px 6px 0;
border: 1px solid var(--line);
border-radius: 5px;
background: #ffffff;
padding: 0 7px;
color: #4c5550;
font-size: 11px;
font-weight: 720;
}
.followup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-top: 10px;
}
.followup-row input {
min-width: 0;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 7px;
background: #ffffff;
padding: 0 10px;
outline: 0;
}
.dialogue-list {
display: grid;
gap: 8px;
}
.dialogue-row {
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
padding: 10px;
}
.dialogue-row.user {
background: #f4f8f6;
}
.dialogue-row strong {
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.dialogue-row div {
white-space: pre-wrap;
color: #27312d;
font-size: 13px;
line-height: 1.58;
}
.ai-box,
.reader-box {
border: 1px solid var(--line);