Add agentic paper search and chat
This commit is contained in:
+321
-27
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user