Add paper browser web app

This commit is contained in:
wuyang
2026-07-08 12:37:16 +08:00
parent 391b3a4732
commit e783a7d54d
6 changed files with 1605 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# Agent Paper Browser
本地论文知识库网页,直接读取仓库里的 `data/index.json``papers/items/*.md`
## Run
```bash
python3 web/app.py --host 0.0.0.0 --port 18080
```
默认 Ollama 地址:
```bash
OLLAMA_URL=http://192.168.1.10:11434
```
访问地址:
```text
http://100.114.68.27:18080
```
## AI Policy
- 默认摘要模型:`ChatGPT-5.6:fast`
- 默认翻译模型:`ChatGPT-5.6:light`
- 深度分析模型:`ChatGPT-5.6:large`,只在前端手动选择时使用
- Ollama 请求单并发执行,并缓存结果到 `web/cache/ai/`
- arXiv 摘要缓存到 `web/cache/arxiv/`
+573
View File
@@ -0,0 +1,573 @@
#!/usr/bin/env python3
"""Serve a local paper browser with lightweight Ollama-backed actions."""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import os
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
WEB_ROOT = ROOT / "web"
STATIC_ROOT = WEB_ROOT / "static"
INDEX_PATH = ROOT / "data" / "index.json"
CACHE_ROOT = WEB_ROOT / "cache"
ARXIV_CACHE = CACHE_ROOT / "arxiv"
AI_CACHE = CACHE_ROOT / "ai"
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://192.168.1.10:11434").rstrip("/")
DEFAULT_MODELS = {
"translate": "ChatGPT-5.6:light",
"summary": "ChatGPT-5.6:fast",
"deep": "ChatGPT-5.6:large",
}
NS = {
"atom": "http://www.w3.org/2005/Atom",
"arxiv": "http://arxiv.org/schemas/atom",
}
INDEX_LOCK = threading.Lock()
OLLAMA_LOCK = threading.Lock()
INDEX_CACHE: dict[str, Any] = {"mtime": 0.0, "papers": [], "by_id": {}}
def ensure_cache_dirs() -> None:
ARXIV_CACHE.mkdir(parents=True, exist_ok=True)
AI_CACHE.mkdir(parents=True, exist_ok=True)
def clean_title(value: Any) -> str:
title = str(value or "").strip()
if len(title) >= 2 and title[0] == title[-1] == '"':
return title[1:-1]
return title
def item_id(path: str) -> str:
return hashlib.sha1(path.encode("utf-8")).hexdigest()[:14]
def as_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
text = str(value).strip()
return [text] if text else []
def score_value(value: Any) -> int:
try:
return int(str(value or "0"))
except ValueError:
return 0
def normalize_paper(item: dict[str, Any]) -> dict[str, Any]:
meta = item.get("meta") or {}
path = str(item.get("path") or "")
title = clean_title(meta.get("title") or item.get("title") or Path(path).stem)
topics = as_list(meta.get("topics"))
datasets = as_list(meta.get("datasets"))
authors = str(meta.get("authors") or "").strip()
paper = {
"id": item_id(path),
"path": path,
"title": title,
"authors": authors,
"year": str(meta.get("year") or ""),
"venue": str(meta.get("venue") or ""),
"url": str(meta.get("url") or ""),
"code_url": as_list(meta.get("code_url")),
"source": str(meta.get("source") or ""),
"published_at": str(meta.get("published_at") or ""),
"updated_at": str(meta.get("updated_at") or ""),
"status": str(meta.get("status") or "unknown"),
"relevance": str(meta.get("relevance") or ""),
"topics": topics,
"datasets": datasets,
"collection_score": score_value(meta.get("collection_score")),
"collection_queries": str(meta.get("collection_queries") or ""),
"meta": meta,
}
search_text = " ".join(
[
title,
authors,
paper["year"],
paper["venue"],
paper["url"],
paper["status"],
paper["relevance"],
paper["collection_queries"],
" ".join(topics),
" ".join(datasets),
]
)
paper["_search"] = search_text.lower()
return paper
def load_papers() -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
mtime = INDEX_PATH.stat().st_mtime
with INDEX_LOCK:
if INDEX_CACHE["mtime"] == mtime:
return INDEX_CACHE["papers"], INDEX_CACHE["by_id"]
raw_items = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
papers = [
normalize_paper(item)
for item in raw_items
if item.get("collection") == "papers" and item.get("path")
]
by_id = {paper["id"]: paper for paper in papers}
INDEX_CACHE.update({"mtime": mtime, "papers": papers, "by_id": by_id})
return papers, by_id
def read_json_body(handler: SimpleHTTPRequestHandler) -> dict[str, Any]:
length = int(handler.headers.get("content-length") or "0")
if length <= 0:
return {}
raw = handler.rfile.read(length)
return json.loads(raw.decode("utf-8"))
def write_json(handler: SimpleHTTPRequestHandler, payload: Any, status: int = 200) -> None:
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
handler.send_response(status)
handler.send_header("content-type", "application/json; charset=utf-8")
handler.send_header("cache-control", "no-store")
handler.send_header("content-length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
def write_error(handler: SimpleHTTPRequestHandler, status: int, message: str, **extra: Any) -> None:
payload = {"error": message, **extra}
write_json(handler, payload, status)
def paper_public(paper: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in paper.items() if not key.startswith("_") and key != "meta"}
def topic_counts(papers: list[dict[str, Any]]) -> list[dict[str, Any]]:
counts: dict[str, int] = {}
for paper in papers:
for topic in paper["topics"]:
counts[topic] = counts.get(topic, 0) + 1
return [
{"topic": topic, "count": count}
for topic, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))
]
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)
return match.group(1) if match else ""
def normalize_space(value: str) -> str:
return re.sub(r"\s+", " ", value).strip()
def fetch_arxiv(arxiv_id: str) -> dict[str, Any]:
ensure_cache_dirs()
cache_path = ARXIV_CACHE / f"{arxiv_id}.json"
if cache_path.exists():
return json.loads(cache_path.read_text(encoding="utf-8"))
query = urllib.parse.urlencode({"id_list": arxiv_id})
request = urllib.request.Request(
f"https://export.arxiv.org/api/query?{query}",
headers={"User-Agent": "agent-kb-paper-browser/0.1"},
)
with urllib.request.urlopen(request, timeout=20) as response:
xml_text = response.read().decode("utf-8", "replace")
root = ET.fromstring(xml_text)
entry = root.find("atom:entry", NS)
if entry is None:
raise ValueError(f"arXiv entry not found: {arxiv_id}")
authors = [
normalize_space(author.findtext("atom:name", default="", namespaces=NS))
for author in entry.findall("atom:author", NS)
]
categories = [
category.attrib.get("term", "")
for category in entry.findall("atom:category", NS)
if category.attrib.get("term")
]
payload = {
"arxiv_id": arxiv_id,
"title": normalize_space(entry.findtext("atom:title", default="", namespaces=NS)),
"abstract": normalize_space(entry.findtext("atom:summary", default="", namespaces=NS)),
"authors": authors,
"published": entry.findtext("atom:published", default="", namespaces=NS)[:10],
"updated": entry.findtext("atom:updated", default="", namespaces=NS)[:10],
"categories": categories,
"url": f"https://arxiv.org/abs/{arxiv_id}",
}
cache_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return payload
def paper_context(paper: dict[str, Any], abstract: dict[str, Any] | None) -> str:
meta = {
"title": paper["title"],
"authors": paper["authors"],
"year": paper["year"],
"venue": paper["venue"],
"url": paper["url"],
"topics": paper["topics"],
"collection_score": paper["collection_score"],
"collection_queries": paper["collection_queries"],
}
parts = [f"Metadata:\n{json.dumps(meta, ensure_ascii=False, indent=2)}"]
if abstract and abstract.get("abstract"):
parts.append(f"Abstract:\n{abstract['abstract']}")
else:
content = (ROOT / paper["path"]).read_text(encoding="utf-8")
parts.append(f"Local note:\n{content[:4000]}")
return "\n\n".join(parts)[:7000]
def prompt_for(mode: str, context: str) -> str:
no_think = "不要输出思考过程,不要输出 <think> 标签,只输出最终结果。"
if mode == "translate":
return (
"你是论文摘要翻译助手。把下面论文摘要准确翻译成中文,保留技术术语,"
f"不要扩写,不要编造。如果没有摘要,只翻译已有材料。{no_think}\n\n"
f"{context}"
)
if mode == "deep":
return (
"你是 Agent 研究员。请基于给定论文材料做中文深度研读笔记,输出:\n"
"1. 一句话定位\n2. 研究问题\n3. 方法/系统设计\n4. 评估方式\n"
"5. 对 Agent 工程实践的启发\n6. 可疑点或待验证问题\n7. 推荐标签。\n"
f"只根据材料回答,信息不足就明确写“不足”。{no_think}\n\n"
f"{context}"
)
return (
"你是 Agent 论文知识库助手。请基于给定论文材料输出中文摘要,结构如下:\n"
"1. 一句话结论\n2. 解决的问题\n3. 核心方法\n4. 评估/实验\n"
"5. 和 Agent 知识库的关系\n6. 推荐优先级:P0/P1/P2。\n"
f"保持精炼,只根据材料回答,禁止编造。{no_think}\n\n"
f"{context}"
)
def clean_model_response(value: str) -> str:
text = re.sub(r"<think>[\s\S]*?</think>", "", value, flags=re.IGNORECASE).strip()
return text or value.strip()
def call_ollama(model: str, prompt: str, mode: str) -> dict[str, Any]:
options = {
"temperature": 0.2,
"num_ctx": 4096,
"num_predict": 520,
}
if mode == "translate":
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})
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"keep_alive": "2m",
"options": options,
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
f"{OLLAMA_URL}/api/generate",
data=data,
headers={"content-type": "application/json"},
method="POST",
)
started = time.time()
with urllib.request.urlopen(request, timeout=240) as response:
result = json.loads(response.read().decode("utf-8"))
return {
"model": model,
"mode": mode,
"response": clean_model_response(str(result.get("response") or "")),
"duration_seconds": round(time.time() - started, 2),
"eval_count": result.get("eval_count"),
"prompt_eval_count": result.get("prompt_eval_count"),
}
def ai_cache_path(paper_id: str, mode: str, model: str) -> Path:
digest = hashlib.sha1(f"{paper_id}:{mode}:{model}".encode("utf-8")).hexdigest()[:18]
return AI_CACHE / f"{digest}.json"
class PaperBrowserHandler(SimpleHTTPRequestHandler):
server_version = "AgentPaperBrowser/0.1"
def log_message(self, format: str, *args: Any) -> None:
print(f"[paper-web] {self.address_string()} - {format % args}")
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
if path == "/":
self.serve_file(STATIC_ROOT / "index.html", "text/html; charset=utf-8")
return
if path.startswith("/static/"):
target = STATIC_ROOT / path.removeprefix("/static/")
content_type = "text/plain; charset=utf-8"
if target.suffix == ".css":
content_type = "text/css; charset=utf-8"
elif target.suffix == ".js":
content_type = "application/javascript; charset=utf-8"
self.serve_file(target, content_type)
return
if path == "/api/health":
self.handle_health()
return
if path == "/api/models":
self.handle_models()
return
if path == "/api/topics":
papers, _ = load_papers()
write_json(self, {"topics": topic_counts(papers)})
return
if path == "/api/papers":
self.handle_paper_search(query)
return
if path == "/api/paper":
self.handle_paper_detail(query)
return
if path == "/api/paper/abstract":
self.handle_abstract(query)
return
write_error(self, HTTPStatus.NOT_FOUND, "not found")
def do_POST(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/api/ai":
self.handle_ai()
return
write_error(self, HTTPStatus.NOT_FOUND, "not found")
def serve_file(self, target: Path, content_type: str) -> None:
try:
resolved = target.resolve()
if not str(resolved).startswith(str(STATIC_ROOT.resolve())):
write_error(self, HTTPStatus.FORBIDDEN, "forbidden")
return
data = resolved.read_bytes()
except FileNotFoundError:
write_error(self, HTTPStatus.NOT_FOUND, "file not found")
return
self.send_response(HTTPStatus.OK)
self.send_header("content-type", content_type)
self.send_header("content-length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def handle_health(self) -> None:
papers, _ = load_papers()
ollama_ok = False
try:
request = urllib.request.Request(f"{OLLAMA_URL}/api/tags")
with urllib.request.urlopen(request, timeout=3) as response:
ollama_ok = response.status == 200
except (urllib.error.URLError, TimeoutError):
ollama_ok = False
write_json(
self,
{
"ok": True,
"paper_count": len(papers),
"ollama_url": OLLAMA_URL,
"ollama_ok": ollama_ok,
"default_models": DEFAULT_MODELS,
},
)
def handle_models(self) -> None:
try:
request = urllib.request.Request(f"{OLLAMA_URL}/api/tags")
with urllib.request.urlopen(request, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
models = [
{
"name": item.get("name"),
"parameter_size": (item.get("details") or {}).get("parameter_size"),
"context_length": (item.get("details") or {}).get("context_length"),
}
for item in payload.get("models", [])
]
write_json(self, {"models": models, "default_models": DEFAULT_MODELS})
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.BAD_GATEWAY, f"ollama unavailable: {exc}")
def handle_paper_search(self, query: dict[str, list[str]]) -> None:
papers, _ = load_papers()
q = (query.get("q") or [""])[0].strip().lower()
topic = (query.get("topic") or [""])[0].strip()
year = (query.get("year") or [""])[0].strip()
status = (query.get("status") or [""])[0].strip()
relevance = (query.get("relevance") or [""])[0].strip()
sort = (query.get("sort") or ["score"])[0].strip()
limit = max(1, min(100, int((query.get("limit") or ["40"])[0])))
offset = max(0, int((query.get("offset") or ["0"])[0]))
filtered = []
for paper in papers:
if q and q not in paper["_search"]:
continue
if topic and topic not in paper["topics"]:
continue
if year and paper["year"] != year:
continue
if status and paper["status"] != status:
continue
if relevance and paper["relevance"] != relevance:
continue
filtered.append(paper)
if sort == "date":
filtered.sort(key=lambda item: (item["published_at"], item["collection_score"]), reverse=True)
elif sort == "title":
filtered.sort(key=lambda item: item["title"].lower())
else:
filtered.sort(key=lambda item: (item["collection_score"], item["published_at"]), reverse=True)
page = filtered[offset : offset + limit]
write_json(
self,
{
"total": len(filtered),
"offset": offset,
"limit": limit,
"items": [paper_public(paper) for paper in page],
},
)
def handle_paper_detail(self, query: dict[str, list[str]]) -> None:
paper_id = (query.get("id") or [""])[0]
_, by_id = load_papers()
paper = by_id.get(paper_id)
if not paper:
write_error(self, HTTPStatus.NOT_FOUND, "paper not found")
return
text = (ROOT / paper["path"]).read_text(encoding="utf-8")
write_json(self, {"paper": paper_public(paper), "markdown": text})
def handle_abstract(self, query: dict[str, list[str]]) -> None:
paper_id = (query.get("id") or [""])[0]
_, by_id = load_papers()
paper = by_id.get(paper_id)
if not paper:
write_error(self, HTTPStatus.NOT_FOUND, "paper not found")
return
arxiv_id = arxiv_id_from_paper(paper)
if not arxiv_id:
write_json(self, {"abstract": None, "message": "not an arXiv paper"})
return
try:
write_json(self, {"abstract": fetch_arxiv(arxiv_id)})
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.BAD_GATEWAY, f"arXiv fetch failed: {exc}")
def handle_ai(self) -> None:
try:
payload = read_json_body(self)
paper_id = str(payload.get("id") or "")
mode = str(payload.get("mode") or "summary")
refresh = bool(payload.get("refresh"))
model = str(payload.get("model") or DEFAULT_MODELS.get(mode) or DEFAULT_MODELS["summary"])
if mode not in {"summary", "translate", "deep"}:
write_error(self, HTTPStatus.BAD_REQUEST, "invalid ai mode")
return
_, by_id = load_papers()
paper = by_id.get(paper_id)
if not paper:
write_error(self, HTTPStatus.NOT_FOUND, "paper not found")
return
ensure_cache_dirs()
cache_path = ai_cache_path(paper_id, 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:
abstract = None
arxiv_id = arxiv_id_from_paper(paper)
if arxiv_id:
abstract = fetch_arxiv(arxiv_id)
context = paper_context(paper, abstract)
result = call_ollama(model, prompt_for(mode, context), mode)
result.update(
{
"id": paper_id,
"title": paper["title"],
"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()
parser.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "18080")))
return parser.parse_args()
def main() -> int:
args = parse_args()
ensure_cache_dirs()
load_papers()
server = ThreadingHTTPServer((args.host, args.port), PaperBrowserHandler)
print(f"Paper browser: http://{args.host}:{args.port}")
print(f"Ollama: {OLLAMA_URL}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopping paper browser")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+367
View File
@@ -0,0 +1,367 @@
const state = {
papers: [],
total: 0,
offset: 0,
limit: 50,
selectedId: null,
selectedPaper: null,
selectedMarkdown: "",
topic: "",
loading: false,
health: null,
};
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"),
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function debounce(fn, delay = 250) {
let timer = null;
return (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => fn(...args), delay);
};
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
"content-type": "application/json",
...(options.headers || {}),
},
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function 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;
}
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 未连接";
} catch (error) {
elements.healthLine.textContent = "服务状态未知";
elements.modelStatus.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 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 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("");
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>
`;
})
.join("");
}
function stripFrontmatter(markdown) {
const text = String(markdown || "");
const match = text.match(/^# .+?\n\n---[\s\S]*?\n---\n\n?/);
return match ? text.slice(match[0].length) : text;
}
function 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();
}
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;
}
}
async function loadAbstract() {
if (!state.selectedId) return;
setBusy(true, "获取 arXiv 摘要");
try {
const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedId)}`);
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");
} catch (error) {
elements.abstractView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
} finally {
setBusy(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 : "";
}
async function runAi(mode) {
if (!state.selectedId) return;
if (mode === "deep") {
const ok = window.confirm("深度研读会调用 large 模型,占用更多 GPU。继续?");
if (!ok) 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>`;
try {
const payload = await api("/api/ai", {
method: "POST",
body: JSON.stringify({ id: state.selectedId, 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>
`;
} catch (error) {
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
} finally {
setBusy(false);
}
}
function activateTab(name) {
document.querySelectorAll(".tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === name);
});
document.querySelectorAll(".tab-view").forEach((view) => {
view.classList.toggle("active", view.id === `${name}View`);
});
}
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.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.resultsList.addEventListener("click", (event) => {
const button = event.target.closest("[data-id]");
if (button) selectPaper(button.dataset.id);
});
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 });
}
boot();
+119
View File
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Paper Browser</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<main class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">AP</div>
<div>
<h1>Agent Papers</h1>
<p id="healthLine">连接中</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>
<label class="field">
<span>排序</span>
<select id="sortSelect">
<option value="score">相关度</option>
<option value="date">发布日期</option>
<option value="title">标题</option>
</select>
</label>
<section class="topic-panel">
<div class="panel-title">
<span>主题</span>
<button id="clearTopicBtn" class="ghost-button" type="button">清空</button>
</div>
<div id="topicChips" class="topic-chips"></div>
</section>
</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>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+515
View File
@@ -0,0 +1,515 @@
:root {
--bg: #f7f8f5;
--paper: #ffffff;
--paper-2: #f0f4f1;
--ink: #18211f;
--muted: #68736f;
--line: #dce2df;
--teal: #0f766e;
--teal-weak: #d8f0eb;
--coral: #b94b35;
--gold: #a66a00;
--green: #3f7b43;
--shadow: 0 16px 38px rgba(24, 33, 31, 0.08);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
letter-spacing: 0;
}
button {
cursor: pointer;
}
.app-shell {
display: grid;
grid-template-columns: 260px minmax(340px, 430px) minmax(0, 1fr);
height: 100vh;
min-height: 680px;
overflow: hidden;
}
.sidebar,
.results-pane,
.detail-pane {
min-width: 0;
min-height: 0;
}
.sidebar {
border-right: 1px solid var(--line);
padding: 20px 18px;
background: #fbfcfa;
overflow: auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 1px solid #a4c7bf;
border-radius: 8px;
background: var(--teal-weak);
color: #07564f;
font-weight: 800;
}
.brand h1,
.results-header h2,
.detail-header h2 {
margin: 0;
line-height: 1.15;
}
.brand h1 {
font-size: 20px;
}
.brand p,
.eyebrow {
color: var(--muted);
font-size: 12px;
}
.brand p {
margin: 4px 0 0;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.field span,
.panel-title {
color: #44504c;
font-size: 12px;
font-weight: 700;
}
input,
select {
width: 100%;
height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--paper);
color: var(--ink);
padding: 0 10px;
outline: none;
}
input:focus,
select:focus {
border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
.filter-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.topic-panel {
margin-top: 16px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ghost-button {
border: 0;
background: transparent;
color: var(--teal);
padding: 4px 0;
font-size: 12px;
}
.topic-chips,
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.topic-chip,
.tag {
border: 1px solid var(--line);
border-radius: 999px;
background: var(--paper);
color: #34413d;
padding: 5px 9px;
font-size: 12px;
white-space: nowrap;
}
.topic-chip.active {
border-color: var(--teal);
background: var(--teal-weak);
color: #064f49;
}
.results-pane {
display: grid;
grid-template-rows: auto 1fr auto;
border-right: 1px solid var(--line);
background: #fdfefd;
}
.results-header,
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.results-header h2,
.detail-header h2 {
font-size: 22px;
}
.count-pill {
min-width: 46px;
height: 30px;
border-radius: 999px;
background: #e9efe9;
color: #33423d;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 800;
}
.results-list {
overflow: auto;
}
.paper-row {
display: grid;
gap: 8px;
width: 100%;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
padding: 15px 18px;
color: inherit;
text-align: left;
}
.paper-row:hover,
.paper-row.active {
background: #f0f6f3;
}
.paper-row.active {
box-shadow: inset 3px 0 0 var(--teal);
}
.paper-title {
font-size: 15px;
font-weight: 750;
line-height: 1.35;
}
.paper-meta,
.paper-topics {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.mini-badge {
border-radius: 4px;
background: #edf0ec;
padding: 3px 6px;
}
.load-more {
height: 44px;
border: 0;
border-top: 1px solid var(--line);
background: #f7faf7;
color: var(--teal);
font-weight: 750;
}
.detail-pane {
display: grid;
grid-template-rows: auto auto auto auto 1fr;
background: var(--bg);
overflow: hidden;
}
.detail-header {
background: var(--paper);
}
.detail-header h2 {
max-width: 920px;
font-size: 24px;
}
.link-button {
display: grid;
min-width: 72px;
height: 34px;
place-items: center;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--teal);
text-decoration: none;
font-size: 13px;
font-weight: 800;
}
.detail-tags {
padding: 12px 20px;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.tag.score {
border-color: #e7c98d;
background: #fff6df;
color: #704a00;
}
.action-bar,
.model-strip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 20px;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.tool-button {
min-height: 36px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbfcfa;
color: #22302d;
padding: 0 12px;
font-weight: 750;
}
.tool-button.primary {
border-color: #0f766e;
background: #0f766e;
color: #fff;
}
.tool-button.caution {
border-color: #d9b169;
background: #fff8e8;
color: #6b4500;
}
.tool-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.model-strip {
justify-content: space-between;
color: var(--muted);
font-size: 12px;
}
.split-content {
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1fr);
gap: 14px;
padding: 14px;
overflow: hidden;
}
.preview-panel,
.ai-panel {
min-width: 0;
min-height: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper);
box-shadow: var(--shadow);
overflow: hidden;
}
.preview-panel {
display: grid;
grid-template-rows: auto 1fr;
}
.preview-panel h3 {
margin: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
font-size: 16px;
}
.markdown-preview,
.tab-view {
overflow: auto;
padding: 16px;
}
.markdown-preview {
line-height: 1.55;
font-size: 14px;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3 {
margin: 16px 0 8px;
line-height: 1.25;
}
.markdown-preview h1 {
font-size: 21px;
}
.markdown-preview h2 {
font-size: 17px;
}
.markdown-preview h3 {
font-size: 15px;
}
.markdown-preview p,
.tab-view p {
margin: 0 0 10px;
}
.markdown-preview ul {
margin: 8px 0 14px;
padding-left: 20px;
}
.markdown-preview code,
.tab-view code {
border-radius: 4px;
background: #eef2ef;
padding: 1px 4px;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--line);
}
.tab {
height: 44px;
border: 0;
border-right: 1px solid var(--line);
background: #fbfcfa;
color: #4d5c57;
padding: 0 18px;
font-weight: 800;
}
.tab.active {
background: var(--paper);
color: var(--teal);
box-shadow: inset 0 -3px 0 var(--teal);
}
.tab-view {
display: none;
max-height: calc(100vh - 266px);
line-height: 1.6;
font-size: 14px;
}
.tab-view.active {
display: block;
}
.ai-output {
white-space: pre-wrap;
}
.empty-state {
color: var(--muted);
padding: 18px;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 240px minmax(320px, 390px) minmax(0, 1fr);
}
.split-content {
grid-template-columns: 1fr;
overflow: auto;
}
.tab-view {
max-height: none;
}
}
@media (max-width: 860px) {
.app-shell {
display: block;
height: auto;
min-height: 100vh;
overflow: visible;
}
.sidebar,
.results-pane,
.detail-pane {
border-right: 0;
}
.results-list,
.split-content {
max-height: none;
overflow: visible;
}
}