diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..e892119 --- /dev/null +++ b/web/README.md @@ -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/` diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..05bf4fa --- /dev/null +++ b/web/app.py @@ -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 = "不要输出思考过程,不要输出 标签,只输出最终结果。" + 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"[\s\S]*?", "", 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()) diff --git a/web/cache/.gitignore b/web/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/web/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..2cf82b4 --- /dev/null +++ b/web/static/app.js @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +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 ``; + }) + .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 = `
${escapeHtml(error.message)}
`; + } 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 = `
没有匹配结果
`; + return; + } + elements.resultsList.innerHTML = state.papers + .map((paper) => { + const active = paper.id === state.selectedId ? " active" : ""; + const topics = paper.topics + .slice(0, 4) + .map((topic) => `${escapeHtml(topic)}`) + .join(""); + return ` + + `; + }) + .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, '$1'); + value = value.replace(/`([^`]+)`/g, "$1"); + return value; +} + +function renderMarkdown(markdown) { + const lines = stripFrontmatter(markdown).split(/\r?\n/); + const output = []; + let inList = false; + const closeList = () => { + if (inList) { + output.push(""); + inList = false; + } + }; + + for (const raw of lines) { + const line = raw.trimEnd(); + if (!line.trim()) { + closeList(); + continue; + } + if (line.startsWith("### ")) { + closeList(); + output.push(`

${inlineMarkdown(line.slice(4))}

`); + } else if (line.startsWith("## ")) { + closeList(); + output.push(`

${inlineMarkdown(line.slice(3))}

`); + } else if (line.startsWith("# ")) { + closeList(); + output.push(`

${inlineMarkdown(line.slice(2))}

`); + } else if (line.startsWith("- ")) { + if (!inList) { + output.push("