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