Evolve paper browser into knowledge atlas

This commit is contained in:
wuyang
2026-07-08 13:01:46 +08:00
parent e783a7d54d
commit 8a4106be68
5 changed files with 1685 additions and 775 deletions
+24 -3
View File
@@ -1,11 +1,11 @@
# Agent Paper Browser
# Agent Knowledge Atlas
本地论文知识库网页,直接读取仓库里的 `data/index.json``papers/items/*.md`
本地 Agent 论文知识地图,直接读取仓库里的 `data/index.json``papers/items/*.md`
## Run
```bash
python3 web/app.py --host 0.0.0.0 --port 18080
python3 web/app.py --host 100.114.68.27 --port 18080
```
默认 Ollama 地址:
@@ -25,5 +25,26 @@ http://100.114.68.27:18080
- 默认摘要模型:`ChatGPT-5.6:fast`
- 默认翻译模型:`ChatGPT-5.6:light`
- 深度分析模型:`ChatGPT-5.6:large`,只在前端手动选择时使用
- Atlas 解释:`ChatGPT-5.6:fast`
- 主题导读、阅读路径、主题比较:`ChatGPT-5.6:large`,只在前端手动确认后调用
- Ollama 请求单并发执行,并缓存结果到 `web/cache/ai/`
- arXiv 摘要缓存到 `web/cache/arxiv/`
## Knowledge Design
先由后端从本地语料轻量计算:
- 主题节点和主题规模
- 最近三个月热度
- 主题共现关系
- 月度趋势
- 关键论文候选
- 主题证据列表
按需调用 AI 的高维动作:
- 解释当前 Atlas
- 生成主题导读
- 生成阅读路径
- 比较技术路线
- 单篇论文摘要、翻译、深度研读
+435 -1
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import argparse
import hashlib
import html
import json
import os
import re
@@ -15,6 +14,8 @@ import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict
from itertools import combinations
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -34,6 +35,10 @@ DEFAULT_MODELS = {
"translate": "ChatGPT-5.6:light",
"summary": "ChatGPT-5.6:fast",
"deep": "ChatGPT-5.6:large",
"atlas": "ChatGPT-5.6:fast",
"topic": "ChatGPT-5.6:large",
"path": "ChatGPT-5.6:large",
"compare": "ChatGPT-5.6:large",
}
NS = {
@@ -44,6 +49,124 @@ NS = {
INDEX_LOCK = threading.Lock()
OLLAMA_LOCK = threading.Lock()
INDEX_CACHE: dict[str, Any] = {"mtime": 0.0, "papers": [], "by_id": {}}
ATLAS_CACHE: dict[str, Any] = {"mtime": 0.0, "atlas": None}
TOPIC_PROFILES = {
"agent-evaluation": {
"label": "Evaluation",
"stance": "评估已经成为 Agent 研究的中心层:不只是看最终答案,而是看轨迹、工具调用、失败点、成本和安全边界。",
"question": "如何证明一个 Agent 真的能在长程、不确定、带工具的环境里可靠完成任务?",
},
"tool-use": {
"label": "Tool Use",
"stance": "工具调用正在从 prompt 技巧变成运行时系统问题:schema、权限、依赖、失败恢复和可观测性都变成核心。",
"question": "模型什么时候应该调用工具,如何调用,失败后如何恢复,工具链如何被治理?",
},
"memory": {
"label": "Memory",
"stance": "Memory 不再只是 RAG 的附属,而是长程 Agent 的状态管理、经验沉淀和安全攻击面。",
"question": "Agent 应该记什么、忘什么、如何写入、如何检索、如何防止记忆污染?",
},
"rag": {
"label": "Agentic RAG",
"stance": "RAG 正在从相似度检索走向主动检索、证据规划、多跳推理和工具化知识访问。",
"question": "Agent 如何把检索变成可验证、可迭代、可追踪的推理过程?",
},
"planning": {
"label": "Planning",
"stance": "Planning 的重点从一次性计划转向执行中的重规划、状态校正和长程任务稳定性。",
"question": "长程任务里,计划如何与真实环境反馈、工具失败和上下文限制协同?",
},
"agent-safety": {
"label": "Safety",
"stance": "Agent safety 已经从拒答问题转向运行时风险:权限、注入、记忆污染、越权动作和治理平面。",
"question": "如何限制 Agent 的行动边界,同时保留足够的自主性和生产力?",
},
"coding-agent": {
"label": "Coding Agent",
"stance": "Coding Agent 正在从单题修 bug 走向仓库级、长程、交互式的软件工程流程。",
"question": "如何评估和构建能长期维护真实仓库的编码 Agent?",
},
"computer-use": {
"label": "Computer Use",
"stance": "GUI/browser/OS Agent 正在成为独立层:视觉状态、动作边界、任务进度和安全确认都需要重新建模。",
"question": "Agent 如何可靠理解屏幕状态,并在真实界面中安全行动?",
},
"multi-agent": {
"label": "Multi-Agent",
"stance": "Multi-agent 正在从角色扮演走向共享记忆、协作协议、路由、治理和组织行为。",
"question": "多个 Agent 如何共享状态、分工、纠错,并避免多数投票和上下文碎片带来的失败?",
},
"workflow-agent": {
"label": "Workflow",
"stance": "Workflow Agent 把模型能力接进企业流程,核心不在聊天,而在状态、权限、审计和可恢复执行。",
"question": "怎样把 Agent 放进真实业务流程,并让它可观察、可治理、可回滚?",
},
"reasoning": {
"label": "Reasoning",
"stance": "Reasoning 在 Agent 中必须和工具、记忆、验证器、环境反馈一起看,而不是孤立的思维链。",
"question": "推理如何真正改善行动,而不是只让轨迹看起来更复杂?",
},
"world-model": {
"label": "World Model",
"stance": "World model 让 Agent 从反应式调用走向对环境、规则和未来状态的内部建模。",
"question": "Agent 需要什么样的世界模型才能更好地预测行动后果?",
},
"embodied-agent": {
"label": "Embodied",
"stance": "Embodied Agent 把语言、视觉、规划和物理约束连接起来,是 Agent 能力外延的重要方向。",
"question": "语言模型如何在有物理约束的环境中做可执行决策?",
},
}
TOPIC_POSITIONS = {
"agent-evaluation": (51, 37),
"tool-use": (37, 52),
"memory": (52, 61),
"rag": (68, 57),
"planning": (48, 48),
"agent-safety": (62, 35),
"coding-agent": (27, 31),
"computer-use": (29, 68),
"multi-agent": (74, 42),
"workflow-agent": (62, 74),
"reasoning": (42, 24),
"world-model": (78, 68),
"embodied-agent": (82, 82),
}
KNOWLEDGE_ROUTES = [
{
"id": "evaluation-runtime",
"title": "从答案评测到运行时评估",
"topics": ["agent-evaluation", "tool-use", "agent-safety", "workflow-agent"],
"summary": "Agent 的评估对象从 final answer 扩展到轨迹、工具调用、权限、失败恢复和上线治理。",
},
{
"id": "memory-state",
"title": "从 RAG 到状态管理",
"topics": ["rag", "memory", "planning", "agent-safety"],
"summary": "长期记忆、主动检索、写入策略和记忆安全正在把 RAG 推成 Agent 的状态层。",
},
{
"id": "coding-long-horizon",
"title": "从 SWE-Bench 到长程软件工程",
"topics": ["coding-agent", "agent-evaluation", "memory", "tool-use"],
"summary": "编码 Agent 的问题从单 bug 修复扩展到仓库理解、长程维护、交互澄清和 CI 反馈循环。",
},
{
"id": "computer-use",
"title": "从工具 API 到真实电脑操作",
"topics": ["computer-use", "tool-use", "planning", "agent-safety"],
"summary": "GUI、浏览器和 OS Agent 让状态识别、动作边界、确认机制和视觉记忆成为新的系统层。",
},
{
"id": "collective-agents",
"title": "从单体智能到协作系统",
"topics": ["multi-agent", "memory", "agent-evaluation", "workflow-agent"],
"summary": "多 Agent 研究正在走向共享记忆、角色分工、路由、群体失败归因和组织行为评估。",
},
]
def ensure_cache_dirs() -> None:
@@ -177,6 +300,249 @@ def topic_counts(papers: list[dict[str, Any]]) -> list[dict[str, Any]]:
]
def month_key(value: str) -> str:
if re.match(r"^\d{4}-\d{2}", value or ""):
return value[:7]
return "unknown"
def profile_for(topic: str) -> dict[str, str]:
fallback = {
"label": topic.replace("-", " ").title(),
"stance": f"{topic} 是当前语料中自动识别出的主题,需要进一步人工导读。",
"question": "这个主题与 Agent 系统能力的关系是什么?",
}
return {**fallback, **TOPIC_PROFILES.get(topic, {})}
def top_papers_for(papers: list[dict[str, Any]], limit: int = 8, recent: bool = False) -> list[dict[str, Any]]:
ranked = list(papers)
if recent:
ranked.sort(key=lambda item: (item["published_at"], item["collection_score"]), reverse=True)
else:
ranked.sort(key=lambda item: (item["collection_score"], item["published_at"]), reverse=True)
return [paper_public(paper) for paper in ranked[:limit]]
def build_atlas() -> dict[str, Any]:
papers, _ = load_papers()
mtime = INDEX_PATH.stat().st_mtime
if ATLAS_CACHE["mtime"] == mtime and ATLAS_CACHE["atlas"]:
return ATLAS_CACHE["atlas"]
topic_to_papers: dict[str, list[dict[str, Any]]] = defaultdict(list)
topic_months: dict[str, Counter] = defaultdict(Counter)
edge_counts: Counter = Counter()
month_counts: Counter = Counter()
for paper in papers:
month = month_key(paper["published_at"])
month_counts[month] += 1
topics = sorted(set(paper["topics"]))
for topic in topics:
topic_to_papers[topic].append(paper)
topic_months[topic][month] += 1
for left, right in combinations(topics, 2):
edge_counts[(left, right)] += 1
months = [month for month in sorted(month_counts) if month != "unknown"]
recent_months = set(months[-3:])
max_count = max((len(items) for items in topic_to_papers.values()), default=1)
max_recent = max(
(sum(topic_months[topic][month] for month in recent_months) for topic in topic_to_papers),
default=1,
)
topics = []
fallback_index = 0
for topic, topic_papers in sorted(topic_to_papers.items(), key=lambda item: (-len(item[1]), item[0])):
profile = profile_for(topic)
recent_count = sum(topic_months[topic][month] for month in recent_months)
if topic in TOPIC_POSITIONS:
x, y = TOPIC_POSITIONS[topic]
else:
x = 18 + (fallback_index % 5) * 16
y = 16 + (fallback_index // 5) * 14
fallback_index += 1
topics.append(
{
"id": topic,
"label": profile["label"],
"count": len(topic_papers),
"recent_count": recent_count,
"share": round(len(topic_papers) / max(len(papers), 1), 3),
"heat": round(recent_count / max_recent, 3),
"radius": round(16 + 34 * (len(topic_papers) / max_count), 1),
"x": x,
"y": y,
"stance": profile["stance"],
"question": profile["question"],
"key_papers": top_papers_for(topic_papers, 5),
}
)
top_topic_ids = {topic["id"] for topic in topics[:14]}
max_edge_count = max(edge_counts.values(), default=1)
edges = [
{
"source": left,
"target": right,
"count": count,
"strength": round(count / max_edge_count, 3),
}
for (left, right), count in edge_counts.most_common(80)
if left in top_topic_ids and right in top_topic_ids
]
timeline = []
for month in months:
row = {"month": month, "total": month_counts[month], "topics": {}}
for topic in top_topic_ids:
row["topics"][topic] = topic_months[topic][month]
timeline.append(row)
atlas = {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"paper_count": len(papers),
"topic_count": len(topics),
"months": months,
"recent_months": sorted(recent_months),
"topics": topics,
"edges": edges,
"timeline": timeline,
"routes": KNOWLEDGE_ROUTES,
"key_papers": top_papers_for(papers, 12),
"recent_papers": top_papers_for(papers, 12, recent=True),
"design_policy": {
"precomputed": [
"主题地图",
"主题规模",
"最近热度",
"主题共现关系",
"月度趋势",
"关键论文候选",
"主题证据列表",
],
"ai_on_demand": [
"解释当前地图",
"生成主题导读",
"生成阅读路径",
"比较技术路线",
"单篇论文研读",
],
},
}
ATLAS_CACHE.update({"mtime": mtime, "atlas": atlas})
return atlas
def topic_focus(topic: str) -> dict[str, Any]:
atlas = build_atlas()
papers, _ = load_papers()
topic_papers = [paper for paper in papers if topic in paper["topics"]]
if not topic_papers:
raise KeyError(topic)
months = {row["month"]: row["topics"].get(topic, 0) for row in atlas["timeline"]}
neighbor_counts: Counter = Counter()
for paper in topic_papers:
for other in paper["topics"]:
if other != topic:
neighbor_counts[other] += 1
profile = profile_for(topic)
routes = [route for route in KNOWLEDGE_ROUTES if topic in route["topics"]]
focus = {
"topic": topic,
"label": profile["label"],
"stance": profile["stance"],
"question": profile["question"],
"count": len(topic_papers),
"months": months,
"neighbors": [
{
"topic": name,
"label": profile_for(name)["label"],
"count": count,
"stance": profile_for(name)["stance"],
}
for name, count in neighbor_counts.most_common(10)
],
"routes": routes,
"key_papers": top_papers_for(topic_papers, 12),
"recent_papers": top_papers_for(topic_papers, 12, recent=True),
"evidence": top_papers_for(topic_papers, 40),
"reading_lens": [
"先看主题问题,再看高分论文,不要从论文列表开始迷路。",
"优先对比相邻主题,它们通常代表真实系统里的交叉问题。",
"把论文当作证据:它支持了哪条技术路线,暴露了哪个未解决问题?",
],
}
return focus
def atlas_context(topic: str = "", topic_b: str = "") -> str:
atlas = build_atlas()
compact = {
"paper_count": atlas["paper_count"],
"recent_months": atlas["recent_months"],
"top_topics": [
{
"topic": item["id"],
"label": item["label"],
"count": item["count"],
"recent_count": item["recent_count"],
"stance": item["stance"],
}
for item in atlas["topics"][:13]
],
"strong_edges": atlas["edges"][:24],
"routes": atlas["routes"],
"key_papers": [
{
"title": paper["title"],
"topics": paper["topics"],
"score": paper["collection_score"],
"published_at": paper["published_at"],
}
for paper in atlas["key_papers"][:10]
],
}
if topic:
compact["topic_focus"] = topic_focus(topic)
if topic_b:
compact["compare_topic"] = topic_focus(topic_b)
return json.dumps(compact, ensure_ascii=False, indent=2)[:12000]
def atlas_prompt_for(mode: str, context: str) -> str:
no_think = "不要输出思考过程,不要输出 <think> 标签,只输出最终结果。"
if mode == "topic":
return (
"你是 Agent 研究编辑。请根据给定知识库统计,为当前主题写一份中文导读:\n"
"1. 主题定位\n2. 为什么最近重要\n3. 主要技术路线\n4. 关键争议\n"
"5. 必读论文阅读顺序\n6. 和相邻主题的关系\n7. 可以做的实验。\n"
f"只基于给定数据,不要编造论文细节。{no_think}\n\n{context}"
)
if mode == "path":
return (
"你是研究导师。请基于给定知识库,为一个想系统学习 Agent 技术的人生成阅读路径。\n"
"输出 4 个阶段:入门地图、核心系统、风险与评估、专题深入。每阶段给出主题、阅读目标、代表论文标题。\n"
f"保持可执行,避免泛泛而谈。{no_think}\n\n{context}"
)
if mode == "compare":
return (
"你是技术路线分析师。请比较给定两个 Agent 主题:共同问题、差异、交叉区域、代表论文、工程启发。\n"
f"只使用给定数据。{no_think}\n\n{context}"
)
return (
"你是 Agent 知识库的研究编辑。请解释这张 Agent 论文 Atlas\n"
"1. 当前版图的主轴是什么\n2. 哪些主题是中心节点\n3. 哪些方向最近升温\n"
"4. 哪些主题之间关系最强\n5. 下一步应该优先精读什么。\n"
f"只基于给定数据。{no_think}\n\n{context}"
)
def arxiv_id_from_paper(paper: dict[str, Any]) -> str:
url = str(paper.get("url") or "")
match = re.search(r"arxiv\.org/(?:abs|html|pdf)/([0-9]{4}\.[0-9]+)", url)
@@ -288,6 +654,8 @@ def call_ollama(model: str, prompt: str, mode: str) -> dict[str, Any]:
options.update({"num_ctx": 3072, "num_predict": 700, "temperature": 0.1})
elif mode == "deep":
options.update({"num_ctx": 8192, "num_predict": 900, "temperature": 0.25})
elif mode in {"atlas", "topic", "path", "compare"}:
options.update({"num_ctx": 8192, "num_predict": 950, "temperature": 0.22})
payload = {
"model": model,
@@ -354,6 +722,12 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
papers, _ = load_papers()
write_json(self, {"topics": topic_counts(papers)})
return
if path == "/api/atlas":
write_json(self, build_atlas())
return
if path == "/api/topic":
self.handle_topic(query)
return
if path == "/api/papers":
self.handle_paper_search(query)
return
@@ -371,6 +745,9 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
if parsed.path == "/api/ai":
self.handle_ai()
return
if parsed.path == "/api/atlas/ai":
self.handle_atlas_ai()
return
write_error(self, HTTPStatus.NOT_FOUND, "not found")
def serve_file(self, target: Path, content_type: str) -> None:
@@ -479,6 +856,16 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
text = (ROOT / paper["path"]).read_text(encoding="utf-8")
write_json(self, {"paper": paper_public(paper), "markdown": text})
def handle_topic(self, query: dict[str, list[str]]) -> None:
topic = (query.get("topic") or [""])[0].strip()
if not topic:
write_error(self, HTTPStatus.BAD_REQUEST, "topic is required")
return
try:
write_json(self, topic_focus(topic))
except KeyError:
write_error(self, HTTPStatus.NOT_FOUND, "topic not found")
def handle_abstract(self, query: dict[str, list[str]]) -> None:
paper_id = (query.get("id") or [""])[0]
_, by_id = load_papers()
@@ -545,6 +932,53 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def handle_atlas_ai(self) -> None:
try:
payload = read_json_body(self)
mode = str(payload.get("mode") or "atlas")
topic = str(payload.get("topic") or "")
topic_b = str(payload.get("topic_b") or "")
refresh = bool(payload.get("refresh"))
model = str(payload.get("model") or DEFAULT_MODELS.get(mode) or DEFAULT_MODELS["atlas"])
if mode not in {"atlas", "topic", "path", "compare"}:
write_error(self, HTTPStatus.BAD_REQUEST, "invalid atlas ai mode")
return
if mode == "topic" and not topic:
write_error(self, HTTPStatus.BAD_REQUEST, "topic is required")
return
if mode == "compare" and (not topic or not topic_b):
write_error(self, HTTPStatus.BAD_REQUEST, "topic and topic_b are required")
return
ensure_cache_dirs()
cache_key = f"atlas:{mode}:{topic}:{topic_b}"
cache_path = ai_cache_path(cache_key, mode, model)
if cache_path.exists() and not refresh:
result = json.loads(cache_path.read_text(encoding="utf-8"))
result["cached"] = True
write_json(self, result)
return
if not OLLAMA_LOCK.acquire(blocking=False):
write_error(self, HTTPStatus.TOO_MANY_REQUESTS, "ollama is busy; try again later")
return
try:
context = atlas_context(topic, topic_b)
result = call_ollama(model, atlas_prompt_for(mode, context), mode)
result.update(
{
"scope": cache_key,
"cached": False,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
}
)
cache_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_json(self, result)
finally:
OLLAMA_LOCK.release()
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
+566 -253
View File
@@ -1,14 +1,14 @@
const state = {
papers: [],
total: 0,
offset: 0,
limit: 50,
selectedId: null,
atlas: null,
health: null,
view: "atlas",
activeTopic: "",
topicFocus: null,
selectedPaper: null,
selectedMarkdown: "",
topic: "",
loading: false,
health: null,
searchItems: [],
searchTotal: 0,
aiBusy: false,
};
const $ = (selector) => document.querySelector(selector);
@@ -16,28 +16,17 @@ const $ = (selector) => document.querySelector(selector);
const elements = {
healthLine: $("#healthLine"),
searchInput: $("#searchInput"),
yearSelect: $("#yearSelect"),
statusSelect: $("#statusSelect"),
sortSelect: $("#sortSelect"),
clearTopicBtn: $("#clearTopicBtn"),
topicChips: $("#topicChips"),
resultTitle: $("#resultTitle"),
resultCount: $("#resultCount"),
resultsList: $("#resultsList"),
loadMoreBtn: $("#loadMoreBtn"),
detailMetaLine: $("#detailMetaLine"),
detailTitle: $("#detailTitle"),
detailTags: $("#detailTags"),
arxivLink: $("#arxivLink"),
paperPreview: $("#paperPreview"),
abstractBtn: $("#abstractBtn"),
summaryBtn: $("#summaryBtn"),
translateBtn: $("#translateBtn"),
deepBtn: $("#deepBtn"),
modelStatus: $("#modelStatus"),
aiStatus: $("#aiStatus"),
abstractView: $("#abstractView"),
aiView: $("#aiView"),
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) {
@@ -49,7 +38,7 @@ function escapeHtml(value) {
.replaceAll("'", "&#039;");
}
function debounce(fn, delay = 250) {
function debounce(fn, delay = 260) {
let timer = null;
return (...args) => {
window.clearTimeout(timer);
@@ -72,98 +61,384 @@ async function api(path, options = {}) {
return payload;
}
function queryParams(extra = {}) {
const params = new URLSearchParams();
const q = elements.searchInput.value.trim();
if (q) params.set("q", q);
if (state.topic) params.set("topic", state.topic);
if (elements.yearSelect.value) params.set("year", elements.yearSelect.value);
if (elements.statusSelect.value) params.set("status", elements.statusSelect.value);
if (elements.sortSelect.value) params.set("sort", elements.sortSelect.value);
params.set("limit", String(state.limit));
params.set("offset", String(state.offset));
Object.entries(extra).forEach(([key, value]) => params.set(key, String(value)));
return params;
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`;
elements.modelStatus.textContent = health.ollama_ok
? `Ollama 已连接 · summary=${health.default_models.summary}`
: "Ollama 未连接";
elements.healthLine.textContent = `${health.paper_count} papers · ${health.ollama_ok ? "Ollama online" : "Ollama offline"}`;
} catch (error) {
elements.healthLine.textContent = "服务状态未知";
elements.modelStatus.textContent = error.message;
elements.healthLine.textContent = error.message;
}
}
async function loadTopics() {
const payload = await api("/api/topics");
elements.topicChips.innerHTML = payload.topics
.slice(0, 24)
.map((item) => {
const active = item.topic === state.topic ? " active" : "";
return `<button class="topic-chip${active}" type="button" data-topic="${escapeHtml(item.topic)}">${escapeHtml(item.topic)} ${item.count}</button>`;
})
.join("");
async function loadAtlas() {
state.atlas = await api("/api/atlas");
elements.mapTitle.textContent = `${state.atlas.paper_count} 篇论文构成的 Agent 版图`;
renderAtlasMap();
renderRoutes();
renderTimeline();
renderOverview();
}
async function loadPapers({ reset = false } = {}) {
if (state.loading) return;
state.loading = true;
if (reset) {
state.offset = 0;
state.papers = [];
}
elements.resultTitle.textContent = state.topic || "论文库";
elements.resultCount.textContent = "...";
try {
const payload = await api(`/api/papers?${queryParams()}`);
state.total = payload.total;
state.papers = reset ? payload.items : [...state.papers, ...payload.items];
state.offset = state.papers.length;
renderResults();
if (!state.selectedId && state.papers.length) {
await selectPaper(state.papers[0].id);
}
} catch (error) {
elements.resultsList.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
} finally {
state.loading = false;
}
}
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,
});
function renderResults() {
elements.resultCount.textContent = String(state.total);
elements.loadMoreBtn.style.display = state.papers.length < state.total ? "block" : "none";
if (!state.papers.length) {
elements.resultsList.innerHTML = `<div class="empty-state">没有匹配结果</div>`;
return;
}
elements.resultsList.innerHTML = state.papers
.map((paper) => {
const active = paper.id === state.selectedId ? " active" : "";
const topics = paper.topics
.slice(0, 4)
.map((topic) => `<span class="mini-badge">${escapeHtml(topic)}</span>`)
.join("");
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 `
<button class="paper-row${active}" type="button" data-id="${paper.id}">
<div class="paper-title">${escapeHtml(paper.title)}</div>
<div class="paper-meta">
<span>${escapeHtml(paper.year || "unknown")}</span>
<span>${escapeHtml(paper.venue || paper.source || "paper")}</span>
<span>score ${escapeHtml(paper.collection_score)}</span>
<span>${escapeHtml(paper.status)}</span>
</div>
<div class="paper-topics">${topics}</div>
</button>
<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 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 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 = `
<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 = "") {
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;
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) {
renderOverview();
return;
}
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>
`;
}
function renderReading() {
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) {
@@ -172,196 +447,234 @@ function stripFrontmatter(markdown) {
return match ? text.slice(match[0].length) : text;
}
function inlineMarkdown(text) {
let value = escapeHtml(text);
value = value.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
return value;
}
function renderMarkdown(markdown) {
const lines = stripFrontmatter(markdown).split(/\r?\n/);
const output = [];
let inList = false;
const closeList = () => {
if (inList) {
output.push("</ul>");
inList = false;
}
};
for (const raw of lines) {
const line = raw.trimEnd();
if (!line.trim()) {
closeList();
continue;
}
if (line.startsWith("### ")) {
closeList();
output.push(`<h3>${inlineMarkdown(line.slice(4))}</h3>`);
} else if (line.startsWith("## ")) {
closeList();
output.push(`<h2>${inlineMarkdown(line.slice(3))}</h2>`);
} else if (line.startsWith("# ")) {
closeList();
output.push(`<h1>${inlineMarkdown(line.slice(2))}</h1>`);
} else if (line.startsWith("- ")) {
if (!inList) {
output.push("<ul>");
inList = true;
}
output.push(`<li>${inlineMarkdown(line.slice(2))}</li>`);
} else {
closeList();
output.push(`<p>${inlineMarkdown(line)}</p>`);
}
}
closeList();
return output.join("");
}
function renderDetail(paper, markdown) {
state.selectedPaper = paper;
state.selectedMarkdown = markdown;
elements.detailMetaLine.textContent = `${paper.year || "unknown"} · ${paper.venue || paper.source || "paper"} · ${paper.status}`;
elements.detailTitle.textContent = paper.title;
elements.arxivLink.href = paper.url || "#";
elements.arxivLink.style.visibility = paper.url ? "visible" : "hidden";
elements.detailTags.innerHTML = [
`<span class="tag score">score ${escapeHtml(paper.collection_score)}</span>`,
`<span class="tag">${escapeHtml(paper.relevance || "relevance")}</span>`,
...paper.topics.map((topic) => `<span class="tag">${escapeHtml(topic)}</span>`),
].join("");
elements.paperPreview.innerHTML = renderMarkdown(markdown);
elements.abstractView.innerHTML = `<p class="empty-state">未加载摘要</p>`;
elements.aiView.innerHTML = `<p class="empty-state">未生成 AI 结果</p>`;
elements.aiStatus.textContent = "";
renderResults();
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) {
state.selectedId = id;
renderResults();
try {
const payload = await api(`/api/paper?id=${encodeURIComponent(id)}`);
renderDetail(payload.paper, payload.markdown);
} catch (error) {
elements.detailTitle.textContent = error.message;
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 = `
<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-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="reader-box markdown-preview">${renderMarkdown(state.selectedMarkdown)}</div>
</section>
`;
}
async function loadAbstract() {
if (!state.selectedId) return;
setBusy(true, "获取 arXiv 摘要");
if (!state.selectedPaper) return;
setAiBusy(true, "摘要获取中");
try {
const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedId)}`);
const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedPaper.id)}`);
const abstract = payload.abstract;
if (!abstract) {
elements.abstractView.innerHTML = `<p class="empty-state">${escapeHtml(payload.message || "没有摘要")}</p>`;
} else {
elements.abstractView.innerHTML = `
<h3>${escapeHtml(abstract.title)}</h3>
<p>${escapeHtml(abstract.abstract)}</p>
<div class="paper-meta">
<span>${escapeHtml(abstract.published)}</span>
<span>${escapeHtml((abstract.categories || []).join(", "))}</span>
</div>
`;
}
activateTab("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) {
elements.abstractView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
renderPaperReader("", `<div class="empty-state">${escapeHtml(error.message)}</div>`);
} finally {
setBusy(false);
setAiBusy(false);
}
}
function setBusy(isBusy, label = "") {
elements.abstractBtn.disabled = isBusy;
elements.summaryBtn.disabled = isBusy;
elements.translateBtn.disabled = isBusy;
elements.deepBtn.disabled = isBusy;
elements.aiStatus.textContent = isBusy ? label : "";
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>`;
}
}
}
async function runAi(mode) {
if (!state.selectedId) return;
if (mode === "deep") {
const ok = window.confirm("深度研读会调用 large 模型,占用更多 GPU。继续?");
if (!ok) return;
}
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");
const label = mode === "translate" ? "翻译中" : mode === "deep" ? "深度研读中" : "摘要生成中";
setBusy(true, `${label} · ${model}`);
activateTab("ai");
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(label)}</p>`;
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.selectedId, mode, model }),
body: JSON.stringify({ id: state.selectedPaper.id, mode, model }),
});
const cache = payload.cached ? "cached" : `${payload.duration_seconds}s`;
elements.aiView.innerHTML = `
<div class="paper-meta">
<span>${escapeHtml(payload.model)}</span>
<span>${escapeHtml(payload.mode)}</span>
<span>${escapeHtml(cache)}</span>
</div>
<div class="ai-output">${escapeHtml(payload.response)}</div>
`;
const html = `<h3>${escapeHtml(payload.model)} · ${escapeHtml(cache)}</h3><div class="ai-output">${escapeHtml(payload.response)}</div>`;
renderPaperReader(html, $("#paperAbstract")?.innerHTML || "");
} catch (error) {
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
renderPaperReader(`<div class="empty-state">${escapeHtml(error.message)}</div>`, $("#paperAbstract")?.innerHTML || "");
} finally {
setBusy(false);
setAiBusy(false);
}
}
function activateTab(name) {
document.querySelectorAll(".tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === name);
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 }),
});
document.querySelectorAll(".tab-view").forEach((view) => {
view.classList.toggle("active", view.id === `${name}View`);
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);
});
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() {
const reload = debounce(() => loadPapers({ reset: true }), 260);
elements.searchInput.addEventListener("input", reload);
elements.yearSelect.addEventListener("change", reload);
elements.statusSelect.addEventListener("change", reload);
elements.sortSelect.addEventListener("change", reload);
elements.clearTopicBtn.addEventListener("click", async () => {
state.topic = "";
await loadTopics();
await loadPapers({ reset: true });
elements.atlasSvg.addEventListener("click", async (event) => {
const node = event.target.closest("[data-topic]");
if (!node) return;
await selectTopic(node.dataset.topic);
scrollLensIntoView();
});
elements.topicChips.addEventListener("click", async (event) => {
const button = event.target.closest("[data-topic]");
if (!button) return;
state.topic = button.dataset.topic === state.topic ? "" : button.dataset.topic;
await loadTopics();
await loadPapers({ reset: true });
elements.routeList.addEventListener("click", (event) => {
const button = event.target.closest("[data-route]");
if (button) {
renderRoute(button.dataset.route);
scrollLensIntoView();
}
});
elements.resultsList.addEventListener("click", (event) => {
const button = event.target.closest("[data-id]");
if (button) selectPaper(button.dataset.id);
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();
});
elements.loadMoreBtn.addEventListener("click", () => loadPapers());
elements.abstractBtn.addEventListener("click", loadAbstract);
elements.summaryBtn.addEventListener("click", () => runAi("summary"));
elements.translateBtn.addEventListener("click", () => runAi("translate"));
elements.deepBtn.addEventListener("click", () => runAi("deep"));
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
});
}
async function boot() {
bindEvents();
await loadHealth();
await loadTopics();
await loadPapers({ reset: true });
await loadAtlas();
}
boot();
+57 -93
View File
@@ -3,115 +3,79 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Paper Browser</title>
<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" />
</head>
<body>
<main class="app-shell">
<aside class="sidebar">
<main class="atlas-app">
<header class="topbar">
<div class="brand">
<div class="brand-mark">AP</div>
<div class="brand-mark">AK</div>
<div>
<h1>Agent Papers</h1>
<p id="healthLine">连接中</p>
<h1>Agent Knowledge Atlas</h1>
<p id="healthLine">loading</p>
</div>
</div>
<label class="field">
<span>检索</span>
<input id="searchInput" type="search" placeholder="memory / safety / GUI / benchmark" autocomplete="off" />
</label>
<div class="filter-grid">
<label class="field">
<span>年份</span>
<select id="yearSelect">
<option value="">全部</option>
<option value="2026">2026</option>
<option value="2025">2025</option>
</select>
</label>
<label class="field">
<span>状态</span>
<select id="statusSelect">
<option value="">全部</option>
<option value="queued">queued</option>
<option value="skimmed">skimmed</option>
<option value="summarized">summarized</option>
</select>
</label>
<div class="command">
<input id="searchInput" type="search" placeholder="搜索主题、论文、问题" autocomplete="off" />
<button id="searchBtn" type="button">Search</button>
</div>
<label class="field">
<span>排序</span>
<select id="sortSelect">
<option value="score">相关度</option>
<option value="date">发布日期</option>
<option value="title">标题</option>
</select>
</label>
<nav class="mode-nav" aria-label="Views">
<button class="mode-button active" data-view="atlas" type="button">Atlas</button>
<button class="mode-button" data-view="search" type="button">Search</button>
<button class="mode-button" data-view="reading" type="button">Reading</button>
</nav>
</header>
<section class="topic-panel">
<div class="panel-title">
<span>主题</span>
<button id="clearTopicBtn" class="ghost-button" type="button">清空</button>
<section class="workspace">
<section class="map-panel">
<div class="map-head">
<div>
<div class="eyebrow">Research Map</div>
<h2 id="mapTitle">Agent 技术版图</h2>
</div>
<div class="atlas-actions">
<button id="explainAtlasBtn" class="quiet-button" type="button">解释地图</button>
<button id="readingPathBtn" class="quiet-button strong" type="button">阅读路径</button>
</div>
</div>
<div id="atlasStage" class="atlas-stage">
<svg id="atlasSvg" viewBox="0 0 170 100" role="img" aria-label="Agent topic map"></svg>
</div>
<div class="route-band">
<div class="band-title">Routes</div>
<div id="routeList" class="route-list"></div>
</div>
<div class="timeline-band">
<div class="band-title">Timeline</div>
<div id="timeline" class="timeline"></div>
</div>
<div id="topicChips" class="topic-chips"></div>
</section>
<aside class="lens-panel">
<div class="lens-head">
<div>
<div id="lensEyebrow" class="eyebrow">Lens</div>
<h2 id="lensTitle">整体脉络</h2>
</div>
<button id="closePaperBtn" class="icon-button" type="button" title="Back">×</button>
</div>
<div id="lensBody" class="lens-body"></div>
</aside>
<section class="results-pane">
<header class="results-header">
<div>
<div class="eyebrow">Corpus</div>
<h2 id="resultTitle">论文库</h2>
</div>
<div id="resultCount" class="count-pill">0</div>
</header>
<div id="resultsList" class="results-list"></div>
<button id="loadMoreBtn" class="load-more" type="button">加载更多</button>
</section>
<section class="detail-pane">
<header class="detail-header">
<div>
<div id="detailMetaLine" class="eyebrow">选择论文</div>
<h2 id="detailTitle">从左侧选择一篇论文</h2>
</div>
<a id="arxivLink" class="link-button" href="#" target="_blank" rel="noreferrer">arXiv</a>
</header>
<div id="detailTags" class="detail-tags"></div>
<div class="action-bar">
<button id="abstractBtn" class="tool-button" type="button">获取摘要</button>
<button id="summaryBtn" class="tool-button primary" type="button">中文摘要</button>
<button id="translateBtn" class="tool-button" type="button">翻译摘要</button>
<button id="deepBtn" class="tool-button caution" type="button">深度研读</button>
</div>
<div class="model-strip">
<span id="modelStatus">Ollama 未检测</span>
<span id="aiStatus"></span>
</div>
<section class="split-content">
<article class="preview-panel">
<h3>预览</h3>
<div id="paperPreview" class="markdown-preview"></div>
</article>
<article class="ai-panel">
<div class="tabs">
<button class="tab active" data-tab="abstract" type="button">摘要</button>
<button class="tab" data-tab="ai" type="button">AI</button>
</div>
<div id="abstractView" class="tab-view active"></div>
<div id="aiView" class="tab-view"></div>
</article>
</section>
</section>
<footer class="mobile-nav">
<button class="mobile-mode active" data-view="atlas" type="button">Atlas</button>
<button class="mobile-mode" data-view="search" type="button">Search</button>
<button class="mobile-mode" data-view="reading" type="button">Reading</button>
</footer>
</main>
<script src="/static/app.js"></script>
+565 -387
View File
File diff suppressed because it is too large Load Diff