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 推送 -![Email Notification](asset/notify.png) +![Email Notification](asset/ui/dailypaperdemo.png) + +示例模板包含:导读摘要、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'main-figure' + 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: + ![](images/xxx.jpg) + 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 = """ +![](images/fig1.jpg) +Figure 1: System overview + +![](https://cdn.mineru.net/fig2.png) +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 = "![](images/fig1.jpg)\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 ( @@ -158,7 +184,11 @@ export function NewPaperModal({ open, onOpenChange }: NewPaperModalProps) { - + setError(null)} + > From Library Manual Entry @@ -166,6 +196,11 @@ export function NewPaperModal({ open, onOpenChange }: NewPaperModalProps) {
+ {error && ( +
+ {error} +
+ )} setSearchQuery(e.target.value)} @@ -189,7 +224,9 @@ export function NewPaperModal({ open, onOpenChange }: NewPaperModalProps) {
{filteredLibraryPapers.map(paper => { const isSelected = selectedPaperIds.has(paper.id) - const isInStudio = studioPaperIds.has(paper.id) + const isInStudio = studioPaperTitles.has( + paper.title.trim().toLowerCase(), + ) return ( +
+
+ + {/* Content */} +
+
+ {/* Search */} +
+ + setQuery(e.target.value)} + placeholder="Search papers..." + className="pl-9 h-9" + /> +
+ + {sortedPapers.length === 0 ? ( + /* Empty state */ +
+
+ +
+

+ {query ? "No papers found" : "Add your first paper"} +

+

+ {query + ? "Try a different search term." + : "Start by adding a paper to reproduce its code. Paste a title and abstract to get started."} +

+ {!query && ( + + )} +
+ ) : ( + /* Responsive grid */ +
+ {sortedPapers.map((paper) => { + const config = statusConfig[paper.status] + const isLoading = + paper.status === "generating" || + paper.status === "running" + + return ( + selectPaper(paper.id)} + className="group relative cursor-pointer gap-0 py-0 transition-all hover:shadow-md hover:border-primary/30" + > + {/* Delete button */} + + + {/* Icon */} +
+ +
+ + {/* Title */} +
+

+ {paper.title || "Untitled"} +

+
+ + {/* Footer: status + time */} +
+ + {isLoading && ( + + )} + {config.label} + + + · + + + {formatRelativeTime( + paper.updatedAt, + )} + +
+
+ ) + })} +
+ )} +
+
+ + + + {/* Delete Confirmation Dialog */} + { + setDeleteConfirmOpen(open) + if (!open) { + setPaperToDelete(null) + setDeleteError(null) + } + }} + > + + + Delete Paper + + Are you sure you want to delete " + {paperToDelete?.title}"? + {paperToDelete?.outputDir && ( + + This will also delete all generated code + files. + + )} + {deleteError && ( + + {deleteError} + + )} + + + + + Cancel + + + {deleting ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + +
+ ) +} diff --git a/web/src/components/studio/PaperIcon.test.tsx b/web/src/components/studio/PaperIcon.test.tsx new file mode 100644 index 00000000..5db99796 --- /dev/null +++ b/web/src/components/studio/PaperIcon.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest" +import { renderToStaticMarkup } from "react-dom/server" +import { PaperIcon, getAbbreviation } from "./PaperIcon" + +describe("PaperIcon", () => { + it("handles abbreviation edge cases", () => { + expect(getAbbreviation("")).toBe("??") + expect(getAbbreviation("the and of")).toBe("??") + expect(getAbbreviation("深度 学习")).toBe("深学") + }) + + it("renders deterministic SVG for the same input", () => { + const first = renderToStaticMarkup( + + ) + const second = renderToStaticMarkup( + + ) + + expect(first).toBe(second) + expect(first).toContain('aria-label="GA"') + }) + + it("falls back to safe icon size for invalid input", () => { + const svg = renderToStaticMarkup( + + ) + expect(svg).toContain('width="48"') + expect(svg).toContain('height="48"') + expect(svg).toContain('viewBox="0 0 48 48"') + }) +}) diff --git a/web/src/components/studio/PaperIcon.tsx b/web/src/components/studio/PaperIcon.tsx new file mode 100644 index 00000000..2771168d --- /dev/null +++ b/web/src/components/studio/PaperIcon.tsx @@ -0,0 +1,108 @@ +"use client" + +/** + * Deterministic geometric icon for papers. + * Hashes the paper ID (djb2) to derive a color and 4×4 grid pattern, + * producing a blocky identicon-like SVG. Also extracts a 2-letter + * abbreviation from the title. + */ + +const PALETTE = [ + "#e63946", "#457b9d", "#2a9d8f", "#e9c46a", + "#f4a261", "#264653", "#6a4c93", "#1982c4", + "#8ac926", "#ff595e", "#6d6875", "#3a86a7", +] + +function djb2(str: string): number { + let hash = 5381 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0 + } + return hash +} + +const SKIP_WORDS = new Set([ + "a", "an", "the", "of", "in", "on", "at", "to", "for", + "and", "or", "is", "by", "with", "from", "as", "via", +]) + +function getAbbreviation(title: string): string { + const words = title + .normalize("NFKC") + .replace(/[^\p{L}\p{N}\s]/gu, "") + .split(/\s+/) + .filter(w => w.length > 0 && !SKIP_WORDS.has(w.toLowerCase())) + if (words.length === 0) return "??" + if (words.length === 1) return Array.from(words[0]).slice(0, 2).join("").toUpperCase() + const first = Array.from(words[0])[0] ?? "?" + const second = Array.from(words[1])[0] ?? "?" + return (first + second).toUpperCase() +} + +interface PaperIconProps { + paperId: string + title: string + size?: number +} + +export function PaperIcon({ paperId, title, size = 48 }: PaperIconProps) { + const iconSize = Number.isFinite(size) && size > 0 ? size : 48 + const hash = djb2(paperId) + const color = PALETTE[hash % PALETTE.length] + const abbr = getAbbreviation(title) + + // Generate a 4×4 symmetric pattern (mirror left half → right half) + // Use 8 bits from the hash for 2×4 cells, then mirror horizontally + const bits = (hash >>> 8) & 0xffff + const cellSize = iconSize / 4 + + const cells: { x: number; y: number }[] = [] + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 2; col++) { + const bitIndex = row * 2 + col + if ((bits >> bitIndex) & 1) { + cells.push({ x: col, y: row }) + cells.push({ x: 3 - col, y: row }) // mirror + } + } + } + + return ( + + + {cells.map((cell, i) => ( + + ))} + + {abbr} + + + ) +} + +export { getAbbreviation } diff --git a/web/src/components/studio/PapersPanel.tsx b/web/src/components/studio/PapersPanel.tsx index 056d24df..cd168cf5 100644 --- a/web/src/components/studio/PapersPanel.tsx +++ b/web/src/components/studio/PapersPanel.tsx @@ -17,6 +17,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog" import { cn } from "@/lib/utils" +import { deleteProjectFiles } from "@/lib/runbook/deleteProjectFiles" import { Plus, Search, @@ -117,11 +118,7 @@ export function PapersPanel() { // Delete generated files if outputDir exists if (paperToDelete.outputDir) { try { - await fetch('/api/runbook/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project_dir: paperToDelete.outputDir }), - }) + await deleteProjectFiles(paperToDelete.outputDir) } catch (e) { console.error('Failed to delete project files:', e) } diff --git a/web/src/components/studio/ReproductionLog.tsx b/web/src/components/studio/ReproductionLog.tsx index f674ad5e..3c355fc4 100644 --- a/web/src/components/studio/ReproductionLog.tsx +++ b/web/src/components/studio/ReproductionLog.tsx @@ -12,6 +12,7 @@ import { DiffModal } from "./DiffViewer" import { WorkspaceSetupDialog } from "./WorkspaceSetupDialog" import { ContextPackPanel } from "./ContextPackPanel" import { GenerationProgressPanel } from "./GenerationProgressPanel" +import { useContextPackGeneration } from "@/hooks/useContextPackGeneration" import { cn } from "@/lib/utils" import { CheckCircle2, @@ -32,6 +33,7 @@ import { Activity, Package, MessageSquare, + Play, } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import Editor from "@monaco-editor/react" @@ -174,12 +176,14 @@ export function ReproductionLog() { liveObservations, addTask, addAction, + appendToLastAction, updateTaskStatus, setLastGenCodeResult, updatePaper, selectPaper, } = useStudioStore() + const { generate: generateContextPack, status: genStatus } = useContextPackGeneration() const { files, activeFile, updateFile, setActiveFile } = useProjectContext() const activeFileData = activeFile ? files[activeFile] : null @@ -293,6 +297,7 @@ export function ReproductionLog() { method_section: selectedPaper.methodSection, } : undefined, project_dir: targetDir, + context_pack_id: contextPack?.context_pack_id, }), }) @@ -302,17 +307,73 @@ export function ReproductionLog() { updateTaskStatus(taskId, "running") + // Track whether the last action is a text block so we can + // append to it (producing one continuous bubble) instead of + // creating a new action per chunk. + let lastActionIsText = false + for await (const evt of readSSE(res.body)) { if (evt?.type === "progress") { - const data = (evt.data ?? {}) as { delta?: string; content?: string; phase?: string; message?: string } - if (data.delta) { - // Streaming text update - addAction(taskId, { type: "text", content: data.delta }) + const data = (evt.data ?? {}) as Record + const cliEvent = data.cli_event as string | undefined + + if (cliEvent === "text") { + // Streaming text — append to current text bubble + const text = (data.text as string) || "" + if (text) { + if (lastActionIsText) { + appendToLastAction(taskId, text) + } else { + addAction(taskId, { type: "text", content: text }) + lastActionIsText = true + } + } + } else if (cliEvent === "tool_use") { + lastActionIsText = false + addAction(taskId, { + type: "function_call", + content: `${data.tool_name}()`, + metadata: { + functionName: data.tool_name as string, + params: data.tool_input as Record, + }, + }) + } else if (cliEvent === "tool_result") { + // Attach result to the most recent function_call action + lastActionIsText = false + addAction(taskId, { + type: "function_call", + content: `${data.tool_name}() result`, + metadata: { + functionName: data.tool_name as string, + result: data.content as string, + }, + }) + } else if (cliEvent === "thinking") { + lastActionIsText = false + addAction(taskId, { type: "thinking", content: (data.text as string) || "Thinking..." }) + } else if (data.keepalive) { + // Keepalive heartbeat — ignore } else if (data.message) { - addAction(taskId, { type: "thinking", content: data.message }) + // Legacy status messages (e.g. "Connecting to Claude CLI...") + lastActionIsText = false + addAction(taskId, { type: "thinking", content: data.message as string }) + } else if (data.delta) { + // Fallback: legacy plain-text streaming (API fallback path) + const text = data.delta as string + if (lastActionIsText) { + appendToLastAction(taskId, text) + } else { + addAction(taskId, { type: "text", content: text }) + lastActionIsText = true + } } } else if (evt?.type === "result") { - addAction(taskId, { type: "complete", content: "Response complete" }) + const data = (evt.data ?? {}) as Record + const summary = data.num_turns + ? `Completed in ${data.num_turns} turns` + : "Completed" + addAction(taskId, { type: "complete", content: summary }) updateTaskStatus(taskId, "completed") setStatus("success") } else if (evt?.type === "error") { @@ -378,11 +439,37 @@ export function ReproductionLog() { {/* Main content area */}
{viewMode === "generating" ? ( - + generationProgress.length === 0 && !contextPackLoading ? ( + /* Idle state — show generate button */ +
+ +

No generation in progress

+ {selectedPaper && ( + + )} +
+ ) : ( + + ) ) : viewMode === "context_pack" ? ( contextPack ? ( @@ -390,6 +477,7 @@ export function ReproductionLog() {

No context pack available yet

+

Please start in the “Progress” section.

) ) : activeFileData ? ( diff --git a/web/src/hooks/useContextPackGeneration.ts b/web/src/hooks/useContextPackGeneration.ts index 595a54e7..9b22f34f 100644 --- a/web/src/hooks/useContextPackGeneration.ts +++ b/web/src/hooks/useContextPackGeneration.ts @@ -65,6 +65,8 @@ export function useContextPackGeneration() { paperId: string userId?: string depth?: "fast" | "standard" | "deep" + title?: string + abstract?: string }) => { const startedAt = Date.now() console.info("[P2C:M3] generate:start", { paperId: params.paperId, depth: params.depth }) @@ -77,17 +79,21 @@ export function useContextPackGeneration() { setContextPackLoading(true) try { + const payload: Record = { + paper_id: params.paperId, + user_id: params.userId ?? "default", + depth: params.depth ?? "standard", + } + if (params.title !== undefined) payload.title = params.title + if (params.abstract !== undefined) payload.abstract = params.abstract + const response = await fetch("/api/research/repro/context", { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, - body: JSON.stringify({ - paper_id: params.paperId, - user_id: params.userId ?? "default", - depth: params.depth ?? "standard", - }), + body: JSON.stringify(payload), }) if (!response.ok || !response.body) { @@ -131,8 +137,8 @@ export function useContextPackGeneration() { setContextPackLoading(false) return } else if (evt.type === "error") { - const error = evt.data as GenerateErrorEvent - const message = error?.error || error?.message || evt.message || "Generation failed" + const error = (evt.data && typeof evt.data === "object" ? evt.data : {}) as GenerateErrorEvent + const message = evt.message || error?.message || error?.error || "Generation failed" console.error("[P2C:M3] generate:error", { message, data: evt.data }) setContextPackError(message) setStatus("error") diff --git a/web/src/lib/runbook/deleteProjectFiles.ts b/web/src/lib/runbook/deleteProjectFiles.ts new file mode 100644 index 00000000..8830551c --- /dev/null +++ b/web/src/lib/runbook/deleteProjectFiles.ts @@ -0,0 +1,24 @@ +export async function deleteProjectFiles(projectDir: string): Promise { + const listRes = await fetch( + `/api/runbook/files?project_dir=${encodeURIComponent(projectDir)}&recursive=true` + ); + if (!listRes.ok) { + throw new Error(`Failed to list project files (${listRes.status})`); + } + + const payload = (await listRes.json()) as { files?: unknown }; + const files = Array.isArray(payload.files) + ? payload.files.filter((v): v is string => typeof v === "string" && v.length > 0) + : []; + + for (const path of files) { + const delRes = await fetch("/api/runbook/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project_dir: projectDir, path }), + }); + if (!delRes.ok) { + throw new Error(`Failed to delete file '${path}' (${delRes.status})`); + } + } +} diff --git a/web/src/lib/store/project-context.ts b/web/src/lib/store/project-context.ts index 030d6436..5593d871 100644 --- a/web/src/lib/store/project-context.ts +++ b/web/src/lib/store/project-context.ts @@ -18,45 +18,7 @@ interface ProjectState { removeFile: (name: string) => void } -const DEFAULT_FILES: Record = { - "model.py": { - name: "model.py", - language: "python", - content: `import torch -import torch.nn as nn - -class Transformer(nn.Module): - def __init__(self, d_model=512, nhead=8, num_encoder_layers=6): - super().__init__() - self.encoder_layer = nn.TransformerEncoderLayer(d_model=d_model, nhead=nhead) - self.transformer_encoder = nn.TransformerEncoder(self.encoder_layer, num_layers=num_encoder_layers) - - def forward(self, src): - output = self.transformer_encoder(src) - return output` - }, - "train.py": { - name: "train.py", - language: "python", - content: `import torch -from model import Transformer - -def train(): - model = Transformer() - optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) - # TODO: Implement training loop` - }, - "config.yaml": { - name: "config.yaml", - language: "yaml", - content: `model: - d_model: 512 - nhead: 8 -training: - batch_size: 32 - epochs: 10` - } -} +const DEFAULT_FILES: Record = {} export const useProjectContext = create((set) => ({ files: DEFAULT_FILES, diff --git a/web/src/lib/store/studio-store.test.ts b/web/src/lib/store/studio-store.test.ts new file mode 100644 index 00000000..899abb34 --- /dev/null +++ b/web/src/lib/store/studio-store.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { useStudioStore } from "./studio-store" +import type { + ReproContextPack, + StageObservationsEvent, + StageProgressEvent, +} from "../types/p2c" + +const resetStore = () => { + useStudioStore.setState(useStudioStore.getInitialState(), true) +} + +describe("studio-store", () => { + beforeEach(() => { + resetStore() + }) + + it("round-trips per-paper cached state when switching papers", () => { + const { addPaper, selectPaper } = useStudioStore.getState() + const firstId = addPaper({ title: "Paper A", abstract: "A" }) + const secondId = addPaper({ title: "Paper B", abstract: "B" }) + + selectPaper(firstId) + + const progress: StageProgressEvent = { + stage: "extract", + progress: 0.5, + message: "Halfway", + } + const observations: StageObservationsEvent = { + stage: "extract", + observations: [], + } + const pack: ReproContextPack = { + context_pack_id: "cp-1", + version: "v1", + created_at: new Date().toISOString(), + paper: { + paper_id: "paper-1", + title: "Paper A", + year: 2024, + authors: [], + identifiers: {}, + }, + paper_type: "experimental", + objective: "Test", + observations: [], + task_roadmap: [], + confidence: { + overall: 0, + literature: 0, + blueprint: 0, + environment: 0, + spec: 0, + roadmap: 0, + metrics: 0, + }, + warnings: [], + } + + useStudioStore.getState().setContextPack(pack) + useStudioStore.getState().setContextPackLoading(true) + useStudioStore.getState().setContextPackError("boom") + useStudioStore.getState().appendGenerationProgress(progress) + useStudioStore.getState().appendLiveObservations(observations) + useStudioStore.getState().setActiveTask("task-1") + + selectPaper(secondId) + + const afterSwitch = useStudioStore.getState() + expect(afterSwitch.contextPack).toBeNull() + expect(afterSwitch.contextPackLoading).toBe(false) + expect(afterSwitch.contextPackError).toBeNull() + expect(afterSwitch.generationProgress).toHaveLength(0) + expect(afterSwitch.liveObservations).toHaveLength(0) + expect(afterSwitch.activeTaskId).toBeNull() + + selectPaper(firstId) + + const restored = useStudioStore.getState() + expect(restored.contextPack).toEqual(pack) + expect(restored.contextPackLoading).toBe(true) + expect(restored.contextPackError).toBe("boom") + expect(restored.generationProgress).toEqual([progress]) + expect(restored.liveObservations).toEqual([observations]) + expect(restored.activeTaskId).toBe("task-1") + }) + + it("appends text to the last action when streaming", () => { + const { addPaper, addTask, addAction, appendToLastAction, selectPaper } = + useStudioStore.getState() + + const paperId = addPaper({ title: "Paper C", abstract: "C" }) + selectPaper(paperId) + + const taskId = addTask("Run task") + addAction(taskId, { type: "text", content: "hello" }) + appendToLastAction(taskId, " world") + + const task = useStudioStore.getState().tasks.find(t => t.id === taskId) + expect(task?.actions).toHaveLength(1) + expect(task?.actions[0].content).toBe("hello world") + }) +}) diff --git a/web/src/lib/store/studio-store.ts b/web/src/lib/store/studio-store.ts index 7143b46f..3953b9e5 100644 --- a/web/src/lib/store/studio-store.ts +++ b/web/src/lib/store/studio-store.ts @@ -101,11 +101,24 @@ function savePapersToStorage(papers: StudioPaper[]): void { } } +// Per-paper cached state (preserved across paper switches) +interface PerPaperCache { + contextPack: ReproContextPack | null + contextPackLoading: boolean + contextPackError: string | null + generationProgress: StageProgressEvent[] + liveObservations: StageObservationsEvent[] + activeTaskId: string | null +} + interface StudioState { // Paper management papers: StudioPaper[] selectedPaperId: string | null + // Per-paper state cache (not cleared on switch) + _paperCache: Record + // Task management (scoped to selected paper) tasks: Task[] activeTaskId: string | null @@ -133,6 +146,7 @@ interface StudioState { addTask: (name: string) => string updateTaskStatus: (taskId: string, status: Task['status']) => void addAction: (taskId: string, action: Omit) => void + appendToLastAction: (taskId: string, text: string) => void setActiveTask: (taskId: string | null) => void setSelectedFileForDiff: (filename: string | null) => void setPaperDraft: (partial: Partial) => void @@ -153,6 +167,7 @@ export const useStudioStore = create((set, get) => ({ // Paper state papers: [], selectedPaperId: null, + _paperCache: {}, // Task state tasks: [], @@ -223,13 +238,22 @@ export const useStudioStore = create((set, get) => ({ savePapersToStorage(newPapers) // Clear selection if deleted paper was selected const newSelectedPaperId = state.selectedPaperId === paperId ? null : state.selectedPaperId + // Remove from cache + const newCache = { ...state._paperCache } + delete newCache[paperId] return { papers: newPapers, selectedPaperId: newSelectedPaperId, + _paperCache: newCache, // Clear draft if deleted paper was selected ...(state.selectedPaperId === paperId ? { paperDraft: { title: '', abstract: '', methodSection: '' }, lastGenCodeResult: null, + contextPack: null, + contextPackLoading: false, + contextPackError: null, + generationProgress: [], + liveObservations: [], } : {}), } }) @@ -237,9 +261,26 @@ export const useStudioStore = create((set, get) => ({ selectPaper: (paperId) => { const state = get() + + // Save current paper's state to cache before switching + const prevCache = { ...state._paperCache } + if (state.selectedPaperId) { + prevCache[state.selectedPaperId] = { + contextPack: state.contextPack, + contextPackLoading: state.contextPackLoading, + contextPackError: state.contextPackError, + generationProgress: state.generationProgress, + liveObservations: state.liveObservations, + activeTaskId: state.activeTaskId, + } + } + const paper = paperId ? state.papers.find(p => p.id === paperId) : null + const cached = paperId ? prevCache[paperId] : undefined + set({ selectedPaperId: paperId, + _paperCache: prevCache, // Sync paperDraft with selected paper paperDraft: paper ? { title: paper.title, abstract: paper.abstract, methodSection: paper.methodSection || '' } @@ -247,12 +288,13 @@ export const useStudioStore = create((set, get) => ({ // Load paper's lastGenCodeResult if available lastGenCodeResult: paper?.lastGenCodeResult || null, workspaceSnapshotId: null, - // Clear active task when switching papers - activeTaskId: null, - contextPack: null, - contextPackLoading: false, - contextPackError: null, - generationProgress: [], + // Restore cached per-paper state, or defaults + activeTaskId: cached?.activeTaskId ?? null, + contextPack: cached?.contextPack ?? null, + contextPackLoading: cached?.contextPackLoading ?? false, + contextPackError: cached?.contextPackError ?? null, + generationProgress: cached?.generationProgress ?? [], + liveObservations: cached?.liveObservations ?? [], }) }, @@ -326,6 +368,18 @@ export const useStudioStore = create((set, get) => ({ })) }, + appendToLastAction: (taskId, text) => { + set(state => ({ + tasks: state.tasks.map(t => { + if (t.id !== taskId || t.actions.length === 0) return t + const last = t.actions[t.actions.length - 1] + if (last.type !== 'text') return t + const updated = { ...last, content: last.content + text } + return { ...t, actions: [...t.actions.slice(0, -1), updated] } + }) + })) + }, + setActiveTask: (taskId) => set({ activeTaskId: taskId }), setSelectedFileForDiff: (filename) => set({ selectedFileForDiff: filename }), diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 00000000..ab76a35a --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + }, +})