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));