Add paper browser web app
This commit is contained in:
@@ -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
@@ -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())
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -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 `<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();
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user