diff --git a/.gitignore b/.gitignore
index 3b337627..133e7647 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,11 @@ __pycache__/
env/
venv/
.env
+.env.*
+**/.env
+**/.env.*
+!.env.example
+!**/.env.example
.venv
build/
dist/
diff --git a/README.md b/README.md
index 13a4c5cf..87290c32 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
| **文献卡片** | Structured Card(LLM 提取 method/dataset/conclusion/limitations),懒加载 + DB 缓存 |
| **导出增强** | BibTeX/RIS/Markdown/CSL-JSON(Zotero 原生导入),Next.js proxy route 修复 |
| **写作辅助** | Related Work 草稿生成(基于 saved papers + topic),[AuthorYear] 引用格式,一键复制 |
-| **每日推送** | DailyPaper 生成后自动推送摘要到 Email/Slack/钉钉,支持 API 手动触发和 ARQ Cron 定时推送;MinerU 图表提取与 Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS)待集成 |
+| **每日推送** | DailyPaper 生成后自动推送到 Email/Slack/钉钉(支持 API 手动触发 + ARQ Cron 定时);已集成 MinerU v4 主方法图提取(公网 URL + inline data URL 回退)、LLM Digest + Judge 评分卡片、Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS) |
| **Model Provider** | 多 LLM 提供商管理(OpenAI/Anthropic/OpenRouter/Ollama),API Key Keychain 安全存储,任务级路由,连接测试 |
| **Deadline Radar** | 会议截止日期追踪,CCF 分级过滤,Research Track 关键词匹配 |
| **论文发现** | 种子论文扩展(引用/被引/共作者),Discovery Graph 可视化,论文集合(Collections)管理 |
@@ -35,7 +35,7 @@
| DailyPaper | ✅ 可用 | `/research/paperscool/daily` | `daily-paper` | 报告生成 + LLM 增强 + Judge + 保存,完整可用 |
| LLM-as-Judge | ✅ 可用 | `/research/paperscool/analyze` | `--with-judge` | 5 维评分 + 多轮校准 + 推荐分级 + Token Budget,SSE 增量推送 |
| Analyze SSE | ✅ 可用 | `/research/paperscool/analyze` | — | Judge / Trend / Insight 三通道 SSE 流式,前端逐卡片渲染 |
-| Push/Notify | 🟡 基本可用 | `/research/paperscool/daily` | `--notify` | Email/Slack/钉钉 已落地;Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS)+ MinerU 图表提取待集成 |
+| Push/Notify | ✅ 可用 | `/research/paperscool/daily` | `--notify` | Email/Slack/钉钉 + Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS);MinerU v4 主方法图(含 inline 回退)+ LLM Digest/Judge 卡片已落地 |
| 学者追踪 | 🟡 基本可用 | `/track` | `track` | 多 Agent 管线 + PIS 评分完整;依赖 Semantic Scholar API Key |
| 深度评审 | 🟡 基本可用 | `/review` | `review` | 模拟同行评审流程完整;输出质量取决于 LLM 后端配置 |
| Paper2Code | 🟡 基本可用 | `/gen-code`(兼容) + `/research/repro/context/*` | `gen-code` | 编排 + RAG + CodeMemory 完整;执行层计划迁移为 AgentSwarm/Codex 专业执行器 |
@@ -172,7 +172,9 @@ Search → Build Report → LLM Enrichment → Judge Scoring → Filter → Save
### Email 推送
-
+
+
+示例模板包含:导读摘要、Must Read 分层、Judge 评分、Digest Card、主方法图(可公开 URL 或 inline data URL 回退)。
## 快速开始
@@ -463,7 +465,12 @@ DB 持久化(统一主数据模型 Paper/Scholar/Event/Run)、任务队列/
### Phase 6 — 每日推送优化
-MinerU PDF 图表提取(主方法图自动识别)、推送内容增强(一句话总结 + 结构化摘要)、Apprise 多渠道统一推送层(Telegram/Discord/企业微信/飞书/RSS)、HuggingFace Daily Papers API 数据源接入。
+已完成:
+- MinerU v4 官方任务流接入(`/extract/task` + 轮询任务结果),主方法图自动识别。
+- 邮件渲染增强:支持主方法图展示(公开 URL)与 `data:image/...` inline 回退(适配 zip 内部图)。
+- 推送内容增强:导读摘要 + 一句话总结 + Digest Card + Judge 评分信息。
+- Apprise 多渠道统一推送层(Telegram/Discord/企业微信/飞书/RSS)落地。
+- HuggingFace Daily Papers API 数据源接入并纳入 DailyPaper 流程。
## 文档索引
diff --git a/asset/ui/dailypaperdemo.png b/asset/ui/dailypaperdemo.png
new file mode 100644
index 00000000..a9411247
Binary files /dev/null and b/asset/ui/dailypaperdemo.png differ
diff --git a/docs/proposals/agentswarm-design.md b/docs/proposals/agentswarm-design.md
new file mode 100644
index 00000000..37feb5c4
--- /dev/null
+++ b/docs/proposals/agentswarm-design.md
@@ -0,0 +1,779 @@
+# AgentSwarm Design Proposal
+
+> **Date**: 2026-03-03
+> **Status**: Draft → Under Review (#214)
+> **Branch**: `feat/daily-push-epic-179`
+> **Related**: `docs/AGENTSWARM_TODO.md`, README AgentSwarm 模块
+
+---
+
+## 1. Executive Summary
+
+AgentSwarm 是 PaperBot 的多 Agent 协作平台,目标是将「论文 → 可运行、可验证的复现工程」流程从当前的单体 Orchestrator 模式升级为多 Agent 协作模式,并统一接入不同 Code Agent(Claude Code / Codex / OpenCode / OpenHands / Cursor / Devin)。
+
+**核心定位**:所有竞品(Devin/OpenHands/SWE-Agent/Cursor/Codex/Claude Code/OpenCode)都是通用软件工程 Agent,**没有任何平台聚焦 paper→reproducible-code 场景**。AgentSwarm 填补这个空白。
+
+```
+ Generic SWE ←─────────────→ Domain-Specific
+ | |
+ Autonomous Devin, Codex │ ★ AgentSwarm
+ ↑ OpenHands │ (Paper→Code)
+ | SWE-Agent, OpenCode │
+ | Cursor, Claude Code │
+ Assisted ↓ │
+```
+
+---
+
+## 2. Current State Assessment
+
+### 2.1 What's Done (可复用)
+
+| Component | Status | Location |
+|-----------|--------|----------|
+| Studio 3-panel UI (Papers / ReproLog / Files) | ✅ | `web/src/app/studio/page.tsx` |
+| Runbook file management (CRUD + snapshot + diff + hunk revert) | ✅ | `api/routes/runbook.py` (800+ lines) |
+| Studio Chat (Claude CLI subprocess, 3 modes) | ✅ | `api/routes/studio_chat.py` |
+| Sandbox job queue + log/metrics streaming | ✅ | `api/routes/sandbox.py` |
+| Paper2Code pipeline (Planning→Blueprint→Env→Gen→Verify) | ✅ | `repro/orchestrator.py` + nodes/ |
+| CodeMemory (cross-file context + SymbolIndex) | ✅ | `repro/memory/` |
+| CodeRAG (pattern retrieval, 10 built-in patterns) | ✅ | `repro/rag/` |
+| Docker + E2B executors | ✅ | `repro/docker_executor.py`, `e2b_executor.py` |
+| Context Pack generation (SSE) | ✅ | `api/routes/gen_code.py` |
+| MCP client (frontend) | ✅ | `web/src/lib/mcp/` |
+| 15+ Studio components (Monaco editor, diff viewer, etc.) | ✅ | `web/src/components/studio/` |
+| Zustand store with paper/task/agent state | ✅ | `web/src/lib/store/studio-store.ts` |
+
+### 2.2 Key Gaps (需要补齐)
+
+| Gap | Impact | Priority |
+|-----|--------|----------|
+| 无多 Agent 运行时 — Orchestrator 驱动顺序执行 | 不能并行、不能自主迭代 | P0 |
+| 无 Agent 间通信 — 共享 `Dict[str,Any]` 无类型 | 脆弱、不可观测 | P0 |
+| 无持久化 Agent Session — 每次 request 新进程 | 无法断点续跑 | P1 |
+| `ParallelOrchestrator` 是空壳 — 直接 fallback 顺序 | 浪费 GPU/token 时间 | P1 |
+| Studio Chat 用 `--print` 单次调用 — 非多轮 session | 无法迭代修复 | P1 |
+| 无 tool approval workflow — `--dangerously-skip-permissions` | 安全风险 | P2 |
+| MCP 前端存在但未接入 chat/backend | 工具扩展被阻断 | P2 |
+| CodeRAG 仅 10 个 hardcoded pattern + keyword 匹配 | 召回率低 | P2 |
+| 无 Agent 活动可视化(terminal/DAG/timeline) | 用户无法观察/干预 | P2 |
+| repro 模块不走 PaperBot 自身的 `LLMService` | 模型路由无法统一 | P1 |
+
+---
+
+## 3. Architecture Design
+
+### 3.1 Overall Architecture
+
+```
+┌─────────────────────── Studio UI (Next.js) ─────────────────────┐
+│ Papers │ AgentSwarm Dashboard │ Workspace │ Terminal │ Timeline │
+│ │ ┌─ Agent Cards ───┐ │ │ (xterm) │ │
+│ │ │ 🧠 Planner │ │ Monaco │ │ Events │
+│ │ │ 🔧 Coder │ │ Editor │ Live │ by Run/ │
+│ │ │ ✅ Verifier │ │ + Diff │ Output │ Step │
+│ │ │ 🐛 Debugger │ │ │ │ │
+│ │ │ 🔍 Reviewer │ │ │ │ │
+│ │ └─────────────────┘ │ │ │ │
+└─────────────────────────────────────────────────────────────────┘
+ │
+ ┌─────────▼──────────┐
+ │ AgentSwarm Gateway │ (FastAPI)
+ │ /api/swarm/* │
+ └─────────┬──────────┘
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ ┌─── Agent Router ───────────────────────────┐
+ │ │
+ │ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
+ │ │ Session │ │ Message │ │ Event │ │
+ │ │ Manager │ │ Bus │ │ Store │ │
+ │ └───────────┘ └──────────┘ └──────────┘ │
+ │ │
+ │ ┌──────────────────────────────────────┐ │
+ │ │ Agent Adapters │ │
+ │ │ ┌────────┐ ┌──────┐ ┌───────────┐ │ │
+ │ │ │Claude │ │Codex │ │ OpenHands │ │ │
+ │ │ │Code │ │ │ │ │ │ │
+ │ │ ├────────┤ ├──────┤ ├───────────┤ │ │
+ │ │ │Cursor │ │Devin │ │ Built-in │ │ │
+ │ │ │ │ │ │ │ (P2C) │ │ │
+ │ │ └────────┘ └──────┘ └───────────┘ │ │
+ │ └──────────────────────────────────────┘ │
+ └─────────────────────────────────────────────┘
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
+ │ Docker │ │ E2B │ │ SSH │
+ │ Sandbox │ │ Cloud │ │ Fleet │
+ └──────────┘ └──────────┘ └──────────┘
+```
+
+### 3.2 Core Components
+
+#### 3.2.1 Agent Router — 任务分发器
+
+```python
+# src/paperbot/infrastructure/swarm/agent_router.py
+
+class AgentRouter:
+ """Route sub-tasks to the best available agent based on task type,
+ model capability, and cost/latency requirements."""
+
+ def __init__(self, adapters: Dict[str, AgentAdapter], config: RouterConfig):
+ self.adapters = adapters
+ self.config = config
+
+ async def dispatch(self, task: SwarmTask) -> AgentResult:
+ """Dispatch a task to the best agent."""
+ adapter = self._select_adapter(task)
+ session = await self.session_mgr.get_or_create(adapter, task.workspace)
+ return await adapter.execute(session, task)
+
+ def _select_adapter(self, task: SwarmTask) -> AgentAdapter:
+ """Selection strategy:
+ - blueprint/planning → Claude (high reasoning)
+ - bulk code generation → Codex (fast, parallel)
+ - config/boilerplate → Haiku (cheap, fast)
+ - debugging with tool use → Claude Code (CLI tools)
+ - verification → Built-in (no LLM needed for syntax/import)
+ """
+ ...
+```
+
+**路由策略参考竞品最佳实践**:
+
+| Task Type | Default Agent | Rationale |
+|-----------|--------------|-----------|
+| Blueprint distillation | Claude Opus/Sonnet | 需要高推理能力理解论文 |
+| File structure planning | Claude Code | 可用 tool use 创建目录/文件 |
+| Config/boilerplate gen | Haiku / Codex | 快速、低成本 |
+| Model architecture code | Claude Opus | 需要深度理解 + CodeRAG |
+| Training loop code | Codex | 模式化、适合批量 |
+| Debugging/repair | Claude Code | 需要运行命令、读错误日志 |
+| Verification | Built-in (no LLM) | 语法检查/import 检查不需要 LLM |
+
+#### 3.2.2 Agent Adapter — 统一接口
+
+```python
+# src/paperbot/infrastructure/swarm/adapters/base.py
+
+class AgentAdapter(ABC):
+ """Unified interface for different code agents."""
+
+ @property
+ @abstractmethod
+ def agent_type(self) -> str: ...
+
+ @abstractmethod
+ async def execute(self, session: AgentSession, task: SwarmTask) -> AgentResult: ...
+
+ @abstractmethod
+ async def create_session(self, workspace: Path, config: dict) -> AgentSession: ...
+
+ @abstractmethod
+ async def destroy_session(self, session: AgentSession) -> None: ...
+```
+
+**已规划的 Adapter 实现**:
+
+| Adapter | 接入方式 | 状态 |
+|---------|---------|------|
+| `BuiltInAdapter` | 直接调用现有 P2C nodes | v1 实现 |
+| `ClaudeCodeAdapter` | Claude Agent SDK / CLI subprocess | v1 实现 |
+| `CodexAdapter` | Codex App Server / MCP / CLI | v2 实现 |
+| `OpenHandsAdapter` | OpenHands SDK / Docker | v2 实现 |
+| `OpenCodeAdapter` | OpenCode CLI (Build/Plan agents) | v2 实现 |
+| `CursorAdapter` | Cursor CLI (if available) | v3 评估 |
+| `DevinAdapter` | Devin API (enterprise) | v3 评估 |
+
+#### 3.2.3 Message Bus — Agent 间通信
+
+取代当前的 `Dict[str, Any]` 共享上下文,采用 typed event-stream 模式(借鉴 OpenHands V1):
+
+```python
+# src/paperbot/infrastructure/swarm/message_bus.py
+
+@dataclass
+class SwarmEvent:
+ event_type: str # "blueprint_ready", "file_generated", "error_found"
+ source: str # agent name
+ timestamp: datetime
+ payload: Dict[str, Any] # typed per event_type
+
+class MessageBus:
+ """Event-driven communication between agents.
+ Replaces the mutable Dict[str, Any] shared context."""
+
+ async def publish(self, event: SwarmEvent) -> None: ...
+ async def subscribe(self, event_type: str, handler: Callable) -> None: ...
+ async def get_history(self, event_type: str = None) -> List[SwarmEvent]: ...
+```
+
+**关键 Event 类型**:
+
+| Event | Producer | Consumer |
+|-------|----------|----------|
+| `blueprint_ready` | PlannerAgent | CoderAgent |
+| `plan_ready` | PlannerAgent | CoderAgent, UI |
+| `file_generated` | CoderAgent | VerifierAgent, CodeMemory |
+| `verification_failed` | VerifierAgent | DebuggerAgent |
+| `repair_applied` | DebuggerAgent | VerifierAgent (re-run) |
+| `all_verified` | VerifierAgent | ReviewerAgent, UI |
+| `review_complete` | ReviewerAgent | UI, EvidencePack |
+
+#### 3.2.4 Session Manager — 持久化 Agent 会话
+
+```python
+# src/paperbot/infrastructure/swarm/session_manager.py
+
+class AgentSession:
+ session_id: str
+ agent_type: str
+ workspace: Path
+ status: Literal["idle", "running", "paused", "terminated"]
+ created_at: datetime
+ events: List[SwarmEvent] # deterministic replay (借鉴 OpenHands)
+
+class SessionManager:
+ """Manage persistent agent sessions with replay capability."""
+
+ async def create(self, agent_type: str, workspace: Path) -> AgentSession: ...
+ async def resume(self, session_id: str) -> AgentSession: ...
+ async def pause(self, session_id: str) -> None: ...
+ async def replay(self, session_id: str) -> List[SwarmEvent]: ...
+```
+
+---
+
+## 4. UI/UX Design
+
+### 4.1 Studio 升级 — 从 3-panel 到 5-zone
+
+借鉴 Devin(cloud IDE)和 Cursor(Mission Control)的最佳实践:
+
+```
+┌────────────────────────────────────────────────────────────┐
+│ Papers │ Agent Dashboard │ Workspace │ Terminal │
+│ (left) │ │ (editor) │ (xterm) │
+│ │ ┌──────────────────┐ │ │ │
+│ paper │ │ SwarmOrchestrator│ │ Monaco │ Agent │
+│ list │ │ ┌──┐ ┌──┐ ┌──┐ │ │ Editor │ stdout │
+│ │ │ │P │→│C │→│V │ │ │ │ │
+│ + add │ │ │ │ │ │ │ │ │ │ + Diff │ + stdin │
+│ │ │ └──┘ └──┘ └──┘ │ │ Viewer │ (user │
+│ │ │ ↗ ↘ │ │ │ approval)│
+│ │ │ ┌──┐ ┌──┐ │ │ │ │
+│ │ │ │D │ │R │ │ │ │ │
+│ │ │ └──┘ └──┘ │ │ │ │
+│ │ └──────────────────┘ │ │ │
+│ │ │ │ │
+│ │ ── Evidence Timeline ── │ │ │
+│ │ [Run #3] Blueprint ✅ │ │ │
+│ │ [Run #3] Gen model.py ✅│ │ │
+│ │ [Run #3] Gen train.py 🔄│ │ │
+│ │ [Run #3] Verify... ⏳ │ │ │
+└────────────────────────────────────────────────────────────┘
+
+P=Planner C=Coder V=Verifier D=Debugger R=Reviewer
+```
+
+### 4.2 Agent Card UI
+
+每个 Agent 显示为可交互卡片:
+
+```
+┌─────────────────────────────┐
+│ 🧠 Planner (Claude Opus) │
+│ Status: ✅ Complete │
+│ Tokens: 12.4k in / 3.2k out│
+│ Duration: 8.3s │
+│ Output: blueprint.json │
+│ [View Log] [View Output] │
+└─────────────────────────────┘
+```
+
+### 4.3 Interactive Planning(借鉴 Devin 2.0)
+
+在代码生成前,展示可编辑的执行计划:
+
+```
+┌── Execution Plan (editable) ──────────────────┐
+│ │
+│ 📄 Paper: "Attention Is All You Need" │
+│ │
+│ Step 1: Extract architecture from Section 3 │
+│ → Multi-head attention + FFN + LayerNorm │
+│ → Model: Claude Opus (high reasoning) │
+│ │
+│ Step 2: Generate file structure │
+│ ├── config.py (hyperparams from Table 3) │
+│ ├── model.py (Transformer architecture) │
+│ ├── data.py (WMT14 En-De loader) │
+│ ├── train.py (Adam, lr warmup from Sec 5.3) │
+│ └── evaluate.py (BLEU score) │
+│ → Model: Claude Code (tool use) │
+│ │
+│ Step 3: Verify against Table 2 results │
+│ → Expected: BLEU 28.4 on WMT14 En-De │
+│ → Sandbox: Docker (GPU optional) │
+│ │
+│ [✏️ Edit Plan] [▶️ Execute] [💾 Save Template] │
+└────────────────────────────────────────────────┘
+```
+
+---
+
+## 5. API Design
+
+### 5.1 New Endpoints
+
+```
+# Swarm orchestration
+POST /api/swarm/sessions # Create new swarm session
+GET /api/swarm/sessions/{id} # Get session status + agent states
+POST /api/swarm/sessions/{id}/start # Start execution
+POST /api/swarm/sessions/{id}/pause # Pause (agents checkpoint)
+POST /api/swarm/sessions/{id}/resume # Resume from checkpoint
+DELETE /api/swarm/sessions/{id} # Terminate + cleanup
+
+# Agent management
+GET /api/swarm/sessions/{id}/agents # List agents in session
+GET /api/swarm/sessions/{id}/agents/{aid} # Agent detail + logs
+POST /api/swarm/sessions/{id}/agents/{aid}/message # Send message to agent
+
+# Event stream
+GET /api/swarm/sessions/{id}/events # SSE stream of all agent events
+
+# Plan editing
+GET /api/swarm/sessions/{id}/plan # Get current plan
+PATCH /api/swarm/sessions/{id}/plan # Edit plan
+POST /api/swarm/sessions/{id}/plan/approve # Approve plan → execute
+
+# Evidence
+GET /api/swarm/sessions/{id}/evidence # List artifacts
+POST /api/swarm/sessions/{id}/evidence/export # Export evidence pack
+```
+
+### 5.2 Backward Compatibility
+
+现有 `/api/gen-code` 保持不变,内部路由到 `BuiltInAdapter`(包裹现有 P2C pipeline)。新 UI 使用 `/api/swarm/*`。
+
+---
+
+## 6. Agent-Computer Interface (ACI)
+
+借鉴 SWE-Agent 的核心洞察:**Agent 需要为 LLM 优化的接口,而非人类接口**。
+
+### 6.1 Paper-Specific ACI Commands
+
+为论文理解设计专用工具(这是 AgentSwarm 区别于通用 coding agent 的核心):
+
+```python
+# 论文理解工具
+extract_equations(section: str) -> List[Equation]
+get_hyperparameters() -> Dict[str, Any]
+get_dataset_requirements() -> DatasetSpec
+get_architecture_description(section: str) -> str
+compare_results(table_number: int) -> ComparisonTable
+
+# 代码生成工具
+query_coderag(pattern_type: str) -> List[CodePattern]
+get_file_context(filename: str) -> FileContext # from CodeMemory
+check_dependency_graph() -> DependencyTree
+validate_imports(filename: str) -> ImportReport
+
+# 验证工具
+run_syntax_check(filename: str) -> CheckResult
+run_smoke_test(command: str, timeout: int) -> TestResult
+compare_output_vs_paper(metric: str, expected: float) -> VerificationResult
+```
+
+### 6.2 MCP Integration
+
+将 ACI 工具注册为 MCP tools,使任何接入的 Agent 都能调用:
+
+```python
+# Paper-specific MCP tools
+mcp_server = MCPServer("paperbot-paper-tools")
+
+@mcp_server.tool("extract_equations")
+async def extract_equations(section: str) -> List[dict]: ...
+
+@mcp_server.tool("query_coderag")
+async def query_coderag(pattern_type: str) -> List[dict]: ...
+
+@mcp_server.tool("run_smoke_test")
+async def run_smoke_test(command: str, timeout: int) -> dict: ...
+```
+
+---
+
+## 7. Phased Roadmap
+
+### Phase 1 (v1) — Foundation: Agent Runtime + Router
+
+**目标**:替换 `Dict[str, Any]` 共享上下文,实现真正的多 Agent 并行执行。
+
+| Task | Description | Est. |
+|------|-------------|------|
+| `swarm/message_bus.py` | Typed event-stream message bus | 3h |
+| `swarm/agent_router.py` | Task → Agent 路由器 | 3h |
+| `swarm/session_manager.py` | 持久化 Agent session + replay | 4h |
+| `swarm/adapters/builtin.py` | 包裹现有 P2C nodes 为 adapter | 2h |
+| `swarm/adapters/claude_code.py` | Claude Code SDK/CLI adapter | 4h |
+| `swarm/orchestrator.py` | 新 Orchestrator(替代旧的,event-driven) | 4h |
+| `api/routes/swarm.py` | Swarm API endpoints | 3h |
+| repro/ LLM 统一 | 让 repro 模块走 `LLMService` | 2h |
+| Tests | 单元测试 + 集成测试 | 4h |
+
+**Deliverables**:
+- `POST /api/swarm/sessions` 可创建 session
+- PlannerAgent + CoderAgent 可并行子任务(独立文件生成)
+- Event stream SSE 可观测 Agent 活动
+- 现有 `/api/gen-code` 不受影响
+
+### Phase 2 (v2) — Multi-Agent + External Agents
+
+**目标**:接入 Codex/OpenHands,实现跨 Agent 协作。
+
+| Task | Description | Est. |
+|------|-------------|------|
+| `swarm/adapters/codex.py` | Codex CLI/API adapter | 4h |
+| `swarm/adapters/openhands.py` | OpenHands SDK adapter | 4h |
+| Interactive planning UI | 可编辑执行计划 + approve/reject | 6h |
+| Agent Dashboard UI | Agent 卡片 + DAG 可视化 | 6h |
+| Terminal component | xterm.js agent stdout 实时流 | 4h |
+| Evidence Timeline UI | Run/Step 分组 + 过滤 | 4h |
+| Paper ACI tools | 论文专用 MCP tools (5-8 个) | 6h |
+| SSH fleet executor | 远端 GPU 执行 | 4h |
+| CodeRAG 升级 | Embedding-based retrieval | 4h |
+
+**Deliverables**:
+- 用户可选择用 Claude Code / Codex / OpenHands 执行子任务
+- 可编辑执行计划
+- Agent 活动实时可视化
+
+### Phase 3 (v3) — Intelligence Layer
+
+**目标**:领域知识积累,自动优化路由。
+
+| Task | Description |
+|------|-------------|
+| Execution history analytics | 成功/失败模式分析,优化 Agent 路由 |
+| Learned patterns in CodeRAG | 从成功生成中提取新 pattern |
+| Paper-to-Repro success predictor | 根据论文特征预测复现难度 |
+| GPU scheduler | nvidia-smi 空闲检测 + 自动调度 |
+| Reproducibility leaderboard | 按领域/venue 展示可复现率 |
+| Cursor/Devin adapter | 评估是否值得接入 |
+
+---
+
+## 8. Key Design Decisions
+
+### Decision 1: Event-Sourced State vs. Shared Mutable State
+
+**选择**:Event-Sourced(借鉴 OpenHands V1)
+
+理由:
+- 可确定性 replay(对学术复现至关重要)
+- 可观测性(每个 Agent action 都有记录)
+- 解耦(Agent 不直接修改共享状态)
+- 已有 `event_log` 基础设施可复用
+
+### Decision 2: Agent SDK vs. CLI Subprocess
+
+**选择**:优先 Agent SDK,CLI 作为 fallback
+
+理由:
+- Claude Agent SDK 提供 programmatic tool use + streaming
+- Codex 有 App Server protocol(比 CLI 更丰富)
+- CLI subprocess 仅用于无 SDK 的 Agent(Cursor)
+
+### Decision 3: Sandbox Per Agent vs. Shared Workspace
+
+**选择**:Shared Workspace + Isolated Verification Sandbox
+
+理由:
+- Agent 间需要看到彼此生成的文件(CodeMemory 依赖此)
+- 仅 Verification 阶段需要隔离 sandbox(防止副作用)
+- 借鉴 Codex 的 sandbox inheritance 模式
+
+### Decision 4: MCP vs. Custom Tool Protocol
+
+**选择**:MCP(前端已有基础设施)
+
+理由:
+- 前端 `MCPClientManager` 已实现
+- Claude Code / Codex / OpenHands 均支持 MCP
+- Paper-specific tools 注册为 MCP server,所有 Agent 可调用
+- 避免自造协议(Codex 选择自建 App Server protocol,但他们有资源维护)
+
+---
+
+## 9. File Structure
+
+```
+src/paperbot/infrastructure/swarm/
+├── __init__.py
+├── agent_router.py # Task → Agent routing
+├── message_bus.py # Event-driven agent communication
+├── session_manager.py # Persistent agent sessions
+├── orchestrator.py # New event-driven orchestrator
+├── models.py # SwarmTask, SwarmEvent, AgentResult, etc.
+├── adapters/
+│ ├── __init__.py
+│ ├── base.py # AgentAdapter ABC
+│ ├── builtin.py # Wraps existing P2C pipeline
+│ ├── claude_code.py # Claude Code SDK/CLI
+│ ├── codex.py # Codex CLI/API (v2)
+│ └── openhands.py # OpenHands SDK (v2)
+├── aci/ # Agent-Computer Interface
+│ ├── __init__.py
+│ ├── paper_tools.py # Paper-specific ACI tools
+│ ├── code_tools.py # Code generation tools
+│ └── mcp_server.py # MCP server exposing ACI tools
+└── evidence/
+ ├── __init__.py
+ └── pack.py # Evidence pack export
+
+src/paperbot/api/routes/swarm.py # Swarm API endpoints
+
+web/src/components/studio/
+├── AgentDashboard.tsx # Agent cards + DAG visualization
+├── AgentCard.tsx # Single agent status card
+├── ExecutionPlan.tsx # Interactive plan editor
+├── EvidenceTimeline.tsx # Run/Step grouped timeline
+└── TerminalPanel.tsx # xterm.js agent output
+```
+
+---
+
+## 10. Competitive Advantages
+
+| Advantage | Details |
+|-----------|---------|
+| **Domain-specific ACI** | Paper-understanding tools (equation extraction, hyperparameter identification, result comparison) that no generic coding agent has |
+| **Verification-centric** | Dedicated VerificationAgent + ReviewerAgent 对比论文报告结果 vs 代码输出 — 目前没有平台做这件事 |
+| **Academic knowledge graph** | 可利用 PaperBot 已有的 Scholar tracking + citation network + venue 数据 |
+| **Multi-agent router** | 根据任务特征自动选择最佳 Agent(Claude for reasoning, Codex for bulk, Haiku for boilerplate) |
+| **Evidence traceability** | Event-sourced state + Evidence Pack 导出,满足学术复现的可追溯要求 |
+| **Existing infrastructure** | CodeMemory, CodeRAG, Docker/E2B executors, Runbook file management 已完成 |
+
+---
+
+## Appendix: Competitive Landscape Summary
+
+| Platform | UX | Agent Loop | Sandbox | Multi-Agent | Key Lesson |
+|----------|-----|-----------|---------|-------------|------------|
+| Devin | Cloud IDE | Planner→Coder→Critic | Cloud VM | MultiDevin parallel | Interactive planning, compound AI |
+| OpenHands | Web IDE + CLI | Event-stream loop | Docker | Hierarchical delegation | Event-sourced state, benchmark rigor |
+| SWE-Agent | CLI | ACI custom commands | Docker/SWE-ReX | Single (external multi) | LM-optimized interfaces > human interfaces |
+| Cursor | IDE | MoE + subagents | Local | Background agents | Codebase indexing, tight feedback loop |
+| Codex | Cloud + CLI | Responses API loop | Container/Seatbelt | Experimental spawn | Dual cloud/local, App Server protocol |
+| Claude Code | CLI | Tool-use loop | Permission-based | Teams (experimental) | Thin wrapper, prefix caching, Unix composability |
+| **OpenCode** | **CLI (Go+Bun)** | **Build/Plan agents** | **Local** | **Subagents** | **112K stars, 75+ providers, scans .claude/skills/** |
+
+---
+
+## Appendix B: Open-Source Orchestration Frameworks
+
+| Framework | Key Pattern | PaperBot Relevance |
+|-----------|-----------|-------------------|
+| [VibeKanban](https://github.com/BloopAI/vibe-kanban) | Git worktree isolation per agent | Parallel Paper2Code jobs 互不冲突 |
+| [MetaSwarm](https://github.com/dsifry/metaswarm) | Cross-model adversarial review + budget enforcement | Writer/Reviewer 用不同 LLM,USD circuit breaker |
+| [Ruflo](https://github.com/ruvnet/ruflo) | Dual-mode Claude+Codex, WASM policy engine | 双 Agent 协作 pattern |
+| [Oh-My-OpenCode](https://github.com/code-yeongyu/oh-my-opencode) | Intent Gate + Category Routing + Boulder Continuation | 编排模式直接借鉴(见 §11.2) |
+
+---
+
+## 11. OpenCode / OpenClaw / oMo 生态集成
+
+### 11.1 OpenCode (opencode.ai)
+
+**概述**:OpenCode 是增长最快的开源 coding agent(112K GitHub stars,250万月活),Go + Bun 架构,支持 75+ LLM provider。
+
+**关键特性**:
+- 原生 SKILL.md 支持(v1.0.190+)
+- **主动扫描 `.claude/skills/` 目录** — 意味着放在这里的 skill 同时被 Claude Code 和 OpenCode 发现
+- 两种 Agent: Build (full access) + Plan (read-only)
+- LSP 深度集成(Global Event Bus)
+- `AGENTS.md` 支持(vs Claude Code 的 `CLAUDE.md`)
+
+**Adapter 规划** (#215):
+```python
+class OpenCodeAdapter(AgentAdapter):
+ """OpenCode CLI as execution backend.
+ Useful for: local model routing (Ollama), non-Anthropic models."""
+ agent_type = "opencode"
+```
+
+### 11.2 Oh-My-OpenCode (oMo)
+
+**概述**:OpenCode 的多 Agent 编排插件层(1,208 TS 文件,143K 行),实现分层 Agent 系统。
+
+**三层 Agent 架构**:
+| Tier | Agents | Role |
+|------|--------|------|
+| Orchestrator | Sisyphus, Prometheus | 规划、委派、驱动 |
+| Specialist | Oracle, Hephaestus, Momus, Metis | 架构、执行、审查、补漏 |
+| Utility | Explore, Librarian | 搜索、文档 |
+
+**PaperBot 应借鉴的三个模式** (#216):
+
+#### Pattern 1: Intent Gate Classification
+在 dispatch 到 Agent 前先分类意图(research / implementation / investigation / review / tracking),避免误路由。
+```python
+class IntentGate:
+ INTENTS = ["research", "implementation", "investigation", "review", "tracking"]
+ async def classify(self, user_input: str) -> str: ...
+```
+
+#### Pattern 2: Category-based Model Routing
+替代硬编码 model mapping,定义 capability categories:
+```python
+MODEL_CATEGORIES = {
+ "orchestration": ["claude-opus-4-6", "kimi-k2.5"],
+ "reasoning": ["claude-opus-4-6", "gpt-5.3"],
+ "speed": ["claude-haiku-4-5", "minimax-text"],
+ "implementation": ["codex", "claude-sonnet-4-6"],
+}
+```
+Agent 声明需要哪个 category,Router 自动选择可用的最佳 model + fallback chain。
+
+#### Pattern 3: Boulder Continuation Enforcer
+长流水线中断时自动注入 continuation prompt(指数退避),配合 DebuggerAgent 形成自愈闭环。PaperBot 的 Paper2Code pipeline 经常在 verification 阶段失败,Boulder 模式可自动重试。
+
+### 11.3 OpenClaw + ClawHub Skills 生态
+
+**概述**:OpenClaw 是自治 AI Agent(100K stars),ClawHub 是其 skills marketplace(13,729 社区 skills)。
+
+**可复用的学术 Skills**:
+
+| ClawHub Skill | PaperBot 对应模块 | 复用策略 |
+|---------------|-------------------|---------|
+| `academic-deep-research` | `agents/research/` | Fork + adapt 评分体系 |
+| `arxiv-reader` | `connectors/arxiv.py` | Fork + 接入现有 connector |
+| `academic-research` (OpenAlex) | `adapters/openalex.py` | Fork + 接入现有 adapter |
+| `agent-brain` (SQLite memory) | `services/memory/` | 参考模式 |
+| `academic-writing-refiner` | 无 | 评估是否新增 |
+
+**PaperBot 独有 Skills(需 build from scratch)**:
+
+| Skill | 说明 | 现有代码 |
+|-------|------|----------|
+| `/reproduce` | 论文→代码完整 pipeline | `repro/orchestrator.py` |
+| `/analyze-paper` | 5维评分 + 推荐分级 | `workflows/analysis/judge.py` |
+| `/scholar-track` | 学者追踪 + PIS 评分 | `agents/scholar_tracking/` |
+| `/daily-digest` | 每日推送生成 | `workflows/dailypaper.py` |
+| `/extract-figures` | MinerU 图表提取 | `extractors/mineru_client.py` |
+| `/verify-results` | 结果验证 | `repro/nodes/verification.py` |
+
+### 11.4 统一 Skills 策略 (#217)
+
+**核心原则**:一套 SKILL.md,多平台兼容。
+
+```
+.claude/skills/ ← Claude Code 扫描
+ ← OpenCode 也扫描(自动互通)
+├── analyze-paper/SKILL.md
+├── reproduce/SKILL.md
+├── extract-figures/SKILL.md
+├── verify-results/SKILL.md
+├── scholar-track/SKILL.md
+├── daily-digest/SKILL.md
+├── arxiv-search/SKILL.md ← adopt from ClawHub arxiv-reader
+└── paperbot-conventions/SKILL.md ← auto-invoked background knowledge
+
+.claude/agents/ ← Claude Code 独有
+├── paper-analyst.md
+├── code-reproducer.md
+└── research-explorer.md
+
+AGENTS.md ← Codex / Cursor / Copilot / OpenCode 读取
+```
+
+**SKILL.md 兼容性保证**:frontmatter 保持最小公约数(`name` + `description` + `license` + `metadata`),不使用任何平台独有字段。平台特定逻辑放在 markdown body 中作为 conditional instructions。
+
+**发布策略**:将 PaperBot 独有 skills 提交到 ClawHub(Fork → PR),扩大 PaperBot 在 Agent 生态中的影响力。
+
+---
+
+## 12. Updated File Structure
+
+```
+src/paperbot/infrastructure/swarm/
+├── __init__.py
+├── agent_router.py # Task → Agent routing
+├── message_bus.py # Event-driven agent communication
+├── session_manager.py # Persistent agent sessions
+├── orchestrator.py # New event-driven orchestrator
+├── models.py # SwarmTask, SwarmEvent, AgentResult, etc.
+├── intent_gate.py # Intent classification (from oMo)
+├── category_router.py # Category-based model routing (from oMo)
+├── boulder_continuation.py # Auto-retry for failed pipeline steps (from oMo)
+├── adversarial_review.py # Cross-model writer/reviewer (from MetaSwarm)
+├── adapters/
+│ ├── __init__.py
+│ ├── base.py # AgentAdapter ABC
+│ ├── builtin.py # Wraps existing P2C pipeline
+│ ├── claude_code.py # Claude Code SDK/CLI
+│ ├── codex.py # Codex App Server / MCP / CLI (v2)
+│ ├── opencode.py # OpenCode Build/Plan agents (v2)
+│ ├── openhands.py # OpenHands SDK (v2)
+│ └── ...
+├── aci/ # Agent-Computer Interface
+│ ├── __init__.py
+│ ├── paper_tools.py # Paper-specific ACI tools
+│ ├── code_tools.py # Code generation tools
+│ └── mcp_server.py # MCP server exposing ACI tools
+└── evidence/
+ ├── __init__.py
+ └── pack.py # Evidence pack export
+
+.claude/skills/ # Unified Skills (Claude Code + OpenCode)
+├── analyze-paper/SKILL.md
+├── reproduce/SKILL.md
+├── extract-figures/SKILL.md
+├── verify-results/SKILL.md
+├── scholar-track/SKILL.md
+├── daily-digest/SKILL.md
+├── arxiv-search/SKILL.md
+└── paperbot-conventions/SKILL.md
+
+.claude/agents/ # Custom subagents (Claude Code)
+├── paper-analyst.md
+├── code-reproducer.md
+└── research-explorer.md
+
+AGENTS.md # Cross-platform agent instructions
+```
+
+---
+
+## Appendix C: Skills Ecosystem Landscape
+
+| Ecosystem | Skills Count | Format | Discovery Path | PaperBot 策略 |
+|-----------|-------------|--------|---------------|---------------|
+| Claude Code | Built-in + custom | SKILL.md | `.claude/skills/` | 主要 target |
+| OpenCode | Native (v1.0.190) | SKILL.md | `.claude/skills/` + `.opencode/skills/` | 自动兼容 |
+| OpenClaw ClawHub | 13,729 | SKILL.md | `~/.openclaw/skills/` | 复用学术 skills + 发布独有 skills |
+| Codex | Automations | AGENTS.md | `AGENTS.md` walk | 通过 AGENTS.md 兼容 |
+| Cursor | Rules | `.cursor/rules/` | `.cursor/rules/*.mdc` | 不主动支持 |
+| VS Code Copilot | Agent Skills | SKILL.md | `.github/skills/` | 自动兼容 |
+
+### Key References
+
+**Agent Platforms:**
+- [OpenCode](https://github.com/opencode-ai/opencode) — 112K stars, 75+ providers
+- [Oh-My-OpenCode](https://github.com/code-yeongyu/oh-my-opencode) — Multi-agent orchestration plugin
+- [OpenClaw](https://github.com/openclaw/openclaw) — 100K stars autonomous agent
+
+**Skills Ecosystem:**
+- [Agent Skills spec](https://agentskills.io/specification) — SKILL.md open standard
+- [ClawHub](https://github.com/openclaw/clawhub) — 13K+ community skills
+- [Anthropic Skills](https://github.com/anthropics/skills) — Official skill examples
+- [AGENTS.md](https://agents.md/) — Linux Foundation open standard
+- [Awesome OpenClaw Skills](https://github.com/VoltAgent/awesome-openclaw-skills) — 5,494 curated skills
diff --git a/env.example b/env.example
index 7611bc42..30a568c0 100644
--- a/env.example
+++ b/env.example
@@ -27,6 +27,14 @@ OPENAI_BASE_URL=
# ----------------------------
SEMANTIC_SCHOLAR_API_KEY=
GITHUB_TOKEN=
+MINERU_API_KEY=
+# MinerU Cloud API v4 async task endpoint
+MINERU_API_BASE_URL=https://mineru.net/api/v4
+# MinerU model_version, e.g. vlm / pipeline
+MINERU_MODEL_VERSION=vlm
+# Max wait for task polling in seconds
+MINERU_MAX_WAIT_SECONDS=180
+# MinerU Cloud limits: URL input only, <=200MB, <=600 pages, github/aws URL may timeout
# CCS download (optional, ACM access URL)
ACM_LIBRARY_URL=
@@ -101,6 +109,8 @@ PAPERBOT_DAILYPAPER_ENABLE_JUDGE=true
PAPERBOT_DAILYPAPER_JUDGE_RUNS=1
PAPERBOT_DAILYPAPER_JUDGE_MAX_ITEMS=5
PAPERBOT_DAILYPAPER_JUDGE_TOKEN_BUDGET=0
+PAPERBOT_DAILYPAPER_ENABLE_FIGURES=false
+PAPERBOT_DAILYPAPER_FIGURES_MAX_ITEMS=5
# ----------------------------
# Daily push notifications
diff --git a/src/paperbot/api/routes/repro_context.py b/src/paperbot/api/routes/repro_context.py
index e9ca48ab..ecba3020 100644
--- a/src/paperbot/api/routes/repro_context.py
+++ b/src/paperbot/api/routes/repro_context.py
@@ -23,6 +23,7 @@
from paperbot.api.streaming import StreamEvent, wrap_generator
from paperbot.application.services.p2c.models import (
GenerateContextRequest as P2CRequest,
+ RawPaperData,
new_context_pack_id,
)
from paperbot.application.services.p2c.orchestrator import ExtractionOrchestrator
@@ -44,6 +45,9 @@ class GenerateContextPackRequest(BaseModel):
project_id: Optional[str] = None
track_id: Optional[int] = None
depth: Literal["fast", "standard", "deep"] = "standard"
+ # Optional inline paper data — when provided, skips the input router lookup.
+ title: Optional[str] = None
+ abstract: Optional[str] = None
class CreateSessionRequest(BaseModel):
@@ -65,17 +69,23 @@ async def _generate_stream(request: GenerateContextPackRequest):
)
# Persist initial "running" record so the frontend can poll status.
- await asyncio.to_thread(
- _store.save,
- pack_id=pack_id,
- user_id=request.user_id,
- paper_id=request.paper_id,
- depth=request.depth,
- pack_data={},
- project_id=request.project_id,
- confidence_overall=0.0,
- warning_count=0,
- )
+ try:
+ await asyncio.to_thread(
+ _store.save,
+ pack_id=pack_id,
+ user_id=request.user_id,
+ paper_id=request.paper_id,
+ depth=request.depth,
+ pack_data={},
+ project_id=request.project_id,
+ confidence_overall=0.0,
+ warning_count=0,
+ )
+ except Exception as exc:
+ Logger.warning(
+ f"[M2] store_save_failed pack_id={pack_id} error={exc} (continuing without persistence)",
+ file=LogFiles.ERROR,
+ )
yield StreamEvent(type="status", data={"pack_id": pack_id, "status": "running"})
@@ -107,15 +117,22 @@ async def on_stage_complete(stage_name: str, observations: list, warnings: list)
}
for o in observations
]
- await asyncio.to_thread(
- _store.save_stage_result,
- pack_id=pack_id,
- stage_name=stage_name,
- status="completed",
- result_data={"observations": [o.to_full() for o in observations]},
- confidence=confidence,
- duration_ms=0,
- )
+ try:
+ await asyncio.to_thread(
+ _store.save_stage_result,
+ pack_id=pack_id,
+ stage_name=stage_name,
+ status="completed",
+ result_data={"observations": [o.to_full() for o in observations]},
+ confidence=confidence,
+ duration_ms=0,
+ )
+ except Exception as exc:
+ Logger.warning(
+ f"[M2] store_save_stage_result_failed pack_id={pack_id} "
+ f"stage={stage_name} error={exc}",
+ file=LogFiles.ERROR,
+ )
Logger.info(
f"[M2] stage_complete pack_id={pack_id} stage={stage_name} obs={len(observations)} warnings={len(warnings)}",
file=LogFiles.API,
@@ -150,9 +167,24 @@ async def on_stage_complete(stage_name: str, observations: list, warnings: list)
orchestrator = ExtractionOrchestrator()
result_holder: list = []
+ # When title+abstract are provided inline, build RawPaperData directly
+ # so the orchestrator skips the input router (which cannot handle studio IDs).
+ inline_raw: Optional[RawPaperData] = None
+ if request.title is not None and request.abstract is not None:
+ inline_raw = RawPaperData(
+ paper_id=request.paper_id,
+ title=request.title,
+ abstract=request.abstract,
+ source_adapter="inline",
+ )
+
async def _run() -> None:
try:
- pack = await orchestrator.run(p2c_request, on_stage_complete=on_stage_complete)
+ pack = await orchestrator.run(
+ p2c_request,
+ raw_paper=inline_raw,
+ on_stage_complete=on_stage_complete,
+ )
result_holder.append(pack)
await queue.put(_DONE)
except ValueError as exc:
@@ -175,8 +207,16 @@ async def _run() -> None:
break
elif item is _ERROR:
err = result_holder[0]
- await asyncio.to_thread(_store.update_status, pack_id, status="failed")
- yield StreamEvent(type="error", data=err)
+ err_message = err.get("message", "Generation failed") if isinstance(err, dict) else str(err)
+ try:
+ await asyncio.to_thread(_store.update_status, pack_id, status="failed")
+ except Exception as exc:
+ Logger.warning(
+ f"[M2] store_update_status_failed pack_id={pack_id} "
+ f"status=failed error={exc}",
+ file=LogFiles.ERROR,
+ )
+ yield StreamEvent(type="error", data=err, message=err_message)
return
else:
yield item # StreamEvent from on_stage_complete
@@ -196,15 +236,21 @@ async def _run() -> None:
pack_dict = _asdict(pack)
pack_dict["context_pack_id"] = pack_id # align with our DB record
- await asyncio.to_thread(
- _store.update_status,
- pack_id,
- status="completed",
- pack_data=pack_dict,
- confidence_overall=pack.confidence.overall,
- warning_count=len(pack.warnings),
- objective=pack.objective,
- )
+ try:
+ await asyncio.to_thread(
+ _store.update_status,
+ pack_id,
+ status="completed",
+ pack_data=pack_dict,
+ confidence_overall=pack.confidence.overall,
+ warning_count=len(pack.warnings),
+ objective=pack.objective,
+ )
+ except Exception as exc:
+ Logger.warning(
+ f"[M2] store_update_failed pack_id={pack_id} error={exc}",
+ file=LogFiles.ERROR,
+ )
Logger.info(
f"[M2] generation_completed pack_id={pack_id} observations={len(pack.observations)} warnings={len(pack.warnings)}",
file=LogFiles.API,
diff --git a/src/paperbot/api/routes/runbook.py b/src/paperbot/api/routes/runbook.py
index 8ebc3e4f..a58fdcca 100644
--- a/src/paperbot/api/routes/runbook.py
+++ b/src/paperbot/api/routes/runbook.py
@@ -155,13 +155,15 @@ def _normalize_user_directory(raw_path: str, field_name: str) -> Path:
if not os.path.isabs(normalized):
normalized = str((Path.cwd() / normalized).resolve(strict=False))
+ normalized_real = os.path.realpath(normalized)
for prefix in _allowed_workdir_prefixes():
prefix_str = str(prefix)
- if normalized == prefix_str:
+ prefix_real = os.path.realpath(prefix_str)
+ if normalized_real == prefix_real:
return prefix
- if normalized.startswith(prefix_str + os.sep):
- suffix = normalized[len(prefix_str):].lstrip("/\\")
+ if normalized_real.startswith(prefix_real + os.sep):
+ suffix = normalized_real[len(prefix_real):].lstrip("/\\")
candidate = (prefix / suffix).resolve(strict=False) if suffix else prefix
if _is_under_prefix(candidate, prefix):
return candidate
@@ -231,15 +233,26 @@ async def add_allowed_dir(body: AddAllowedDirRequest):
detail="runtime allowlist mutation is disabled"
)
- try:
- resolved = _normalize_user_directory(body.directory, field_name="directory")
- except HTTPException:
- raise
+ raw = body.directory.strip()
+ if not raw or "\x00" in raw:
+ raise HTTPException(status_code=400, detail="invalid directory path")
+
+ # Normalize and validate against configured safe prefixes before touching FS.
+ # This prevents path-injection style access to arbitrary locations.
+ resolved = _normalize_user_directory(raw, field_name="directory")
+
+ denied_roots = {Path(denied).resolve() for denied in _DENIED_PATHS}
+ if resolved in denied_roots:
+ raise HTTPException(
+ status_code=403,
+ detail=f"adding '{resolved}' is not allowed — path is too broad or sensitive",
+ )
- if str(resolved) in _DENIED_PATHS:
+ # Deny home directory itself — too broad
+ if resolved == Path.home().resolve():
raise HTTPException(
status_code=403,
- detail=f"adding '{resolved}' is not allowed — path is too broad or sensitive"
+ detail="adding home directory is not allowed — too broad",
)
if not resolved.exists() or not resolved.is_dir():
diff --git a/src/paperbot/api/routes/studio_chat.py b/src/paperbot/api/routes/studio_chat.py
index f6fe12eb..0b335662 100644
--- a/src/paperbot/api/routes/studio_chat.py
+++ b/src/paperbot/api/routes/studio_chat.py
@@ -10,10 +10,13 @@
import asyncio
import json
+import logging
import os
import shutil
import subprocess
-from typing import List, Optional, Literal, AsyncGenerator
+import tempfile
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Literal, AsyncGenerator
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
@@ -46,6 +49,7 @@ class StudioChatRequest(BaseModel):
project_dir: Optional[str] = None
history: List[ChatMessage] = []
session_id: Optional[str] = None
+ context_pack_id: Optional[str] = None
def find_claude_cli() -> Optional[str]:
@@ -123,8 +127,316 @@ def build_prompt_with_context(message: str, paper: Optional[PaperContext], mode:
return "\n".join(parts)
+log = logging.getLogger(__name__)
+
+
+def _load_runtime_allowed_dirs() -> List[Path]:
+ f = Path("data/runbook_allowed_dirs.json")
+ if not f.exists():
+ return []
+ try:
+ data = json.loads(f.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError, ValueError):
+ return []
+ if not isinstance(data, list):
+ return []
+ dirs: List[Path] = []
+ for item in data:
+ if isinstance(item, str) and item.strip():
+ try:
+ dirs.append(Path(item).resolve())
+ except Exception:
+ continue
+ return dirs
+
+
+def _allowed_workdir_prefixes() -> List[Path]:
+ prefixes: List[Path] = [Path(tempfile.gettempdir()).resolve()]
+ try:
+ prefixes.append(Path.cwd().resolve())
+ except Exception:
+ pass
+
+ extra = os.getenv("PAPERBOT_RUNBOOK_ALLOW_DIR_PREFIXES", "").strip()
+ if extra:
+ for p in extra.split(","):
+ p = p.strip()
+ if p:
+ try:
+ prefixes.append(Path(p).expanduser().resolve())
+ except Exception:
+ continue
+
+ prefixes.extend(_load_runtime_allowed_dirs())
+
+ unique: List[Path] = []
+ seen: set[str] = set()
+ for p in prefixes:
+ key = str(p)
+ if key in seen:
+ continue
+ seen.add(key)
+ unique.append(p)
+ return unique
+
+
+def _is_under_prefix(path: Path, prefix: Path) -> bool:
+ path_real = os.path.realpath(str(path))
+ prefix_real = os.path.realpath(str(prefix))
+ return path_real == prefix_real or path_real.startswith(prefix_real + os.sep)
+
+
+def _resolve_cli_project_dir(raw: Optional[str]) -> Path:
+ """Resolve and validate project_dir used by studio CLI execution."""
+ if not raw:
+ return Path.cwd().resolve()
+
+ cleaned = raw.strip()
+ if not cleaned or "\x00" in cleaned:
+ raise ValueError("invalid project_dir")
+
+ if cleaned == "~":
+ normalized = str(Path.home())
+ elif cleaned.startswith("~/"):
+ normalized = str(Path.home() / cleaned[2:])
+ else:
+ normalized = cleaned
+
+ # Normalize to real path and reconstruct from an allowed prefix.
+ # This avoids resolving an arbitrary user-controlled path directly.
+ if not os.path.isabs(normalized):
+ normalized = str((Path.cwd() / normalized).resolve(strict=False))
+ normalized_real = os.path.realpath(normalized)
+
+ resolved: Optional[Path] = None
+ for prefix in _allowed_workdir_prefixes():
+ prefix_real = os.path.realpath(str(prefix))
+ if normalized_real == prefix_real:
+ resolved = prefix
+ break
+ if normalized_real.startswith(prefix_real + os.sep):
+ suffix = normalized_real[len(prefix_real):].lstrip("/\\")
+ candidate = (prefix / suffix).resolve(strict=False) if suffix else prefix
+ if _is_under_prefix(candidate, prefix):
+ resolved = candidate
+ break
+ raise ValueError("project_dir is not allowed")
+
+ if resolved is None:
+ raise ValueError("project_dir is not allowed")
+
+ if not resolved.exists() or not resolved.is_dir():
+ raise ValueError("project_dir must be an existing directory")
+ return resolved
+
+
+def _load_context_pack(pack_id: str) -> Optional[Dict[str, Any]]:
+ """Load a context pack from the database by ID."""
+ try:
+ from ...infrastructure.stores.repro_context_store import SqlAlchemyReproContextStore
+
+ store = SqlAlchemyReproContextStore()
+ return store.get(pack_id)
+ except Exception as exc:
+ log.warning("Failed to load context pack %s: %s", pack_id, exc)
+ return None
+
+
+def _format_context_pack_markdown(pack: Dict[str, Any]) -> str:
+ """Format a context pack as a Markdown document for Claude CLI to read."""
+ lines: list[str] = []
+
+ lines.append("# Reproduction Context Pack")
+ lines.append("")
+
+ # Paper metadata
+ paper = pack.get("paper", {})
+ if paper:
+ lines.append("## Paper")
+ if paper.get("title"):
+ lines.append(f"**Title:** {paper['title']}")
+ if paper.get("authors"):
+ authors = paper["authors"]
+ if isinstance(authors, list):
+ lines.append(f"**Authors:** {', '.join(authors)}")
+ else:
+ lines.append(f"**Authors:** {authors}")
+ if paper.get("year"):
+ lines.append(f"**Year:** {paper['year']}")
+ if paper.get("arxiv_id"):
+ lines.append(f"**arXiv:** {paper['arxiv_id']}")
+ if paper.get("doi"):
+ lines.append(f"**DOI:** {paper['doi']}")
+ lines.append("")
+
+ # Objective
+ if pack.get("objective"):
+ lines.append("## Objective")
+ lines.append(pack["objective"])
+ lines.append("")
+
+ # Task roadmap
+ roadmap = pack.get("task_roadmap", [])
+ if roadmap:
+ lines.append("## Task Roadmap")
+ lines.append("")
+ for i, step in enumerate(roadmap, 1):
+ title = step.get("title", f"Step {i}")
+ lines.append(f"### Step {i}: {title}")
+ if step.get("description"):
+ lines.append(step["description"])
+ if step.get("acceptance_criteria"):
+ lines.append("")
+ lines.append("**Acceptance criteria:**")
+ criteria = step["acceptance_criteria"]
+ if isinstance(criteria, list):
+ for c in criteria:
+ lines.append(f"- {c}")
+ else:
+ lines.append(f"- {criteria}")
+ lines.append("")
+
+ # Observations
+ observations = pack.get("observations", [])
+ if observations:
+ lines.append("## Observations")
+ lines.append("")
+ for obs in observations:
+ obs_type = obs.get("type", "note")
+ title = obs.get("title", "Untitled")
+ confidence = obs.get("confidence", 0)
+ lines.append(f"### [{obs_type.upper()}] {title} (confidence: {confidence:.0%})")
+ if obs.get("content"):
+ lines.append(obs["content"])
+ elif obs.get("description"):
+ lines.append(obs["description"])
+ if obs.get("code_snippet"):
+ lines.append("")
+ lang = obs.get("language", "")
+ lines.append(f"```{lang}")
+ lines.append(obs["code_snippet"])
+ lines.append("```")
+ lines.append("")
+
+ # Warnings
+ warnings = pack.get("warnings", [])
+ if warnings:
+ lines.append("## Warnings")
+ for w in warnings:
+ lines.append(f"- {w}")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def _ensure_context_pack_on_disk(pack_id: str, project_dir: str) -> Optional[str]:
+ """Load context pack from DB and write CONTEXT.md into project_dir.
+
+ Returns the file path on success, None on failure.
+ """
+ pack = _load_context_pack(pack_id)
+ if pack is None:
+ return None
+
+ md = _format_context_pack_markdown(pack)
+ target = Path(project_dir) / "CONTEXT.md"
+ try:
+ target.write_text(md, encoding="utf-8")
+ return str(target)
+ except OSError as exc:
+ log.warning("Failed to write CONTEXT.md to %s: %s", project_dir, exc)
+ return None
+
+
+def _parse_cli_content_blocks(content_blocks: list) -> list[StreamEvent]:
+ """Convert Claude CLI assistant content blocks into StreamEvents."""
+ events: list[StreamEvent] = []
+ for block in content_blocks:
+ btype = block.get("type", "")
+
+ if btype == "text":
+ text = block.get("text", "")
+ if text:
+ events.append(StreamEvent(
+ type="progress",
+ data={"cli_event": "text", "text": text},
+ ))
+
+ elif btype == "tool_use":
+ events.append(StreamEvent(
+ type="progress",
+ data={
+ "cli_event": "tool_use",
+ "tool_name": block.get("name", "unknown"),
+ "tool_input": block.get("input", {}),
+ "tool_id": block.get("id", ""),
+ },
+ ))
+
+ elif btype == "thinking":
+ thinking = block.get("thinking", "")
+ if thinking:
+ events.append(StreamEvent(
+ type="progress",
+ data={"cli_event": "thinking", "text": thinking},
+ ))
+
+ return events
+
+
+def _parse_cli_event(line_data: Dict[str, Any]) -> list[StreamEvent]:
+ """Parse a single NDJSON line from `claude -p --output-format stream-json`.
+
+ Claude CLI stream-json emits one JSON object per line:
+ - {"type":"assistant","message":{...}} — assistant turn with content blocks
+ - {"type":"tool_result","tool_name":"...","content":"..."} — tool output
+ - {"type":"result","subtype":"success","result":"...","cost_usd":...} — final
+ - {"type":"system",...} — session init (ignored)
+ """
+ etype = line_data.get("type", "")
+ events: list[StreamEvent] = []
+
+ if etype == "assistant":
+ msg = line_data.get("message", {})
+ content_blocks = msg.get("content", [])
+ events.extend(_parse_cli_content_blocks(content_blocks))
+
+ elif etype == "tool_result":
+ events.append(StreamEvent(
+ type="progress",
+ data={
+ "cli_event": "tool_result",
+ "tool_name": line_data.get("tool_name", ""),
+ "content": _truncate(str(line_data.get("content", "")), 2000),
+ },
+ ))
+
+ elif etype == "result":
+ events.append(StreamEvent(
+ type="result",
+ data={
+ "cli_event": "done",
+ "result": line_data.get("result", ""),
+ "cost_usd": line_data.get("cost_usd"),
+ "duration_ms": line_data.get("duration_ms"),
+ "num_turns": line_data.get("num_turns"),
+ },
+ ))
+
+ # Ignore "system" and other meta events
+ return events
+
+
+def _truncate(s: str, max_len: int) -> str:
+ return s if len(s) <= max_len else s[:max_len] + "..."
+
+
async def stream_claude_cli(request: StudioChatRequest) -> AsyncGenerator[StreamEvent, None]:
- """Stream Claude CLI output as SSE events."""
+ """Stream Claude CLI output as structured SSE events.
+
+ Uses ``--output-format stream-json`` so we get real-time NDJSON events
+ (text, tool_use, tool_result) instead of buffered plain text.
+ """
claude_path = find_claude_cli()
@@ -135,24 +447,18 @@ async def stream_claude_cli(request: StudioChatRequest) -> AsyncGenerator[Stream
)
return
- # Build command
+ # Build command — use stream-json for structured real-time output
cmd = [claude_path]
- # Add model flag (use CLI alias)
model_id = get_model_id(request.model, for_cli=True)
cmd.extend(["--model", model_id])
- # Add mode flag
mode_flag = get_mode_flag(request.mode)
if mode_flag:
cmd.append(mode_flag)
- # Build prompt with context
prompt = build_prompt_with_context(request.message, request.paper, request.mode)
-
- # Add the prompt with proper flags
- # Note: --print requires --verbose for stream-json, so we use regular output
- cmd.extend(["--print", prompt])
+ cmd.extend(["-p", prompt, "--output-format", "stream-json", "--verbose"])
yield StreamEvent(
type="progress",
@@ -164,72 +470,95 @@ async def stream_claude_cli(request: StudioChatRequest) -> AsyncGenerator[Stream
)
try:
- # Set working directory
- cwd = request.project_dir or os.getcwd()
- if not os.path.isdir(cwd):
- cwd = os.getcwd()
+ try:
+ cwd = str(_resolve_cli_project_dir(request.project_dir))
+ except ValueError as exc:
+ yield StreamEvent(type="error", message=str(exc))
+ return
+
+ # Write context pack to working directory so Claude CLI can read it
+ if request.context_pack_id:
+ pack_path = await asyncio.to_thread(
+ _ensure_context_pack_on_disk, request.context_pack_id, cwd,
+ )
+ if pack_path:
+ log.info("Wrote context pack to %s", pack_path)
- # Spawn Claude CLI process
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
- env={**os.environ, "FORCE_COLOR": "0"}, # Disable ANSI colors
+ env={**os.environ, "FORCE_COLOR": "0"},
)
+ stderr_chunks: list[str] = []
- full_content = ""
-
- # Stream stdout - read chunks for real-time streaming
- async def read_stream():
- nonlocal full_content
+ async def _drain_stderr() -> None:
+ if process.stderr is None:
+ return
while True:
- # Read in chunks for smoother streaming
- chunk = await process.stdout.read(100)
+ chunk = await process.stderr.read(4096)
if not chunk:
- break
-
- text = chunk.decode("utf-8", errors="replace")
-
- # Skip ANSI escape codes
- import re
- text = re.sub(r'\x1B\[[0-9;]*[a-zA-Z]', '', text)
-
- if text:
- full_content += text
- yield StreamEvent(
- type="progress",
- data={
- "delta": text,
- "content": full_content,
- "mode": request.mode,
- }
- )
-
- async for event in read_stream():
- yield event
+ return
+ stderr_chunks.append(chunk.decode("utf-8", errors="replace"))
+
+ stderr_task = asyncio.create_task(_drain_stderr())
+
+ _KEEPALIVE_SECONDS = 15
+ line_buffer = ""
+
+ # Read stdout line-by-line (NDJSON) with keepalive heartbeats.
+ while True:
+ try:
+ chunk = await asyncio.wait_for(
+ process.stdout.read(4096),
+ timeout=_KEEPALIVE_SECONDS,
+ )
+ except asyncio.TimeoutError:
+ yield StreamEvent(
+ type="progress",
+ data={"keepalive": True, "mode": request.mode},
+ )
+ continue
+
+ if not chunk:
+ break
+
+ line_buffer += chunk.decode("utf-8", errors="replace")
+
+ # Process complete lines (each is a JSON object)
+ while "\n" in line_buffer:
+ line, line_buffer = line_buffer.split("\n", 1)
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ data = json.loads(line)
+ except json.JSONDecodeError:
+ log.debug("Skipping non-JSON CLI line: %s", line[:120])
+ continue
+
+ for event in _parse_cli_event(data):
+ yield event
+
+ # Process any trailing data in buffer
+ if line_buffer.strip():
+ try:
+ data = json.loads(line_buffer.strip())
+ for event in _parse_cli_event(data):
+ yield event
+ except json.JSONDecodeError:
+ pass
- # Wait for process to complete
await process.wait()
+ await stderr_task
- # Check for errors
if process.returncode != 0:
- stderr = await process.stderr.read()
- error_msg = stderr.decode("utf-8", errors="replace").strip()
+ error_msg = "".join(stderr_chunks).strip()
if error_msg:
yield StreamEvent(type="error", message=error_msg)
return
- # Emit final result
- yield StreamEvent(
- type="result",
- data={
- "content": full_content,
- "mode": request.mode,
- "model": request.model,
- }
- )
-
except FileNotFoundError:
yield StreamEvent(
type="error",
diff --git a/src/paperbot/application/services/email_template.py b/src/paperbot/application/services/email_template.py
index f37851e8..b0c17f0d 100644
--- a/src/paperbot/application/services/email_template.py
+++ b/src/paperbot/application/services/email_template.py
@@ -152,6 +152,47 @@ def _method_framework_html(item: Dict[str, Any]) -> str:
)
+def _main_figure_html(item: Dict[str, Any]) -> str:
+ """Render method figure block when image source is displayable."""
+ mf = item.get("main_figure") or {}
+ if not isinstance(mf, dict):
+ return ""
+
+ image_src = ""
+ url = str(mf.get("url") or "").strip()
+ url_lower = url.lower()
+ inline_data_url = str(mf.get("inline_data_url") or "").strip()
+
+ if inline_data_url.startswith("data:image/"):
+ image_src = inline_data_url
+ elif (
+ url_lower.startswith(("http://", "https://"))
+ and ".zip#" not in url_lower
+ and ".zip?" not in url_lower
+ and not url_lower.endswith(".zip")
+ ):
+ image_src = url
+
+ if not image_src:
+ return ""
+
+ caption = _esc(str(mf.get("caption") or ""))
+ caption_html = (
+ f'
{caption}
'
+ if caption
+ else ""
+ )
+ return (
+ f''
+ f'
🖼️ 主方法图
'
+ f'
})
'
+ f'{caption_html}'
+ f'
'
+ )
+
+
def _paper_card_full_html(idx: int, item: Dict[str, Any]) -> str:
"""Full paper card with method framework (for must_read / worth_reading)."""
title = _esc(item.get("title") or "Untitled")
@@ -189,6 +230,7 @@ def _paper_card_full_html(idx: int, item: Dict[str, Any]) -> str:
summary_html = f'💬 {_esc(one_line)}
'
framework = _method_framework_html(item)
+ main_figure = _main_figure_html(item)
# Digest card (highlight + tags)
digest_card = item.get("digest_card") or {}
@@ -227,6 +269,7 @@ def _paper_card_full_html(idx: int, item: Dict[str, Any]) -> str:
f'{idx}.{title_html}
'
f'{meta_html}
'
f'{summary_html}'
+ f'{main_figure}'
f'{digest_html}'
f'{framework}'
f''
diff --git a/src/paperbot/application/workflows/dailypaper.py b/src/paperbot/application/workflows/dailypaper.py
index a784a6e7..ece562b7 100644
--- a/src/paperbot/application/workflows/dailypaper.py
+++ b/src/paperbot/application/workflows/dailypaper.py
@@ -16,11 +16,23 @@
SUPPORTED_LLM_FEATURES = ("summary", "trends", "insight", "relevance", "digest_card")
+def _is_publishable_figure_url(url: str) -> bool:
+ u = (url or "").strip().lower()
+ if not u.startswith(("http://", "https://")):
+ return False
+ if u.endswith(".zip") or ".zip#" in u or ".zip?" in u:
+ return False
+ return True
+
+
def extract_figures_for_report(
report: Dict[str, Any],
*,
api_key: str = "",
max_items: int = 5,
+ base_url: str = "",
+ model_version: str = "vlm",
+ max_wait_seconds: float = 180.0,
) -> Dict[str, Any]:
"""Optionally extract figures from top papers using MinerU Cloud API.
@@ -32,7 +44,14 @@ def extract_figures_for_report(
from paperbot.infrastructure.extractors.mineru_client import MineruClient
- client = MineruClient(api_key=api_key)
+ client_kwargs: Dict[str, Any] = {
+ "api_key": api_key,
+ "model_version": model_version,
+ "max_wait_seconds": max_wait_seconds,
+ }
+ if (base_url or "").strip():
+ client_kwargs["base_url"] = base_url.strip()
+ client = MineruClient(**client_kwargs)
enriched = copy.deepcopy(report)
count = 0
@@ -50,7 +69,13 @@ def extract_figures_for_report(
{"url": f.url, "caption": f.caption, "page": f.page} for f in figures[:5]
]
if main:
- item["main_figure"] = {"url": main.url, "caption": main.caption}
+ if _is_publishable_figure_url(main.url):
+ item["main_figure"] = {"url": main.url, "caption": main.caption}
+ elif (main.inline_data_url or "").startswith("data:image/"):
+ item["main_figure"] = {
+ "caption": main.caption,
+ "inline_data_url": main.inline_data_url,
+ }
count += 1
return enriched
diff --git a/src/paperbot/infrastructure/extractors/mineru_client.py b/src/paperbot/infrastructure/extractors/mineru_client.py
index 7b80bd9d..d70af343 100644
--- a/src/paperbot/infrastructure/extractors/mineru_client.py
+++ b/src/paperbot/infrastructure/extractors/mineru_client.py
@@ -1,18 +1,30 @@
"""MinerU Cloud API client for PDF figure extraction.
-Uses the MinerU Cloud API (HTTP) to extract figures from PDFs.
-Falls back gracefully when the service is unavailable.
+This client uses MinerU v4 async task API:
+- POST /extract/task
+- GET /extract/task/{task_id}
+
+API constraints (as documented by MinerU Cloud):
+- Remote file URL only (no direct upload)
+- File size <= 200MB
+- Page count <= 600 pages
+- Some overseas hosts (e.g. github/aws) may timeout from MinerU side
"""
+
from __future__ import annotations
+import base64
import hashlib
+import io
import json
import logging
import re
import time
+import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
+from urllib.parse import urlparse
import httpx
@@ -20,6 +32,15 @@
_DEFAULT_BASE_URL = "https://mineru.net/api/v4"
_DEFAULT_TIMEOUT = 60.0
+_DEFAULT_MODEL_VERSION = "vlm"
+_DEFAULT_POLL_INTERVAL_SECONDS = 2.0
+_DEFAULT_MAX_WAIT_SECONDS = 180.0
+
+_UNSUPPORTED_HOST_HINTS = (
+ "github.com",
+ "githubusercontent.com",
+ "amazonaws.com",
+)
@dataclass
@@ -32,6 +53,7 @@ class Figure:
width: int = 0
height: int = 0
index: int = 0
+ inline_data_url: str = ""
@property
def area(self) -> int:
@@ -39,10 +61,7 @@ def area(self) -> int:
class MineruClient:
- """Client for MinerU Cloud API figure extraction.
-
- Falls back gracefully when API is unavailable or API key is not set.
- """
+ """Client for MinerU Cloud API figure extraction."""
def __init__(
self,
@@ -52,54 +71,271 @@ def __init__(
timeout: float = _DEFAULT_TIMEOUT,
cache_dir: str = "",
cache_ttl_seconds: int = 24 * 3600,
+ model_version: str = _DEFAULT_MODEL_VERSION,
+ poll_interval_seconds: float = _DEFAULT_POLL_INTERVAL_SECONDS,
+ max_wait_seconds: float = _DEFAULT_MAX_WAIT_SECONDS,
):
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._cache_dir = Path(cache_dir).expanduser() if cache_dir else None
self._cache_ttl_seconds = max(0, int(cache_ttl_seconds))
+ self._model_version = (model_version or _DEFAULT_MODEL_VERSION).strip() or _DEFAULT_MODEL_VERSION
+ self._poll_interval_seconds = max(0.5, float(poll_interval_seconds))
+ self._max_wait_seconds = max(5.0, float(max_wait_seconds))
def extract_figures(self, pdf_url: str) -> List[Figure]:
"""Extract figures from a PDF URL via MinerU Cloud API.
- Returns an empty list if extraction fails or API is unavailable.
+ Returns an empty list if extraction fails or API key is not set.
"""
if not self._api_key:
logger.debug("MinerU API key not set, skipping figure extraction")
return []
- if not pdf_url or not pdf_url.strip():
+ normalized_url = (pdf_url or "").strip()
+ if not normalized_url:
return []
- cached = self._load_cached_figures(pdf_url)
+ cached = self._load_cached_figures(normalized_url)
if cached is not None:
return cached
try:
- figures = self._call_extract(pdf_url)
- self._store_cached_figures(pdf_url, figures)
+ figures = self._call_extract(normalized_url)
+ self._store_cached_figures(normalized_url, figures)
return figures
except Exception as exc:
logger.warning("MinerU figure extraction failed: %s", exc)
return []
def _call_extract(self, pdf_url: str) -> List[Figure]:
+ self._validate_source_url(pdf_url)
+
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
- payload = {"url": pdf_url, "extract_figures": True}
with httpx.Client(timeout=self._timeout) as client:
- resp = client.post(
- f"{self._base_url}/extract",
- json=payload,
+ task_id = self._create_task(client, headers=headers, pdf_url=pdf_url)
+ detail = self._poll_until_done(client, headers=headers, task_id=task_id)
+
+ # Some deployments may return figures directly in task payload.
+ parsed = self._parse_figures(detail)
+ if parsed:
+ return parsed
+
+ zip_url = str(detail.get("full_zip_url") or "").strip()
+ if not zip_url:
+ return []
+ return self._extract_figures_from_zip(client, zip_url)
+
+ def _validate_source_url(self, pdf_url: str) -> None:
+ parsed = urlparse(pdf_url)
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
+ raise ValueError("MinerU source URL must be an absolute http(s) URL")
+
+ host = (parsed.netloc or "").lower()
+ if any(host == hint or host.endswith(f".{hint}") for hint in _UNSUPPORTED_HOST_HINTS):
+ raise ValueError(
+ "MinerU Cloud may timeout on github/aws URLs; provide a publicly "
+ "accessible non-github/non-aws URL"
+ )
+
+ def _create_task(self, client: httpx.Client, *, headers: Dict[str, str], pdf_url: str) -> str:
+ payload = {"url": pdf_url, "model_version": self._model_version}
+ body = self._request_json(
+ client,
+ method="POST",
+ url=f"{self._base_url}/extract/task",
+ headers=headers,
+ json_payload=payload,
+ )
+ task_id = str((body.get("data") or {}).get("task_id") or "").strip()
+ if not task_id:
+ raise RuntimeError(f"invalid MinerU task response: {body}")
+ return task_id
+
+ def _poll_until_done(
+ self,
+ client: httpx.Client,
+ *,
+ headers: Dict[str, str],
+ task_id: str,
+ ) -> Dict[str, Any]:
+ deadline = time.time() + self._max_wait_seconds
+ while time.time() <= deadline:
+ body = self._request_json(
+ client,
+ method="GET",
+ url=f"{self._base_url}/extract/task/{task_id}",
headers=headers,
)
- resp.raise_for_status()
- data = resp.json()
+ detail = body.get("data") or {}
+ state = str(detail.get("state") or "").strip().lower()
+
+ if state == "done":
+ return detail
+ if state == "failed":
+ err_msg = str(detail.get("err_msg") or "task failed")
+ raise RuntimeError(err_msg)
+
+ time.sleep(self._poll_interval_seconds)
+
+ raise TimeoutError(f"MinerU task timed out after {self._max_wait_seconds:.0f}s")
+
+ def _request_json(
+ self,
+ client: httpx.Client,
+ *,
+ method: str,
+ url: str,
+ headers: Dict[str, str],
+ json_payload: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ response = client.request(method, url, headers=headers, json=json_payload)
+ response.raise_for_status()
+
+ body = response.json()
+ if not isinstance(body, dict):
+ raise RuntimeError("invalid MinerU response payload")
+
+ code = body.get("code")
+ if code is not None and int(code) != 0:
+ msg = str(body.get("msg") or "unknown error")
+ raise RuntimeError(f"MinerU API error code={code}: {msg}")
+
+ return body
+
+ def _extract_figures_from_zip(self, client: httpx.Client, zip_url: str) -> List[Figure]:
+ """Download MinerU result zip and parse figures from generated markdown."""
+ response = client.get(zip_url)
+ response.raise_for_status()
+
+ with zipfile.ZipFile(io.BytesIO(response.content), "r") as zf:
+ md_members = [name for name in zf.namelist() if name.lower().endswith(".md")]
+ if not md_members:
+ return []
+
+ # Prefer the shortest markdown path, usually the top-level document markdown.
+ target_md = sorted(md_members, key=len)[0]
+ markdown_text = zf.read(target_md).decode("utf-8", errors="ignore")
+ return self._parse_figures_from_markdown(markdown_text, zip_url=zip_url, zip_file=zf)
+
+ def _parse_figures_from_markdown(
+ self,
+ markdown_text: str,
+ *,
+ zip_url: str = "",
+ zip_file: Optional[zipfile.ZipFile] = None,
+ ) -> List[Figure]:
+ """Parse figures from MinerU markdown output.
+
+ Typical pattern:
+ 
+ Figure 1: ...
+ """
+ if not markdown_text.strip():
+ return []
+
+ lines = markdown_text.splitlines()
+ figures: List[Figure] = []
+ image_pattern = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")
+ caption_pattern = re.compile(r"^\s*(?:Figure|Fig\.?)\s*\d+\s*[:.-]", re.IGNORECASE)
+
+ for idx, line in enumerate(lines):
+ match = image_pattern.search(line)
+ if not match:
+ continue
+
+ raw_ref = str(match.group(1) or "").strip()
+ if not raw_ref:
+ continue
+
+ caption = ""
+ for offset in (1, 2, 3):
+ cursor = idx + offset
+ if cursor >= len(lines):
+ break
+ candidate = (lines[cursor] or "").strip()
+ if not candidate:
+ continue
+ if caption_pattern.match(candidate):
+ caption = candidate
+ break
+
+ figure_url = self._resolve_figure_url(raw_ref=raw_ref, zip_url=zip_url)
+ inline_data_url = self._build_inline_data_url(zip_file=zip_file, raw_ref=raw_ref)
+ figures.append(
+ Figure(
+ url=figure_url,
+ caption=caption,
+ index=len(figures),
+ inline_data_url=inline_data_url,
+ )
+ )
- return self._parse_figures(data)
+ return figures
+
+ def _build_inline_data_url(
+ self,
+ *,
+ zip_file: Optional[zipfile.ZipFile],
+ raw_ref: str,
+ ) -> str:
+ if zip_file is None:
+ return ""
+ if raw_ref.startswith(("http://", "https://")):
+ return ""
+
+ target = raw_ref.strip().lstrip("/")
+ if not target:
+ return ""
+
+ candidates = {target, f"./{target}"}
+ if "/" in target:
+ candidates.add(target.split("/", 1)[1])
+
+ member_name = ""
+ namelist = set(zip_file.namelist())
+ for c in candidates:
+ if c in namelist:
+ member_name = c
+ break
+ if not member_name:
+ return ""
+
+ try:
+ blob = zip_file.read(member_name)
+ except Exception:
+ return ""
+ if not blob:
+ return ""
+ # Keep inline payload bounded to avoid very large report/cache artifacts.
+ if len(blob) > 1_500_000:
+ return ""
+
+ ext = member_name.lower().rsplit(".", 1)[-1] if "." in member_name else ""
+ mime = {
+ "png": "image/png",
+ "jpg": "image/jpeg",
+ "jpeg": "image/jpeg",
+ "webp": "image/webp",
+ "gif": "image/gif",
+ }.get(ext)
+ if not mime:
+ return ""
+
+ encoded = base64.b64encode(blob).decode("ascii")
+ return f"data:{mime};base64,{encoded}"
+
+ def _resolve_figure_url(self, *, raw_ref: str, zip_url: str) -> str:
+ if raw_ref.startswith(("http://", "https://")):
+ return raw_ref
+ if zip_url:
+ return f"{zip_url}#/{raw_ref}"
+ return raw_ref
def _cache_path(self, pdf_url: str) -> Optional[Path]:
if self._cache_dir is None:
@@ -111,13 +347,16 @@ def _load_cached_figures(self, pdf_url: str) -> Optional[List[Figure]]:
cache_path = self._cache_path(pdf_url)
if cache_path is None or not cache_path.exists():
return None
+
try:
raw = json.loads(cache_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return None
+
saved_at = float(raw.get("saved_at") or 0)
if self._cache_ttl_seconds > 0 and (time.time() - saved_at) > self._cache_ttl_seconds:
return None
+
rows = raw.get("figures") or []
figures: List[Figure] = []
for row in rows:
@@ -131,12 +370,14 @@ def _load_cached_figures(self, pdf_url: str) -> Optional[List[Figure]]:
width=int(row.get("width") or 0),
height=int(row.get("height") or 0),
index=int(row.get("index") or 0),
+ inline_data_url=str(row.get("inline_data_url") or "").strip(),
)
)
if figures:
return figures
except Exception:
return None
+
return None
def _store_cached_figures(self, pdf_url: str, figures: List[Figure]) -> None:
@@ -155,6 +396,7 @@ def _store_cached_figures(self, pdf_url: str, figures: List[Figure]) -> None:
"width": figure.width,
"height": figure.height,
"index": figure.index,
+ "inline_data_url": figure.inline_data_url,
}
for figure in figures
],
@@ -204,28 +446,32 @@ def identify_main_figure(self, figures: List[Figure]) -> Optional[Figure]:
if not candidates:
candidates = figures
- # Score each candidate
scored: List[tuple[float, Figure]] = []
for fig in candidates:
score = 0.0
caption_lower = fig.caption.lower()
- # Caption keyword bonus
- main_keywords = ["overview", "architecture", "framework", "pipeline",
- "main", "proposed", "system", "model", "approach"]
+ main_keywords = [
+ "overview",
+ "architecture",
+ "framework",
+ "pipeline",
+ "main",
+ "proposed",
+ "system",
+ "model",
+ "approach",
+ ]
for kw in main_keywords:
if kw in caption_lower:
score += 10.0
- # Figure 1 / Fig. 1 bonus
if re.search(r"(?:figure|fig\.?)\s*1\b", caption_lower):
score += 15.0
- # Early page bonus (pages 1-3)
if 1 <= fig.page <= 3:
score += 5.0
- # Larger figures get slight bonus
if fig.area > 0:
score += min(fig.area / 100000, 5.0)
diff --git a/src/paperbot/infrastructure/queue/arq_worker.py b/src/paperbot/infrastructure/queue/arq_worker.py
index 2b40813b..5191561b 100644
--- a/src/paperbot/infrastructure/queue/arq_worker.py
+++ b/src/paperbot/infrastructure/queue/arq_worker.py
@@ -70,6 +70,16 @@ def _parse_bool_env(name: str, default: bool = False) -> bool:
return raw.strip().lower() in ("1", "true", "yes", "y", "on")
+def _parse_float_env(name: str, default: float) -> float:
+ raw = os.getenv(name)
+ if raw is None:
+ return float(default)
+ try:
+ return float(raw)
+ except (TypeError, ValueError):
+ return float(default)
+
+
async def cron_track_subscriptions(ctx) -> Dict[str, Any]:
"""
Cron entrypoint: enqueue tracking jobs for all subscribed scholars.
@@ -203,6 +213,9 @@ async def cron_daily_papers(ctx) -> Dict[str, Any]:
judge_token_budget = int(os.getenv("PAPERBOT_DAILYPAPER_JUDGE_TOKEN_BUDGET", "0"))
enable_figures = _parse_bool_env("PAPERBOT_DAILYPAPER_ENABLE_FIGURES", False)
figures_max_items = int(os.getenv("PAPERBOT_DAILYPAPER_FIGURES_MAX_ITEMS", "5"))
+ mineru_api_base_url = os.getenv("MINERU_API_BASE_URL", "")
+ mineru_model_version = os.getenv("MINERU_MODEL_VERSION", "vlm")
+ mineru_max_wait_seconds = _parse_float_env("MINERU_MAX_WAIT_SECONDS", 180.0)
job = await redis.enqueue_job(
"daily_papers_job",
@@ -222,6 +235,9 @@ async def cron_daily_papers(ctx) -> Dict[str, Any]:
judge_token_budget=judge_token_budget,
enable_figures=enable_figures,
figures_max_items=max(1, int(figures_max_items)),
+ mineru_api_base_url=mineru_api_base_url,
+ mineru_model_version=mineru_model_version,
+ mineru_max_wait_seconds=max(5.0, float(mineru_max_wait_seconds)),
notify=notify_enabled,
notify_channels=notify_channels,
save=True,
@@ -234,6 +250,9 @@ async def cron_daily_papers(ctx) -> Dict[str, Any]:
"branches": branches,
"enable_figures": enable_figures,
"figures_max_items": max(1, int(figures_max_items)),
+ "mineru_api_base_url": mineru_api_base_url,
+ "mineru_model_version": mineru_model_version,
+ "mineru_max_wait_seconds": max(5.0, float(mineru_max_wait_seconds)),
}
elog.append(
make_event(
@@ -346,6 +365,9 @@ async def daily_papers_job(
save: bool = True,
enable_figures: bool = False,
figures_max_items: int = 5,
+ mineru_api_base_url: str = "",
+ mineru_model_version: str = "vlm",
+ mineru_max_wait_seconds: float = 180.0,
) -> Dict[str, Any]:
"""ARQ job: generate DailyPaper report and bridge highlights into feed events."""
run_id = new_run_id()
@@ -381,6 +403,11 @@ async def daily_papers_job(
"judge_token_budget": judge_token_budget,
"notify": notify,
"notify_channels": notify_channels or [],
+ "enable_figures": enable_figures,
+ "figures_max_items": max(1, int(figures_max_items)),
+ "mineru_api_base_url": mineru_api_base_url,
+ "mineru_model_version": mineru_model_version,
+ "mineru_max_wait_seconds": max(5.0, float(mineru_max_wait_seconds)),
},
)
)
@@ -437,6 +464,9 @@ async def daily_papers_job(
report,
api_key=mineru_key,
max_items=max(1, int(figures_max_items)),
+ base_url=mineru_api_base_url,
+ model_version=mineru_model_version,
+ max_wait_seconds=max(5.0, float(mineru_max_wait_seconds)),
)
try:
diff --git a/src/paperbot/presentation/cli/main.py b/src/paperbot/presentation/cli/main.py
index b8512ceb..cfeac3e3 100644
--- a/src/paperbot/presentation/cli/main.py
+++ b/src/paperbot/presentation/cli/main.py
@@ -168,6 +168,22 @@ def create_parser() -> argparse.ArgumentParser:
default=None,
help="MinerU Cloud API Key(也可通过 MINERU_API_KEY 环境变量设置)",
)
+ daily_parser.add_argument(
+ "--mineru-api-base-url",
+ default=None,
+ help="MinerU API Base URL(默认 https://mineru.net/api/v4,也可通过 MINERU_API_BASE_URL 设置)",
+ )
+ daily_parser.add_argument(
+ "--mineru-model-version",
+ default=None,
+ help="MinerU model_version(默认 vlm,也可通过 MINERU_MODEL_VERSION 设置)",
+ )
+ daily_parser.add_argument(
+ "--mineru-max-wait-seconds",
+ type=float,
+ default=None,
+ help="MinerU 任务轮询最长等待秒数(默认 180,也可通过 MINERU_MAX_WAIT_SECONDS 设置)",
+ )
daily_parser.add_argument(
"--figures-max-items",
type=int,
@@ -398,10 +414,25 @@ def _run_daily_paper(parsed: argparse.Namespace) -> int:
if figures_enabled:
mineru_key = parsed.mineru_api_key or os.getenv("MINERU_API_KEY", "")
if mineru_key:
+ mineru_base_url = parsed.mineru_api_base_url or os.getenv("MINERU_API_BASE_URL", "")
+ mineru_model_version = parsed.mineru_model_version or os.getenv(
+ "MINERU_MODEL_VERSION", "vlm"
+ )
+ try:
+ mineru_max_wait_seconds = (
+ float(parsed.mineru_max_wait_seconds)
+ if parsed.mineru_max_wait_seconds is not None
+ else float(os.getenv("MINERU_MAX_WAIT_SECONDS", "180"))
+ )
+ except (TypeError, ValueError):
+ mineru_max_wait_seconds = 180.0
report = extract_figures_for_report(
report,
api_key=mineru_key,
max_items=max(1, int(parsed.figures_max_items)),
+ base_url=mineru_base_url,
+ model_version=mineru_model_version,
+ max_wait_seconds=max(5.0, mineru_max_wait_seconds),
)
else:
print("warning: --with-figures requires --mineru-api-key or MINERU_API_KEY env var",
diff --git a/tests/unit/test_arq_daily_papers.py b/tests/unit/test_arq_daily_papers.py
index 05af7d72..539e3caa 100644
--- a/tests/unit/test_arq_daily_papers.py
+++ b/tests/unit/test_arq_daily_papers.py
@@ -49,6 +49,9 @@ def append(self, event):
monkeypatch.setattr(arq_worker, "_event_log", lambda: _NoopEventLog())
monkeypatch.setenv("PAPERBOT_DAILYPAPER_ENABLE_FIGURES", "true")
monkeypatch.setenv("PAPERBOT_DAILYPAPER_FIGURES_MAX_ITEMS", "7")
+ monkeypatch.setenv("MINERU_API_BASE_URL", "https://mineru.net/api/v4")
+ monkeypatch.setenv("MINERU_MODEL_VERSION", "vlm")
+ monkeypatch.setenv("MINERU_MAX_WAIT_SECONDS", "120")
redis = _FakeRedis()
result = await arq_worker.cron_daily_papers({"redis": redis})
@@ -58,6 +61,9 @@ def append(self, event):
assert redis.kwargs["name"] == "daily_papers_job"
assert redis.kwargs["enable_figures"] is True
assert redis.kwargs["figures_max_items"] == 7
+ assert redis.kwargs["mineru_api_base_url"] == "https://mineru.net/api/v4"
+ assert redis.kwargs["mineru_model_version"] == "vlm"
+ assert redis.kwargs["mineru_max_wait_seconds"] == 120.0
@pytest.mark.asyncio
diff --git a/tests/unit/test_daily_digest_card.py b/tests/unit/test_daily_digest_card.py
index 568d8b0e..518db644 100644
--- a/tests/unit/test_daily_digest_card.py
+++ b/tests/unit/test_daily_digest_card.py
@@ -154,6 +154,73 @@ def test_digest_card_in_email_html():
assert "AI" in html
+def test_main_figure_inline_in_email_html():
+ report = build_daily_paper_report(
+ search_result=_sample_search_result(), title="Digest Test", top_n=5,
+ )
+ item = report["queries"][0]["top_items"][0]
+ item["judge"] = {
+ "overall": 4.0,
+ "recommendation": "must_read",
+ "one_line_summary": "good",
+ "relevance": {"score": 5, "rationale": ""},
+ "novelty": {"score": 4, "rationale": ""},
+ "rigor": {"score": 4, "rationale": ""},
+ "impact": {"score": 4, "rationale": ""},
+ "clarity": {"score": 4, "rationale": ""},
+ }
+ item["main_figure"] = {
+ "caption": "Figure 1: Overview",
+ "inline_data_url": "data:image/png;base64,QUFBQQ==",
+ }
+
+ html = build_digest_html(report)
+ assert "主方法图" in html
+ assert "data:image/png;base64,QUFBQQ==" in html
+ assert "Figure 1: Overview" in html
+
+
+def test_main_figure_http_url_in_email_html():
+ report = build_daily_paper_report(
+ search_result=_sample_search_result(), title="Digest Test", top_n=5,
+ )
+ item = report["queries"][0]["top_items"][0]
+ item["judge"] = {
+ "overall": 4.0,
+ "recommendation": "must_read",
+ "one_line_summary": "good",
+ }
+ item["main_figure"] = {
+ "caption": "Figure 1: Public",
+ "url": "https://cdn.example.com/figure1.png",
+ }
+
+ html = build_digest_html(report)
+ assert "主方法图" in html
+ assert "https://cdn.example.com/figure1.png" in html
+ assert "Figure 1: Public" in html
+
+
+def test_main_figure_zip_query_url_is_rejected_in_email_html():
+ report = build_daily_paper_report(
+ search_result=_sample_search_result(), title="Digest Test", top_n=5,
+ )
+ item = report["queries"][0]["top_items"][0]
+ item["judge"] = {
+ "overall": 4.0,
+ "recommendation": "must_read",
+ "one_line_summary": "good",
+ }
+ item["main_figure"] = {
+ "caption": "Figure 1: Should Not Render",
+ "url": "https://cdn.example.com/result.ZIP?token=abc#/images/fig1.jpg",
+ }
+
+ html = build_digest_html(report)
+ assert "主方法图" not in html
+ assert "result.ZIP?token=abc#/images/fig1.jpg" not in html
+
+
def test_digest_card_in_email_text():
report = build_daily_paper_report(
search_result=_sample_search_result(), title="Digest Test", top_n=5,
diff --git a/tests/unit/test_dailypaper.py b/tests/unit/test_dailypaper.py
index 3bcb2603..14fd54fa 100644
--- a/tests/unit/test_dailypaper.py
+++ b/tests/unit/test_dailypaper.py
@@ -1,5 +1,6 @@
from paperbot.application.workflows.dailypaper import (
DailyPaperReporter,
+ _is_publishable_figure_url,
apply_judge_scores_to_report,
build_daily_paper_report,
enrich_daily_paper_report,
@@ -7,6 +8,7 @@
normalize_output_formats,
render_daily_paper_markdown,
)
+from paperbot.infrastructure.extractors.mineru_client import Figure
class _FakeLLMService:
@@ -105,6 +107,50 @@ def test_normalize_llm_features_filters_unknown_items():
assert normalize_llm_features(["summary", "foo", "trends", "summary"]) == ["summary", "trends"]
+def test_publishable_figure_url_rejects_zip_artifacts():
+ assert _is_publishable_figure_url("https://cdn.example.com/fig1.png")
+ assert not _is_publishable_figure_url("https://cdn.example.com/result.zip")
+ assert not _is_publishable_figure_url("https://cdn.example.com/result.zip#/images/fig1.jpg")
+
+
+def test_extract_figures_for_report_keeps_inline_main_figure(monkeypatch):
+ import paperbot.application.workflows.dailypaper as daily_mod
+ import paperbot.infrastructure.extractors.mineru_client as mineru_mod
+
+ class _FakeMineruClient:
+ def __init__(self, **kwargs):
+ pass
+
+ def extract_figures(self, pdf_url: str):
+ return [
+ Figure(
+ url="https://cdn.example.com/result.zip#/images/fig1.jpg",
+ caption="Figure 1: Demo",
+ inline_data_url="data:image/jpeg;base64,ZmFrZS1kYXRh",
+ )
+ ]
+
+ def identify_main_figure(self, figures):
+ return figures[0] if figures else None
+
+ monkeypatch.setattr(mineru_mod, "MineruClient", _FakeMineruClient)
+
+ report = {
+ "queries": [
+ {
+ "top_items": [
+ {"title": "Paper A", "pdf_url": "https://arxiv.org/pdf/2401.00001.pdf"},
+ ]
+ }
+ ]
+ }
+ result = daily_mod.extract_figures_for_report(report, api_key="test-key", max_items=1)
+ item = result["queries"][0]["top_items"][0]
+ assert "main_figure" in item
+ assert "url" not in item["main_figure"]
+ assert item["main_figure"]["inline_data_url"].startswith("data:image/")
+
+
def test_apply_judge_scores_to_report(monkeypatch):
report = build_daily_paper_report(
search_result=_sample_search_result(), title="Judge Daily", top_n=5
diff --git a/tests/unit/test_mineru_client.py b/tests/unit/test_mineru_client.py
index d7b83d3d..22c0ea50 100644
--- a/tests/unit/test_mineru_client.py
+++ b/tests/unit/test_mineru_client.py
@@ -1,4 +1,9 @@
"""Tests for MinerU Cloud API client."""
+import io
+import zipfile
+
+import pytest
+
from paperbot.infrastructure.extractors.mineru_client import Figure, MineruClient
@@ -14,6 +19,36 @@ def test_empty_url_returns_empty():
assert result == []
+def test_validate_source_url_requires_http():
+ client = MineruClient(api_key="test-key")
+ with pytest.raises(ValueError) as exc:
+ client._validate_source_url("file:///tmp/a.pdf")
+ assert "http(s)" in str(exc.value)
+
+
+def test_validate_source_url_rejects_github_and_aws():
+ client = MineruClient(api_key="test-key")
+ for url in (
+ "https://github.com/a/b/raw/main/paper.pdf",
+ "https://raw.githubusercontent.com/a/b/main/paper.pdf",
+ "https://bucket.s3.amazonaws.com/paper.pdf",
+ ):
+ with pytest.raises(ValueError) as exc:
+ client._validate_source_url(url)
+ assert "github/aws" in str(exc.value)
+
+
+def test_extract_figures_rejects_unsupported_host_early(monkeypatch):
+ client = MineruClient(api_key="test-key")
+
+ def _should_not_call(*args, **kwargs):
+ raise AssertionError("network call should not be reached for unsupported URL")
+
+ monkeypatch.setattr(client, "_create_task", _should_not_call)
+ result = client.extract_figures("https://github.com/a/b/raw/main/paper.pdf")
+ assert result == []
+
+
def test_parse_figures_from_api_response():
client = MineruClient(api_key="test-key")
data = {
@@ -67,10 +102,53 @@ def test_parse_figures_from_images_key():
assert figures[0].page == 2
+def test_parse_figures_from_markdown_zip_refs():
+ client = MineruClient(api_key="test-key")
+ markdown = """
+
+Figure 1: System overview
+
+
+Fig. 2: Attention map
+""".strip()
+ zip_url = "https://cdn-mineru.example.com/result.zip"
+
+ figures = client._parse_figures_from_markdown(markdown, zip_url=zip_url)
+
+ assert len(figures) == 2
+ assert figures[0].url == f"{zip_url}#/images/fig1.jpg"
+ assert figures[0].caption == "Figure 1: System overview"
+ assert figures[1].url == "https://cdn.mineru.net/fig2.png"
+ assert figures[1].caption == "Fig. 2: Attention map"
+
+
+def test_parse_figures_from_markdown_with_inline_zip_image():
+ client = MineruClient(api_key="test-key")
+ markdown = "\nFigure 1: System overview\n"
+ zip_url = "https://cdn-mineru.example.com/result.zip"
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("images/fig1.jpg", b"fake-jpeg-bytes")
+
+ with zipfile.ZipFile(io.BytesIO(buf.getvalue()), "r") as zf:
+ figures = client._parse_figures_from_markdown(markdown, zip_url=zip_url, zip_file=zf)
+
+ assert len(figures) == 1
+ assert figures[0].url == f"{zip_url}#/images/fig1.jpg"
+ assert figures[0].inline_data_url.startswith("data:image/jpeg;base64,")
+
+
def test_identify_main_figure_prefers_figure_1():
figures = [
Figure(url="fig2.png", caption="Figure 2: Results table", page=4, width=400, height=300),
- Figure(url="fig1.png", caption="Figure 1: System architecture overview", page=1, width=600, height=400),
+ Figure(
+ url="fig1.png",
+ caption="Figure 1: System architecture overview",
+ page=1,
+ width=600,
+ height=400,
+ ),
Figure(url="fig3.png", caption="Figure 3: Comparison chart", page=6, width=500, height=350),
]
client = MineruClient(api_key="test")
diff --git a/tests/unit/test_studio_chat_path_validation.py b/tests/unit/test_studio_chat_path_validation.py
new file mode 100644
index 00000000..5763883a
--- /dev/null
+++ b/tests/unit/test_studio_chat_path_validation.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from paperbot.api.routes.studio_chat import _resolve_cli_project_dir
+
+
+def test_resolve_cli_project_dir_defaults_to_cwd(tmp_path: Path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ assert _resolve_cli_project_dir(None) == tmp_path.resolve()
+
+
+def test_resolve_cli_project_dir_accepts_path_under_cwd(tmp_path: Path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ target = tmp_path / "workspace" / "paper-a"
+ target.mkdir(parents=True, exist_ok=True)
+ assert _resolve_cli_project_dir(str(target)) == target.resolve()
+
+
+def test_resolve_cli_project_dir_rejects_outside_allowed_prefixes(tmp_path: Path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ disallowed = Path.home().parent / "paperbot-studio-chat-disallowed"
+ disallowed.mkdir(parents=True, exist_ok=True)
+
+ with pytest.raises(ValueError, match="not allowed"):
+ _resolve_cli_project_dir(str(disallowed))
diff --git a/web/.gitignore b/web/.gitignore
index 5ef6a520..539a8fc1 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -12,6 +12,10 @@
# testing
/coverage
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache
# next.js
/.next/
diff --git a/web/e2e/papers.spec.ts b/web/e2e/papers.spec.ts
new file mode 100644
index 00000000..b3f57727
--- /dev/null
+++ b/web/e2e/papers.spec.ts
@@ -0,0 +1,23 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Papers Page E2E", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/papers");
+ await page.waitForLoadState("networkidle");
+ });
+
+ test("papers page has saved papers section", async ({ page }) => {
+ const content = await page.textContent("body");
+ const hasPapersContent =
+ content?.includes("Paper") ||
+ content?.includes("Saved") ||
+ content?.includes("Collection") ||
+ content?.includes("Library");
+ expect(hasPapersContent).toBeTruthy();
+ });
+
+ test("papers page has import/export options", async ({ page }) => {
+ const exportButton = page.getByRole("button", { name: /export/i });
+ await expect(exportButton).toBeVisible();
+ });
+});
diff --git a/web/e2e/research.spec.ts b/web/e2e/research.spec.ts
new file mode 100644
index 00000000..c25e0ef7
--- /dev/null
+++ b/web/e2e/research.spec.ts
@@ -0,0 +1,28 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Research Page E2E", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/research");
+ await page.waitForLoadState("networkidle");
+ });
+
+ test("research page has search input", async ({ page }) => {
+ // Should have a search/query input
+ const searchInput = page.locator(
+ 'input[type="text"], input[type="search"], textarea'
+ );
+ const count = await searchInput.count();
+ expect(count).toBeGreaterThan(0);
+ });
+
+ test("research page has track/topic tabs or sections", async ({ page }) => {
+ // The research page should show tracks or topic workflow sections
+ const content = await page.textContent("body");
+ const hasResearchContent =
+ content?.includes("Track") ||
+ content?.includes("Research") ||
+ content?.includes("Discovery") ||
+ content?.includes("Topic");
+ expect(hasResearchContent).toBeTruthy();
+ });
+});
diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts
new file mode 100644
index 00000000..3454c553
--- /dev/null
+++ b/web/e2e/smoke.spec.ts
@@ -0,0 +1,49 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("PaperBot Smoke Tests", () => {
+ test("homepage loads and shows sidebar navigation", async ({ page }) => {
+ await page.goto("/");
+ await expect(page).toHaveTitle(/PaperBot/i);
+
+ // Sidebar should have main navigation links
+ const sidebar = page.locator("nav, aside").first();
+ await expect(sidebar).toBeVisible();
+ });
+
+ test("dashboard page renders", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.waitForLoadState("networkidle");
+ // Should not show an unhandled error page
+ await expect(page.locator("text=Application error")).not.toBeVisible();
+ });
+
+ test("papers page renders", async ({ page }) => {
+ await page.goto("/papers");
+ await page.waitForLoadState("networkidle");
+ await expect(page.locator("text=Application error")).not.toBeVisible();
+ });
+
+ test("research page renders", async ({ page }) => {
+ await page.goto("/research");
+ await page.waitForLoadState("networkidle");
+ await expect(page.locator("text=Application error")).not.toBeVisible();
+ });
+
+ test("scholars page renders", async ({ page }) => {
+ await page.goto("/scholars");
+ await page.waitForLoadState("networkidle");
+ await expect(page.locator("text=Application error")).not.toBeVisible();
+ });
+
+ test("settings page renders", async ({ page }) => {
+ await page.goto("/settings");
+ await page.waitForLoadState("networkidle");
+ await expect(page.locator("text=Application error")).not.toBeVisible();
+ });
+
+ test("studio page renders", async ({ page }) => {
+ await page.goto("/studio");
+ await page.waitForLoadState("networkidle");
+ await expect(page.locator("text=Application error")).not.toBeVisible();
+ });
+});
diff --git a/web/next.config.ts b/web/next.config.ts
index b5467043..8e168edc 100644
--- a/web/next.config.ts
+++ b/web/next.config.ts
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
+ httpAgentOptions: {
+ keepAlive: true,
+ },
async rewrites() {
return [
{
diff --git a/web/package-lock.json b/web/package-lock.json
index 2eee76b5..f6e01789 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -49,6 +49,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
@@ -1525,6 +1526,22 @@
"node": ">=8.0.0"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -6783,6 +6800,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -9752,6 +9784,38 @@
"node": ">=16.20.0"
}
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
diff --git a/web/package.json b/web/package.json
index 43d7f08c..0d881a25 100644
--- a/web/package.json
+++ b/web/package.json
@@ -6,7 +6,12 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "test": "vitest run",
+ "test:e2e": "npx playwright test",
+ "test:e2e:ui": "npx playwright test --ui",
+ "test:e2e:headed": "npx playwright test --headed",
+ "test:e2e:report": "npx playwright show-report"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.56",
@@ -50,6 +55,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
@@ -59,6 +65,7 @@
"eslint-config-next": "16.1.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^2.1.4"
}
}
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
new file mode 100644
index 00000000..50051285
--- /dev/null
+++ b/web/playwright.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: [["html", { open: "never" }], ["list"]],
+
+ use: {
+ baseURL: process.env.E2E_BASE_URL || "http://localhost:3000",
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+
+ /* Start Next.js dev server before tests if not already running */
+ webServer: process.env.E2E_BASE_URL
+ ? undefined
+ : {
+ command: "npm run dev",
+ url: "http://localhost:3000",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/web/src/app/studio/page.tsx b/web/src/app/studio/page.tsx
index aa2c2bed..6f2059f7 100644
--- a/web/src/app/studio/page.tsx
+++ b/web/src/app/studio/page.tsx
@@ -2,16 +2,27 @@
import { useEffect, Suspense, useRef } from "react"
import { useSearchParams, useRouter } from "next/navigation"
-import { PanelsTopLeft } from "lucide-react"
-import { PapersPanel } from "@/components/studio/PapersPanel"
+import { ArrowLeft, PanelsTopLeft, Loader2 } from "lucide-react"
+import { PaperGallery } from "@/components/studio/PaperGallery"
import { ReproductionLog } from "@/components/studio/ReproductionLog"
import { FilesPanel } from "@/components/studio/FilesPanel"
import { MCPProvider } from "@/lib/mcp"
-import { useStudioStore } from "@/lib/store/studio-store"
+import { useStudioStore, type StudioPaperStatus } from "@/lib/store/studio-store"
import { useContextPackGeneration } from "@/hooks/useContextPackGeneration"
import type { ReproContextPack } from "@/lib/types/p2c"
+import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
+import { cn } from "@/lib/utils"
+
+const statusConfig: Record = {
+ draft: { label: "Draft", className: "bg-zinc-500/90 text-white" },
+ generating: { label: "Code", className: "bg-blue-500 text-white" },
+ ready: { label: "Ready", className: "bg-emerald-500 text-white" },
+ running: { label: "Run", className: "bg-violet-500 text-white" },
+ completed: { label: "Done", className: "bg-emerald-500 text-white" },
+ error: { label: "Error", className: "bg-red-500 text-white" },
+}
function isReproContextPack(payload: unknown): payload is ReproContextPack {
if (!payload || typeof payload !== "object") return false
@@ -39,6 +50,7 @@ function StudioContent() {
selectPaper,
loadPapers,
papers,
+ selectedPaperId,
setContextPack,
setContextPackLoading,
setContextPackError,
@@ -146,44 +158,65 @@ function StudioContent() {
setContextPackLoading,
])
+ // Gallery view — no paper selected
+ if (!selectedPaperId) {
+ return
+ }
+
+ // Workspace view — paper selected
+ const selectedPaper = papers.find(p => p.id === selectedPaperId)
+ const paperTitle = selectedPaper?.title || "Untitled"
+ const paperStatus = selectedPaper?.status || "draft"
+ const config = statusConfig[paperStatus]
+ const isLoading = paperStatus === "generating" || paperStatus === "running"
+
return (
- {/* Top Bar - minimal */}
-
-
-
DeepCode Studio
+ {/* Top Bar */}
+
+
+
+
{paperTitle}
+
+ {isLoading && }
+ {config.label}
+
- {/* Desktop: 3-panel CodePilot-style layout */}
+ {/* Desktop: 2-panel workspace (FilesPanel left, ReproductionLog right) */}
- {/* Left: Papers Panel - fixed width */}
-
-
- {/* Middle + Right: Resizable */}
- {/* Middle: Reproduction Log */}
-
-
+ {/* Left: Files Panel */}
+
+
- {/* Right: Files Panel */}
-
-
+ {/* Right: Reproduction Log */}
+
+
- {/* Mobile: Tab navigation */}
+ {/* Mobile: 2-tab workspace */}
-
+
-
- Papers
-
Reproduction
@@ -191,9 +224,6 @@ function StudioContent() {
Files
-
-
-
diff --git a/web/src/components/studio/NewPaperModal.tsx b/web/src/components/studio/NewPaperModal.tsx
index 2862ec16..0e46f249 100644
--- a/web/src/components/studio/NewPaperModal.tsx
+++ b/web/src/components/studio/NewPaperModal.tsx
@@ -98,6 +98,13 @@ export function NewPaperModal({ open, onOpenChange }: NewPaperModalProps) {
return
}
+ const normalizedTitle = title.trim().toLowerCase()
+ const duplicate = papers.find(p => p.title.trim().toLowerCase() === normalizedTitle)
+ if (duplicate) {
+ setError("A paper with this title already exists in the studio.")
+ return
+ }
+
addPaper({
title: title.trim(),
abstract: abstract.trim(),
@@ -108,19 +115,38 @@ export function NewPaperModal({ open, onOpenChange }: NewPaperModalProps) {
}
const handleImportSelected = () => {
- const existingIds = new Set(papers.map(p => p.id))
+ const existingTitles = new Set(papers.map(p => p.title.trim().toLowerCase()))
- for (const paperId of selectedPaperIds) {
- // Skip if already in studio
- if (existingIds.has(paperId)) continue
+ let importedCount = 0
+ const skippedTitles: string[] = []
+ for (const paperId of selectedPaperIds) {
const paper = libraryPapers.find(p => p.id === paperId)
- if (paper) {
- addPaper({
- title: paper.title,
- abstract: paper.abstract || "",
- })
+ if (!paper) continue
+
+ const normalizedTitle = paper.title.trim().toLowerCase()
+
+ // Skip if already in studio (by title)
+ if (existingTitles.has(normalizedTitle)) {
+ skippedTitles.push(paper.title)
+ continue
}
+
+ addPaper({
+ title: paper.title,
+ abstract: paper.abstract || "",
+ })
+ existingTitles.add(normalizedTitle)
+ importedCount++
+ }
+
+ if (importedCount === 0 && skippedTitles.length > 0) {
+ setError(
+ skippedTitles.length === 1
+ ? "This paper already exists in the studio."
+ : "The selected papers already exist in the studio."
+ )
+ return
}
resetAndClose()
@@ -146,7 +172,7 @@ export function NewPaperModal({ open, onOpenChange }: NewPaperModalProps) {
})
// Check which papers are already in studio
- const studioPaperIds = new Set(papers.map(p => p.id))
+ const studioPaperTitles = new Set(papers.map(p => p.title.trim().toLowerCase()))
return (