diff --git a/README.md b/README.md
index 2d80b7a..f07edf8 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,198 @@
-# Deep Research (Single-User Local Edition)
+# Deep Research
-## Run backend (uv + Python 3.12)
+一个以“会话驱动”为核心的深度研究系统:
+
+- 左侧会话管理(可多轮、多会话)
+- 中间聊天时间线(方案、进度、报告)
+- 右侧 Markdown 研究方案编辑器(可手改可执行)
+- 后端异步执行引擎(规划 DAG -> 检索证据 -> 冲突检测 -> 生成报告)
+
+---
+
+## 1. 当前能力(基于当前代码)
+
+- FastAPI + SQLite 后端,支持任务与会话两套 API。
+- 会话主流程:
+ 1. 创建会话自动生成首版方案(`PLAN_DRAFT`)
+ 2. 用户在聊天里继续“改方案 / 改报告 / 触发重跑”
+ 3. 执行完成后在时间线返回 `FINAL_REPORT`
+- 执行引擎支持:暂停、恢复、终止、快照恢复。
+- 检索支持 Tavily / arXiv / Semantic Scholar(按配置与可用 Key 启用)。
+- 分析支持:证据打分、数值冲突检测、冲突投票接口。
+- 写作支持:报告模板化生成、可选 LLM 润色、`.md + .bib` 产物落盘。
+- 前端支持:移动端抽屉、进度分组折叠、历史轮次显示/隐藏、报告下载。
+
+---
+
+## 2. 技术栈
+
+### 后端
+
+- Python `>=3.11`(脚本默认 `3.12`)
+- FastAPI / Pydantic v2 / Uvicorn
+- SQLite(WAL)
+
+### 前端
+
+- React 18 + TypeScript + Vite
+- 纯前端状态管理(`useState/useMemo/useEffect`)
+
+### 测试
+
+- pytest(unit + integration)
+- ruff
+
+---
+
+## 3. 快速启动
+
+### 3.1 环境要求
+
+- `uv`
+- Node.js `20+`
+- npm `10+`
+
+### 3.2 后端
```bash
./scripts/run_backend.sh
```
-## Configure API keys (`.env`)
-
-Project root now includes a `.env` file template.
-Fill only the providers you plan to use:
+该脚本会执行:
-- LLM: `DR_OPENROUTER_API_KEY`, `DR_DEEPSEEK_API_KEY`, `DR_OPENAI_API_KEY`, `DR_ANTHROPIC_API_KEY`
-- Search: `DR_SERPER_API_KEY`, `DR_SERPAPI_API_KEY`, `DR_TAVILY_API_KEY`, `DR_BRAVE_API_KEY`, `DR_BING_SUBSCRIPTION_KEY`, `DR_GOOGLE_CSE_API_KEY`, `DR_GOOGLE_CSE_CX`
+1. 创建 `.venv`(Python 3.12)
+2. 安装 `backend[dev]`
+3. 启动 `uvicorn app.main:app --reload --host 127.0.0.1 --port 8000`
-Also configurable:
+启动后可访问:
-- `DR_DEFAULT_LLM_PROVIDER` (for example `openrouter` or `deepseek`)
-- `DR_DEFAULT_LLM_MODEL`
-- `DR_USE_MOCK_SOURCES` (`true` for mock mode, `false` for real API calls)
+- API: `http://127.0.0.1:8000`
+- OpenAPI: `http://127.0.0.1:8000/docs`
+- 健康检查: `GET /healthz`
-## Run frontend
+### 3.3 前端
```bash
./scripts/run_frontend.sh
```
-## Test and build
+默认地址(见 `frontend/vite.config.ts`):
+
+- `http://127.0.0.1:5174`
+
+> 如果端口被占用,Vite 会提示并可能切换端口。
+
+---
+
+## 4. `.env` 配置
+
+后端配置读取规则:
+
+- 文件:项目根目录 `.env`
+- 前缀:`DR_`
+- 定义位置:`backend/app/core/config.py`
+
+关键项:
+
+- 执行模式:`DR_USE_MOCK_SOURCES`
+ - `true`:完全走 mock 数据(测试/离线验证)
+ - `false`:调用真实检索/模型接口
+- 默认模型路由:
+ - `DR_DEFAULT_LLM_PROVIDER`(`openrouter | deepseek | openai`)
+ - `DR_DEFAULT_LLM_MODEL`
+- 检索 Key:`DR_TAVILY_API_KEY` 等
+
+安全建议:
+
+- 不要把真实 API Key 提交到仓库。
+- 如果曾提交过,立即轮换(rotate)并改为本地私有配置。
+
+---
+
+## 5. 最短使用流程(UI)
+
+1. 打开前端页面,点“新建研究”。
+2. 第一条消息输入研究主题(最多 500 字)。
+3. 等待 Agent 返回第一版研究方案(自动写入右侧草稿)。
+4. 可直接编辑草稿并“保存草稿”。
+5. 点击“开始研究”。
+6. 中间时间线查看进度分组(SEARCHING / WRITING_SECTION 等阶段)。
+7. 完成后出现“当前报告”,可继续发送“改写报告/补检索/重跑”指令。
+8. 点击“下载 Markdown”导出报告。
+
+---
+
+## 6. 常用 API(按实现)
+
+### 任务
+
+- `POST /api/v1/tasks`
+- `GET /api/v1/tasks`
+- `GET /api/v1/tasks/{task_id}`
+- `PUT /api/v1/tasks/{task_id}`
+- `DELETE /api/v1/tasks/{task_id}`
+- `GET /api/v1/tasks/{task_id}/dag`
+- `POST /api/v1/tasks/{task_id}/start|pause|resume|abort|recover`
+- `GET /api/v1/tasks/{task_id}/snapshot`
+- `GET /api/v1/tasks/{task_id}/report`
+- `GET /api/v1/tasks/{task_id}/report/download`
+- `WS /api/v1/ws/task/{task_id}/progress`
+
+### 会话
+
+- `POST /api/v1/conversations`
+- `GET /api/v1/conversations`
+- `GET /api/v1/conversations/{conversation_id}`
+- `PATCH /api/v1/conversations/{conversation_id}`(重命名)
+- `DELETE /api/v1/conversations/{conversation_id}`
+- `DELETE /api/v1/conversations`(全部删除)
+- `POST /api/v1/conversations/{conversation_id}/plan/revise`
+- `PUT /api/v1/conversations/{conversation_id}/plan`
+- `POST /api/v1/conversations/{conversation_id}/run`
+- `GET /api/v1/conversations/{conversation_id}/report/download`
+
+### 证据与冲突
+
+- `GET /api/v1/evidence`
+- `GET /api/v1/evidence/{evidence_id}`
+- `POST /api/v1/evidence/{evidence_id}/vote`
+- `GET /api/v1/tasks/{task_id}/conflicts`
+
+### MCP
+
+- `POST /api/v1/mcp/execute`
+ - `mode=read` -> 直接执行
+ - `mode=write|execute` -> `USER_CONFIRMATION_REQUIRED`
+
+---
+
+## 7. 目录结构(核心)
+
+```text
+backend/
+ app/
+ api/routes/ # REST + WS
+ core/ # config / sqlite schema
+ models/schemas.py # 全部 Pydantic 模型
+ repositories/ # 持久化层
+ services/ # 规划/检索/分析/写作/执行/会话编排
+frontend/
+ src/
+ App.tsx # 三栏主状态机
+ api.ts # 所有 HTTP 调用封装
+ components/ # 时间线/侧栏/编辑器/对话框
+scripts/
+ run_backend.sh
+ run_frontend.sh
+ run_real_case.py
+tests/
+ unit/
+ integration/
+```
+
+---
+
+## 8. 测试与构建
```bash
uv venv --python 3.12 .venv --clear
@@ -37,14 +203,34 @@ pytest tests/unit tests/integration
cd frontend && npm run build
```
-## Current status
+---
+
+## 9. 一键跑真实案例(脚本)
+
+```bash
+python scripts/run_real_case.py \
+ --title "2026年AI Agent在软件工程中的应用现状与挑战" \
+ --description "基于公开资料分析进展、风险与落地策略" \
+ --sources "tavily" \
+ --timeout 120
+```
+
+该脚本会:创建任务 -> 启动执行 -> 轮询终态 -> 打印证据数 -> 输出报告预览。
+
+---
+
+## 10. 已知边界
+
+- 单用户本地模式;无鉴权/多租户。
+- 执行引擎当前按节点顺序执行(非分布式并行调度)。
+- `ConversationAgent` 的意图识别基于关键词规则,不是分类模型。
+- `MCPExecutor` 目前是最小可用实现(read 模拟执行)。
+
+---
-Local alpha workflow is complete:
-- Task lifecycle + FSM + DAG planner
-- Retrieval + evidence management
-- Analyst conflict detection + vote resolution
-- Writer report generation (`.md` + `.bib`)
-- MCP minimal execution API
-- Frontend six-state workflow console
+## 11. 深入文档
-See `docs/LOCAL_RELEASE.md` for release details.
+- 详细实现说明:`doc.md`
+- 端到端流程手册:`WORKFLOW.md`
+- 本地发布说明:`docs/LOCAL_RELEASE.md`
+- UI 回归清单:`docs/UI_REGRESSION_CHECKLIST.md`
diff --git a/WORKFLOW.md b/WORKFLOW.md
index 447cbd5..9dde9c9 100644
--- a/WORKFLOW.md
+++ b/WORKFLOW.md
@@ -1,943 +1,360 @@
-# Deep Research 深度科研辅助系统 - 运行流程详解
+# Deep Research 运行流程手册(代码对齐版)
-## 目录
+> 文档目的:把“系统实际如何运行”写成可操作流程,覆盖主流程、分支、异常和恢复。
-1. [系统架构概述](#1-系统架构概述)
-2. [全流程交互逻辑](#2-全流程交互逻辑)
-3. [核心代理详解](#3-核心代理详解)
-4. [数据流转关系](#4-数据流转关系)
-5. [API接口规范](#5-api接口规范)
-6. [错误处理与容错](#6-错误处理与容错)
+---
+
+## 1. 角色与核心对象
+
+- 用户:在前端发起主题、修订计划、触发执行、改写报告。
+- 前端:三栏工作台(会话、时间线、计划编辑器)。
+- 会话编排器:`ConversationAgent`。
+- 执行引擎:`ExecutionEngine`。
+- 数据层:SQLite + 报告文件。
+
+核心 ID:
+- `conversation_id`:会话维度。
+- `task_id`:每次运行生成的新任务维度。
+- `message_id`:时间线消息维度。
+- `evidence_id`:证据维度。
---
-## 1. 系统架构概述
-
-### 1.1 分层架构图
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ 交互层 (Interaction Layer) │
-│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
-│ │ CoT 编辑器 │ │ 实时证据看板 │ │ Markdown 分屏预览 │ │
-│ └─────────────┘ └──────────────┘ └──────────────────────────┘ │
-├─────────────────────────────────────────────────────────────────┤
-│ 编排层 (Orchestration Layer) │
-│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │
-│ │ 主规划器 (Master Planner)│ │ 状态管理器 (State Manager) │ │
-│ │ - DAG 任务图谱管理 │ │ - FSM 状态机 │ │
-│ │ - 动态任务调度 │ │ - 上下文快照 │ │
-│ └─────────────────────────┘ └──────────────────────────────┘ │
-├─────────────────────────────────────────────────────────────────┤
-│ 代理层 (Agent Layer) │
-│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
-│ │ 检索代理 │ │ 分析代理 │ │MCP执行代理 │ │ 写作代理 │ │
-│ └───────────┘ └───────────┘ └───────────┘ └───────────────┘ │
-├─────────────────────────────────────────────────────────────────┤
-│ 基础设施层 (Infrastructure) │
-│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │
-│ │ 向量数据库 │ │ MCP 服务器集群 │ │ 本地文件系统接口 │ │
-│ │ (L2 缓存) │ │ │ │ │ │
-│ └─────────────┘ └─────────────┘ └──────────────────────────┘ │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-### 1.2 核心组件说明
-
-| 层级 | 组件 | 职责 |
-|------|------|------|
-| **交互层** | CoT 编辑器 | 展示和编辑思维链,允许用户干预任务规划 |
-| **交互层** | 实时证据看板 | 展示收集到的证据流,支持点击锚定 |
-| **交互层** | Markdown 分屏预览 | 实时预览生成的报告,支持段落锁定 |
-| **编排层** | Master Planner | 任务拆解、DAG 生成、动态调度 |
-| **编排层** | State Manager | FSM 状态管理、上下文快照与恢复 |
-| **代理层** | Retrieval Agent | 信息检索、网页解析、缓存管理 |
-| **代理层** | Analyst Agent | 数值冲突检测、信誉评分、语义对齐 |
-| **代理层** | MCP Executor | 外部工具调用、沙盒执行 |
-| **代理层** | Writer Agent | 增量文档生成、引用管理 |
-| **基础设施层** | Vector DB | L2 向量缓存 |
-| **基础设施层** | MCP Servers | 外部工具和数据源连接 |
-| **基础设施层** | File System | 本地文件访问 |
+## 2. 状态机
+
+## 2.1 会话状态(ConversationStatus)
+
+- `DRAFTING_PLAN`
+- `PLAN_READY`
+- `RUNNING`
+- `COMPLETED`
+- `FAILED`
+
+## 2.2 任务状态(TaskStatus)
+
+- `READY`
+- `PLANNING`
+- `EXECUTING`
+- `REVIEWING`
+- `SYNTHESIZING`
+- `FINALIZING`
+- `COMPLETED`
+- `FAILED`
+- `SUSPENDED`
+- `ABORTED`
+
+迁移规则由 `state_machine.py` 的 `ALLOWED_TRANSITIONS` 严格控制。
---
-## 2. 全流程交互逻辑
-
-### 2.1 状态机 (FSM) 流程图
-
-```
- ┌─────────────┐
- │ Ready │ ← 录入议题、上传种子文件、配置偏好
- └──────┬──────┘
- │ [启动研究]
- ▼
- ┌─────────────┐
- │ Planning │ ← 生成 DAG、用户可调整节点/依赖
- └──────┬──────┘
- │ [执行计划]
- ▼
- ┌─────────────┐
- │ Executing │ ← 检索/分析并行、实时推送证据
- └──────┬──────┘
- │ [发现冲突]
- ┌──────▼──────┐
- │ Reviewing │ ← 冲突高亮、用户选择采信源
- └──────┬──────┘
- │ [冲突解决]
- ┌──────▼──────┐
- │ Synthesizing│ ← 整合证据、生成报告、锁定段落
- └──────┬──────┘
- │ [完成确认]
- ▼
- ┌─────────────┐
- │ Finalizing │ ← 导出文档、生成参考文献、归档
- └─────────────┘
-```
-
-### 2.2 各状态详解
-
-#### **状态 1: Ready (就绪)**
-
-| 属性 | 说明 |
-|------|------|
-| **UI 表现** | 初始化界面,含多模态输入模块 |
-| **用户操作** | 录入研究议题、上传种子文件、配置偏好 |
-| **系统响应** | 预加载常用 MCP 服务列表 |
-
-**输入数据:**
-```json
-{
- "title": "研究标题",
- "description": "研究描述",
- "config": {
- "maxDepth": 3,
- "maxNodes": 50,
- "searchSources": ["arXiv", "Google Scholar", "IEEE"],
- "priority": 5
- }
-}
-```
-
-**输出数据:**
-```json
-{
- "taskId": "uuid-xxxx",
- "status": "READY",
- "createdAt": "2024-01-01T00:00:00Z"
-}
-```
+## 3. 主流程 A:新建会话并完成研究
+
+## 3.1 创建会话
+
+### 用户动作
+在“新建研究”后发送第一条主题消息。
+
+### 前端调用
+`POST /api/v1/conversations`
+
+### 后端行为(`ConversationAgent.create_conversation`)
+1. 新建会话,状态 `DRAFTING_PLAN`。
+2. 写入首条用户消息 `USER_TEXT`。
+3. 生成首版计划(LLM 或 fallback)。
+4. 写入 `PLAN_DRAFT` 消息。
+5. 状态切到 `PLAN_READY`。
+
+### 前端表现
+- 中间时间线出现首版计划。
+- 右侧计划编辑器自动填充 Markdown。
---
-#### **状态 2: Planning (规划)**
-
-| 属性 | 说明 |
-|------|------|
-| **UI 表现** | 展示任务图谱节点 (DAG) |
-| **用户操作** | 暂停修改节点、拖拽调整依赖、删除冗余路径 |
-| **系统响应** | 规划器拆解任务,生成依赖关系图 |
-
-**Master Planner 处理流程:**
-
-```
-输入: CreateTaskRequest
- │
- ├─> [BFS 展开] 生成首级子课题
- │ ├─> 背景研究
- │ ├─> 现状分析
- │ └─> 挑战识别
- │
- ├─> [DFS 深挖] 根据反馈垂直展开
- │
- ├─> [循环检测] 维护 VisitedStack 防止无限循环
- │
- ├─> [动态剪枝] 计算 infoGainScore
- │ └─> 若连续 < 0.2,合并/舍弃分支
- │
- └─> 输出: DAGGraph
-```
-
-**输出数据 (DAG):**
-```json
-{
- "nodes": [
- {
- "taskId": "node-1",
- "title": "背景研究",
- "status": "PENDING",
- "priority": 5,
- "depth": 1
- }
- ],
- "edges": [
- {
- "from": "node-1",
- "to": "node-2",
- "type": "DEPENDS_ON"
- }
- ]
-}
-```
+## 3.2 用户修订计划(可选)
+
+### 方式 1:右侧编辑器直接改
+- 前端调用:`PUT /api/v1/conversations/{id}/plan`
+- 后端写入新 `PlanRevision` + `PLAN_EDITED` 消息。
+
+### 方式 2:聊天输入自然语言“改方案”
+- 前端调用:`POST /api/v1/conversations/{id}/plan/revise`
+- 后端按指令生成新版本并追加 `PLAN_REVISION` 消息。
---
-#### **状态 3: Executing (执行)**
-
-| 属性 | 说明 |
-|------|------|
-| **UI 表现** | 进度条显示、实时证据流看板 |
-| **用户操作** | 干预调整检索词、调整优先级、授权 MCP 调用 |
-| **系统响应** | 检索/执行代理并行运作,推送 Evidence[] |
-
-**WebSocket 实时推送:**
-```json
-{
- "event": "EVIDENCE_FOUND",
- "timestamp": "2024-01-01T00:00:00Z",
- "data": {
- "taskId": "node-1",
- "evidence": { ... }
- }
-}
-```
+## 3.3 启动研究
+
+### 用户动作
+点击“开始研究”或在时间线点“继续执行”。
+
+### 前端调用
+`POST /api/v1/conversations/{id}/run`
+
+### 后端行为(`ConversationAgent.start_research`)
+1. 读取当前计划,解析 front matter:
+ - `title`
+ - `max_depth`
+ - `max_nodes`
+ - `priority`
+ - `search_sources`
+2. 使用解析结果创建**新任务**(每次 run 都是新 task_id)。
+3. 会话绑定 task_id,状态置 `RUNNING`。
+4. 写一条系统消息“研究任务已启动”。
+5. 调 `execution_engine.start(task_id)`。
---
-#### **状态 4: Reviewing (审查)**
-
-| 属性 | 说明 |
-|------|------|
-| **UI 表现** | 冲突节点高亮显示、对比弹窗 |
-| **用户操作** | 选择采信源、指令深挖争议点 |
-| **系统响应** | 分析代理检测一致性,聚合冲突数据 |
-
-**冲突检测流程:**
-```
-输入: Evidence[]
- │
- ├─> [单位标准化] 统一转换为 SI 单位
- │
- ├─> [阈值判定]
- │ └─> 若 (ValueA - ValueB) / Max > 15% 且环境相似
- │ └─> 生成 ConflictRecord
- │
- └─> 输出: ConflictRecord[]
-```
+## 3.4 执行引擎流水线
+
+`ExecutionEngine._run_task` 时序如下:
+
+1. 推送 `TASK_STARTED`。
+2. 若 DAG 为空:
+ - `READY -> PLANNING`
+ - `MasterPlanner.build_dag`
+ - `save_dag`
+ - 推送 `TASK_PROGRESS(phase=BUILDING_PLAN, progress=20)`
+3. 迁移到 `EXECUTING`。
+4. 遍历可执行节点(非 root 且非 PRUNED):
+ - 节点置 `RUNNING`
+ - 生成 query(`task.title + node.title`)
+ - 推送 `TASK_PROGRESS(SEARCHING)`
+ - `ResearchAgent.collect_evidence`
+ - `EvidenceRepository.save_many`
+ - 对每条证据推送 `EVIDENCE_FOUND`
+ - 节点置 `COMPLETED`
+ - 推送 `TASK_PROGRESS(NODE_COMPLETED)`
+ - 保存 snapshot
+5. 节点完成后进行分析:
+ - 对证据计算 score
+ - 检测冲突(阈值默认 0.15)
+ - 有冲突:`EXECUTING -> REVIEWING -> SYNTHESIZING`
+ - 无冲突:`EXECUTING -> SYNTHESIZING`
+6. 写作阶段:
+ - 推送 `OUTLINING`
+ - 逐 section 推送 `WRITING_SECTION`
+ - `ReportAgent.generate_report` 输出 `.md/.bib`
+7. 收尾:
+ - `SYNTHESIZING -> FINALIZING -> COMPLETED`
+ - 写 `report_path`
+ - 推送 `TASK_COMPLETED(progress=100)`
---
-#### **状态 5: Synthesizing (合成)**
-
-| 属性 | 说明 |
-|------|------|
-| **UI 表现** | 文档分屏预览、实时高亮 |
-| **用户操作** | 查看引用源、指令重写、锁定段落 |
-| **系统响应** | 写作代理整合证据,执行增量润色 |
-
-**写作流程:**
-```
-输入: Evidence[] + TaskNode[]
- │
- ├─> [分段绑定] 每个 # Section 绑定 TaskNode
- │
- ├─> [局部生成] 节点完成时触发对应段落生成
- │
- ├─> [段落锁定] 人工编辑段落标记为 LOCKED
- │
- ├─> [溯源索引] 维护 UUID -> Citation 映射
- │
- └─> 输出: Markdown 文档
-```
+## 3.5 事件回流会话
+
+执行引擎事件通过 listener 回到 `ConversationAgent.on_task_event`:
+
+- `TASK_PROGRESS`
+ - 进入 `ConversationRepository.append_progress_entry`
+ - 聚合为 `PROGRESS_GROUP` 消息
+- `TASK_COMPLETED`
+ - 会话状态置 `COMPLETED`
+ - 读取报告文件,追加 `FINAL_REPORT` 消息
+- `ERROR/TASK_FAILED/TASK_ABORTED`
+ - 会话状态置 `FAILED`
+ - 追加 `ERROR` 消息
+
+前端时间线会自动显示这些消息。
---
-#### **状态 6: Finalizing (归档)**
+## 4. 主流程 B:已完成后继续提要求
+
+`POST /plan/revise` 在“已有报告”场景会做意图分流。
+
+## 4.1 PLAN 模式(继续改计划)
+触发词示例:`研究方案 / max_depth / 任务树 / 执行步骤`。
+
+行为:
+- 新增 `PlanRevision`
+- 状态保持/回到 `PLAN_READY`
+
+## 4.2 RESEARCH 模式(补检索并重跑)
+触发词示例:`重跑 / 补充检索 / 再搜索 / 查询最新`。
+
+行为:
+1. 先修订计划。
+2. 立即调用 `start_research`。
+3. 新建 task_id 并进入 RUNNING。
-| 属性 | 说明 |
-|------|------|
-| **UI 表现** | 最终报告生成、参考文献列表 |
-| **用户操作** | 导出文档、同步 Zotero、启动后续研究 |
-| **系统响应** | 生成 .bib 文件、清理临时上下文 |
+## 4.3 REPORT 模式(只改报告,不重检索)
+触发词示例:`改写报告 / 演讲稿 / 改风格 / rewrite`。
+
+行为:
+1. 会话状态置 `RUNNING`。
+2. 写一条“正在修改中”系统消息。
+3. 异步执行报告改写任务:
+ - 优先基于现有报告做 LLM 改写
+ - 若指令包含“从证据重写/全量重写”等标记,可触发证据重建
+4. 追加新的 `FINAL_REPORT`。
+5. 会话回 `COMPLETED`。
---
-## 3. 核心代理详解
-
-### 3.1 Master Planner Agent (首席任务规划代理)
-
-#### 职能
-将抽象的科研目标转化为可执行的有向无环图 (DAG)。
-
-#### 输入
-
-```typescript
-interface CreateTaskRequest {
- title: string; // 研究标题
- description: string; // 研究描述
- config: {
- maxDepth: number; // 最大搜索深度 (默认 3)
- maxNodes: number; // 最大节点数 (默认 50)
- searchSources: string[]; // 数据源列表
- priority: number; // 优先级 (1-5)
- };
-}
-```
-
-#### 处理流程
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Master Planner 处理流程 │
-├─────────────────────────────────────────────────────────────────┤
-│ │
-│ 1. [初始化] 创建根节点 │
-│ └─> taskId = uuid(), parentTaskId = null │
-│ │
-│ 2. [BFS 展开] 首级子课题 │
-│ ├─> 背景研究 (Background Research) │
-│ ├─> 现状分析 (State of the Art) │
-│ └─> 挑战识别 (Challenge Identification) │
-│ │
-│ 3. [DFS 深挖] 根据检索反馈垂直展开 │
-│ └─> 递归展开子任务,直至达到 maxDepth │
-│ │
-│ 4. [循环检测] 防止无限循环 │
-│ └─> 维护 VisitedStack,检测重复访问 │
-│ │
-│ 5. [动态剪枝] 信息增益判定 │
-│ ├─> 计算 infoGainScore = newInfo / existingInfo │
-│ └─> 若连续 < 0.2,标记为 PRUNED │
-│ │
-│ 6. [依赖解析] 构建任务依赖关系 │
-│ └─> 生成 edges: [{from, to, type}] │
-│ │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-#### 调度算法
-
-```
-PRIORITY_QUEUE ← 所有 PENDING 节点
-VISITED_SET ← ∅
-
-while PRIORITY_QUEUE 非空:
- task ← PRIORITY_QUEUE.pop()
-
- if task.id ∈ VISITED_SET:
- continue
-
- if task.dependencies 未全部完成:
- continue
-
- task.status ← RUNNING
- VISITED_SET.add(task.id)
-
- // 调用对应 Agent 处理
- result ← execute_task(task)
-
- // 计算信息增益
- infoGain ← calculate_info_gain(result)
- if infoGain < 0.2:
- task.status ← PRUNED
- else:
- task.status ← COMPLETED
- task.output ← result
-
- // 激活子任务
- for child in task.children:
- PRIORITY_QUEUE.push(child)
-```
-
-#### 输出
-
-```typescript
-interface DAGGraph {
- nodes: TaskNode[];
- edges: Edge[];
-}
-
-interface TaskNode {
- taskId: string;
- parentTaskId: string | null;
- title: string;
- description: string;
- status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'SUSPENDED' | 'PRUNED';
- priority: number;
- dependencies: string[];
- children: string[];
- metadata: {
- estimatedTokenCost: number;
- searchDepth: number;
- infoGainScore: number;
- createdAt: string;
- updatedAt: string;
- };
-}
-```
+## 5. 前端视角流程
+
+## 5.1 页面启动
+- `listConversations()` 拉侧栏。
+- 若有会话自动选第一条。
+
+## 5.2 轮询策略
+当 active 会话状态为:
+- `RUNNING`
+- `DRAFTING_PLAN`
+
+前端按 `VITE_CONVERSATION_REFRESH_MS`(默认 2500ms)轮询:
+- `GET /conversations/{id}`
+- `GET /conversations`
+
+## 5.3 时间线渲染规则
+- `PLAN_*` 消息:代码块 + “打开草稿抽屉”。
+- `PROGRESS_GROUP`:可折叠,显示 phase/state/progress 明细。
+- `FINAL_REPORT`:支持全宽、收起、下载。
+- `ERROR`:红色文本。
+
+## 5.4 历史轮次
+同一会话多次运行会产生多个 task_id。
+时间线支持:
+- 展示全部历史轮次
+- 仅显示当前轮次(按消息中的 taskId 过滤)
---
-### 3.2 Retrieval Agent (检索代理)
-
-#### 职能
-执行全域信息捕获,配置三级缓存机制。
-
-#### 输入
-
-```typescript
-interface RetrievalRequest {
- taskNode: TaskNode;
- query: string;
- sources: string[]; // ['arXiv', 'Google Scholar', 'IEEE', ...]
-}
-```
-
-#### 处理流程
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Retrieval Agent 处理流程 │
-├─────────────────────────────────────────────────────────────────┤
-│ │
-│ 1. [查询扩展] 生成结构化检索式 │
-│ 格式: (Term OR Abbreviation) AND (Year) AND (Action Verb) │
-│ │
-│ 2. [L1 缓存校验] 内存缓存 (LRU, 最大 1000 条, TTL 1h) │
-│ └─> 若相似度 > 0.9,直接返回 │
-│ │
-│ 3. [L2 缓存校验] 向量数据库 (Qdrant, TTL 24h) │
-│ ├─> 向量化查询 │
-│ └─> 若相似度 > 0.9,直接返回 │
-│ │
-│ 4. [API 调用] 执行外部检索 │
-│ ├─> Serper / Google Search (网页) │
-│ ├─> arXiv API (学术论文) │
-│ ├─> Semantic Scholar (元数据增强) │
-│ └─> CrossRef (DOI 解析) │
-│ │
-│ 5. [网页解析] 提取正文内容 │
-│ ├─> 使用 Readability/trafilatura │
-│ ├─> 检测
标签 → 提取题注 + 独立 evidenceId │
-│ └─> 检测
标签 → 提取题注 + 独立 evidenceId │
-│ │
-│ 6. [缓存写入] 更新 L1 和 L2 缓存 │
-│ │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-#### 查询扩展示例
-
-```
-输入: "transformer architecture"
-
-扩展后:
- ("transformer" OR "attention mechanism") AND
- (2024 OR 2023 OR 2022) AND
- ("improve" OR "optimize" OR "analyze" OR "review")
-```
-
-#### 输出
-
-```typescript
-interface Evidence {
- id: string;
- sourceType: 'PAPER' | 'WEB' | 'PATENT' | 'MCP';
- url: string;
- content: string; // Markdown 格式
- metadata: {
- authors: string[];
- publishDate: string;
- title: string;
- abstract: string;
- impactFactor: number;
- isPeerReviewed: boolean;
- relevanceScore: number; // 0-1
- citationCount: number;
- };
- score: number; // 综合信誉评分 0-1
- extractedData: {
- tables: Array<{caption: string, data: any}>;
- images: Array<{caption: string, url: string}>;
- numericalValues: Array<{value: number, unit: string, context: string}>;
- };
-}
-```
+## 6. 检索与证据流
+
+## 6.1 查询生成
+`RetrievalService.expand_query` 统一附加近 3 年窗口。
+
+## 6.2 Provider 选择
+输入 `searchSources` 会做归一化:
+- `arxiv` / `arxivorg` -> `arxiv`
+- `semanticscholar` / `s2` -> `semanticscholar`
+- `tavily` -> `tavily`
+
+## 6.3 并发调用
+每个 provider 独立 task,失败只影响自己,不影响总流程。
+
+## 6.4 质量过滤
+证据写库前会过滤掉:
+- 非 http(s)
+- placeholder host
+- 内容太短
---
-### 3.3 Analyst Agent (分析代理)
-
-#### 职能
-作为质量控制核心,执行语义数值对齐 (SNA) 和冲突检测。
-
-#### 输入
-
-```typescript
-interface AnalysisRequest {
- evidences: Evidence[];
-}
-```
-
-#### 处理流程
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Analyst Agent 处理流程 │
-├─────────────────────────────────────────────────────────────────┤
-│ │
-│ 1. [信誉评分计算] │
-│ │ │
-│ ├─> 基础权重: │
-│ │ • 论文 = 1.0 │
-│ │ • 专利 = 0.8 │
-│ │ • 网页 = 0.5 │
-│ │ • MCP 数据 = 0.9 │
-│ │ │
-│ ├─> 影响因子加成 = IF / 10 (最高 1.5) │
-│ │ │
-│ ├─> 同行评议加成 = 1.2x (若已评议) │
-│ │ │
-│ ├─> 时效性衰减: │
-│ │ • < 5 年: 1.0x │
-│ │ • 5-10 年: 线性衰减 │
-│ │ • > 10 年: 0.5x │
-│ │ │
-│ └─> 最终评分 = 基础 × 影响因子 × 同行评议 × 时效性 × 相关性 │
-│ │
-│ 2. [单位标准化] 统一转换为 SI 单位 │
-│ • 1 km → 1000 m │
-│ • 1 GB → 1e9 bytes │
-│ • 等 │
-│ │
-│ 3. [冲突检测] │
-│ │ │
-│ ├─> 提取所有数值 │
-│ │ └─> {value, unit, context, evidenceId} │
-│ │ │
-│ ├─> 按 (parameter + context) 分组 │
-│ │ │
-│ ├─> 计算差异: variance = (ValueA - ValueB) / Max │
-│ │ │
-│ └─> 若 variance > 15% 且环境相似 │
-│ └─> 生成 ConflictRecord │
-│ │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-#### 输出
-
-```typescript
-interface ConflictRecord {
- conflictId: string;
- parameter: string; // 发生冲突的参数名称
- disputedValues: Array<{
- value: number;
- unit: string;
- evidenceId: string;
- source: string;
- }>;
- variance: number; // 差异程度 (百分比)
- context: string; // 冲突上下文
- resolutionStatus: 'OPEN' | 'RESOLVED' | 'IGNORED';
- resolution?: {
- selectedEvidenceId: string;
- reason: string;
- resolvedAt: string;
- };
-}
-```
+## 7. 冲突与投票流程
+
+1. 引擎进入分析阶段,生成 `ConflictRecord[]`(可为空)。
+2. 前端可调用:
+ - `GET /api/v1/tasks/{task_id}/conflicts`
+3. 用户投票:
+ - `POST /api/v1/evidence/{evidence_id}/vote`
+ - body: `conflictId + selectedEvidenceId + reason`
+4. `ConflictRepository.resolve` 更新为 `RESOLVED`。
---
-### 3.4 MCP Executor Agent (MCP 执行代理)
-
-#### 职能
-安全地连接外部工具与数据源。
-
-#### 输入
-
-```typescript
-interface MCPExecutionRequest {
- toolName: string;
- method: string;
- params: Record;
- mode: 'read' | 'write' | 'execute';
-}
-```
-
-#### 处理流程
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ MCP Executor 处理流程 │
-├─────────────────────────────────────────────────────────────────┤
-│ │
-│ 1. [协议处理] 基于 JSON-RPC 2.0 │
-│ └─> 构建 JSON-RPC 请求体 │
-│ │
-│ 2. [权限检查] │
-│ │ │
-│ ├─> Read-Only 模式 │
-│ │ └─> 允许直接执行 │
-│ │ │
-│ └─> Write/Execute 模式 │
-│ ├─> 挂起任务 │
-│ ├─> 返回 USER_CONFIRMATION_REQUIRED │
-│ └─> 等待 UI 授权后执行 │
-│ │
-│ 3. [执行调用] │
-│ │ │
-│ ├─> 短耗时任务 │
-│ │ └─> 同步等待结果 │
-│ │ │
-│ └─> 长耗时任务 │
-│ ├─> 返回 JOB_ID │
-│ └─> 每 5 秒轮询一次状态 │
-│ │
-│ 4. [沙盒隔离] │
-│ └─> 若执行失败,隔离错误,返回部分结果 │
-│ │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-#### JSON-RPC 请求示例
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "tools/call",
- "params": {
- "name": "python-executor",
- "arguments": {
- "code": "print('Hello World')",
- "timeout": 30
- }
- },
- "id": 1
-}
-```
-
-#### 输出
-
-```typescript
-interface MCPExecutionResult {
- status: 'SUCCESS' | 'USER_CONFIRMATION_REQUIRED' | 'FAILED';
- result?: any;
- jobId?: string; // 长耗时任务
- error?: string;
-}
-```
+## 8. 暂停/恢复/终止/恢复快照
+
+## 8.1 暂停
+`POST /tasks/{id}/pause`
+- `control.paused = true`
+- 任务状态置 `SUSPENDED`
+
+## 8.2 恢复
+`POST /tasks/{id}/resume`
+- 清 `paused`
+- 重新调用 `start()`
+
+## 8.3 终止
+`POST /tasks/{id}/abort`
+- `control.aborted = true`
+- 状态置 `ABORTED`
+
+## 8.4 快照恢复
+`POST /tasks/{id}/recover`
+- 读取 snapshot 的 `completed_nodes`
+- 从剩余节点继续执行
---
-### 3.5 Writer Agent (写作代理)
-
-#### 职能
-执行增量式文档生成作业。
-
-#### 输入
-
-```typescript
-interface WritingRequest {
- taskNode: TaskNode;
- evidences: Evidence[];
- existingContent: string; // 已存在的内容
- lockedSections: string[]; // 已锁定的段落 ID
-}
-```
-
-#### 处理流程
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Writer Agent 处理流程 │
-├─────────────────────────────────────────────────────────────────┤
-│ │
-│ 1. [分段绑定] │
-│ └─> 每个 # Section 绑定至特定 TaskNode │
-│ │
-│ 2. [锁定检查] │
-│ └─> 检查 lockedSections,跳过已锁定段落 │
-│ │
-│ 3. [局部生成] │
-│ │ │
-│ ├─> 节点完成时触发 │
-│ ├─> 仅生成对应段落 │
-│ └─> 使用 LLM (GPT-4 / Claude Opus) │
-│ │
-│ 4. [溯源索引] │
-│ │ │
-│ ├─> 维护 UUID -> Citation 映射 │
-│ └─> 自动生成 [1][2] 引用标记 │
-│ │
-│ 5. [增量润色] │
-│ │ │
-│ ├─> 合并新段落到现有内容 │
-│ └─> 检测并修复格式问题 │
-│ │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-#### 输出
-
-```typescript
-interface WritingResult {
- content: string; // Markdown 格式
- citations: Record; // UUID -> 映射
- lockedSections: string[]; // 更新后的锁定列表
-}
-
-interface Citation {
- id: string;
- authors: string[];
- title: string;
- year: number;
- source: string;
- url: string;
-}
-```
+## 9. 报告生成与下载
+
+## 9.1 报告文件
+输出目录:`backend/.data/reports/`
+- `{task_id}.md`
+- `{task_id}.bib`
+
+## 9.2 下载接口
+- 任务下载:`GET /tasks/{task_id}/report/download`
+- 会话下载:`GET /conversations/{conversation_id}/report/download`
+
+前端通过 Blob 触发浏览器下载。
---
-## 4. 数据流转关系
-
-### 4.1 全局数据流图
-
-```
-┌──────────────┐
-│ User Input │
-│ (研究议题) │
-└──────┬───────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Master Planner │
-│ 输入: CreateTaskRequest │
-│ 输出: DAGGraph (nodes + edges) │
-└──────┬──────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Retrieval Agent │
-│ 输入: TaskNode + Query │
-│ 输出: Evidence[] │
-└──────┬──────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Analyst Agent │
-│ 输入: Evidence[] │
-│ 输出: Evidence[] (带评分) + ConflictRecord[] │
-└──────┬──────────────────────────────────────────────────────────┘
- │
- ├──────────────┐
- │ ▼
- │ ┌─────────────────┐
- │ │ Conflict? │──Yes──> Reviewing 状态
- │ └────────┬────────┘
- │ │ No
- │ ▼
- │ ┌─────────────────┐
- │ │ MCP Required? │──Yes──> MCP Executor
- │ └────────┬────────┘
- │ │ No
- ▼ ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Writer Agent │
-│ 输入: Evidence[] + TaskNode[] + existingContent │
-│ 输出: Markdown Document + Bibliography │
-└──────┬──────────────────────────────────────────────────────────┘
- │
- ▼
-┌──────────────┐
-│ Final Report │
-│ (导出) │
-└──────────────┘
-```
-
-### 4.2 数据结构关系图
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ 数据结构关系 │
-├─────────────────────────────────────────────────────────────────┤
-│ │
-│ TaskNode (1) ────────────────┬────────> (N) Evidence │
-│ ├─ taskId: UUID │ ├─ id: UUID │
-│ ├─ status: enum │ ├─ sourceType: enum │
-│ ├─ dependencies: UUID[] │ ├─ content: string │
-│ ├─ children: UUID[] │ ├─ score: number │
-│ └─ output: Evidence[] ────┘ └─ extractedData │
-│ │ │
-│ ▼ │
-│ ConflictRecord │
-│ ├─ conflictId: UUID │
-│ ├─ disputedValues[] │
-│ └─ resolutionStatus │
-│ │
-│ TaskNode (N) ──> DAGGraph │
-│ ├─ nodes: TaskNode[] │
-│ └─ edges: Edge[] │
-│ ├─ from: UUID │
-│ └─ to: UUID │
-│ │
-└─────────────────────────────────────────────────────────────────┘
-```
+## 10. API 调用顺序示例
+
+## 10.1 从 0 到 1 完整跑通
+
+1. `POST /conversations`
+2. `GET /conversations/{id}`(前端轮询)
+3. `PUT /conversations/{id}/plan`(可选)
+4. `POST /conversations/{id}/run`
+5. `GET /conversations/{id}`(轮询直到 COMPLETED)
+6. `GET /conversations/{id}/report/download`
+
+## 10.2 已完成后补检索重跑
+
+1. `POST /conversations/{id}/plan/revise`(指令含“补充检索/重跑”)
+2. 后端自动触发新任务运行
+3. 前端轮询 `GET /conversations/{id}` 直到完成
+
+## 10.3 已完成后改写报告
+
+1. `POST /conversations/{id}/plan/revise`(指令含“改写报告/演讲稿”)
+2. 后端异步报告改写
+3. 时间线出现报告改写进度组
+4. 完成后追加新 `FINAL_REPORT`
---
-## 5. API 接口规范
-
-### 5.1 RESTful API 端点
-
-#### 任务管理
-
-| 方法 | 端点 | 描述 | 请求体 | 响应 |
-|------|------|------|--------|------|
-| POST | `/api/v1/tasks` | 创建新研究任务 | CreateTaskRequest | TaskNode |
-| GET | `/api/v1/tasks/{task_id}` | 获取任务详情 | - | TaskNode |
-| PUT | `/api/v1/tasks/{task_id}` | 更新任务配置 | UpdateTaskRequest | TaskNode |
-| DELETE | `/api/v1/tasks/{task_id}` | 删除任务 | - | DeleteResponse |
-| GET | `/api/v1/tasks/{task_id}/dag` | 获取任务 DAG 结构 | - | DAGGraph |
-
-#### 证据管理
-
-| 方法 | 端点 | 描述 | 请求体 | 响应 |
-|------|------|------|--------|------|
-| GET | `/api/v1/evidence` | 获取证据列表 | QueryParams | Evidence[] |
-| GET | `/api/v1/evidence/{evidence_id}` | 获取证据详情 | - | Evidence |
-| POST | `/api/v1/evidence/{evidence_id}/vote` | 证据投票 | VoteRequest | VoteResponse |
-
-#### 状态控制
-
-| 方法 | 端点 | 描述 | 请求体 | 响应 |
-|------|------|------|--------|------|
-| POST | `/api/v1/tasks/{task_id}/start` | 启动任务执行 | - | StateResponse |
-| POST | `/api/v1/tasks/{task_id}/pause` | 暂停任务 | - | StateResponse |
-| POST | `/api/v1/tasks/{task_id}/resume` | 恢复任务 | - | StateResponse |
-| POST | `/api/v1/tasks/{task_id}/abort` | 终止任务 | - | StateResponse |
-
-### 5.2 WebSocket 事件流
-
-**订阅频道:** `task/{task_id}/progress`
-
-**推送事件类型:**
-
-| 事件类型 | 说明 | 数据结构 |
-|---------|------|----------|
-| TASK_STARTED | 任务开始执行 | `{taskId, timestamp, node}` |
-| TASK_PROGRESS | 进度更新 | `{taskId, timestamp, progress: 0-100}` |
-| EVIDENCE_FOUND | 发现新证据 | `{taskId, timestamp, evidence}` |
-| TASK_COMPLETED | 任务完成 | `{taskId, timestamp, result}` |
-| ERROR | 执行错误 | `{taskId, timestamp, error}` |
-
-### 5.3 请求/响应示例
-
-#### 创建任务
-
-**请求** (POST /api/v1/tasks):
-```json
-{
- "title": "大语言模型的幻觉问题研究",
- "description": "调研 LLM 幻觉问题的成因、检测方法和缓解策略",
- "config": {
- "maxDepth": 3,
- "maxNodes": 50,
- "searchSources": ["arXiv", "Google Scholar"],
- "priority": 5
- }
-}
-```
-
-**响应** (201 Created):
-```json
-{
- "taskId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
- "status": "READY",
- "createdAt": "2024-01-01T00:00:00Z",
- "dag": {
- "nodes": [...],
- "edges": [...]
- }
-}
-```
+## 11. 错误分支与降级策略
+
+- LLM 不可用:
+ - 计划生成回退 `_fallback_plan`
+ - 报告改写回退 `_fallback_revised_report`
+- 检索 provider 失败:
+ - 单 provider 警告,其他 provider 继续
+- 状态迁移非法:
+ - 抛 `InvalidStateTransition`
+ - 任务置 `FAILED`
+- 前端请求超时:
+ - API 层给出统一中文超时提示
---
-## 6. 错误处理与容错
-
-### 6.1 错误分类与处理策略
-
-| 错误类型 | 示例 | 处理策略 |
-|---------|------|----------|
-| 网络超时 | API 请求超时 (>30s) | 重试 3 次,指数退避 (1s, 2s, 4s) |
-| API 失败 | 429/5xx 响应 | 切换备用 API,记录日志 |
-| 解析失败 | 网页内容提取异常 | 标记为低质量,继续处理 |
-| DAG 冲突 | 循环依赖检测 | 拒绝提交,返回错误路径 |
-| 资源耗尽 | Token 限额超出 | 暂停任务,用户确认后续 |
-| MCP 失败 | 外部工具调用失败 | 沙盒隔离,返回部分结果 |
-
-### 6.2 状态恢复机制
-
-**快照保存:**
-- 触发时机: 每个任务节点完成时
-- 存储位置: Redis
-- 过期时间: 24 小时
-
-**快照结构:**
-```typescript
-interface StateSnapshot {
- task_id: string;
- timestamp: string;
- fsm_state: 'READY' | 'PLANNING' | 'EXECUTING' | 'REVIEWING' | 'SYNTHESIZING' | 'FINALIZING';
- completed_nodes: string[];
- pending_nodes: string[];
- evidence_cache: Record;
- conflict_records: ConflictRecord[];
-}
-```
-
-**恢复逻辑:**
-1. 检查 Redis 中是否存在快照
-2. 若存在,反序列化并恢复状态
-3. 从断点处继续执行
+## 12. 调试建议
+
+1. 后端日志看 `ExecutionEngine` 阶段事件是否完整。
+2. 查 `conversation_messages` 中 `PROGRESS_GROUP` 的 `metadata.entries` 是否连续。
+3. 报告异常优先检查:
+ - `tasks.report_path`
+ - `backend/.data/reports/*.md`
+4. 若计划解析不符合预期,检查 front matter 字段名是否严格使用:
+ - `title/topic/max_depth/max_nodes/priority/search_sources`
---
-## 附录
-
-### A. 状态转换表
-
-| 当前状态 | 触发事件 | 目标状态 |
-|---------|---------|----------|
-| READY | start() | PLANNING |
-| PLANNING | dag_generated | EXECUTING |
-| EXECUTING | conflict_detected | REVIEWING |
-| EXECUTING | all_completed | SYNTHESIZING |
-| REVIEWING | conflict_resolved | EXECUTING |
-| REVIEWING | all_resolved | SYNTHESIZING |
-| SYNTHESIZING | content_approved | FINALIZING |
-| * | abort() | FINALIZING |
-| * | pause() | SUSPENDED |
-| SUSPENDED | resume() | 恢复原状态 |
-
-### B. 配置参数汇总
-
-| 参数 | 默认值 | 说明 |
-|------|--------|------|
-| maxDepth | 3 | 最大搜索深度 |
-| maxNodes | 50 | 最大任务节点数 |
-| L1_CACHE_SIZE | 1000 | L1 缓存容量 |
-| L1_CACHE_TTL | 3600 | L1 缓存过期时间 (秒) |
-| L2_SIMILARITY_THRESHOLD | 0.9 | L2 相似度阈值 |
-| L2_CACHE_TTL | 86400 | L2 缓存过期时间 (秒) |
-| INFO_GAIN_THRESHOLD | 0.2 | 信息增益剪枝阈值 |
-| CONFLICT_VARIANCE_THRESHOLD | 0.15 | 冲突检测差异阈值 (15%) |
-| MAX_CONCURRENT_TASKS | 5 | 最大并发任务数 |
-| RETRY_MAX_ATTEMPTS | 3 | 最大重试次数 |
-| RETRY_BASE_DELAY | 1 | 重试基础延迟 (秒) |
+## 13. 回归检查最小清单
+
+1. 创建会话 -> 生成计划 -> 启动研究 -> 完成 -> 下载报告。
+2. 完成后发送“改成演讲稿”,确认追加新 `FINAL_REPORT`。
+3. 完成后发送“补充检索并重跑”,确认 task_id 变化。
+4. 删除单会话和删除全部会话均可用,运行中任务会被中断。
+5. 移动端抽屉开关、对话框 Esc/遮罩关闭、Enter 发送均正常。
---
-*文档版本: 1.0*
-*更新日期: 2024*
+如需继续扩展,请先读 `doc.md` 的模块说明,再根据本手册挑选插点。
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index 9089f39..d82c916 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -1,5 +1,6 @@
from fastapi import APIRouter
+from app.api.routes.conversations import router as conversations_router
from app.api.routes.evidence import router as evidence_router
from app.api.routes.mcp import router as mcp_router
from app.api.routes.tasks import router as tasks_router
@@ -8,3 +9,4 @@
api_router.include_router(tasks_router)
api_router.include_router(evidence_router)
api_router.include_router(mcp_router)
+api_router.include_router(conversations_router)
diff --git a/backend/app/api/routes/conversations.py b/backend/app/api/routes/conversations.py
new file mode 100644
index 0000000..ce36c7b
--- /dev/null
+++ b/backend/app/api/routes/conversations.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse
+
+from app.deps import conversation_agent, conversation_repository, task_repository
+from app.models.schemas import (
+ ConversationBulkDeleteResponse,
+ ConversationDeleteResponse,
+ ConversationDetail,
+ ConversationSummary,
+ CreateConversationRequest,
+ PlanRevision,
+ RevisePlanRequest,
+ RevisePlanResponse,
+ RunConversationRequest,
+ RunConversationResponse,
+ UpdateConversationRequest,
+ UpdatePlanRequest,
+)
+
+router = APIRouter(prefix="/api/v1")
+
+
+@router.post("/conversations", response_model=ConversationDetail, status_code=201)
+async def create_conversation(payload: CreateConversationRequest) -> ConversationDetail:
+ return await conversation_agent.create_conversation(topic=payload.topic, config=payload.config)
+
+
+@router.get("/conversations", response_model=list[ConversationSummary])
+def list_conversations() -> list[ConversationSummary]:
+ return conversation_repository.list_summaries()
+
+
+@router.get("/conversations/{conversation_id}", response_model=ConversationDetail)
+def get_conversation(conversation_id: str) -> ConversationDetail:
+ try:
+ return conversation_repository.get_detail(conversation_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc
+
+
+@router.delete("/conversations/{conversation_id}", response_model=ConversationDeleteResponse)
+def delete_conversation(conversation_id: str) -> ConversationDeleteResponse:
+ try:
+ conversation_agent.delete_conversation(conversation_id=conversation_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc
+ return ConversationDeleteResponse(conversationId=conversation_id, deleted=True)
+
+
+@router.delete("/conversations", response_model=ConversationBulkDeleteResponse)
+def delete_all_conversations() -> ConversationBulkDeleteResponse:
+ deleted_count = conversation_agent.delete_all_conversations()
+ return ConversationBulkDeleteResponse(deleted=True, deletedCount=deleted_count)
+
+
+@router.patch("/conversations/{conversation_id}", response_model=ConversationDetail)
+def rename_conversation(conversation_id: str, payload: UpdateConversationRequest) -> ConversationDetail:
+ try:
+ return conversation_agent.rename_conversation(
+ conversation_id=conversation_id,
+ topic=payload.topic,
+ sync_current_plan=payload.syncCurrentPlan,
+ )
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.post("/conversations/{conversation_id}/plan/revise", response_model=RevisePlanResponse)
+async def revise_plan(conversation_id: str, payload: RevisePlanRequest) -> RevisePlanResponse:
+ try:
+ plan, message = await conversation_agent.revise_plan(
+ conversation_id=conversation_id,
+ instruction=payload.instruction,
+ )
+ return RevisePlanResponse(plan=plan, message=message)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.put("/conversations/{conversation_id}/plan", response_model=PlanRevision)
+def update_plan(conversation_id: str, payload: UpdatePlanRequest) -> PlanRevision:
+ try:
+ return conversation_agent.update_plan(conversation_id=conversation_id, markdown=payload.markdown)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.post("/conversations/{conversation_id}/run", response_model=RunConversationResponse)
+async def run_conversation(conversation_id: str, payload: RunConversationRequest) -> RunConversationResponse:
+ _ = payload
+ try:
+ return await conversation_agent.start_research(conversation_id=conversation_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/conversations/{conversation_id}/report/download")
+def download_conversation_report(conversation_id: str) -> FileResponse:
+ try:
+ summary = conversation_repository.get_summary(conversation_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc
+ if not summary.taskId:
+ raise HTTPException(status_code=404, detail="Conversation has no task yet")
+ try:
+ task = task_repository.get_task(summary.taskId)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Task not found: {summary.taskId}") from exc
+ if not task.reportPath:
+ raise HTTPException(status_code=404, detail="Report not generated yet")
+ path = Path(task.reportPath)
+ if not path.exists():
+ raise HTTPException(status_code=404, detail="Report file does not exist")
+ return FileResponse(path, media_type="text/markdown", filename=f"{conversation_id}.md")
diff --git a/backend/app/api/routes/tasks.py b/backend/app/api/routes/tasks.py
index bb2b994..f08791a 100644
--- a/backend/app/api/routes/tasks.py
+++ b/backend/app/api/routes/tasks.py
@@ -3,6 +3,7 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
+from fastapi.responses import FileResponse
from app.core.utils import new_id
from app.deps import conflict_repository, execution_engine, progress_hub, task_repository
@@ -156,6 +157,20 @@ def get_report(task_id: str) -> dict[str, str]:
return {"taskId": task_id, "content": path.read_text(encoding="utf-8")}
+@router.get("/tasks/{task_id}/report/download")
+def download_report(task_id: str) -> FileResponse:
+ try:
+ task = task_repository.get_task(task_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc
+ if not task.reportPath:
+ raise HTTPException(status_code=404, detail="Report not generated yet")
+ path = Path(task.reportPath)
+ if not path.exists():
+ raise HTTPException(status_code=404, detail="Report file does not exist")
+ return FileResponse(path, media_type="text/markdown", filename=f"{task_id}.md")
+
+
@router.get("/tasks/{task_id}/snapshot")
def get_snapshot(task_id: str) -> dict:
try:
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index fd3e633..5435fa7 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -6,7 +6,7 @@ class Settings(BaseSettings):
api_prefix: str = "/api/v1"
db_path: str = "backend/.data/deep_research.db"
log_level: str = "INFO"
- use_mock_sources: bool = True
+ use_mock_sources: bool = False
default_llm_provider: str = "openrouter"
default_llm_model: str = "deepseek/deepseek-chat-v3-0324"
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 832edcd..c271333 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -70,6 +70,40 @@
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+
+CREATE TABLE IF NOT EXISTS conversations (
+ conversation_id TEXT PRIMARY KEY,
+ topic TEXT NOT NULL,
+ status TEXT NOT NULL,
+ config_json TEXT NOT NULL,
+ task_id TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plan_revisions (
+ conversation_id TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ author TEXT NOT NULL,
+ markdown TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (conversation_id, version)
+);
+
+CREATE TABLE IF NOT EXISTS conversation_messages (
+ message_id TEXT PRIMARY KEY,
+ conversation_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ content TEXT NOT NULL,
+ metadata_json TEXT NOT NULL,
+ collapsed INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_conversations_task_id ON conversations(task_id);
+CREATE INDEX IF NOT EXISTS idx_conversation_messages_created_at
+ ON conversation_messages(conversation_id, created_at ASC);
"""
diff --git a/backend/app/deps.py b/backend/app/deps.py
index cf74ba9..db44691 100644
--- a/backend/app/deps.py
+++ b/backend/app/deps.py
@@ -1,9 +1,12 @@
from __future__ import annotations
+from app.repositories.conversation_repository import ConversationRepository
from app.repositories.conflict_repository import ConflictRepository
from app.repositories.evidence_repository import EvidenceRepository
from app.repositories.task_repository import TaskRepository
+from app.services.agents import ReportAgent, ResearchAgent
from app.services.analyst import AnalystService
+from app.services.conversation_agent import ConversationAgent
from app.services.execution_engine import ExecutionEngine
from app.services.mcp_executor import MCPExecutor
from app.services.planner import MasterPlanner
@@ -14,12 +17,15 @@
task_repository = TaskRepository()
evidence_repository = EvidenceRepository()
conflict_repository = ConflictRepository()
+conversation_repository = ConversationRepository()
planner = MasterPlanner()
progress_hub = ProgressHub()
retrieval_service = RetrievalService()
analyst_service = AnalystService()
writer_service = WriterService()
mcp_executor = MCPExecutor()
+research_agent = ResearchAgent(retrieval_service=retrieval_service, mcp_executor=mcp_executor)
+report_agent = ReportAgent(writer_service=writer_service)
execution_engine = ExecutionEngine(
task_repository,
planner,
@@ -29,4 +35,14 @@
conflict_repository,
analyst_service,
writer_service,
+ research_agent,
+ report_agent,
)
+conversation_agent = ConversationAgent(
+ repository=conversation_repository,
+ task_repository=task_repository,
+ execution_engine=execution_engine,
+ evidence_repository=evidence_repository,
+ report_agent=report_agent,
+)
+execution_engine.set_event_listener(conversation_agent.on_task_event)
diff --git a/backend/app/main.py b/backend/app/main.py
index 33ba5b4..51f0eea 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import settings
@@ -13,6 +14,13 @@ async def lifespan(_: FastAPI):
app = FastAPI(title=settings.app_name, lifespan=lifespan)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
app.include_router(api_router)
diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py
index 7d4a9a1..eb6fc1d 100644
--- a/backend/app/models/schemas.py
+++ b/backend/app/models/schemas.py
@@ -19,6 +19,14 @@ class TaskStatus(StrEnum):
ABORTED = "ABORTED"
+class ConversationStatus(StrEnum):
+ DRAFTING_PLAN = "DRAFTING_PLAN"
+ PLAN_READY = "PLAN_READY"
+ RUNNING = "RUNNING"
+ COMPLETED = "COMPLETED"
+ FAILED = "FAILED"
+
+
class NodeStatus(StrEnum):
PENDING = "PENDING"
RUNNING = "RUNNING"
@@ -108,6 +116,98 @@ class ProgressEvent(BaseModel):
data: dict[str, Any]
+class MessageRole(StrEnum):
+ USER = "user"
+ ASSISTANT = "assistant"
+ SYSTEM = "system"
+
+
+class MessageKind(StrEnum):
+ USER_TEXT = "USER_TEXT"
+ PLAN_DRAFT = "PLAN_DRAFT"
+ PLAN_EDITED = "PLAN_EDITED"
+ PLAN_REVISION = "PLAN_REVISION"
+ PROGRESS_GROUP = "PROGRESS_GROUP"
+ FINAL_REPORT = "FINAL_REPORT"
+ ERROR = "ERROR"
+
+
+class PlanRevision(BaseModel):
+ conversationId: str
+ version: int
+ author: MessageRole
+ markdown: str
+ createdAt: str
+
+
+class ConversationMessage(BaseModel):
+ messageId: str
+ conversationId: str
+ role: MessageRole
+ kind: MessageKind
+ content: str
+ metadata: dict[str, Any] = Field(default_factory=dict)
+ collapsed: bool = False
+ createdAt: str
+
+
+class ConversationSummary(BaseModel):
+ conversationId: str
+ topic: str
+ status: ConversationStatus
+ taskId: str | None = None
+ createdAt: str
+ updatedAt: str
+
+
+class ConversationDetail(ConversationSummary):
+ currentPlan: PlanRevision | None = None
+ messages: list[ConversationMessage] = Field(default_factory=list)
+
+
+class CreateConversationRequest(BaseModel):
+ topic: str = Field(min_length=2, max_length=500)
+ config: TaskConfig | None = None
+
+
+class UpdateConversationRequest(BaseModel):
+ topic: str = Field(min_length=2, max_length=500)
+ syncCurrentPlan: bool = True
+
+
+class RevisePlanRequest(BaseModel):
+ instruction: str = Field(min_length=2, max_length=4000)
+
+
+class UpdatePlanRequest(BaseModel):
+ markdown: str = Field(min_length=10, max_length=60000)
+
+
+class RunConversationRequest(BaseModel):
+ pass
+
+
+class RevisePlanResponse(BaseModel):
+ plan: PlanRevision
+ message: ConversationMessage
+
+
+class RunConversationResponse(BaseModel):
+ conversationId: str
+ taskId: str
+ status: ConversationStatus
+
+
+class ConversationDeleteResponse(BaseModel):
+ conversationId: str
+ deleted: bool
+
+
+class ConversationBulkDeleteResponse(BaseModel):
+ deleted: bool
+ deletedCount: int
+
+
class SourceType(StrEnum):
PAPER = "PAPER"
WEB = "WEB"
diff --git a/backend/app/repositories/conversation_repository.py b/backend/app/repositories/conversation_repository.py
new file mode 100644
index 0000000..bf638cd
--- /dev/null
+++ b/backend/app/repositories/conversation_repository.py
@@ -0,0 +1,443 @@
+from __future__ import annotations
+
+import json
+
+from app.core.database import get_connection
+from app.core.utils import now_iso
+from app.models.schemas import (
+ ConversationDetail,
+ ConversationMessage,
+ ConversationStatus,
+ ConversationSummary,
+ MessageKind,
+ MessageRole,
+ PlanRevision,
+ TaskConfig,
+)
+
+
+class ConversationRepository:
+ def create_conversation(
+ self,
+ *,
+ conversation_id: str,
+ topic: str,
+ status: ConversationStatus,
+ config: TaskConfig,
+ ) -> ConversationSummary:
+ ts = now_iso()
+ with get_connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO conversations(
+ conversation_id, topic, status, config_json, task_id, created_at, updated_at
+ ) VALUES(?, ?, ?, ?, ?, ?, ?)
+ """,
+ (conversation_id, topic, status.value, config.model_dump_json(), None, ts, ts),
+ )
+ conn.commit()
+ return self.get_summary(conversation_id)
+
+ def get_summary(self, conversation_id: str) -> ConversationSummary:
+ with get_connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM conversations WHERE conversation_id = ?",
+ (conversation_id,),
+ ).fetchone()
+ if row is None:
+ raise KeyError(conversation_id)
+ return ConversationSummary(
+ conversationId=row["conversation_id"],
+ topic=row["topic"],
+ status=ConversationStatus(row["status"]),
+ taskId=row["task_id"],
+ createdAt=row["created_at"],
+ updatedAt=row["updated_at"],
+ )
+
+ def list_summaries(self) -> list[ConversationSummary]:
+ with get_connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM conversations
+ ORDER BY updated_at DESC, created_at DESC
+ """
+ ).fetchall()
+ return [
+ ConversationSummary(
+ conversationId=row["conversation_id"],
+ topic=row["topic"],
+ status=ConversationStatus(row["status"]),
+ taskId=row["task_id"],
+ createdAt=row["created_at"],
+ updatedAt=row["updated_at"],
+ )
+ for row in rows
+ ]
+
+ def get_config(self, conversation_id: str) -> TaskConfig:
+ with get_connection() as conn:
+ row = conn.execute(
+ "SELECT config_json FROM conversations WHERE conversation_id = ?",
+ (conversation_id,),
+ ).fetchone()
+ if row is None:
+ raise KeyError(conversation_id)
+ return TaskConfig.model_validate_json(row["config_json"])
+
+ def set_status(self, conversation_id: str, status: ConversationStatus) -> None:
+ with get_connection() as conn:
+ conn.execute(
+ """
+ UPDATE conversations
+ SET status = ?, updated_at = ?
+ WHERE conversation_id = ?
+ """,
+ (status.value, now_iso(), conversation_id),
+ )
+ conn.commit()
+ if conn.total_changes == 0:
+ raise KeyError(conversation_id)
+
+ def set_task_id(self, conversation_id: str, task_id: str) -> None:
+ with get_connection() as conn:
+ conn.execute(
+ """
+ UPDATE conversations
+ SET task_id = ?, updated_at = ?
+ WHERE conversation_id = ?
+ """,
+ (task_id, now_iso(), conversation_id),
+ )
+ conn.commit()
+ if conn.total_changes == 0:
+ raise KeyError(conversation_id)
+
+ def find_by_task_id(self, task_id: str) -> ConversationSummary | None:
+ with get_connection() as conn:
+ row = conn.execute(
+ "SELECT conversation_id FROM conversations WHERE task_id = ? LIMIT 1",
+ (task_id,),
+ ).fetchone()
+ if row is None:
+ return None
+ return self.get_summary(row["conversation_id"])
+
+ def update_topic(self, conversation_id: str, topic: str) -> ConversationSummary:
+ with get_connection() as conn:
+ conn.execute(
+ """
+ UPDATE conversations
+ SET topic = ?, updated_at = ?
+ WHERE conversation_id = ?
+ """,
+ (topic, now_iso(), conversation_id),
+ )
+ conn.commit()
+ if conn.total_changes == 0:
+ raise KeyError(conversation_id)
+ return self.get_summary(conversation_id)
+
+ def delete_conversation(self, conversation_id: str) -> None:
+ self.get_summary(conversation_id)
+ with get_connection() as conn:
+ conn.execute(
+ "DELETE FROM conversation_messages WHERE conversation_id = ?",
+ (conversation_id,),
+ )
+ conn.execute(
+ "DELETE FROM plan_revisions WHERE conversation_id = ?",
+ (conversation_id,),
+ )
+ conn.execute(
+ "DELETE FROM conversations WHERE conversation_id = ?",
+ (conversation_id,),
+ )
+ conn.commit()
+
+ def delete_all_conversations(self) -> int:
+ with get_connection() as conn:
+ row = conn.execute("SELECT COUNT(*) AS count FROM conversations").fetchone()
+ deleted_count = int(row["count"]) if row else 0
+ conn.execute("DELETE FROM conversation_messages")
+ conn.execute("DELETE FROM plan_revisions")
+ conn.execute("DELETE FROM conversations")
+ conn.commit()
+ return deleted_count
+
+ def add_plan_revision(self, conversation_id: str, *, author: MessageRole, markdown: str) -> PlanRevision:
+ self.get_summary(conversation_id)
+ ts = now_iso()
+ with get_connection() as conn:
+ existing = conn.execute(
+ """
+ SELECT COALESCE(MAX(version), 0) AS max_version
+ FROM plan_revisions
+ WHERE conversation_id = ?
+ """,
+ (conversation_id,),
+ ).fetchone()
+ max_version = int(existing["max_version"]) if existing else 0
+ next_version = max_version + 1
+ conn.execute(
+ """
+ INSERT INTO plan_revisions(conversation_id, version, author, markdown, created_at)
+ VALUES(?, ?, ?, ?, ?)
+ """,
+ (conversation_id, next_version, author.value, markdown, ts),
+ )
+ conn.execute(
+ """
+ UPDATE conversations
+ SET updated_at = ?
+ WHERE conversation_id = ?
+ """,
+ (ts, conversation_id),
+ )
+ conn.commit()
+ return self.get_plan_revision(conversation_id, next_version)
+
+ def get_plan_revision(self, conversation_id: str, version: int) -> PlanRevision:
+ with get_connection() as conn:
+ row = conn.execute(
+ """
+ SELECT * FROM plan_revisions
+ WHERE conversation_id = ? AND version = ?
+ """,
+ (conversation_id, version),
+ ).fetchone()
+ if row is None:
+ raise KeyError(f"{conversation_id}:{version}")
+ return PlanRevision(
+ conversationId=row["conversation_id"],
+ version=row["version"],
+ author=MessageRole(row["author"]),
+ markdown=row["markdown"],
+ createdAt=row["created_at"],
+ )
+
+ def get_current_plan(self, conversation_id: str) -> PlanRevision | None:
+ with get_connection() as conn:
+ row = conn.execute(
+ """
+ SELECT * FROM plan_revisions
+ WHERE conversation_id = ?
+ ORDER BY version DESC
+ LIMIT 1
+ """,
+ (conversation_id,),
+ ).fetchone()
+ if row is None:
+ return None
+ return PlanRevision(
+ conversationId=row["conversation_id"],
+ version=row["version"],
+ author=MessageRole(row["author"]),
+ markdown=row["markdown"],
+ createdAt=row["created_at"],
+ )
+
+ def add_message(
+ self,
+ conversation_id: str,
+ *,
+ message_id: str,
+ role: MessageRole,
+ kind: MessageKind,
+ content: str,
+ metadata: dict | None = None,
+ collapsed: bool = False,
+ ) -> ConversationMessage:
+ self.get_summary(conversation_id)
+ ts = now_iso()
+ metadata_json = json.dumps(metadata or {}, ensure_ascii=False)
+ with get_connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO conversation_messages(
+ message_id, conversation_id, role, kind, content, metadata_json, collapsed, created_at
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ message_id,
+ conversation_id,
+ role.value,
+ kind.value,
+ content,
+ metadata_json,
+ 1 if collapsed else 0,
+ ts,
+ ),
+ )
+ conn.execute(
+ """
+ UPDATE conversations
+ SET updated_at = ?
+ WHERE conversation_id = ?
+ """,
+ (ts, conversation_id),
+ )
+ conn.commit()
+ return self.get_message(message_id)
+
+ def get_message(self, message_id: str) -> ConversationMessage:
+ with get_connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM conversation_messages WHERE message_id = ?",
+ (message_id,),
+ ).fetchone()
+ if row is None:
+ raise KeyError(message_id)
+ return ConversationMessage(
+ messageId=row["message_id"],
+ conversationId=row["conversation_id"],
+ role=MessageRole(row["role"]),
+ kind=MessageKind(row["kind"]),
+ content=row["content"],
+ metadata=json.loads(row["metadata_json"]),
+ collapsed=bool(row["collapsed"]),
+ createdAt=row["created_at"],
+ )
+
+ def list_messages(self, conversation_id: str, *, limit: int = 300) -> list[ConversationMessage]:
+ with get_connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM conversation_messages
+ WHERE conversation_id = ?
+ ORDER BY created_at ASC
+ LIMIT ?
+ """,
+ (conversation_id, limit),
+ ).fetchall()
+ return [
+ ConversationMessage(
+ messageId=row["message_id"],
+ conversationId=row["conversation_id"],
+ role=MessageRole(row["role"]),
+ kind=MessageKind(row["kind"]),
+ content=row["content"],
+ metadata=json.loads(row["metadata_json"]),
+ collapsed=bool(row["collapsed"]),
+ createdAt=row["created_at"],
+ )
+ for row in rows
+ ]
+
+ def append_progress_entry(
+ self,
+ conversation_id: str,
+ *,
+ task_id: str,
+ message_id: str,
+ phase: str,
+ state: str,
+ summary: str,
+ progress: int | None,
+ payload: dict,
+ ) -> ConversationMessage:
+ with get_connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM conversation_messages
+ WHERE conversation_id = ? AND kind = ?
+ ORDER BY created_at DESC
+ LIMIT 8
+ """,
+ (conversation_id, MessageKind.PROGRESS_GROUP.value),
+ ).fetchall()
+
+ for row in rows:
+ metadata = json.loads(row["metadata_json"])
+ metadata_task_id = str(metadata.get("taskId", "")).strip()
+ if not metadata_task_id:
+ raw_entries = metadata.get("entries")
+ if isinstance(raw_entries, list):
+ for entry in reversed(raw_entries):
+ if not isinstance(entry, dict):
+ continue
+ raw = entry.get("raw")
+ if not isinstance(raw, dict):
+ continue
+ raw_task_id = raw.get("taskId")
+ if isinstance(raw_task_id, str) and raw_task_id.strip():
+ metadata_task_id = raw_task_id.strip()
+ break
+ if metadata_task_id != task_id:
+ continue
+ if str(metadata.get("phase", "")).strip() != phase:
+ continue
+ entries = metadata.get("entries")
+ if not isinstance(entries, list):
+ entries = []
+ entries.append(
+ {
+ "summary": summary,
+ "state": state,
+ "phase": phase,
+ "progress": progress,
+ "raw": payload,
+ }
+ )
+ metadata["entries"] = entries[-50:]
+ metadata["phase"] = phase
+ metadata["state"] = state
+ metadata["latestProgress"] = progress
+ metadata["latestSummary"] = summary
+ metadata["taskId"] = task_id
+ conn.execute(
+ """
+ UPDATE conversation_messages
+ SET content = ?, metadata_json = ?
+ WHERE message_id = ?
+ """,
+ (summary, json.dumps(metadata, ensure_ascii=False), row["message_id"]),
+ )
+ conn.execute(
+ """
+ UPDATE conversations
+ SET updated_at = ?
+ WHERE conversation_id = ?
+ """,
+ (now_iso(), conversation_id),
+ )
+ conn.commit()
+ return self.get_message(row["message_id"])
+
+ return self.add_message(
+ conversation_id,
+ message_id=message_id,
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.PROGRESS_GROUP,
+ content=summary,
+ metadata={
+ "taskId": task_id,
+ "phase": phase,
+ "state": state,
+ "latestProgress": progress,
+ "latestSummary": summary,
+ "entries": [
+ {
+ "summary": summary,
+ "state": state,
+ "phase": phase,
+ "progress": progress,
+ "raw": payload,
+ }
+ ],
+ },
+ collapsed=True,
+ )
+
+ def get_detail(self, conversation_id: str) -> ConversationDetail:
+ summary = self.get_summary(conversation_id)
+ return ConversationDetail(
+ conversationId=summary.conversationId,
+ topic=summary.topic,
+ status=summary.status,
+ taskId=summary.taskId,
+ createdAt=summary.createdAt,
+ updatedAt=summary.updatedAt,
+ currentPlan=self.get_current_plan(conversation_id),
+ messages=self.list_messages(conversation_id),
+ )
diff --git a/backend/app/services/agents.py b/backend/app/services/agents.py
new file mode 100644
index 0000000..b0de509
--- /dev/null
+++ b/backend/app/services/agents.py
@@ -0,0 +1,387 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import re
+
+from app.models.schemas import Citation, Evidence
+from app.services.mcp_executor import MCPExecutor
+from app.services.retrieval import RetrievalService
+from app.services.writer import ReportBlueprint, WriterService
+
+
+class ResearchAgent:
+ """Collect evidence from configured providers and optional MCP read tools."""
+
+ def __init__(self, retrieval_service: RetrievalService, mcp_executor: MCPExecutor | None = None) -> None:
+ self.retrieval_service = retrieval_service
+ self.mcp_executor = mcp_executor
+
+ async def collect_evidence(
+ self,
+ *,
+ task_id: str,
+ node_id: str,
+ query: str,
+ sources: list[str],
+ mcp_read_tools: list[str] | None = None,
+ ) -> list[Evidence]:
+ evidences = await self.retrieval_service.retrieve(
+ task_id=task_id,
+ node_id=node_id,
+ query=query,
+ sources=sources,
+ )
+ if not self.mcp_executor or not mcp_read_tools:
+ return evidences
+
+ # Placeholder MCP hook for future expansion.
+ for tool_name in mcp_read_tools:
+ await self.mcp_executor.execute(
+ tool_name=tool_name,
+ method="tools/call",
+ params={"query": query, "taskId": task_id, "nodeId": node_id},
+ mode="read",
+ )
+ return evidences
+
+
+class ReportAgent:
+ """Generate report artifacts from structured sections and evidence."""
+
+ def __init__(
+ self,
+ writer_service: WriterService,
+ format_agent: "ReportFormatAgent | None" = None,
+ review_agent: "ReportReviewAgent | None" = None,
+ revision_agent: "ReportRevisionAgent | None" = None,
+ max_review_rounds: int = 3,
+ ) -> None:
+ self.writer_service = writer_service
+ self.format_agent = format_agent or ReportFormatAgent()
+ self.review_agent = review_agent or ReportReviewAgent()
+ self.revision_agent = revision_agent or ReportRevisionAgent(writer_service=writer_service)
+ self.max_review_rounds = max(1, max_review_rounds)
+
+ def generate_report(
+ self,
+ *,
+ task_id: str,
+ task_title: str,
+ task_description: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ locked_sections: set[str] | None = None,
+ ) -> tuple[str, str, dict[str, Citation]]:
+ blueprint = self.format_agent.design_blueprint(
+ task_title=task_title,
+ task_description=task_description,
+ )
+ draft_body = self.writer_service.generate_body(
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+ review_result = self.review_agent.review(body=draft_body, blueprint=blueprint, evidences=evidences)
+ for _ in range(self.max_review_rounds):
+ if review_result.approved:
+ break
+ draft_body = self.revision_agent.revise(
+ draft_body=draft_body,
+ feedback=review_result,
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+ review_result = self.review_agent.review(body=draft_body, blueprint=blueprint, evidences=evidences)
+ if not review_result.approved:
+ draft_body = self.revision_agent.rewrite_with_template(
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+ return self.writer_service.write_report(
+ task_id=task_id,
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ locked_sections=locked_sections,
+ blueprint=blueprint,
+ report_body=draft_body,
+ )
+
+
+class ReportFormatAgent:
+ """Infer requested output format and section blueprint from task text."""
+
+ FORMAT_PATTERN = re.compile(r"(?:格式|体裁|输出形式)\s*[::]\s*([^\n,。;;]+)")
+
+ def design_blueprint(self, *, task_title: str, task_description: str) -> ReportBlueprint:
+ source_text = f"{task_title}\n{task_description}"
+ lowered = source_text.lower()
+ custom_format = self._extract_custom_format(source_text)
+
+ if self._contains_any(lowered, ["演讲", "演讲稿", "speech", "keynote"]):
+ return ReportBlueprint(
+ output_format="演讲稿",
+ objective="面向听众传达问题背景、关键观点与行动方案",
+ tone="清晰有节奏、以结论驱动",
+ section_titles=["开场", "背景", "核心观点", "证据支撑", "行动建议", "结语"],
+ )
+ if self._contains_any(lowered, ["论文", "paper", "academic", "journal", "学术"]):
+ return ReportBlueprint(
+ output_format="论文",
+ objective="形成可复核的研究论证与结构化结论",
+ tone="严谨客观、术语准确",
+ section_titles=["摘要", "引言", "相关工作", "方法", "结果与讨论", "结论"],
+ )
+ if self._contains_any(lowered, ["报告", "report", "调研"]):
+ return ReportBlueprint(
+ output_format="研究报告",
+ objective="给出跨维度、可追溯且可执行的分析结论与决策建议",
+ tone="客观中立、论证充分、结论先行",
+ section_titles=["摘要", "研究范围与方法", "背景", "关键发现", "分析", "风险与局限", "结论与建议"],
+ )
+ if custom_format:
+ return ReportBlueprint(
+ output_format=custom_format,
+ objective=f"按“{custom_format}”体裁交付内容,并保持结构化表达",
+ tone="清晰、克制、信息密集",
+ section_titles=["开篇", "主体", "结论"],
+ )
+ return ReportBlueprint(
+ output_format="通用文章",
+ objective="在保持可读性的前提下,深度回答用户研究需求",
+ tone="客观清晰、信息密集",
+ section_titles=["摘要", "研究背景", "主体分析", "结论与下一步"],
+ )
+
+ @classmethod
+ def _extract_custom_format(cls, source_text: str) -> str:
+ match = cls.FORMAT_PATTERN.search(source_text)
+ if not match:
+ return ""
+ return match.group(1).strip()
+
+ @staticmethod
+ def _contains_any(text: str, keywords: list[str]) -> bool:
+ return any(keyword in text for keyword in keywords)
+
+
+@dataclass(frozen=True)
+class ReportReviewResult:
+ approved: bool
+ issues: list[str]
+
+
+class ReportReviewAgent:
+ """Review report quality and block intermediate traces from leaking to users."""
+
+ TRACE_PATTERNS = (
+ re.compile(r"(?im)^\s*##\s*trace section\b"),
+ re.compile(r"(?im)^\s*\[locked\]"),
+ re.compile(r"(?im)^\s*挑战识别\s*:"),
+ )
+ PLACEHOLDER_PATTERNS = (
+ re.compile(r"(?i)\[mock\]"),
+ re.compile(r"(?i)\b(?:arxiv|semantic scholar|semanticscholar|tavily|web)\s+result\s+for\b"),
+ re.compile(r"(?i)synthetic evidence"),
+ )
+ EVIDENCE_REF_PATTERN = re.compile(r"\[evidence:([^\]]+)\]")
+ MIN_BODY_BASE_CHARS = 780
+ MIN_BODY_PER_SECTION_CHARS = 170
+ MIN_SECTION_CHARS = 180
+ MIN_SUMMARY_SECTION_CHARS = 110
+ MIN_PARAGRAPHS_PER_SECTION = 2
+ MIN_PARAGRAPHS_FOR_SHORT_SECTION = 1
+ SHORT_SECTION_KEYWORDS = ("摘要", "结论", "结语", "开场")
+
+ def review(self, *, body: str, blueprint: ReportBlueprint, evidences: list[Evidence]) -> ReportReviewResult:
+ issues: list[str] = []
+ stripped = body.strip()
+ if not stripped:
+ return ReportReviewResult(approved=False, issues=["正文为空。"])
+
+ if any(pattern.search(body) for pattern in self.TRACE_PATTERNS):
+ issues.append("包含中间过程痕迹(如 Trace Section 或过程标签)。")
+ if any(pattern.search(body) for pattern in self.PLACEHOLDER_PATTERNS):
+ issues.append("包含占位检索文本,降低内容可信度。")
+
+ missing_sections = [title for title in blueprint.section_titles if f"## {title}" not in body]
+ if missing_sections:
+ issues.append(f"章节不完整,缺少:{', '.join(missing_sections[:4])}。")
+ section_contents = self._section_contents(body)
+ shallow_sections: list[str] = []
+ sparse_sections: list[str] = []
+ for title in blueprint.section_titles:
+ content = section_contents.get(title, "").strip()
+ if not content:
+ continue
+ min_chars = self._section_min_chars(title)
+ if len(content) < min_chars:
+ shallow_sections.append(title)
+ min_paragraphs = self._section_min_paragraphs(title)
+ if self._paragraph_count(content) < min_paragraphs:
+ sparse_sections.append(title)
+ if shallow_sections:
+ issues.append(f"章节深度不足,内容偏短:{', '.join(shallow_sections[:4])}。")
+ if sparse_sections:
+ issues.append(f"章节展开不足,段落层次不够:{', '.join(sparse_sections[:4])}。")
+
+ evidence_ids = {ev.id for ev in evidences}
+ cited_ids = set(self.EVIDENCE_REF_PATTERN.findall(body))
+ cited_known = cited_ids.intersection(evidence_ids)
+ if evidence_ids and not cited_known:
+ issues.append("关键结论缺少有效证据ID引用。")
+ if evidence_ids and len(cited_known) < min(2, len(evidence_ids)):
+ issues.append("证据覆盖不足,至少应引用两个不同证据。")
+
+ required_chars = self._minimum_body_chars(blueprint)
+ if len(stripped) < required_chars:
+ issues.append(f"正文过短,信息密度不足(当前 {len(stripped)},要求至少 {required_chars})。")
+ return ReportReviewResult(approved=not issues, issues=issues)
+
+ @classmethod
+ def _section_contents(cls, body: str) -> dict[str, str]:
+ section_map: dict[str, list[str]] = {}
+ current_heading = ""
+ for line in body.splitlines():
+ heading_match = re.match(r"^\s*##\s+(.+?)\s*$", line)
+ if heading_match:
+ current_heading = heading_match.group(1).strip()
+ section_map.setdefault(current_heading, [])
+ continue
+ if not current_heading:
+ continue
+ section_map[current_heading].append(line)
+ return {heading: "\n".join(lines).strip() for heading, lines in section_map.items()}
+
+ @classmethod
+ def _minimum_body_chars(cls, blueprint: ReportBlueprint) -> int:
+ return cls.MIN_BODY_BASE_CHARS + len(blueprint.section_titles) * cls.MIN_BODY_PER_SECTION_CHARS
+
+ @classmethod
+ def _section_min_chars(cls, title: str) -> int:
+ return cls.MIN_SUMMARY_SECTION_CHARS if cls._is_short_section(title) else cls.MIN_SECTION_CHARS
+
+ @classmethod
+ def _section_min_paragraphs(cls, title: str) -> int:
+ if cls._is_short_section(title):
+ return cls.MIN_PARAGRAPHS_FOR_SHORT_SECTION
+ return cls.MIN_PARAGRAPHS_PER_SECTION
+
+ @classmethod
+ def _is_short_section(cls, title: str) -> bool:
+ return any(keyword in title for keyword in cls.SHORT_SECTION_KEYWORDS)
+
+ @staticmethod
+ def _paragraph_count(text: str) -> int:
+ text = text.strip()
+ if not text:
+ return 0
+ by_blank = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
+ if len(by_blank) > 1:
+ return len(by_blank)
+ by_lines = [line.strip() for line in text.splitlines() if line.strip()]
+ if len(by_lines) > 1:
+ return len(by_lines)
+ return 1
+
+
+class ReportRevisionAgent:
+ """Revise report body according to reviewer feedback."""
+
+ TRACE_LINE_PATTERNS = (
+ re.compile(r"(?i)^\s*##\s*trace section\b"),
+ re.compile(r"(?i)^\s*\[locked\]"),
+ re.compile(r"^\s*挑战识别\s*:"),
+ )
+ PLACEHOLDER_LINE_PATTERNS = (
+ re.compile(r"(?i)\[mock\]"),
+ re.compile(r"(?i)\b(?:arxiv|semantic scholar|semanticscholar|tavily|web)\s+result\s+for\b"),
+ re.compile(r"(?i)synthetic evidence"),
+ )
+
+ def __init__(self, writer_service: WriterService) -> None:
+ self.writer_service = writer_service
+
+ def revise(
+ self,
+ *,
+ draft_body: str,
+ feedback: ReportReviewResult,
+ task_title: str,
+ task_description: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ blueprint: ReportBlueprint,
+ ) -> str:
+ cleaned = self._strip_noisy_lines(draft_body)
+ if self._requires_template_rewrite(cleaned, feedback):
+ return self.rewrite_with_template(
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+ return cleaned
+
+ def rewrite_with_template(
+ self,
+ *,
+ task_title: str,
+ task_description: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ blueprint: ReportBlueprint,
+ ) -> str:
+ _ = task_description
+ return self.writer_service.generate_template_body(
+ task_title=task_title,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+
+ def _strip_noisy_lines(self, text: str) -> str:
+ kept: list[str] = []
+ for line in text.splitlines():
+ if any(pattern.search(line) for pattern in self.TRACE_LINE_PATTERNS):
+ continue
+ if any(pattern.search(line) for pattern in self.PLACEHOLDER_LINE_PATTERNS):
+ continue
+ kept.append(line.rstrip())
+
+ compacted: list[str] = []
+ blank_run = 0
+ for line in kept:
+ if line.strip():
+ blank_run = 0
+ compacted.append(line)
+ continue
+ blank_run += 1
+ if blank_run <= 1:
+ compacted.append("")
+ return "\n".join(compacted).strip()
+
+ @staticmethod
+ def _requires_template_rewrite(cleaned_body: str, feedback: ReportReviewResult) -> bool:
+ minimum_chars = ReportReviewAgent.MIN_BODY_BASE_CHARS + ReportReviewAgent.MIN_BODY_PER_SECTION_CHARS
+ if len(cleaned_body.strip()) < minimum_chars:
+ return True
+ blocking_keywords = (
+ "章节不完整",
+ "章节深度不足",
+ "章节展开不足",
+ "证据覆盖不足",
+ "缺少有效证据ID引用",
+ "正文过短",
+ )
+ return any(any(keyword in issue for keyword in blocking_keywords) for issue in feedback.issues)
diff --git a/backend/app/services/conversation_agent.py b/backend/app/services/conversation_agent.py
new file mode 100644
index 0000000..50db0b1
--- /dev/null
+++ b/backend/app/services/conversation_agent.py
@@ -0,0 +1,1040 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from pathlib import Path
+import re
+from typing import Callable
+
+import httpx
+
+from app.core.config import settings
+from app.core.utils import new_id
+from app.models.schemas import (
+ ConversationDetail,
+ ConversationMessage,
+ ConversationStatus,
+ MessageKind,
+ MessageRole,
+ NodeStatus,
+ PlanRevision,
+ RunConversationResponse,
+ TaskConfig,
+ TaskStatus,
+)
+from app.repositories.conversation_repository import ConversationRepository
+from app.repositories.evidence_repository import EvidenceRepository
+from app.repositories.task_repository import TaskRepository
+from app.services.agents import ReportAgent
+from app.services.execution_engine import ExecutionEngine
+
+
+@dataclass(frozen=True)
+class ParsedPlan:
+ title: str
+ config: TaskConfig
+ warnings: list[str]
+
+
+class ConversationAgent:
+ _FRONT_MATTER_PATTERN = re.compile(r"\A---\s*\n(?P.*?)\n---\s*\n?", re.DOTALL)
+ _KV_PATTERN = re.compile(r"^\s*([a-zA-Z_]+)\s*:\s*(.*?)\s*$")
+ _PLAN_INTENT_MARKERS = (
+ "研究方案",
+ "研究计划",
+ "front matter",
+ "max_depth",
+ "max_nodes",
+ "priority",
+ "search_sources",
+ "任务树",
+ "执行步骤",
+ "改方案",
+ )
+ _RESEARCH_INTENT_MARKERS = (
+ "重新研究",
+ "重新执行",
+ "再执行",
+ "再跑",
+ "重跑",
+ "补充检索",
+ "补充资料",
+ "补充证据",
+ "再检索",
+ "再搜索",
+ "补充文献",
+ "查询最新",
+ "更新最新",
+ "追加调研",
+ )
+ _REPORT_REBUILD_MARKERS = (
+ "从证据重写",
+ "依据证据重写",
+ "基于证据重写",
+ "重新生成报告",
+ "全量重写",
+ )
+ _REPORT_INTENT_MARKERS = (
+ "改写报告",
+ "修改报告",
+ "重写报告",
+ "润色",
+ "演讲稿",
+ "口播",
+ "摘要版",
+ "精简版",
+ "扩写",
+ "改语气",
+ "改风格",
+ "rewrite",
+ "speech",
+ "tone",
+ "style",
+ )
+
+ def __init__(
+ self,
+ *,
+ repository: ConversationRepository,
+ task_repository: TaskRepository,
+ execution_engine: ExecutionEngine,
+ evidence_repository: EvidenceRepository | None = None,
+ report_agent: ReportAgent | None = None,
+ ) -> None:
+ self.repository = repository
+ self.task_repository = task_repository
+ self.execution_engine = execution_engine
+ self.evidence_repository = evidence_repository
+ self.report_agent = report_agent
+
+ async def create_conversation(self, *, topic: str, config: TaskConfig | None = None) -> ConversationDetail:
+ selected_config = config or TaskConfig()
+ conversation_id = new_id()
+ self.repository.create_conversation(
+ conversation_id=conversation_id,
+ topic=topic,
+ status=ConversationStatus.DRAFTING_PLAN,
+ config=selected_config,
+ )
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.USER,
+ kind=MessageKind.USER_TEXT,
+ content=topic,
+ metadata={"stage": "CREATED"},
+ )
+ markdown = await asyncio.to_thread(self._generate_initial_plan, topic=topic, config=selected_config)
+ revision = self.repository.add_plan_revision(
+ conversation_id,
+ author=MessageRole.ASSISTANT,
+ markdown=markdown,
+ )
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.ASSISTANT,
+ kind=MessageKind.PLAN_DRAFT,
+ content=revision.markdown,
+ metadata={"version": revision.version},
+ )
+ self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY)
+ return self.repository.get_detail(conversation_id)
+
+ async def revise_plan(self, *, conversation_id: str, instruction: str) -> tuple[PlanRevision, ConversationMessage]:
+ summary = self.repository.get_summary(conversation_id)
+ if summary.status == ConversationStatus.RUNNING:
+ raise ValueError("当前会话正在处理中,请等待完成后再发送新需求。")
+ current_plan = self.repository.get_current_plan(conversation_id)
+ if current_plan is None:
+ raise ValueError("当前会话没有可修订方案。")
+
+ has_report = self._has_persisted_report(summary.taskId)
+ mode = self._infer_instruction_mode(has_report=has_report, instruction=instruction)
+ if mode == "RESEARCH":
+ revision, _ = await self._apply_plan_revision(
+ conversation_id=conversation_id,
+ topic=summary.topic,
+ current_plan=current_plan.markdown,
+ instruction=instruction,
+ )
+ await self.start_research(conversation_id=conversation_id)
+ return revision, self._latest_message(conversation_id)
+
+ if mode == "REPORT":
+ message = await self._start_report_revision(
+ conversation_id=conversation_id,
+ task_id=summary.taskId or "",
+ instruction=instruction,
+ )
+ return current_plan, message
+
+ return await self._apply_plan_revision(
+ conversation_id=conversation_id,
+ topic=summary.topic,
+ current_plan=current_plan.markdown,
+ instruction=instruction,
+ )
+
+ def _infer_instruction_mode(self, *, has_report: bool, instruction: str) -> str:
+ if not has_report:
+ return "PLAN"
+ if self._matches_any_marker(instruction, self._RESEARCH_INTENT_MARKERS):
+ return "RESEARCH"
+ if self._matches_any_marker(instruction, self._PLAN_INTENT_MARKERS):
+ return "PLAN"
+ if self._matches_any_marker(instruction, self._REPORT_INTENT_MARKERS):
+ return "REPORT"
+ return "REPORT"
+
+ async def _start_report_revision(
+ self,
+ *,
+ conversation_id: str,
+ task_id: str,
+ instruction: str,
+ ) -> ConversationMessage:
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.USER,
+ kind=MessageKind.USER_TEXT,
+ content=instruction,
+ )
+ self.repository.set_status(conversation_id, ConversationStatus.RUNNING)
+ ack_message = self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.USER_TEXT,
+ content="正在修改中,Agent 会基于当前报告完成改写并自动更新结果。",
+ metadata={"taskId": task_id, "stage": "REPORT_REVISING"},
+ )
+ asyncio.create_task(
+ self._run_report_revision_job(
+ conversation_id=conversation_id,
+ task_id=task_id,
+ instruction=instruction,
+ )
+ )
+ return ack_message
+
+ async def _run_report_revision_job(self, *, conversation_id: str, task_id: str, instruction: str) -> None:
+ def progress_callback(
+ progress: int,
+ phase: str,
+ state: str,
+ summary: str,
+ payload: dict | None = None,
+ ) -> None:
+ self._emit_report_revision_progress(
+ conversation_id=conversation_id,
+ task_id=task_id,
+ progress=progress,
+ phase=phase,
+ state=state,
+ summary=summary,
+ payload=payload,
+ )
+
+ progress_callback(
+ 5,
+ "ANALYZING_REQUIREMENT",
+ "REPORT_REVISING",
+ "已接收改稿任务,正在分析修改意图。",
+ {"taskId": task_id, "instruction": instruction[:120]},
+ )
+ try:
+ await asyncio.to_thread(
+ self._revise_report_and_record,
+ conversation_id=conversation_id,
+ task_id=task_id,
+ instruction=instruction,
+ progress_callback=progress_callback,
+ )
+ progress_callback(
+ 100,
+ "REPORT_COMPLETED",
+ "COMPLETED",
+ "报告改写完成,已更新到会话中。",
+ {"taskId": task_id},
+ )
+ self.repository.set_status(conversation_id, ConversationStatus.COMPLETED)
+ except KeyError:
+ return
+ except Exception as exc: # noqa: BLE001
+ try:
+ progress_callback(
+ 100,
+ "REPORT_FAILED",
+ "FAILED",
+ f"报告改写失败:{exc}",
+ {"taskId": task_id, "error": str(exc)},
+ )
+ self.repository.set_status(conversation_id, ConversationStatus.FAILED)
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.ERROR,
+ content=f"报告改写失败:{exc}",
+ metadata={"taskId": task_id, "stage": "REPORT_REVISION"},
+ )
+ except KeyError:
+ return
+
+ def _emit_report_revision_progress(
+ self,
+ *,
+ conversation_id: str,
+ task_id: str,
+ progress: int,
+ phase: str,
+ state: str,
+ summary: str,
+ payload: dict | None = None,
+ ) -> None:
+ normalized_task_id = task_id.strip() if task_id.strip() else f"report-revision:{conversation_id}"
+ progress_value = max(0, min(100, int(progress)))
+ event_payload = {"taskId": normalized_task_id, "phase": phase, "state": state, "progress": progress_value}
+ if payload:
+ event_payload.update(payload)
+ event_payload["taskId"] = normalized_task_id
+ try:
+ self.repository.append_progress_entry(
+ conversation_id,
+ task_id=normalized_task_id,
+ message_id=new_id(),
+ phase=phase,
+ state=state,
+ summary=summary,
+ progress=progress_value,
+ payload=event_payload,
+ )
+ except KeyError:
+ return
+
+ async def _apply_plan_revision(
+ self,
+ *,
+ conversation_id: str,
+ topic: str,
+ current_plan: str,
+ instruction: str,
+ ) -> tuple[PlanRevision, ConversationMessage]:
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.USER,
+ kind=MessageKind.USER_TEXT,
+ content=instruction,
+ )
+ config = self.repository.get_config(conversation_id)
+ revised = await asyncio.to_thread(
+ self._generate_revised_plan,
+ topic=topic,
+ config=config,
+ current_plan=current_plan,
+ instruction=instruction,
+ )
+ revision = self.repository.add_plan_revision(
+ conversation_id,
+ author=MessageRole.ASSISTANT,
+ markdown=revised,
+ )
+ message = self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.ASSISTANT,
+ kind=MessageKind.PLAN_REVISION,
+ content=revision.markdown,
+ metadata={"version": revision.version},
+ )
+ self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY)
+ return revision, message
+
+ def update_plan(self, *, conversation_id: str, markdown: str) -> PlanRevision:
+ summary = self.repository.get_summary(conversation_id)
+ if summary.status == ConversationStatus.RUNNING:
+ raise ValueError("研究执行中,暂不支持直接编辑方案。")
+ revision = self.repository.add_plan_revision(
+ conversation_id,
+ author=MessageRole.USER,
+ markdown=markdown,
+ )
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.USER,
+ kind=MessageKind.PLAN_EDITED,
+ content=revision.markdown,
+ metadata={"version": revision.version},
+ )
+ self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY)
+ return revision
+
+ def rename_conversation(
+ self,
+ *,
+ conversation_id: str,
+ topic: str,
+ sync_current_plan: bool = True,
+ ) -> ConversationDetail:
+ topic_text = topic.strip()
+ if len(topic_text) < 2:
+ raise ValueError("会话标题至少需要 2 个字符。")
+ summary = self.repository.update_topic(conversation_id, topic_text)
+
+ current_plan = self.repository.get_current_plan(conversation_id) if sync_current_plan else None
+ if current_plan is not None:
+ base_config = self.repository.get_config(conversation_id)
+ rewritten = self._rewrite_plan_topic(
+ current_plan.markdown,
+ topic=topic_text,
+ config=base_config,
+ )
+ if rewritten.strip() != current_plan.markdown.strip():
+ revision = self.repository.add_plan_revision(
+ conversation_id,
+ author=MessageRole.SYSTEM,
+ markdown=rewritten,
+ )
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.USER_TEXT,
+ content=f"会话已重命名为:{topic_text}(方案已同步到 v{revision.version})",
+ metadata={
+ "stage": "RENAMED",
+ "planVersion": revision.version,
+ "topic": topic_text,
+ },
+ )
+ else:
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.USER_TEXT,
+ content=f"会话已重命名为:{topic_text}",
+ metadata={"stage": "RENAMED", "topic": topic_text},
+ )
+ else:
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.USER_TEXT,
+ content=f"会话已重命名为:{topic_text}",
+ metadata={"stage": "RENAMED", "topic": topic_text},
+ )
+
+ if summary.status == ConversationStatus.DRAFTING_PLAN:
+ self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY)
+ return self.repository.get_detail(conversation_id)
+
+ def delete_conversation(self, *, conversation_id: str) -> None:
+ summary = self.repository.get_summary(conversation_id)
+ self._abort_task_if_active(summary.taskId)
+ self.repository.delete_conversation(conversation_id)
+
+ def delete_all_conversations(self) -> int:
+ for summary in self.repository.list_summaries():
+ self._abort_task_if_active(summary.taskId)
+ return self.repository.delete_all_conversations()
+
+ async def start_research(self, *, conversation_id: str) -> RunConversationResponse:
+ summary = self.repository.get_summary(conversation_id)
+ if summary.status == ConversationStatus.RUNNING:
+ if summary.taskId:
+ return RunConversationResponse(
+ conversationId=conversation_id,
+ taskId=summary.taskId,
+ status=ConversationStatus.RUNNING,
+ )
+ raise ValueError("会话状态异常:RUNNING 但 taskId 缺失。")
+
+ current_plan = self.repository.get_current_plan(conversation_id)
+ if current_plan is None:
+ raise ValueError("没有可执行方案,请先生成或编辑研究方案。")
+ base_config = self.repository.get_config(conversation_id)
+ parsed = self._parse_plan(current_plan.markdown, topic=summary.topic, base_config=base_config)
+ for warning in parsed.warnings:
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.ERROR,
+ content=warning,
+ )
+ task_description = self._extract_plan_body(current_plan.markdown)[:5000]
+ if len(task_description.strip()) < 3:
+ task_description = f"围绕主题“{summary.topic}”执行系统化研究。"
+ task = self.task_repository.create_task(
+ task_id=new_id(),
+ title=parsed.title[:200],
+ description=task_description,
+ config=parsed.config,
+ )
+ self.repository.set_task_id(conversation_id, task.taskId)
+ self.repository.set_status(conversation_id, ConversationStatus.RUNNING)
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.USER_TEXT,
+ content="研究任务已启动,Agent 正在按方案执行。",
+ metadata={"taskId": task.taskId},
+ )
+ await self.execution_engine.start(task.taskId)
+ return RunConversationResponse(
+ conversationId=conversation_id,
+ taskId=task.taskId,
+ status=ConversationStatus.RUNNING,
+ )
+
+ async def on_task_event(self, task_id: str, event: str, data: dict) -> None:
+ summary = self.repository.find_by_task_id(task_id)
+ if summary is None:
+ return
+ conversation_id = summary.conversationId
+
+ if event == "TASK_PROGRESS":
+ phase = str(data.get("phase") or data.get("state") or "UNKNOWN").strip() or "UNKNOWN"
+ state = str(data.get("state") or "UNKNOWN").strip() or "UNKNOWN"
+ progress = data.get("progress")
+ if isinstance(progress, float):
+ progress = int(progress)
+ if not isinstance(progress, int):
+ progress = None
+ summary_line = self._progress_summary(data)
+ self.repository.append_progress_entry(
+ conversation_id,
+ task_id=task_id,
+ message_id=new_id(),
+ phase=phase,
+ state=state,
+ summary=summary_line,
+ progress=progress,
+ payload=data,
+ )
+ return
+
+ if event == "TASK_COMPLETED":
+ self.repository.set_status(conversation_id, ConversationStatus.COMPLETED)
+ report_content = self._load_report(task_id)
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.ASSISTANT,
+ kind=MessageKind.FINAL_REPORT,
+ content=report_content or "研究已完成,但报告正文暂不可用。",
+ metadata={"taskId": task_id},
+ )
+ return
+
+ if event in {"TASK_FAILED", "TASK_ABORTED", "ERROR"}:
+ self.repository.set_status(conversation_id, ConversationStatus.FAILED)
+ error_text = str(data.get("error") or "研究执行失败,请检查日志。").strip()
+ self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.SYSTEM,
+ kind=MessageKind.ERROR,
+ content=error_text,
+ metadata={"taskId": task_id, "event": event},
+ )
+
+ def _load_report(self, task_id: str) -> str:
+ try:
+ task = self.task_repository.get_task(task_id)
+ except KeyError:
+ return ""
+ if not task.reportPath:
+ return ""
+ path = Path(task.reportPath)
+ if not path.exists():
+ return ""
+ return path.read_text(encoding="utf-8")
+
+ def _revise_report_and_record(
+ self,
+ *,
+ conversation_id: str,
+ task_id: str,
+ instruction: str,
+ progress_callback: Callable[[int, str, str, str, dict | None], None] | None = None,
+ ) -> ConversationMessage:
+ def emit(progress: int, phase: str, summary: str, payload: dict | None = None) -> None:
+ if progress_callback is None:
+ return
+ progress_callback(progress, phase, "REPORT_REVISING", summary, payload)
+
+ current_report = self._load_report(task_id)
+ if not current_report:
+ latest_report = self._latest_report_message(conversation_id)
+ current_report = latest_report.content if latest_report else ""
+ emit(
+ 20,
+ "PREPARING_DRAFT",
+ "已读取现有报告,正在整理可保留内容。",
+ {"taskId": task_id, "currentReportLength": len(current_report)},
+ )
+
+ report_text = ""
+ if current_report.strip():
+ emit(
+ 45,
+ "WRITING_DRAFT",
+ "正在按你的要求改写报告结构与语气。",
+ {"taskId": task_id},
+ )
+ llm_revised = self._rewrite_report_with_llm(current_report=current_report, instruction=instruction)
+ report_text = llm_revised or self._fallback_revised_report(
+ current_report=current_report,
+ instruction=instruction,
+ )
+ emit(
+ 72,
+ "WRITING_DRAFT",
+ "主体改写完成,正在保存新版本。",
+ {"taskId": task_id},
+ )
+ self._persist_report(task_id=task_id, content=report_text)
+
+ should_rebuild = self._matches_any_marker(instruction, self._REPORT_REBUILD_MARKERS)
+ if should_rebuild or not report_text:
+ emit(
+ 78,
+ "EVIDENCE_REBUILD",
+ "正在基于已有证据重建报告内容。",
+ {"taskId": task_id},
+ )
+ report_text = self._regenerate_report_from_existing_artifacts(task_id=task_id, instruction=instruction)
+ if report_text:
+ emit(
+ 90,
+ "EVIDENCE_REBUILD",
+ "证据重建完成,正在合并输出。",
+ {"taskId": task_id},
+ )
+ if not report_text:
+ report_text = self._fallback_revised_report(current_report=current_report, instruction=instruction)
+ self._persist_report(task_id=task_id, content=report_text)
+ emit(
+ 96,
+ "PERSISTING_REPORT",
+ "正在写入会话并刷新报告预览。",
+ {"taskId": task_id},
+ )
+
+ return self.repository.add_message(
+ conversation_id,
+ message_id=new_id(),
+ role=MessageRole.ASSISTANT,
+ kind=MessageKind.FINAL_REPORT,
+ content=report_text,
+ metadata={"taskId": task_id, "mode": "REPORT_REVISION"},
+ )
+
+ def _regenerate_report_from_existing_artifacts(self, *, task_id: str, instruction: str) -> str:
+ if not self.report_agent or not self.evidence_repository:
+ return ""
+ try:
+ task = self.task_repository.get_task(task_id)
+ except KeyError:
+ return ""
+
+ try:
+ dag = self.task_repository.get_dag(task_id, allow_empty=True)
+ except Exception:
+ return ""
+ sections = [
+ (node.taskId, f"{node.title}\n\n{node.description}")
+ for node in dag.nodes
+ if node.taskId != task_id and node.status != NodeStatus.PRUNED
+ ]
+ evidences = self.evidence_repository.list(task_id=task_id, limit=1000).items
+ revised_description = (
+ f"{task.description}\n\n"
+ f"用户补充要求:{instruction}\n"
+ "请在保持证据可追溯的前提下重写完整报告。"
+ )
+ try:
+ md_path, _, _ = self.report_agent.generate_report(
+ task_id=task_id,
+ task_title=task.title,
+ task_description=revised_description,
+ sections=sections,
+ evidences=evidences,
+ locked_sections=set(),
+ )
+ self.task_repository.set_report_path(task_id, md_path)
+ return Path(md_path).read_text(encoding="utf-8")
+ except Exception:
+ return ""
+
+ def _rewrite_report_with_llm(self, *, current_report: str, instruction: str) -> str:
+ if not current_report.strip():
+ return ""
+ prompt = (
+ "你是报告改写 Agent。请基于用户提供的“当前报告”完成改写。\n"
+ "要求:\n"
+ "1. 仅输出最终 Markdown,不要解释过程;\n"
+ "2. 保留事实准确性,尽量保留证据 ID(例如 [evidence:xxxx]);\n"
+ "3. 如果用户要求体裁变化(如演讲稿),需完整重排结构与语气;\n"
+ "4. 未被用户要求删除的信息请尽量保留。"
+ )
+ user_input = (
+ f"用户要求:{instruction}\n\n"
+ f"当前报告:\n{current_report[:45000]}"
+ )
+ return self._chat_complete(system_prompt=prompt, user_prompt=user_input).strip()
+
+ @staticmethod
+ def _fallback_revised_report(*, current_report: str, instruction: str) -> str:
+ base = current_report.strip() or "# 修订报告"
+ return (
+ f"{base}\n\n"
+ "## 修订说明\n"
+ f"- 用户要求:{instruction}\n"
+ "- 已触发自动修订流程;若需补充外部资料,请在指令中明确“补充检索/重新研究”。\n"
+ )
+
+ def _persist_report(self, *, task_id: str, content: str) -> None:
+ try:
+ task = self.task_repository.get_task(task_id)
+ except KeyError:
+ return
+ if task.reportPath:
+ report_path = Path(task.reportPath)
+ else:
+ report_path = Path("backend/.data/reports") / f"{task_id}.md"
+ report_path.parent.mkdir(parents=True, exist_ok=True)
+ report_path.write_text(content, encoding="utf-8")
+ self.task_repository.set_report_path(task_id, str(report_path))
+
+ def _latest_report_message(self, conversation_id: str) -> ConversationMessage | None:
+ messages = self.repository.get_detail(conversation_id).messages
+ for message in reversed(messages):
+ if message.kind == MessageKind.FINAL_REPORT:
+ return message
+ return None
+
+ def _latest_message(self, conversation_id: str) -> ConversationMessage:
+ messages = self.repository.get_detail(conversation_id).messages
+ if not messages:
+ raise ValueError("会话中没有消息记录。")
+ return messages[-1]
+
+ def _has_persisted_report(self, task_id: str | None) -> bool:
+ if not task_id:
+ return False
+ return bool(self._load_report(task_id).strip())
+
+ @staticmethod
+ def _matches_any_marker(text: str, markers: tuple[str, ...]) -> bool:
+ lowered = text.lower()
+ return any(marker in lowered for marker in markers)
+
+ @staticmethod
+ def _extract_plan_body(markdown: str) -> str:
+ match = ConversationAgent._FRONT_MATTER_PATTERN.match(markdown.strip())
+ if not match:
+ return markdown.strip()
+ return markdown[match.end() :].strip()
+
+ def _parse_plan(self, markdown: str, *, topic: str, base_config: TaskConfig) -> ParsedPlan:
+ warnings: list[str] = []
+ config_data = base_config.model_dump()
+ parsed_title = topic
+ match = self._FRONT_MATTER_PATTERN.match(markdown.strip())
+ if not match:
+ warnings.append("未检测到方案 front matter,已回退为默认执行配置。")
+ return ParsedPlan(title=parsed_title, config=TaskConfig(**config_data), warnings=warnings)
+
+ header = match.group("header")
+ for raw_line in header.splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#"):
+ continue
+ kv = self._KV_PATTERN.match(line)
+ if not kv:
+ warnings.append(f"忽略无法解析的 front matter 行:{line}")
+ continue
+ key = kv.group(1).strip().lower()
+ value = kv.group(2).strip()
+ if key == "title" and value:
+ parsed_title = value.strip().strip('"').strip("'")
+ continue
+ if key == "topic":
+ continue
+ if key == "max_depth":
+ config_data["maxDepth"] = self._int_or_default(value, base_config.maxDepth, min_value=1, max_value=8)
+ continue
+ if key == "max_nodes":
+ config_data["maxNodes"] = self._int_or_default(value, base_config.maxNodes, min_value=1, max_value=500)
+ continue
+ if key == "priority":
+ config_data["priority"] = self._int_or_default(value, base_config.priority, min_value=1, max_value=5)
+ continue
+ if key == "search_sources":
+ parsed_sources = self._parse_sources(value)
+ if parsed_sources:
+ config_data["searchSources"] = parsed_sources
+ else:
+ warnings.append("search_sources 为空,已回退默认数据源配置。")
+
+ return ParsedPlan(title=parsed_title[:200], config=TaskConfig(**config_data), warnings=warnings)
+
+ @staticmethod
+ def _int_or_default(raw: str, default: int, *, min_value: int, max_value: int) -> int:
+ try:
+ value = int(raw.strip())
+ except Exception:
+ return default
+ return max(min_value, min(max_value, value))
+
+ @staticmethod
+ def _parse_sources(raw: str) -> list[str]:
+ text = raw.strip()
+ if text.startswith("[") and text.endswith("]"):
+ text = text[1:-1]
+ parts = [part.strip().strip('"').strip("'") for part in text.split(",")]
+ return [part for part in parts if part]
+
+ def _generate_initial_plan(self, *, topic: str, config: TaskConfig) -> str:
+ prompt = (
+ "请为用户生成一个可执行的深度研究方案,输出必须是 Markdown,并且必须包含 front matter。\n"
+ "front matter 字段固定为:title, topic, max_depth, max_nodes, priority, search_sources。\n"
+ "正文至少包含:研究目标、研究问题拆解、方法与来源、执行步骤、风险与边界、交付标准。\n"
+ "严禁输出解释性前言,直接返回完整 Markdown。"
+ )
+ user_input = (
+ f"主题:{topic}\n"
+ f"配置建议:max_depth={config.maxDepth}, max_nodes={config.maxNodes}, "
+ f"priority={config.priority}, search_sources={config.searchSources}\n"
+ "输出语言:中文。"
+ )
+ generated = self._chat_complete(system_prompt=prompt, user_prompt=user_input)
+ if generated:
+ normalized = self._ensure_front_matter(generated, topic=topic, config=config)
+ if normalized:
+ return normalized
+ return self._fallback_plan(topic=topic, config=config)
+
+ def _generate_revised_plan(
+ self,
+ *,
+ topic: str,
+ config: TaskConfig,
+ current_plan: str,
+ instruction: str,
+ ) -> str:
+ prompt = (
+ "你是研究计划修订 Agent。请根据用户指令修订“当前研究方案”。\n"
+ "输出必须是完整 Markdown,且必须包含完整 front matter。\n"
+ "不要解释你做了什么,不要输出多余文本,只返回最终方案。"
+ )
+ user_input = (
+ f"主题:{topic}\n"
+ f"用户指令:{instruction}\n\n"
+ f"当前方案如下:\n{current_plan}\n\n"
+ f"保底配置:max_depth={config.maxDepth}, max_nodes={config.maxNodes}, "
+ f"priority={config.priority}, search_sources={config.searchSources}"
+ )
+ generated = self._chat_complete(system_prompt=prompt, user_prompt=user_input)
+ if generated:
+ normalized = self._ensure_front_matter(generated, topic=topic, config=config)
+ if normalized:
+ return normalized
+ return self._fallback_revision(current_plan=current_plan, instruction=instruction, topic=topic, config=config)
+
+ def _chat_complete(self, *, system_prompt: str, user_prompt: str) -> str:
+ if settings.use_mock_sources:
+ return ""
+ base_url, api_key, model = self._resolve_provider()
+ if not base_url or not api_key:
+ return ""
+ try:
+ with httpx.Client(timeout=60) as client:
+ response = client.post(
+ f"{base_url}/chat/completions",
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": model,
+ "temperature": 0.2,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ },
+ )
+ response.raise_for_status()
+ payload = response.json()
+ return (
+ payload.get("choices", [{}])[0]
+ .get("message", {})
+ .get("content", "")
+ .strip()
+ )
+ except Exception:
+ return ""
+
+ @staticmethod
+ def _resolve_provider() -> tuple[str, str, str]:
+ provider = settings.default_llm_provider.lower().strip()
+ if provider == "openrouter":
+ return settings.openrouter_base_url, settings.openrouter_api_key, settings.openrouter_model
+ if provider == "deepseek":
+ return settings.deepseek_base_url, settings.deepseek_api_key, settings.deepseek_model
+ if provider == "openai":
+ return settings.openai_base_url, settings.openai_api_key, settings.openai_model
+ return "", "", ""
+
+ def _abort_task_if_active(self, task_id: str | None) -> None:
+ if not task_id:
+ return
+ try:
+ task = self.task_repository.get_task(task_id)
+ except KeyError:
+ return
+ if task.status in {
+ TaskStatus.READY,
+ TaskStatus.PLANNING,
+ TaskStatus.EXECUTING,
+ TaskStatus.REVIEWING,
+ TaskStatus.SYNTHESIZING,
+ TaskStatus.FINALIZING,
+ TaskStatus.SUSPENDED,
+ }:
+ self.execution_engine.abort(task_id)
+
+ def _rewrite_plan_topic(self, markdown: str, *, topic: str, config: TaskConfig) -> str:
+ text = markdown.strip()
+ if not text:
+ return self._ensure_front_matter(markdown, topic=topic, config=config)
+
+ match = self._FRONT_MATTER_PATTERN.match(text)
+ if not match:
+ return self._ensure_front_matter(text, topic=topic, config=config)
+
+ header = match.group("header")
+ body = text[match.end() :].strip()
+ header_lines = header.splitlines()
+ rewritten_lines: list[str] = []
+ has_title = False
+ has_topic = False
+ for raw_line in header_lines:
+ kv = self._KV_PATTERN.match(raw_line)
+ if not kv:
+ rewritten_lines.append(raw_line)
+ continue
+ key = kv.group(1).strip()
+ key_l = key.lower()
+ if key_l == "title":
+ rewritten_lines.append(f"{key}: {self._yaml_value(topic)}")
+ has_title = True
+ continue
+ if key_l == "topic":
+ rewritten_lines.append(f"{key}: {self._yaml_value(topic)}")
+ has_topic = True
+ continue
+ rewritten_lines.append(raw_line)
+
+ if not has_title:
+ rewritten_lines.append(f"title: {self._yaml_value(topic)}")
+ if not has_topic:
+ rewritten_lines.append(f"topic: {self._yaml_value(topic)}")
+
+ rebuilt_header = "\n".join(rewritten_lines)
+ rebuilt = f"---\n{rebuilt_header}\n---"
+ if body:
+ return f"{rebuilt}\n\n{body}"
+ return rebuilt
+
+ @staticmethod
+ def _yaml_value(value: str) -> str:
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
+ return f'"{escaped}"'
+
+ def _ensure_front_matter(self, markdown: str, *, topic: str, config: TaskConfig) -> str:
+ text = markdown.strip()
+ if not text:
+ return ""
+ if self._FRONT_MATTER_PATTERN.match(text):
+ return text
+ return (
+ "---\n"
+ f"title: {topic}\n"
+ f"topic: {topic}\n"
+ f"max_depth: {config.maxDepth}\n"
+ f"max_nodes: {config.maxNodes}\n"
+ f"priority: {config.priority}\n"
+ f"search_sources: [{', '.join(config.searchSources)}]\n"
+ "---\n\n"
+ f"{text}"
+ )
+
+ @staticmethod
+ def _fallback_plan(*, topic: str, config: TaskConfig) -> str:
+ return (
+ "---\n"
+ f"title: {topic} 深度研究方案\n"
+ f"topic: {topic}\n"
+ f"max_depth: {config.maxDepth}\n"
+ f"max_nodes: {config.maxNodes}\n"
+ f"priority: {config.priority}\n"
+ f"search_sources: [{', '.join(config.searchSources)}]\n"
+ "---\n\n"
+ "## 研究目标\n"
+ "围绕主题建立可验证的结论链路,输出可执行决策建议。\n\n"
+ "## 研究问题拆解\n"
+ "1. 核心概念与边界是什么。\n"
+ "2. 当前主流方法与证据来源有哪些。\n"
+ "3. 风险、局限与落地障碍分别是什么。\n\n"
+ "## 方法与来源\n"
+ "- 使用学术论文与高可信 Web 来源交叉验证。\n"
+ "- 对关键结论保留可追溯证据 ID。\n\n"
+ "## 执行步骤\n"
+ "1. 规划任务树并确定检索查询。\n"
+ "2. 检索、清洗并打分证据。\n"
+ "3. 处理冲突并形成综合分析。\n"
+ "4. 生成最终 Markdown 报告。\n\n"
+ "## 风险与边界\n"
+ "- 时效性偏差:关注近三年数据,必要时补充最新动态。\n"
+ "- 来源偏差:至少两类来源交叉验证。\n\n"
+ "## 交付标准\n"
+ "- 报告含摘要、方法、发现、分析、建议。\n"
+ "- 关键结论标注证据引用并给出行动建议。\n"
+ )
+
+ def _fallback_revision(self, *, current_plan: str, instruction: str, topic: str, config: TaskConfig) -> str:
+ normalized = self._ensure_front_matter(current_plan, topic=topic, config=config)
+ return (
+ f"{normalized}\n\n"
+ "## 修订记录\n"
+ f"- 用户新要求:{instruction}\n"
+ "- 已按要求在执行步骤与交付标准中应用该约束,请在右侧继续手工微调。"
+ )
+
+ @staticmethod
+ def _progress_summary(data: dict) -> str:
+ state = str(data.get("state") or "EXECUTING")
+ phase = str(data.get("phase") or "UNKNOWN")
+ progress = data.get("progress")
+ progress_text = f"{progress}%" if isinstance(progress, (int, float)) else "--"
+ node_title = str(data.get("currentNodeTitle") or "").strip()
+ section_title = str(data.get("currentSectionTitle") or "").strip()
+ query = str(data.get("searchQuery") or "").strip()
+ if section_title:
+ return f"[{state}/{phase}] {progress_text} 正在写作:{section_title}"
+ if node_title and query:
+ return f"[{state}/{phase}] {progress_text} 节点:{node_title} | 查询:{query}"
+ if node_title:
+ return f"[{state}/{phase}] {progress_text} 节点:{node_title}"
+ return f"[{state}/{phase}] {progress_text}"
diff --git a/backend/app/services/execution_engine.py b/backend/app/services/execution_engine.py
index 8e37eda..20e92c6 100644
--- a/backend/app/services/execution_engine.py
+++ b/backend/app/services/execution_engine.py
@@ -2,12 +2,15 @@
import asyncio
from dataclasses import dataclass, field
+import logging
+from typing import Awaitable, Callable
from app.core.utils import now_iso
from app.models.schemas import NodeStatus, TaskStatus
from app.repositories.conflict_repository import ConflictRepository
from app.repositories.evidence_repository import EvidenceRepository
from app.repositories.task_repository import TaskRepository
+from app.services.agents import ReportAgent, ResearchAgent
from app.services.analyst import AnalystService
from app.services.planner import MasterPlanner
from app.services.progress_hub import ProgressHub
@@ -15,6 +18,8 @@
from app.services.state_machine import InvalidStateTransition, transition_or_raise
from app.services.writer import WriterService
+logger = logging.getLogger(__name__)
+
@dataclass
class TaskControlState:
@@ -35,6 +40,9 @@ def __init__(
conflict_repository: ConflictRepository,
analyst_service: AnalystService,
writer_service: WriterService,
+ research_agent: ResearchAgent | None = None,
+ report_agent: ReportAgent | None = None,
+ event_listener: Callable[[str, str, dict], Awaitable[None]] | None = None,
) -> None:
self.repository = repository
self.planner = planner
@@ -44,8 +52,22 @@ def __init__(
self.conflict_repository = conflict_repository
self.analyst_service = analyst_service
self.writer_service = writer_service
+ self.research_agent = research_agent or ResearchAgent(retrieval_service=retrieval_service)
+ self.report_agent = report_agent or ReportAgent(writer_service=writer_service)
+ self.event_listener = event_listener
self._control: dict[str, TaskControlState] = {}
+ def set_event_listener(self, listener: Callable[[str, str, dict], Awaitable[None]] | None) -> None:
+ self.event_listener = listener
+
+ async def _emit_event(self, task_id: str, event: str, payload: dict) -> None:
+ await self.hub.emit(task_id, event, payload)
+ if self.event_listener is not None:
+ try:
+ await self.event_listener(task_id, event, payload)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("Event listener failed for task=%s event=%s: %s", task_id, event, exc)
+
async def start(self, task_id: str) -> None:
task = self.repository.get_task(task_id)
control = self._control.setdefault(task_id, TaskControlState())
@@ -81,14 +103,23 @@ async def recover(self, task_id: str) -> None:
async def _run_task(self, task_id: str, current_status: TaskStatus) -> None:
try:
- await self.hub.emit(task_id, "TASK_STARTED", {"taskId": task_id, "status": current_status.value})
+ await self._emit_event(task_id, "TASK_STARTED", {"taskId": task_id, "status": current_status.value})
task = self.repository.get_task(task_id)
config = task.config
if not task.dag or not task.dag.nodes:
self.repository.update_status(task_id, transition_or_raise(task.status, TaskStatus.PLANNING))
dag = self.planner.build_dag(task_id, task.title, task.description, config)
self.repository.save_dag(task_id, dag)
- await self.hub.emit(task_id, "TASK_PROGRESS", {"taskId": task_id, "progress": 20, "state": "PLANNING"})
+ await self._emit_event(
+ task_id,
+ "TASK_PROGRESS",
+ {
+ "taskId": task_id,
+ "progress": 20,
+ "state": "PLANNING",
+ "phase": "BUILDING_PLAN",
+ },
+ )
current = self.repository.get_task(task_id)
if current.status == TaskStatus.SUSPENDED:
@@ -110,19 +141,34 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None:
while control.paused and not control.aborted:
await asyncio.sleep(0.2)
if control.aborted:
- await self.hub.emit(task_id, "ERROR", {"taskId": task_id, "error": "Task aborted by user"})
+ await self._emit_event(task_id, "ERROR", {"taskId": task_id, "error": "Task aborted by user"})
return
self.repository.update_node_status(task_id, node.taskId, NodeStatus.RUNNING, node.metadata.infoGainScore)
await asyncio.sleep(0.2)
- evidences = await self.retrieval_service.retrieve(
+ query = f"{task.title} {node.title}"
+ searching_progress = 20 + int(((idx - 1) / total) * 60)
+ await self._emit_event(
+ task_id,
+ "TASK_PROGRESS",
+ {
+ "taskId": task_id,
+ "progress": searching_progress,
+ "currentNode": node.taskId,
+ "currentNodeTitle": node.title,
+ "searchQuery": query,
+ "state": "EXECUTING",
+ "phase": "SEARCHING",
+ },
+ )
+ evidences = await self.research_agent.collect_evidence(
task_id=task_id,
node_id=node.taskId,
- query=node.title,
+ query=query,
sources=task.config.searchSources,
)
self.evidence_repository.save_many(evidences)
for ev in evidences:
- await self.hub.emit(
+ await self._emit_event(
task_id,
"EVIDENCE_FOUND",
{"taskId": task_id, "nodeId": node.taskId, "evidence": ev.model_dump()},
@@ -130,14 +176,18 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None:
self.repository.update_node_status(task_id, node.taskId, NodeStatus.COMPLETED, node.metadata.infoGainScore)
control.completed_nodes.append(node.taskId)
progress = 20 + int((idx / total) * 60)
- await self.hub.emit(
+ await self._emit_event(
task_id,
"TASK_PROGRESS",
{
"taskId": task_id,
"progress": progress,
"currentNode": node.taskId,
+ "currentNodeTitle": node.title,
+ "searchQuery": query,
+ "evidenceCount": len(evidences),
"state": "EXECUTING",
+ "phase": "NODE_COMPLETED",
},
)
self.repository.save_snapshot(
@@ -160,16 +210,26 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None:
if conflicts:
self.repository.update_status(task_id, transition_or_raise(TaskStatus.EXECUTING, TaskStatus.REVIEWING))
self.conflict_repository.save_many(conflicts)
- await self.hub.emit(
+ await self._emit_event(
task_id,
"TASK_PROGRESS",
- {"taskId": task_id, "progress": 85, "state": "REVIEWING", "conflictCount": len(conflicts)},
+ {
+ "taskId": task_id,
+ "progress": 85,
+ "state": "REVIEWING",
+ "phase": "REVIEWING_CONFLICTS",
+ "conflictCount": len(conflicts),
+ },
)
# Single-user default: continue with unresolved conflicts recorded for later voting.
self.repository.update_status(task_id, transition_or_raise(TaskStatus.REVIEWING, TaskStatus.SYNTHESIZING))
else:
self.repository.update_status(task_id, transition_or_raise(TaskStatus.EXECUTING, TaskStatus.SYNTHESIZING))
- await self.hub.emit(task_id, "TASK_PROGRESS", {"taskId": task_id, "progress": 90, "state": "SYNTHESIZING"})
+ await self._emit_event(
+ task_id,
+ "TASK_PROGRESS",
+ {"taskId": task_id, "progress": 90, "state": "SYNTHESIZING", "phase": "OUTLINING"},
+ )
await asyncio.sleep(0.1)
dag = self.repository.get_dag(task_id)
sections = [
@@ -177,24 +237,46 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None:
for node in dag.nodes
if node.taskId != task_id and node.status != NodeStatus.PRUNED
]
- md_path, bib_path, _ = self.writer_service.write_report(
+ total_sections = max(1, len(sections))
+ for section_idx, (_, section_text) in enumerate(sections, start=1):
+ section_title = section_text.splitlines()[0].strip() if section_text else ""
+ write_progress = 90 + int((section_idx / total_sections) * 6)
+ await self._emit_event(
+ task_id,
+ "TASK_PROGRESS",
+ {
+ "taskId": task_id,
+ "progress": write_progress,
+ "state": "SYNTHESIZING",
+ "phase": "WRITING_SECTION",
+ "currentSectionTitle": section_title or f"Section {section_idx}",
+ },
+ )
+ md_path, bib_path, _ = await asyncio.to_thread(
+ self.report_agent.generate_report,
task_id=task_id,
task_title=task.title,
+ task_description=task.description,
sections=sections,
evidences=evidences,
locked_sections=set(),
)
self.repository.update_status(task_id, transition_or_raise(TaskStatus.SYNTHESIZING, TaskStatus.FINALIZING))
+ await self._emit_event(
+ task_id,
+ "TASK_PROGRESS",
+ {"taskId": task_id, "progress": 98, "state": "FINALIZING", "phase": "PERSISTING_REPORT"},
+ )
self.repository.set_report_path(task_id, md_path)
self.repository.update_status(task_id, transition_or_raise(TaskStatus.FINALIZING, TaskStatus.COMPLETED))
- await self.hub.emit(
+ await self._emit_event(
task_id,
"TASK_COMPLETED",
{"taskId": task_id, "progress": 100, "reportPath": md_path, "bibPath": bib_path},
)
except InvalidStateTransition as exc:
self.repository.update_status(task_id, TaskStatus.FAILED, last_error=str(exc))
- await self.hub.emit(task_id, "ERROR", {"taskId": task_id, "error": str(exc)})
+ await self._emit_event(task_id, "ERROR", {"taskId": task_id, "error": str(exc)})
except Exception as exc: # noqa: BLE001
self.repository.update_status(task_id, TaskStatus.FAILED, last_error=str(exc))
- await self.hub.emit(task_id, "ERROR", {"taskId": task_id, "error": f"Unhandled error: {exc}"})
+ await self._emit_event(task_id, "ERROR", {"taskId": task_id, "error": f"Unhandled error: {exc}"})
diff --git a/backend/app/services/retrieval.py b/backend/app/services/retrieval.py
index 5d583d1..b2830a1 100644
--- a/backend/app/services/retrieval.py
+++ b/backend/app/services/retrieval.py
@@ -1,17 +1,24 @@
from __future__ import annotations
import asyncio
+import logging
+import re
from collections import OrderedDict
from datetime import UTC, datetime, timedelta
from hashlib import sha1
+from urllib.parse import urlparse
+from xml.etree import ElementTree
import httpx
from app.core.config import settings
+from app.core.utils import now_iso
from app.core.utils import new_id
from app.models.schemas import Evidence, EvidenceMetadata, ExtractedData, SourceType
from app.services.retry import retry_async
+logger = logging.getLogger(__name__)
+
class L1EvidenceCache:
def __init__(self, max_size: int = 1000, ttl_seconds: int = 3600) -> None:
@@ -38,6 +45,8 @@ def set(self, key: str, value: list[Evidence]) -> None:
class RetrievalService:
+ _PLACEHOLDER_HOSTS = {"example.org", "example.com", "localhost", "127.0.0.1", "httpbin.org"}
+
def __init__(self) -> None:
self.cache = L1EvidenceCache()
@@ -58,9 +67,13 @@ async def retrieve(self, *, task_id: str, node_id: str, query: str, sources: lis
@staticmethod
def expand_query(query: str) -> str:
term = query.strip()
- year_part = "(2026 OR 2025 OR 2024)"
- verb_part = "(analyze OR improve OR evaluate)"
- return f"({term} OR {term} review) AND {year_part} AND {verb_part}"
+ year = datetime.now(tz=UTC).year
+ year_part = f"({year} OR {year - 1} OR {year - 2})"
+ if any(ord(ch) > 127 for ch in term):
+ focus_part = f"({term})"
+ else:
+ focus_part = f"({term} OR {term} review)"
+ return f"{focus_part} AND {year_part}"
async def _mock_retrieve(self, *, task_id: str, node_id: str, query: str, sources: list[str]) -> list[Evidence]:
await asyncio.sleep(0.05)
@@ -72,13 +85,13 @@ async def _mock_retrieve(self, *, task_id: str, node_id: str, query: str, source
taskId=task_id,
nodeId=node_id,
sourceType=SourceType.PAPER,
- url=f"https://example.org/paper/{node_id}",
- content=f"Mock evidence generated for query: {query}",
+ url=f"mock://paper/{node_id}",
+ content=f"[MOCK] Synthetic evidence for query: {query}",
metadata=EvidenceMetadata(
authors=["Mock Author"],
publishDate="2025-01-01T00:00:00Z",
- title=f"{source} result for {query[:40]}",
- abstract="This is a mock abstract.",
+ title=f"[MOCK] {source} result for {query[:40]}",
+ abstract="[MOCK] This abstract is synthetic and for test mode only.",
impactFactor=5.2,
isPeerReviewed=True,
relevanceScore=synthetic_metric,
@@ -87,19 +100,105 @@ async def _mock_retrieve(self, *, task_id: str, node_id: str, query: str, source
score=synthetic_metric,
extractedData=ExtractedData(
tables=[{"caption": "Sample table", "data": {"rows": 3}}],
- images=[{"caption": "Sample figure", "url": "https://example.org/img/1.png"}],
+ images=[{"caption": "Sample figure", "url": "mock://img/1.png"}],
numericalValues=[{"value": synthetic_metric, "unit": "score", "context": "relevance"}],
),
)
]
async def _real_retrieve(self, *, task_id: str, node_id: str, query: str, sources: list[str]) -> list[Evidence]:
- normalized_sources = [s.lower() for s in sources] if sources else ["tavily"]
- if "tavily" in normalized_sources and settings.tavily_api_key:
- tavily_results = await self._retrieve_from_tavily(task_id=task_id, node_id=node_id, query=query)
- if tavily_results:
- return tavily_results
- return await self._fallback_web_retrieve(task_id=task_id, node_id=node_id, query=query, sources=sources)
+ normalized_sources = [self._normalize_source_name(s) for s in sources] if sources else []
+ if not normalized_sources:
+ normalized_sources = ["tavily", "arxiv", "semanticscholar"]
+
+ provider_calls: list[tuple[str, asyncio.Future]] = []
+ for source in normalized_sources:
+ if source == "tavily":
+ if settings.tavily_api_key:
+ provider_calls.append(
+ (
+ source,
+ asyncio.create_task(
+ self._safe_provider_call(
+ source,
+ self._retrieve_from_tavily,
+ task_id=task_id,
+ node_id=node_id,
+ query=query,
+ )
+ ),
+ )
+ )
+ continue
+ if source == "arxiv":
+ provider_calls.append(
+ (
+ source,
+ asyncio.create_task(
+ self._safe_provider_call(
+ source,
+ self._retrieve_from_arxiv,
+ task_id=task_id,
+ node_id=node_id,
+ query=query,
+ )
+ ),
+ )
+ )
+ continue
+ if source == "semanticscholar":
+ provider_calls.append(
+ (
+ source,
+ asyncio.create_task(
+ self._safe_provider_call(
+ source,
+ self._retrieve_from_semantic_scholar,
+ task_id=task_id,
+ node_id=node_id,
+ query=query,
+ )
+ ),
+ )
+ )
+
+ if not provider_calls and settings.tavily_api_key:
+ provider_calls.append(
+ (
+ "tavily",
+ asyncio.create_task(
+ self._safe_provider_call(
+ "tavily",
+ self._retrieve_from_tavily,
+ task_id=task_id,
+ node_id=node_id,
+ query=query,
+ )
+ ),
+ )
+ )
+
+ gathered: list[Evidence] = []
+ for _, provider_task in provider_calls:
+ gathered.extend(await provider_task)
+
+ valid = self._validate_evidences(gathered, allow_mock=False)
+ return self._dedupe_by_url(valid)
+
+ async def _safe_provider_call(
+ self,
+ provider_name: str,
+ provider_func,
+ *,
+ task_id: str,
+ node_id: str,
+ query: str,
+ ) -> list[Evidence]:
+ try:
+ return await provider_func(task_id=task_id, node_id=node_id, query=query)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("Provider '%s' failed: %s", provider_name, exc)
+ return []
async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str) -> list[Evidence]:
async with httpx.AsyncClient(timeout=20) as client:
@@ -112,7 +211,7 @@ async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str)
"search_depth": "advanced",
"max_results": 5,
"include_answer": False,
- "include_raw_content": False,
+ "include_raw_content": True,
},
),
max_attempts=3,
@@ -141,7 +240,7 @@ async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str)
content=content,
metadata=EvidenceMetadata(
authors=[],
- publishDate=str(item.get("published_date") or "2026-01-01T00:00:00Z"),
+ publishDate=str(item.get("published_date") or now_iso()),
title=title,
abstract=content[:500],
impactFactor=0,
@@ -163,43 +262,218 @@ async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str)
)
return evidences
- async def _fallback_web_retrieve(
- self, *, task_id: str, node_id: str, query: str, sources: list[str]
- ) -> list[Evidence]:
- if not sources:
- sources = ["web"]
- results: list[Evidence] = []
- async with httpx.AsyncClient(timeout=10) as client:
- for source in sources[:2]:
- url = f"https://httpbin.org/anything/{source}"
- resp = await retry_async(
- lambda: client.get(url, params={"q": query}),
- max_attempts=3,
- base_delay_seconds=0.5,
+ async def _retrieve_from_arxiv(self, *, task_id: str, node_id: str, query: str) -> list[Evidence]:
+ search_query = f"all:{self._keyword_query_for_paper_apis(query)}"
+ params = {
+ "search_query": search_query,
+ "start": 0,
+ "max_results": 5,
+ "sortBy": "relevance",
+ "sortOrder": "descending",
+ }
+ async with httpx.AsyncClient(timeout=20) as client:
+ resp = await retry_async(
+ lambda: client.get("https://export.arxiv.org/api/query", params=params),
+ max_attempts=3,
+ base_delay_seconds=0.7,
+ )
+ assert isinstance(resp, httpx.Response)
+ resp.raise_for_status()
+ payload = resp.text
+
+ root = ElementTree.fromstring(payload)
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
+ entries = root.findall("atom:entry", ns)
+ evidences: list[Evidence] = []
+ for idx, entry in enumerate(entries):
+ title = self._read_xml_text(entry, "atom:title", ns)
+ summary = self._read_xml_text(entry, "atom:summary", ns)
+ if not title and not summary:
+ continue
+ url = self._read_xml_text(entry, "atom:id", ns)
+ if url.startswith("http://"):
+ url = "https://" + url[len("http://") :]
+ published = self._read_xml_text(entry, "atom:published", ns) or now_iso()
+ authors = [
+ name.text.strip()
+ for name in entry.findall("atom:author/atom:name", ns)
+ if name.text and name.text.strip()
+ ]
+ rank_score = max(0.45, round(0.9 - idx * 0.08, 3))
+ evidences.append(
+ Evidence(
+ id=new_id(),
+ taskId=task_id,
+ nodeId=node_id,
+ sourceType=SourceType.PAPER,
+ url=url,
+ content=summary or title,
+ metadata=EvidenceMetadata(
+ authors=authors,
+ publishDate=published,
+ title=title or "arXiv paper",
+ abstract=summary[:500] if summary else "",
+ impactFactor=0,
+ isPeerReviewed=False,
+ relevanceScore=rank_score,
+ citationCount=0,
+ ),
+ score=rank_score,
+ extractedData=ExtractedData(),
)
- assert isinstance(resp, httpx.Response)
- resp.raise_for_status()
- payload = resp.json()
- results.append(
- Evidence(
- id=new_id(),
- taskId=task_id,
- nodeId=node_id,
- sourceType=SourceType.WEB,
- url=url,
- content=f"Fetched payload echo for source={source}.",
- metadata=EvidenceMetadata(
- authors=[],
- publishDate="2026-01-01T00:00:00Z",
- title=f"Web result ({source})",
- abstract=str(payload)[:300],
- impactFactor=0,
- isPeerReviewed=False,
- relevanceScore=0.65,
- citationCount=0,
- ),
- score=0.65,
- extractedData=ExtractedData(),
- )
+ )
+ return evidences
+
+ async def _retrieve_from_semantic_scholar(self, *, task_id: str, node_id: str, query: str) -> list[Evidence]:
+ paper_query = self._keyword_query_for_paper_apis(query)
+ params = {
+ "query": paper_query,
+ "limit": 5,
+ "fields": "title,abstract,authors,year,url,publicationDate,citationCount,paperId,openAccessPdf",
+ }
+ async with httpx.AsyncClient(timeout=20) as client:
+ resp = await retry_async(
+ lambda: client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params),
+ max_attempts=3,
+ base_delay_seconds=0.8,
+ )
+ assert isinstance(resp, httpx.Response)
+ resp.raise_for_status()
+ payload = resp.json()
+
+ results = payload.get("data", [])
+ evidences: list[Evidence] = []
+ for idx, item in enumerate(results):
+ abstract = str(item.get("abstract") or "").strip()
+ title = str(item.get("title") or "Semantic Scholar paper").strip()
+ if not abstract and not title:
+ continue
+ authors = [str(author.get("name", "")).strip() for author in item.get("authors", []) if author.get("name")]
+ year = item.get("year")
+ publication_date = str(item.get("publicationDate") or "").strip()
+ if not publication_date and isinstance(year, int):
+ publication_date = f"{year}-01-01T00:00:00Z"
+ if not publication_date:
+ publication_date = now_iso()
+ url = str(item.get("url") or "").strip()
+ if not url:
+ open_pdf = item.get("openAccessPdf") or {}
+ url = str(open_pdf.get("url") or "").strip()
+ if not url:
+ paper_id = str(item.get("paperId") or "").strip()
+ if paper_id:
+ url = f"https://www.semanticscholar.org/paper/{paper_id}"
+
+ citation_count = int(item.get("citationCount") or 0)
+ rank_bonus = max(0.0, 0.15 - idx * 0.03)
+ score = max(0.45, min(0.95, round(0.52 + min(citation_count, 400) / 1200 + rank_bonus, 3)))
+
+ evidences.append(
+ Evidence(
+ id=new_id(),
+ taskId=task_id,
+ nodeId=node_id,
+ sourceType=SourceType.PAPER,
+ url=url,
+ content=abstract or title,
+ metadata=EvidenceMetadata(
+ authors=authors,
+ publishDate=publication_date,
+ title=title,
+ abstract=(abstract or title)[:500],
+ impactFactor=0,
+ isPeerReviewed=False,
+ relevanceScore=score,
+ citationCount=citation_count,
+ ),
+ score=score,
+ extractedData=ExtractedData(
+ numericalValues=[
+ {
+ "value": float(citation_count),
+ "unit": "citations",
+ "context": "semantic_scholar_citation_count",
+ }
+ ]
+ ),
)
- return results
+ )
+ return evidences
+
+ @classmethod
+ def _normalize_source_name(cls, source: str) -> str:
+ lowered = source.strip().lower().replace("-", "").replace("_", "").replace(" ", "")
+ if lowered in {"arxiv", "arxivorg"}:
+ return "arxiv"
+ if lowered in {"semanticscholar", "s2"}:
+ return "semanticscholar"
+ if lowered == "tavily":
+ return "tavily"
+ return lowered
+
+ @classmethod
+ def _is_placeholder_url(cls, url: str) -> bool:
+ parsed = urlparse(url)
+ hostname = (parsed.hostname or "").lower()
+ return hostname in cls._PLACEHOLDER_HOSTS
+
+ @staticmethod
+ def _clean_text(content: str) -> str:
+ return " ".join(content.split())
+
+ @staticmethod
+ def _read_xml_text(entry, path: str, ns: dict[str, str]) -> str:
+ item = entry.find(path, ns)
+ return item.text.strip() if item is not None and item.text else ""
+
+ @staticmethod
+ def _keyword_query_for_paper_apis(query: str) -> str:
+ cleaned_query = re.sub(r"[\(\)]", " ", query)
+ ascii_tokens = re.findall(r"[A-Za-z][A-Za-z0-9\-_]{1,}", cleaned_query)
+ stop_tokens = {"and", "or", "review", "analyze", "improve", "evaluate"}
+ picked = [token for token in ascii_tokens if token.lower() not in stop_tokens]
+ if picked:
+ base = " ".join(picked[:8])
+ if any(word in query for word in ["软件", "工程", "开发", "代码"]):
+ base += " software engineering"
+ if "测试" in query:
+ base += " testing"
+ if "挑战" in query:
+ base += " challenges"
+ return base.strip()
+ return "artificial intelligence agent software engineering"
+
+ @classmethod
+ def _validate_evidences(cls, evidences: list[Evidence], *, allow_mock: bool) -> list[Evidence]:
+ valid: list[Evidence] = []
+ for ev in evidences:
+ parsed = urlparse(ev.url)
+ if not ev.url:
+ continue
+ if allow_mock and parsed.scheme == "mock":
+ valid.append(ev)
+ continue
+ if parsed.scheme not in {"http", "https"}:
+ continue
+ if cls._is_placeholder_url(ev.url):
+ continue
+ cleaned = cls._clean_text(ev.content)
+ if len(cleaned) < 30:
+ continue
+ ev.content = cleaned
+ if not ev.metadata.title:
+ ev.metadata.title = ev.url
+ valid.append(ev)
+ return valid
+
+ @staticmethod
+ def _dedupe_by_url(evidences: list[Evidence]) -> list[Evidence]:
+ deduped: list[Evidence] = []
+ seen: set[str] = set()
+ for ev in evidences:
+ key = ev.url.rstrip("/")
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(ev)
+ return deduped
diff --git a/backend/app/services/writer.py b/backend/app/services/writer.py
index c5e6274..17974eb 100644
--- a/backend/app/services/writer.py
+++ b/backend/app/services/writer.py
@@ -1,6 +1,9 @@
from __future__ import annotations
+from collections import defaultdict
+from dataclasses import dataclass
from pathlib import Path
+import re
import httpx
@@ -8,7 +11,20 @@
from app.models.schemas import Citation, Evidence
+@dataclass(frozen=True)
+class ReportBlueprint:
+ output_format: str
+ objective: str
+ tone: str
+ section_titles: list[str]
+
+
class WriterService:
+ URL_PATTERN = re.compile(r"https?://\S+")
+ PLACEHOLDER_TITLE_PATTERN = re.compile(
+ r"(?i)(^\[mock\]|result\s+for|synthetic evidence|semantic scholar result|arxiv result|web result)"
+ )
+
def __init__(self, output_dir: str = "backend/.data/reports") -> None:
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
@@ -18,27 +34,33 @@ def write_report(
*,
task_id: str,
task_title: str,
+ task_description: str = "",
sections: list[tuple[str, str]],
evidences: list[Evidence],
locked_sections: set[str] | None = None,
+ blueprint: ReportBlueprint | None = None,
+ report_body: str | None = None,
) -> tuple[str, str, dict[str, Citation]]:
- locked_sections = locked_sections or set()
+ _ = locked_sections
+ blueprint = blueprint or self._default_blueprint()
citation_map = self._build_citations(evidences)
- generated_body = self._generate_body(task_title=task_title, sections=sections, evidences=evidences)
+ if report_body is None:
+ generated_body = self.generate_body(
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+ else:
+ generated_body = report_body
lines = [f"# {task_title}", "", f"_taskId: {task_id}_", ""]
lines.extend(generated_body.splitlines())
lines.append("")
- # Keep section-to-task traceability even when LLM text is used.
- for idx, (section_id, content) in enumerate(sections, start=1):
- lines.append(f"## Trace Section {idx}")
- if section_id in locked_sections:
- lines.append(f"[LOCKED] {content}")
- else:
- lines.append(content)
- lines.append("")
-
+ lines.extend(self._build_evidence_appendix(evidences).splitlines())
+ lines.append("")
lines.append("## References")
for i, cid in enumerate(citation_map, start=1):
c = citation_map[cid]
@@ -65,26 +87,119 @@ def write_report(
bib_path.write_text("\n".join(bib_lines), encoding="utf-8")
return str(md_path), str(bib_path), citation_map
- def _generate_body(self, *, task_title: str, sections: list[tuple[str, str]], evidences: list[Evidence]) -> str:
- if not settings.use_mock_sources:
- llm_text = self._generate_with_llm(task_title=task_title, sections=sections, evidences=evidences)
- if llm_text:
- return llm_text
- return self._generate_template(task_title=task_title, sections=sections, evidences=evidences)
+ def generate_body(
+ self,
+ *,
+ task_title: str,
+ task_description: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ blueprint: ReportBlueprint | None = None,
+ ) -> str:
+ selected_blueprint = blueprint or self._default_blueprint()
+ return self._generate_body(
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ blueprint=selected_blueprint,
+ )
+
+ def generate_template_body(
+ self,
+ *,
+ task_title: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ blueprint: ReportBlueprint | None = None,
+ ) -> str:
+ selected_blueprint = blueprint or self._default_blueprint()
+ return self._generate_template(
+ task_title=task_title,
+ sections=sections,
+ evidences=evidences,
+ blueprint=selected_blueprint,
+ )
+
+ def _generate_body(
+ self,
+ *,
+ task_title: str,
+ task_description: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ blueprint: ReportBlueprint,
+ ) -> str:
+ evidence_rich_template = self._generate_template(
+ task_title=task_title,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+ if settings.use_mock_sources:
+ return evidence_rich_template
+
+ llm_text = self._generate_with_llm(
+ task_title=task_title,
+ task_description=task_description,
+ sections=sections,
+ evidences=evidences,
+ blueprint=blueprint,
+ )
+ if not llm_text:
+ return evidence_rich_template
+ sanitized = self._strip_inline_urls(llm_text.strip())
+ return "\n".join(
+ [
+ "## AI 综合解读",
+ sanitized,
+ "",
+ evidence_rich_template,
+ ]
+ )
- def _generate_with_llm(self, *, task_title: str, sections: list[tuple[str, str]], evidences: list[Evidence]) -> str:
+ def _generate_with_llm(
+ self,
+ *,
+ task_title: str,
+ task_description: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ blueprint: ReportBlueprint,
+ ) -> str:
base_url, api_key, model = self._resolve_provider()
if not base_url or not api_key:
return ""
evidence_snippets = "\n".join(
- f"- {ev.metadata.title}: {ev.content[:300]} (url={ev.url})" for ev in evidences[:12]
+ (
+ f"- [{ev.id}] {self._display_title(ev)} | 类型:{ev.sourceType.value} | "
+ f"时间:{ev.metadata.publishDate or '未知'}\n"
+ f" 摘要:{self._compact_text(ev.content, 320)}"
+ )
+ for ev in evidences[:18]
+ )
+ section_snippets = "\n".join(
+ f"- {self._compact_text(content, 260)}" for _, content in sections[:12]
)
- section_snippets = "\n".join(f"- {content[:180]}" for _, content in sections[:8])
+ blueprint_snippets = "\n".join(f"- {title}" for title in blueprint.section_titles)
prompt = (
f"研究题目:{task_title}\n"
- "请生成中文研究报告,包含:摘要、背景、关键发现、方法与证据、局限性、结论与后续建议。\n"
- "保持结构化、客观、可读性强。不要编造不存在的数据。\n\n"
+ f"任务背景与用户要求:{task_description[:1200]}\n"
+ f"输出体裁:{blueprint.output_format}\n"
+ f"写作目标:{blueprint.objective}\n"
+ f"风格要求:{blueprint.tone}\n"
+ "请按如下章节生成正文:\n"
+ f"{blueprint_snippets}\n"
+ "保持结构化、客观、可读性强。禁止编造不存在的数据、论文、链接。\n"
+ "深度与广度要求:\n"
+ "1) 正文总字数不少于 2200 字。\n"
+ "2) 除摘要/结论章节外,每个章节至少 2 段,每段不少于 120 字。\n"
+ "3) 每个章节至少覆盖 3 个维度:现状、驱动机制、影响评估、风险边界、落地策略。\n"
+ "4) 每个章节至少引用 2 个不同证据 ID;证据不足时须明确写出缺口与补充方向。\n"
+ "5) 结论必须给出可执行动作、优先级与前置条件。\n"
+ "每个关键结论必须引用至少一个证据ID(例如 [evidence:xxxx])。\n"
+ "正文中不要放证据网址,不要输出参考文献与证据附录,网址统一在文末追加。\n\n"
f"任务分段信息:\n{section_snippets}\n\n"
f"证据片段:\n{evidence_snippets}\n"
)
@@ -126,29 +241,174 @@ def _resolve_provider(self) -> tuple[str, str, str]:
return settings.openai_base_url, settings.openai_api_key, settings.openai_model
return "", "", ""
- @staticmethod
- def _generate_template(*, task_title: str, sections: list[tuple[str, str]], evidences: list[Evidence]) -> str:
- top = evidences[:5]
+ def _generate_template(
+ self,
+ *,
+ task_title: str,
+ sections: list[tuple[str, str]],
+ evidences: list[Evidence],
+ blueprint: ReportBlueprint,
+ ) -> str:
+ ranked = sorted(evidences, key=lambda item: item.score, reverse=True)
+ top = ranked[:8]
+ source_types = sorted({ev.sourceType.value for ev in ranked})
lines = [
- "## 摘要",
- f"本文围绕“{task_title}”进行快速研究,总结了当前公开资料中的关键观点与证据。",
+ "## 输出格式",
+ f"体裁:{blueprint.output_format}",
+ f"目标:{blueprint.objective}",
+ f"风格:{blueprint.tone}",
"",
- "## 关键发现",
]
- for idx, ev in enumerate(top, start=1):
- lines.append(f"{idx}. {ev.metadata.title}(score={ev.score:.2f})")
- lines.extend(["", "## 分析说明"])
- for _, section_content in sections[:4]:
- lines.append(f"- {section_content[:220]}")
- lines.extend(
- [
- "",
- "## 结论",
- "当前证据已覆盖主要趋势,但仍需针对高争议结论补充更高质量、可复现实验的数据。",
- ]
- )
+ section_to_evidence: dict[str, list[Evidence]] = defaultdict(list)
+ for ev in ranked:
+ section_to_evidence[ev.nodeId].append(ev)
+
+ used_ids: set[str] = set()
+ for idx, heading in enumerate(blueprint.section_titles):
+ lines.append(f"## {heading}")
+ if idx == 0:
+ ref_summary = "、".join(f"[evidence:{ev.id}]" for ev in top[:4]) if top else "无"
+ lines.append(
+ f"围绕“{task_title}”共纳入 {len(ranked)} 条证据,来源类型:"
+ f"{', '.join(source_types) or '无'}。"
+ )
+ lines.append(
+ "本报告从问题背景、机制解释、影响评估、风险边界与行动路径五个层面展开,"
+ "优先基于可追溯证据构建结论链条,避免仅停留在概念罗列。"
+ )
+ if top:
+ lines.append(f"高优先证据线索:{ref_summary}")
+ else:
+ lines.append("当前没有检索到可用证据,请检查数据源配置与网络连通性后重试。")
+ lines.append("")
+ continue
+
+ if not sections:
+ lines.append(
+ "暂无可用任务分段,当前仅能给出高层研判。建议先补充分解:问题定义、"
+ "关键变量、评估指标、对照样本,再回填到本章节。"
+ )
+ lines.append(
+ "在缺少结构化分段时,本节仍应覆盖成因解释、影响范围和可执行动作,"
+ "并明确当前结论的可信度边界。"
+ )
+ lines.append("")
+ continue
+
+ section_idx = min(idx - 1, len(sections) - 1)
+ section_id, section_content = sections[section_idx]
+ section_lines = [line.strip() for line in section_content.splitlines() if line.strip()]
+ section_title = section_lines[0] if section_lines else section_id
+ section_desc = section_lines[1] if len(section_lines) > 1 else section_content.strip()
+ matched = section_to_evidence.get(section_id, [])[:3]
+ if not matched:
+ matched = [ev for ev in ranked if ev.id not in used_ids][:3]
+ if matched:
+ evidence_details: list[str] = []
+ for ev in matched:
+ used_ids.add(ev.id)
+ evidence_details.append(
+ f"{self._display_title(ev)} 指出“{self._compact_text(ev.content, 95)}”"
+ f" [evidence:{ev.id}]"
+ )
+ lines.append(
+ f"研究问题:{section_title}。本节围绕“{self._compact_text(section_desc, 260)}”展开,"
+ "重点刻画问题定义、驱动因素、关键影响与可行约束。"
+ )
+ lines.append(
+ f"证据解读:{';'.join(evidence_details)}。以上证据共同支持本节判断,"
+ "同时提示结论需结合场景差异进行校准。"
+ )
+ else:
+ lines.append(
+ f"研究问题:{section_title}。当前尚未命中该分段的直接证据,"
+ "建议扩展检索词并增加跨来源验证,以避免单一视角偏差。"
+ )
+ lines.append(self._section_focus_hint(heading))
+ lines.append("")
+
+ leftovers = [ev for ev in ranked if ev.id not in used_ids][:4]
+ if leftovers:
+ lines.append("## 补充线索")
+ for ev in leftovers:
+ lines.append(
+ f"- {self._display_title(ev)}:{self._compact_text(ev.content, 120)} "
+ f"[evidence:{ev.id}]"
+ )
+ lines.append("")
+
return "\n".join(lines)
+ @staticmethod
+ def _section_focus_hint(heading: str) -> str:
+ if "方法" in heading or "范围" in heading:
+ return (
+ "方法与范围:明确样本覆盖、数据时效与对照口径,补充可复现实验步骤,"
+ "避免仅用定性描述替代可验证过程。"
+ )
+ if "背景" in heading:
+ return (
+ "背景延展:说明该问题在产业、技术与政策层面的演化脉络,"
+ "并区分长期趋势与短期波动,减少时点偏差。"
+ )
+ if "发现" in heading:
+ return (
+ "关键发现:将共识点与争议点并列呈现,分别标注证据强度,"
+ "并解释不同来源出现结论分歧的潜在原因。"
+ )
+ if "风险" in heading or "局限" in heading:
+ return (
+ "风险与局限:识别证据覆盖盲区、外部变量干扰与实施前提缺失,"
+ "同时给出最可能导致结论失效的边界条件。"
+ )
+ if "结论" in heading or "建议" in heading:
+ return (
+ "行动建议:按短期(1-3个月)、中期(3-12个月)拆分任务优先级,"
+ "给出落地前置条件、负责人角色与效果评估指标。"
+ )
+ return (
+ "综合分析:从机制解释、影响传播与实施可行性三个层面推进论证,"
+ "并对关键假设进行显式标注,便于后续复核。"
+ )
+
+ @staticmethod
+ def _compact_text(text: str, limit: int) -> str:
+ compacted = " ".join(text.split()).strip()
+ if not compacted:
+ return "暂无内容。"
+ return compacted[:limit]
+
+ def _build_evidence_appendix(self, evidences: list[Evidence]) -> str:
+ ranked = sorted(evidences, key=lambda item: item.score, reverse=True)
+ lines = ["## 证据说明与来源链接"]
+ if not ranked:
+ lines.append("暂无可用证据。")
+ return "\n".join(lines)
+
+ for idx, ev in enumerate(ranked, start=1):
+ snippet = " ".join(ev.content.split())
+ lines.append(f"{idx}. [{ev.id}] {self._display_title(ev)}")
+ lines.append(f"说明:{snippet[:220] or '该来源未返回可展示摘要。'}")
+ lines.append(
+ f"来源:{ev.sourceType.value} | 发表时间:{ev.metadata.publishDate or '未知'} | 评分:{ev.score:.2f}"
+ )
+ lines.append(f"网址:{ev.url}")
+ lines.append("")
+ return "\n".join(lines).rstrip()
+
+ @classmethod
+ def _strip_inline_urls(cls, text: str) -> str:
+ return cls.URL_PATTERN.sub("[链接见文末证据附录]", text)
+
+ @staticmethod
+ def _default_blueprint() -> ReportBlueprint:
+ return ReportBlueprint(
+ output_format="研究报告",
+ objective="给出跨维度、可复核且可执行的研究结论",
+ tone="客观中立、论证充分、信息密集",
+ section_titles=["摘要", "研究范围与方法", "背景", "关键发现", "分析", "结论与建议"],
+ )
+
def _build_citations(self, evidences: list[Evidence]) -> dict[str, Citation]:
citations: dict[str, Citation] = {}
for ev in evidences:
@@ -158,9 +418,23 @@ def _build_citations(self, evidences: list[Evidence]) -> dict[str, Citation]:
citations[ev.id] = Citation(
id=ev.id,
authors=ev.metadata.authors or ["Unknown"],
- title=ev.metadata.title,
+ title=self._display_title(ev),
year=year,
source=ev.sourceType.value,
url=ev.url,
)
return citations
+
+ @classmethod
+ def _display_title(cls, evidence: Evidence) -> str:
+ raw_title = " ".join(evidence.metadata.title.split()).strip()
+ if raw_title and not cls._looks_placeholder_title(raw_title):
+ return raw_title
+ fallback = " ".join(evidence.content.split()).strip()
+ if fallback:
+ return fallback[:120]
+ return "未命名证据"
+
+ @classmethod
+ def _looks_placeholder_title(cls, title: str) -> bool:
+ return bool(cls.PLACEHOLDER_TITLE_PATTERN.search(title.strip()))
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 188a60b..4274f86 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -10,6 +10,7 @@ requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn>=0.30.0",
+ "websockets>=12.0",
"pydantic>=2.9.0",
"pydantic-settings>=2.5.0"
]
diff --git a/doc.md b/doc.md
index ecd8414..cd66808 100644
--- a/doc.md
+++ b/doc.md
@@ -1,501 +1,653 @@
-# **Deep Research 深度科研辅助系统总体设计规格说明书**
+# Deep Research 实现级技术说明
-## **1. 系统综述与核心架构**
+> 更新时间:2026-02-21(按仓库当前实现整理)
-### **1.1 设计主旨**
+---
-本系统之构建,旨在矫正传统科研检索工具所固有之"黑箱化"运作模式及"浅层化"信息处理弊端。其核心技术差异性体现于以下三点:
+## 1. 项目定位
-* **白盒推理机制 (White-box Reasoning)**:系统推理逻辑需完全透明化,允许操作端实时监视并干预任务规划思维链(CoT)。
-* **实验生态闭环 (Experimental Loop)**:藉由 MCP 协议突破纯文本处理之局限,实现本地数据与计算工具的连接与调用。
-* **语义对齐校验 (Semantic Alignment)**:引入严谨的数值冲突检测算法与语义数值对齐(SNA)机制,以确保数据的一致性。
+本项目是一个本地单用户的“研究会话 + 任务执行引擎”系统,核心目标是:
+- 用会话驱动研究任务;
+- 让用户可编辑计划、可查看进度、可改写最终报告;
+- 把检索、分析、写作串成可复跑的流水线。
-### **1.2 总体架构图 (System Architecture)**
+当前实现重点是端到端闭环,非生产多租户平台。
-本系统架构采行分层设计原则,以确立推理逻辑、信息检索与任务执行之解耦。
+### 1.1 这个项目能做什么
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ 交互层 (Interaction Layer) │
-│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
-│ │ CoT 编辑器 │ │ 实时证据看板 │ │ Markdown 分屏预览 │ │
-│ └─────────────┘ └──────────────┘ └──────────────────────────┘ │
-├─────────────────────────────────────────────────────────────────┤
-│ 编排层 (Orchestration Layer) │
-│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │
-│ │ 主规划器 (Master Planner)│ │ 状态管理器 (State Manager) │ │
-│ │ - DAG 任务图谱管理 │ │ - FSM 状态机 │ │
-│ │ - 动态任务调度 │ │ - 上下文快照 │ │
-│ └─────────────────────────┘ └──────────────────────────────┘ │
-├─────────────────────────────────────────────────────────────────┤
-│ 代理层 (Agent Layer) │
-│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
-│ │ 检索代理 │ │ 分析代理 │ │MCP执行代理 │ │ 写作代理 │ │
-│ └───────────┘ └───────────┘ └───────────┘ └───────────────┘ │
-├─────────────────────────────────────────────────────────────────┤
-│ 基础设施层 (Infrastructure) │
-│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │
-│ │ 向量数据库 │ │ MCP 服务器集群 │ │ 本地文件系统接口 │ │
-│ │ (L2 缓存) │ │ │ │ │ │
-│ └─────────────┘ └─────────────┘ └──────────────────────────┘ │
-└─────────────────────────────────────────────────────────────────┘
-```
+从用户视角看,Deep Research 不是单次问答工具,而是一个“可持续推进的研究工作台”。它可以完成以下工作:
-* **交互层 (Interaction Layer)**:涵盖思维链(CoT)编辑器、实时证据流监控看板及 Markdown 分屏预览界面。
-* **编排层 (Orchestration Layer)**:
- * **主规划器 (Master Planner)**:负责动态有向无环图(DAG)任务图谱的管理与调度。
- * **状态管理器 (State Manager)**:维护全局有限状态机与上下文快照。
-* **代理层 (Agent Layer)**:
- * 检索代理 (Retrieval Agent):执行全域信息的捕获。
- * 分析代理 (Analyst Agent):执行数据质量控制与审查。
- * MCP 执行代理 (MCP Executor):执行外部工具调用。
- * 写作代理 (Writer Agent):执行知识的结构化合成。
-* **基础设施层 (Infrastructure)**:集成向量数据库(L2 缓存)、MCP 服务器集群及本地文件系统接口。
+1. 把一个研究主题转成可执行计划。
+用户给出主题后,系统会自动生成第一版研究方案(Markdown + front matter),并允许用户继续细化范围、深度、数据源和优先级。
----
+2. 把计划转成实际执行。
+系统会基于计划参数创建任务,自动拆解为节点 DAG,并逐节点检索证据、记录过程、生成快照。
-## **2. 技术选型与依赖**
+3. 把执行过程透明化。
+研究中的阶段、节点、检索查询、进度百分比、证据条目会实时回流到会话时间线,用户可以看到“现在进行到哪一步、做了什么”。
-### **2.1 核心技术栈**
+4. 把证据转成结构化报告。
+系统会汇总证据、做冲突检测、完成报告写作,并落盘为 `.md + .bib`,可直接下载。
-| 模块 | 技术选型 | 版本要求 | 说明 |
-|------|----------|----------|------|
-| 后端框架 | Python / FastAPI | ≥3.11, ≥0.104 | 异步支持,自动 API 文档 |
-| LLM 接口 | OpenAI API / Anthropic API | - | GPT-4 / Claude Opus |
-| 向量数据库 | Qdrant | ≥1.7 | 支持本地部署,高性能相似度搜索 |
-| 文档解析 | Readability /trafilatura | ≥1.12 | 网页正文提取 |
-| 状态管理 | Redis | ≥7.0 | FSM 状态持久化 |
-| 任务队列 | Celery + Redis | ≥5.3 | DAG 任务调度 |
-| 前端框架 | React + TypeScript | ≥18 | 交互界面开发 |
-| 构建工具 | Vite | ≥5.0 | 快速开发构建 |
+5. 把“后续需求”纳入同一会话闭环。
+研究完成后,用户可以继续发指令:改方案、重跑研究、改写报告(如改成演讲稿)。系统会根据意图自动路由到相应流程。
-### **2.2 外部依赖服务**
+### 1.2 它是怎么做的(高层机制)
-| 服务 | 用途 | API 文档 |
-|------|------|----------|
-| Serper / Google Search | 网页检索 | https://serper.dev/api |
-| arXiv API | 学术论文检索 | https://arxiv.org/help/api |
-| Semantic Scholar | 论文元数据增强 | https://api.semanticscholar.org |
-| CrossRef | DOI 解析 | https://www.crossref.org/services/api |
+项目核心是“会话编排层 + 执行引擎层”的双层结构:
-## **3. 核心代理(Agent)技术规范**
+- 会话编排层(`ConversationAgent`)负责“理解用户当前要什么”。
+同样是“请调整一下”这种输入,系统会根据上下文与关键词判断你是在改方案、要重跑,还是只改报告,并选择不同处理路径。
-### **3.1 首席任务规划代理 (Master Planner Agent)**
+- 执行引擎层(`ExecutionEngine`)负责“把研究做完”。
+引擎按状态机推进任务,从规划、检索、分析到写作逐步执行,并通过事件把过程同步回前端与会话消息。
-该模组之职能在于将抽象的科研目标转化为可执行的有向无环图 (DAG)。
+这意味着系统既有“聊天交互能力”,也有“工程化流水线能力”,两者通过统一数据模型(Task/Conversation/Evidence/Conflict)连接起来。
-* **任务拆解策略**:
- * **广度优先搜索 (BFS)**:于初始阶段展开首级子课题(涵盖背景、现状及挑战等维度)。
- * **深度优先搜索 (DFS)**:依据检索反馈结果进行垂直领域的深度挖掘。
- * **动态剪枝算法**:实时计算 infoGainScore(信息增益),若连续两个子任务之增益值低于预设阈值(0.2),系统将自动合并或舍弃后续分支。
+### 1.3 这个实现的优势
-* **循环检测机制**:利用 DFS 遍历算法维护 VisitedStack,以防止任务执行陷入无限循环。
+1. 过程可追踪,而不是黑盒回答。
+用户可以看到计划版本、运行轮次、进度分组、证据清单、冲突记录,便于复盘和迭代。
-* **调度算法逻辑**:
- 1. 使用优先队列管理待执行任务,维护已访问节点集合防止重复处理
- 2. 执行前检查所有依赖任务是否已完成,未完成则跳过
- 3. 任务执行时标记状态为 RUNNING,调用对应 Agent 处理
- 4. 执行完成后计算信息增益分数,低于阈值(0.2)则标记为 PRUNED
- 5. 剪枝通过后标记为 COMPLETED,保存输出结果
- 6. 递归激活并处理所有子任务节点
+2. 支持多轮研究,而不是一次性输出。
+会话与任务解耦:同一个 `conversation_id` 可以对应多次 `task_id` 运行,适合“先做一版,再补证据再重跑”的真实研究节奏。
-### **3.2 检索代理 (Research & Retrieval Agent)**
+3. 结果可交付,而不仅是聊天记录。
+最终产物是本地文件(Markdown + BibTeX),可进入论文草稿、内部报告、知识库或二次编辑流程。
-该模组负责执行全域信息的捕获,并配置三级缓存机制。
+4. 对外部依赖有降级策略。
+在模型或网络不可用时,系统仍可用 fallback 计划/报告逻辑保持主流程可运行;测试场景可用 mock 模式保证稳定复现。
-* **检索管线流程**:
- 1. **查询扩展 (Query Expansion)**:生成符合 `(Term OR Abbreviation) AND (Year) AND (Action Verb)` 格式之结构化检索式。
- 2. **L1/L2 缓存校验**:优先检索本地内存与向量数据库,若相似度阈值 > 0.9,则跳过外部 API 调用。
- 3. **API 调用**:执行 Serper 或 arXiv 接口调用。
- 4. **网页解析 (WebParse)**:提取正文内容;若检测到 `` 或 `
` 标签,需提取其题注(Caption)并分配独立 evidenceId。
+5. 结构清晰,便于二次开发。
+路由、仓储、服务、代理职责分明;前后端接口边界明确,适合继续扩展 provider、MCP 工具、并发调度和权限体系。
-* **缓存策略配置**:
- * **L1 内存缓存**:采用 LRU 淘汰策略,最大容量 1000 条,过期时间 1 小时
- * **L2 向量数据库缓存**:使用 Qdrant 存储,集合名称为 evidence_cache,相似度阈值设为 0.9,过期时间 24 小时
+### 1.4 核心特性清单
-### **3.3 分析代理 (Analyst & Critic Agent)**
+- 会话驱动研究:会话状态与消息历史完整保存。
+- 计划版本化:每次修订产生 `PlanRevision`,可追溯。
+- 任务可控:支持 `start/pause/resume/abort/recover`。
+- 实时进度:WebSocket + 会话内进度分组聚合。
+- 检索可配置:支持 `searchSources`,按 provider 并发采集。
+- 证据治理:证据清洗、去重、打分、冲突检测与投票。
+- 报告多形态:研究报告/论文/演讲稿等体裁蓝图。
+- 质量闸门:报告审查与修订循环,降低占位内容与过程噪音。
+- 本地优先:SQLite 持久化 + 本地文件产物,部署成本低。
-该模组作为质量控制核心,执行语义数值对齐 (SNA)。
+### 1.5 典型使用场景
-* **冲突检测机制**:
- * **单位标准化**:将异构计量单位统一转换为国际单位制 (SI)。
- * **阈值判定**:当 `(ValueA - ValueB) / Max > 15%` 且环境条件相似时,生成冲突记录 ConflictRecord。
+- 研发团队做技术路线调研:先快速跑一版,再补充新证据重跑。
+- 产品/战略团队做专题分析:输出结构化报告并保留可追溯证据链。
+- 内容团队做格式改写:研究完成后直接改成演讲稿、摘要版或简报文稿。
+- 教学/实验环境:通过 mock 模式稳定演示完整流程与系统行为。
-* **信誉评分计算逻辑**:
- * **基础权重**:论文 1.0、专利 0.8、网页 0.5、MCP 数据 0.9
- * **影响因子加成**:以 IF/10 计算,最高加成 1.5 倍
- * **同行评议加成**:经同行评议的来源乘以 1.2 倍
- * **时效性衰减**:发布超过 5 年开始衰减,10 年后降至 0.5 倍
- * **最终评分**:基础权重 × 影响因子加成 × 同行评议加成 × 时效性 × 相关性分数
+---
+
+## 2. 总体架构
+
+```text
+Frontend (React/Vite)
+ ├─ ConversationSidebar 会话列表/新建/重命名/删除
+ ├─ ChatTimeline 消息流/进度组/报告预览
+ ├─ Composer 指令输入
+ └─ PlanEditorPane 方案编辑与启动
+
+Backend (FastAPI)
+ ├─ API Routes /tasks /conversations /evidence /mcp
+ ├─ ConversationAgent 会话编排与指令路由
+ ├─ ExecutionEngine 任务执行主循环
+ ├─ Services planner/retrieval/analyst/writer/agents
+ ├─ Repositories SQLite 持久化读写
+ └─ ProgressHub WebSocket 进度推送
+
+Storage
+ ├─ SQLite (backend/.data/deep_research.db)
+ └─ Reports (.md/.bib, backend/.data/reports)
+```
-### **3.4 MCP 执行代理 (MCP Executor Agent)**
+---
-该模组用于安全地连接外部工具与数据源。
+## 3. 后端实现细节
-* **协议实现**:基于 JSON-RPC 2.0 标准。
+## 3.1 应用入口与配置
-* **安全沙盒机制**:
- * **只读模式 (Read-Only)**:允许直接执行。
- * **写/执行模式 (Write/Execute)**:强制挂起任务,返回 `USER_CONFIRMATION_REQUIRED` 状态,待 UI 授权后方可执行。
+### `backend/app/main.py`
+- FastAPI 生命周期里调用 `init_db()` 初始化数据库 schema。
+- CORS 允许 `localhost/127.0.0.1` 任意端口。
+- 注册统一路由 `api_router`。
+- 健康检查接口:`GET /healthz`。
-* **异步轮询机制**:针对长耗时任务,返回 `JOB_ID` 并进入轮询模式,每隔 5 秒同步一次状态。
+### `backend/app/core/config.py`
+配置由 `pydantic-settings` 驱动:
+- `.env` + 前缀 `DR_`
+- 关键项:
+ - `use_mock_sources`
+ - `default_llm_provider` / `default_llm_model`
+ - `db_path`
+ - 各模型和检索服务 key/base_url/model
-### **3.5 写作代理 (Writer Agent)**
+### `backend/app/core/utils.py`
+- `now_iso()`:UTC,去微秒 ISO 时间。
+- `new_id()`:UUID4 字符串。
-该模组执行增量式文档生成作业。
+---
-* **分段加锁机制**:
- * 每个 `# Section` 绑定至特定的 TaskNode。
- * 节点任务完成时,触发局部内容生成。
- * 经人工编辑之段落将被标记为 `LOCKED`,禁止 AI 覆盖。
+## 3.2 数据模型(Pydantic)
-* **溯源索引**:维护 `UUID -> Citation` 映射表,以自动生成符合规范的参考文献列表。
+定义文件:`backend/app/models/schemas.py`。
----
+### 任务相关
+- `TaskStatus`: `READY -> ... -> COMPLETED/FAILED/ABORTED`
+- `NodeStatus`: `PENDING/RUNNING/COMPLETED/FAILED/SUSPENDED/PRUNED`
+- `TaskConfig`: `maxDepth/maxNodes/searchSources/priority`
+- `TaskNode`, `DAGGraph`, `DAGEdge`
+- `TaskResponse`, `StateResponse`
-## **4. API 接口规范**
+### 会话相关
+- `ConversationStatus`: `DRAFTING_PLAN/PLAN_READY/RUNNING/COMPLETED/FAILED`
+- `MessageRole`, `MessageKind`
+- `PlanRevision`, `ConversationMessage`
+- `ConversationSummary`, `ConversationDetail`
-### **4.1 RESTful API 端点**
+### 证据与冲突
+- `Evidence`, `EvidenceMetadata`, `ExtractedData`
+- `ConflictRecord`, `DisputedValue`, `ResolutionStatus`
+- `VoteRequest`, `VoteResponse`
-#### **4.1.1 任务管理**
+### MCP
+- `MCPExecutionRequest` (`mode`: read/write/execute)
+- `MCPExecutionResult`
-| 方法 | 端点 | 描述 | 请求体 | 响应 |
-|------|------|------|--------|------|
-| POST | `/api/v1/tasks` | 创建新研究任务 | CreateTaskRequest | TaskNode |
-| GET | `/api/v1/tasks/{task_id}` | 获取任务详情 | - | TaskNode |
-| PUT | `/api/v1/tasks/{task_id}` | 更新任务配置 | UpdateTaskRequest | TaskNode |
-| DELETE | `/api/v1/tasks/{task_id}` | 删除任务 | - | DeleteResponse |
-| GET | `/api/v1/tasks/{task_id}/dag` | 获取任务 DAG 结构 | - | DAGGraph |
+---
-#### **4.1.2 证据管理**
+## 3.3 数据库层
+
+### `backend/app/core/database.py`
+SQLite schema(WAL)包含表:
+- `tasks`
+- `task_nodes`
+- `snapshots`
+- `evidences`
+- `conflicts`
+- `conversations`
+- `plan_revisions`
+- `conversation_messages`
+
+索引:
+- `idx_conversations_task_id`
+- `idx_conversation_messages_created_at`
+
+### Repository 职责
+
+#### `TaskRepository`
+- CRUD:任务主表
+- DAG 持久化:`save_dag/get_dag`
+- 节点状态更新:`update_node_status`
+- 快照:`save_snapshot/load_snapshot`
+- 报告路径:`set_report_path`
+
+#### `EvidenceRepository`
+- `save_many/get/list`
+- 过滤参数:`taskId/nodeId/limit`
+
+#### `ConflictRepository`
+- `save_many/get/list_by_task/resolve`
+- `resolve` 将状态置为 `RESOLVED` 并记录 `resolution_json`
+
+#### `ConversationRepository`
+- 会话摘要:创建、查询、更新 topic/status/task_id、删除
+- 方案版本:`add_plan_revision/get_current_plan`
+- 消息历史:`add_message/list_messages`
+- 进度聚合:`append_progress_entry`
+ - 同会话、同 `taskId`、同 `phase` 会复用已有 `PROGRESS_GROUP` 消息
+ - 每组最多保留最近 50 条 entry
-| 方法 | 端点 | 描述 | 请求体 | 响应 |
-|------|------|------|--------|------|
-| GET | `/api/v1/evidence` | 获取证据列表 | QueryParams | Evidence[] |
-| GET | `/api/v1/evidence/{evidence_id}` | 获取证据详情 | - | Evidence |
-| POST | `/api/v1/evidence/{evidence_id}/vote` | 证据投票(冲突解决) | VoteRequest | VoteResponse |
+---
-#### **4.1.3 状态控制**
+## 3.4 API 路由
-| 方法 | 端点 | 描述 | 请求体 | 响应 |
-|------|------|------|--------|------|
-| POST | `/api/v1/tasks/{task_id}/start` | 启动任务执行 | - | StateResponse |
-| POST | `/api/v1/tasks/{task_id}/pause` | 暂停任务 | - | StateResponse |
-| POST | `/api/v1/tasks/{task_id}/resume` | 恢复任务 | - | StateResponse |
-| POST | `/api/v1/tasks/{task_id}/abort` | 终止任务 | - | StateResponse |
+### 路由聚合
+`backend/app/api/router.py` 包含四个路由文件:
+- `tasks.py`
+- `evidence.py`
+- `mcp.py`
+- `conversations.py`
-### **4.2 WebSocket 事件流**
+### 任务路由(`tasks.py`)
+- 任务管理:创建/查询/更新/删除/取 DAG
+- 状态控制:`start/pause/resume/abort/recover`
+- 辅助读取:`/conflicts` `/report` `/report/download` `/snapshot`
+- WebSocket:`/ws/task/{task_id}/progress`
-客户端通过订阅 `task/{task_id}/progress` 频道接收实时推送。
+### 会话路由(`conversations.py`)
+- 创建、查询、重命名、删除(单个/全部)
+- 计划修订:`/plan/revise`、`/plan`(PUT)
+- 执行:`/run`
+- 报告下载:`/report/download`
-服务端推送的事件类型包括:
-* **TASK_STARTED**:任务开始执行
-* **TASK_PROGRESS**:任务进度更新(包含进度百分比 0-100)
-* **EVIDENCE_FOUND**:发现新证据
-* **TASK_COMPLETED**:任务完成
-* **ERROR**:执行错误
+### 证据路由(`evidence.py`)
+- 列表与详情
+- 冲突投票:`/evidence/{evidence_id}/vote`
-事件数据结构包含时间戳、任务 ID、当前节点、进度值、证据对象或错误信息。
+### MCP 路由(`mcp.py`)
+- 统一执行入口:`/mcp/execute`
-### **4.3 请求/响应示例**
+---
-#### **创建任务**
+## 3.5 服务层(核心行为)
+
+## 3.5.1 状态机
+`backend/app/services/state_machine.py`
+- 定义 `ALLOWED_TRANSITIONS`
+- `transition_or_raise(current, target)` 非法迁移直接抛错
+
+## 3.5.2 规划器
+`backend/app/services/planner.py` `MasterPlanner.build_dag(...)`
+- 生成根节点 + BFS 扩展
+- depth=0 固定首层主题:`背景研究/现状分析/挑战识别`
+- 后续层主题:`{父标题} - 深入方向`
+- 基于 `_estimate_info_gain` 做简化剪枝(低增益可标 `PRUNED`)
+- 受 `maxDepth/maxNodes` 硬约束
+
+## 3.5.3 检索服务
+`backend/app/services/retrieval.py`
+
+### L1 缓存
+- `L1EvidenceCache`:
+ - LRU(`OrderedDict`)
+ - 默认大小 1000
+ - TTL 3600s
+
+### 查询扩展
+- `expand_query(query)` -> `() AND (year OR year-1 OR year-2)`
+
+### 模式分支
+- `DR_USE_MOCK_SOURCES=true`:`_mock_retrieve` 返回合成证据
+- 否则:`_real_retrieve`
+
+### 真实检索源
+- Tavily(有 key 才调用)
+- arXiv(可直接调用)
+- Semantic Scholar(可直接调用)
+
+并行策略:
+- 为 provider 建立 `asyncio.create_task` 并聚合结果
+- 单个 provider 错误不会中断总流程(`_safe_provider_call`)
+
+### 清洗与过滤
+- `_validate_evidences` 过滤:
+ - URL 为空/协议非 http(s)
+ - placeholder host(如 `example.org`)
+ - 内容过短(<30 字符)
+- `_dedupe_by_url` URL 去重
+
+## 3.5.4 分析服务
+`backend/app/services/analyst.py`
+- `score(evidence)`:
+ - source 权重(PAPER 1.0 / PATENT 0.8 / WEB 0.5 / MCP 0.9)
+ - 影响因子增益
+ - peer-review 增益
+ - 时间衰减(<=2016 降到 0.5,<=2021 为 0.8)
+- `detect_conflicts(...)`:
+ - 从 `extractedData.numericalValues` 建桶
+ - 单位归一化(km/cm/gb/mb)
+ - 方差阈值默认 0.15,超过即生成 `ConflictRecord`
+
+## 3.5.5 写作服务
+`backend/app/services/writer.py`
+
+`WriterService.write_report(...)` 输出:
+- Markdown:`backend/.data/reports/{task_id}.md`
+- BibTeX:`backend/.data/reports/{task_id}.bib`
+- 引文映射:`dict[evidence_id, Citation]`
+
+内容生成路径:
+1. 通过 `ReportBlueprint` 确定结构。
+2. `_generate_body`:
+ - mock 模式:直接模板体
+ - real 模式:先尝试 LLM,再拼接证据模板
+3. 去除正文内 URL(URL 放文末证据附录)。
+4. 追加 `## 证据说明与来源链接` 与 `## References`。
+
+标题处理:
+- 过滤 placeholder 标题(如 `[MOCK]` / `result for`)并回退到内容摘要。
+
+## 3.5.6 Agent 组合层
+`backend/app/services/agents.py`
+
+### `ResearchAgent`
+- 封装检索
+- 可挂 MCP read 工具(目前为占位调用)
+
+### `ReportAgent`
+报告生成管线:
+1. `ReportFormatAgent.design_blueprint` 推断体裁与章节。
+2. `WriterService.generate_body` 生成初稿。
+3. `ReportReviewAgent.review` 质量审查:
+ - 检测 trace 泄漏
+ - 检测 placeholder 文本
+ - 检查章节完整性/段落深度/证据引用
+4. 若不通过,`ReportRevisionAgent.revise` 清洗或模板重写。
+5. 最终 `WriterService.write_report` 落盘。
+
+### `ReportFormatAgent`
+- 关键词识别:演讲稿/论文/研究报告/自定义格式
+- 输出对应 `section_titles`
+
+### `ReportReviewAgent`
+- 审核规则包括:
+ - 章节缺失
+ - 内容过短
+ - 段落层次不足
+ - 证据 ID 引用不足
+
+### `ReportRevisionAgent`
+- 清理 trace/噪音行
+- 必要时回退模板全文重写
+
+## 3.5.7 MCP 执行器
+`backend/app/services/mcp_executor.py`
+- `mode in {write, execute}` -> 返回 `USER_CONFIRMATION_REQUIRED`
+- `mode=read` -> 模拟 JSON-RPC 成功返回
+
+## 3.5.8 进度中心
+`backend/app/services/progress_hub.py`
+- task_id 维度保存 WebSocket 连接集合
+- `emit` 广播 `ProgressEvent`
+- 发送失败连接自动剔除
+
+## 3.5.9 重试工具
+`backend/app/services/retry.py`
+- `retry_async(fn, max_attempts, base_delay_seconds)`
+- 指数退避(`2**attempt`)
+- 最终抛 `RetryableError`
-**请求**(POST /api/v1/tasks):
-* title:研究标题
-* description:研究描述
-* config.maxDepth:最大搜索深度(默认 3)
-* config.maxNodes:最大节点数(默认 50)
-* config.searchSources:数据源列表(arXiv、Google Scholar、IEEE 等)
-* config.priority:优先级(1-5)
+---
-**响应**(201 Created):
-* taskId:任务唯一标识符(UUID 格式)
-* status:任务状态(READY)
-* createdAt:创建时间(ISO8601 格式)
-* dag:任务 DAG 结构(包含 nodes 和 edges)
+## 3.6 执行引擎(关键主链路)
+
+文件:`backend/app/services/execution_engine.py`
+
+### 核心对象
+- `TaskControlState`:`paused/aborted/running_task/completed_nodes`
+- `ExecutionEngine`:系统执行主协调器
+
+### `_run_task` 阶段
+
+1. **TASK_STARTED**
+ - 发出 `TASK_STARTED` 事件
+
+2. **规划**
+ - 若任务无 DAG,状态迁移 `READY->PLANNING`
+ - 调 `MasterPlanner.build_dag`
+ - 落库后推送 `TASK_PROGRESS (BUILDING_PLAN, 20%)`
+
+3. **执行节点**
+ - 状态迁移到 `EXECUTING`
+ - 跳过 root 与 `PRUNED` 节点
+ - 对每个节点:
+ - 可暂停轮询 (`paused`)
+ - 可终止 (`aborted`)
+ - 节点置 `RUNNING`
+ - `ResearchAgent.collect_evidence`
+ - 证据入库
+ - 推送 `EVIDENCE_FOUND`
+ - 节点置 `COMPLETED`
+ - 写快照(包含已完成节点与缓存摘要)
+
+4. **审查冲突**
+ - 对所有证据重算 score
+ - `AnalystService.detect_conflicts`
+ - 有冲突:`EXECUTING->REVIEWING`,存冲突后继续 `REVIEWING->SYNTHESIZING`
+ - 无冲突:`EXECUTING->SYNTHESIZING`
+
+5. **写作与落盘**
+ - 推送 `OUTLINING/WRITING_SECTION`
+ - `ReportAgent.generate_report` 生成 `.md/.bib`
+ - `SYNTHESIZING->FINALIZING->COMPLETED`
+ - 发 `TASK_COMPLETED`(含 reportPath/bibPath)
+
+6. **异常处理**
+ - 状态置 `FAILED`
+ - 推送 `ERROR`
---
-## **5. 全流程交互逻辑设计**
+## 3.7 会话编排(ConversationAgent)
+
+文件:`backend/app/services/conversation_agent.py`
+
+这是后端“对话行为”的核心。
+
+### `create_conversation`
+- 新建会话(`DRAFTING_PLAN`)
+- 写入首条用户消息
+- 调 `_generate_initial_plan`(LLM 或 fallback)
+- 存 `PLAN_DRAFT` 消息
+- 状态转 `PLAN_READY`
+
+### `revise_plan`
+会先根据“是否已有报告 + 指令关键词”判定模式:
+- `PLAN`:修订研究方案
+- `RESEARCH`:先修订方案,再立即触发新一轮研究
+- `REPORT`:进入报告改写异步任务
+
+关键关键词集合:
+- 方案意图:`研究方案/任务树/max_depth/...`
+- 重跑意图:`重跑/补充检索/更新最新/...`
+- 报告意图:`改写报告/演讲稿/rewrite/style/...`
+
+### 报告改写链路
+- `_start_report_revision` 先写“正在修改中”消息并置 `RUNNING`
+- 后台 `asyncio.create_task(_run_report_revision_job)`
+- 进度通过 `append_progress_entry` 写入 `PROGRESS_GROUP`
+- 优先路径:
+ 1. 读取当前报告
+ 2. LLM 改写 `_rewrite_report_with_llm`
+ 3. 需要时触发“基于既有证据重建报告”
+ 4. 回写报告文件
+ 5. 追加 `FINAL_REPORT` 消息
+
+### `start_research`
+- 检查当前计划存在
+- 解析 front matter(`title/max_depth/max_nodes/priority/search_sources`)
+- 总是新建 `task_id`
+- 会话绑定新 task,状态置 `RUNNING`
+- 调 `execution_engine.start(task_id)`
+
+### `on_task_event`
+监听执行引擎事件并投递到会话消息:
+- `TASK_PROGRESS` -> 聚合为 `PROGRESS_GROUP`
+- `TASK_COMPLETED` -> 会话置 `COMPLETED`,注入 `FINAL_REPORT`
+- `ERROR/TASK_FAILED/TASK_ABORTED` -> 会话置 `FAILED`,注入错误消息
+
+### 计划 front matter 解析
+- `_parse_plan`:从 YAML 头读取配置
+- 解析失败会产生 warning,并回退默认配置
+- `_ensure_front_matter`:保证计划文本总有 front matter
-交互子系统之运作逻辑,系由 **State-Trigger-Action** 有限状态机驱动。
+---
-| 状态 | UI 表现 | 可执行操作 | 系统后端响应 |
-| :---- | :---- | :---- | :---- |
-| **就绪 (Ready)** | 初始化界面,含多模态输入模块 | 录入议题、上传种子文件、配置偏好 | 预加载常用 MCP 服务列表 |
-| **规划 (Planning)** | 展示任务图谱节点 | **[暂停]** 修改节点,**[拖拽]** 调整依赖,**[删除]** 冗余路径 | 规划器拆解任务,生成依赖关系图 |
-| **执行 (Executing)** | 进度条显示,实时证据流看板 | **[干预]** 调整检索词,**[优先级]** 提权,**[授权]** 批准 MCP 调用 | 检索/执行代理并行运作,推送 Evidence[] |
-| **审查 (Reviewing)** | 冲突节点高亮显示,对比弹窗 | 选择采信源、指令深挖争议点 | 分析代理检测一致性,聚合冲突数据 |
-| **合成 (Synthesizing)** | 文档分屏预览,实时高亮 | 查看引用源,指令重写,锁定段落 | 写作代理整合证据,执行增量润色 |
-| **归档 (Finalizing)** | 最终报告生成,参考文献列表 | 导出文档,同步 Zotero,启动后续研究 | 生成 .bib 文件,清理临时上下文 |
+## 3.8 依赖装配
-### **5.1 深度交互细节说明**
+文件:`backend/app/deps.py`
-* **白盒化编辑**:操作端对任务树之增删改操作,需直接映射为后端图结构的 `UpdateNode` 指令。
-* **实时锚定**:点击证据卡片时,需高亮任务树中的对应来源节点及报告中的引用段落。
-* **搜索真空处理**:当公网检索无结果时,UI 需提示连接私有数据库或启用 AI 模拟推演。
-* **负载预警**:实时显示"认知负荷指数",建议操作端精简任务分支。
+在模块加载时单例化:
+- repositories
+- services
+- agents
+- execution_engine
+- conversation_agent
----
+并调用:
+- `execution_engine.set_event_listener(conversation_agent.on_task_event)`
-## **6. 全局数据结构定义 (Data Schemas)**
-
-本节规定系统核心数据通信标准。
-
-### **6.1 任务节点 (TaskNode)**
-
-* **taskId**:任务唯一标识符(UUID 格式)
-* **parentTaskId**:父任务 ID(根节点为 null)
-* **title**:任务标题
-* **description**:任务描述
-* **status**:任务状态(PENDING、RUNNING、COMPLETED、FAILED、SUSPENDED、PRUNED)
-* **priority**:优先级(1-5 的整数)
-* **dependencies**:依赖任务 ID 列表
-* **children**:子任务 ID 列表
-* **metadata**:元数据
- * estimatedTokenCost:预计 Token 消耗
- * searchDepth:搜索深度
- * infoGainScore:信息增益分数
- * createdAt/updatedAt:创建/更新时间
-* **conflicts**:冲突记录列表
-* **output**:输出证据数组
-
-### **6.2 证据片段 (Evidence)**
-
-* **id**:证据唯一标识符(UUID)
-* **sourceType**:来源类型(PAPER、WEB、PATENT、MCP)
-* **url**:原始链接
-* **content**:正文内容(Markdown 格式)
-* **metadata**:元数据
- * authors:作者列表
- * publishDate:发布日期(ISO8601)
- * title:标题
- * abstract:摘要
- * impactFactor:影响因子
- * isPeerReviewed:是否经同行评议
- * relevanceScore:相关性评分(0-1)
- * citationCount:引用次数
-* **score**:综合信誉评分(0-1)
-* **extractedData**:提取数据
- * tables:表格列表(含 caption 和 data)
- * images:图片列表(含 caption 和 url)
- * numericalValues:数值列表(含 value、unit、context)
-
-### **6.3 冲突记录 (ConflictRecord)**
-
-* **conflictId**:冲突记录唯一标识符(UUID)
-* **parameter**:发生冲突的参数名称
-* **disputedValues**:争议值列表(包含 value、unit、evidenceId、source)
-* **variance**:差异程度(百分比)
-* **context**:冲突上下文说明
-* **resolutionStatus**:解决状态(OPEN、RESOLVED、IGNORED)
-* **resolution**:解决方案
- * selectedEvidenceId:采信的证据 ID
- * reason:解决原因
- * resolvedAt:解决时间(ISO8601)
+即执行事件天然回流到会话时间线。
---
-## **7. 错误处理与容错机制**
-
-### **7.1 错误分类与处理策略**
-
-| 错误类型 | 示例 | 处理策略 |
-|---------|------|----------|
-| 网络超时 | API 请求超时 (>30s) | 重试 3 次,指数退避 |
-| API 失败 | 429/5xx 响应 | 切换备用 API,记录日志 |
-| 解析失败 | 网页内容提取异常 | 标记为低质量,继续处理 |
-| DAG 冲突 | 循环依赖检测 | 拒绝提交,返回错误路径 |
-| 资源耗尽 | Token 限额超出 | 暂停任务,用户确认后续 |
-| MCP 失败 | 外部工具调用失败 | 沙盒隔离,返回部分结果 |
-
-### **7.2 重试机制**
-
-**重试配置参数**:
-* 最大重试次数:3 次
-* 指数退避基数:2
-* 初始延迟:1 秒
-* 可重试错误类型:TIMEOUT、CONNECTION_ERROR、RATE_LIMIT_EXCEEDED、SERVICE_UNAVAILABLE
-
-**重试逻辑**:
-1. 执行目标函数
-2. 若发生可重试错误,按指数退避计算等待时间(1 秒、2 秒、4 秒)
-3. 达到最大重试次数后仍未成功则抛出异常
-4. 非可重试错误直接抛出
-
-### **7.3 状态恢复机制**
-
-**状态快照包含字段**:
-* task_id:任务唯一标识符
-* timestamp:快照时间戳
-* fsm_state:当前 FSM 状态
-* completed_nodes:已完成节点 ID 列表
-* pending_nodes:待处理节点 ID 列表
-* evidence_cache:证据缓存映射
-* conflict_records:冲突记录列表
-
-**恢复逻辑**:
-1. 保存检查点:将快照序列化为 JSON 存入 Redis,设置 24 小时过期
-2. 加载检查点:从 Redis 读取数据并反序列化为 StateSnapshot 对象
-3. 若无检查点数据则返回 null,需从头开始执行
+## 4. 前端实现细节
+
+## 4.1 启动与构建
+
+- 入口:`frontend/src/main.tsx`
+- Vite 端口:`5174`(`frontend/vite.config.ts`)
---
-## **8. 部署架构**
+## 4.2 API 客户端
-### **8.1 部署模式**
+文件:`frontend/src/api.ts`
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Nginx / Caddy │
-│ (反向代理 + SSL) │
-├─────────────────────────────────────────────────────────────────┤
-│ Docker Compose 部署 │
-│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
-│ │ FastAPI │ │ Celery │ │ Qdrant │ │
-│ │ (API Server) │ │ (Worker) │ │ (Vector DB) │ │
-│ │ Port: 8000 │ │ N workers │ │ Port: 6333 │ │
-│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
-│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
-│ │ Redis │ │ React App │ │ File Store │ │
-│ │ (State/Queue) │ │ (Frontend) │ │ (MinIO/NFS) │ │
-│ │ Port: 6379 │ │ Port: 3000 │ │ Port: 9000 │ │
-│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
-└─────────────────────────────────────────────────────────────────┘
-```
+特点:
+- 统一 `json()` 包装,带超时和错误提取。
+- 支持普通超时和计划接口长超时(`PLAN_API_TIMEOUT_MS` 默认 120s)。
+- 统一下载方法(Blob + ``)。
+- WebSocket 连接带 15s 心跳 ping。
-### **8.2 Docker Compose 配置**
+---
+
+## 4.3 `App.tsx` 主状态机
-**服务定义**:
-* **api**:FastAPI 服务(端口 8000),依赖 redis 和 qdrant
-* **worker**:Celery 异步任务处理器,使用 celery worker 启动
-* **redis**:Redis 7 Alpine 镜像(端口 6379),数据持久化到 redis_data 卷
-* **qdrant**:Qdrant v1.7.0 镜像(端口 6333),数据持久化到 qdrant_data 卷
-* **frontend**:React 前端构建(端口 3000),依赖 api 服务
+核心状态:
+- 会话:`summaries/activeConversationId/activeDetail`
+- 草稿:`planDraft/planVersion/draftDirty/editorMode`
+- 发送控制:`sending/saving/starting/downloading`
+- UI 控制:左右侧栏显示、移动端抽屉、确认弹窗、重命名弹窗
-**数据卷**:
-* redis_data:Redis 持久化存储
-* qdrant_data:Qdrant 向量数据存储
+关键行为:
+- 首次加载自动拉会话列表。
+- 活跃会话在 `RUNNING/DRAFTING_PLAN` 时轮询刷新(默认 2.5s)。
+- 创建草稿模式后,第一条消息作为“研究主题”。
+- 发送指令后做 optimistic UI(先插入临时 user 消息)。
+- 删除/重命名/全部删除都通过自定义 `Dialog`。
+
+---
-### **8.3 环境变量配置**
+## 4.4 组件职责
-创建 `.env` 文件,包含以下配置项:
+### `ConversationSidebar`
+- 展示会话列表与状态 chip
+- 单会话菜单:重命名 / 删除
+- 全局菜单:全部删除
-**API 密钥**:
-* OPENAI_API_KEY:OpenAI API 密钥
-* ANTHROPIC_API_KEY:Anthropic API 密钥
-* SERPER_API_KEY:Serper 搜索 API 密钥
+### `ChatTimeline`
+- 渲染用户/Agent/System 消息
+- 方案消息可一键应用到右侧编辑器
+- `PROGRESS_GROUP` 聚合展示并可折叠
+- 报告消息使用 `ReportViewer`
+- 支持隐藏历史轮次(只看当前 task)
-**数据库连接**:
-* REDIS_URL:Redis 连接地址
-* QDRANT_URL:Qdrant 连接地址
+### `PlanEditorPane`
+- Markdown 编辑/预览
+- 保存草稿
+- 启动研究(状态允许时)
+- 下载报告(`COMPLETED` 时)
-**应用配置**:
-* LOG_LEVEL:日志级别(INFO/DEBUG/ERROR)
-* MAX_CONCURRENT_TASKS:最大并发任务数(默认 5)
-* DEFAULT_SEARCH_DEPTH:默认搜索深度(默认 3)
-* CACHE_TTL:缓存过期时间(秒)
+### `Composer`
+- Enter 发送,Shift+Enter 换行
+- 根据会话状态动态禁用
-**安全配置**:
-* SECRET_KEY:应用密钥
-* ALLOWED_ORIGINS:允许的跨域来源
+### `Dialog`
+- Esc、遮罩点击关闭(可禁用)
+- 聚焦管理
-**MCP 配置**:
-* MCP_SERVER_CONFIG_PATH:MCP 服务器配置文件路径
+### `ReportViewer`
+- 报告展开/收起/下载
---
-## **9. 测试策略**
+## 4.5 样式系统
-### **9.1 测试分层**
+文件:`frontend/src/styles.css`
-| 测试类型 | 覆盖范围 | 工具 | 目标覆盖率 |
-|---------|----------|------|-----------|
-| 单元测试 | Agent 模块、工具函数 | pytest | ≥80% |
-| 集成测试 | API 端点、数据库交互 | pytest-httpx | ≥70% |
-| 契约测试 | Agent 间通信协议 | pact | 关键路径 100% |
-| 端到端测试 | 完整研究流程 | Playwright | 核心场景 |
+实现特征:
+- 三栏布局 + 渐变背景 + 玻璃拟态卡片。
+- 桌面端侧栏可折叠(left/right hidden)。
+- 移动端(<=1120px)左右抽屉 + 遮罩,且防背景滚动。
+- 进度/打字动画提供 `prefers-reduced-motion` 降级。
-### **9.2 测试用例示例**
+---
-**DAG 生成测试**:
-* 创建测试研究任务
-* 调用 Master Planner 生成 DAG
-* 验证图中无循环依赖
-* 验证根节点标题正确
-* 验证层级深度不超过配置的最大深度
+## 5. 运行脚本
-**剪枝机制测试**:
-* 创建模拟的低信息增益节点(info_gain < 0.2)
-* 连续创建多个低增益节点
-* 调用 should_prune 判断是否应剪枝
-* 验证返回结果为 True
+### `scripts/run_backend.sh`
+- 创建/激活虚拟环境
+- 安装后端依赖
+- 启动 uvicorn
----
+### `scripts/run_frontend.sh`
+- `npm install`
+- `npm run dev`
-## **10. 开发实施路线图**
+### `scripts/run_real_case.py`
+- 使用 `TestClient` 在进程内跑真实案例
+- 创建任务 -> 启动 -> 轮询 -> 输出 evidence 总数和报告前 30 行
-### **10.1 阶段规划**
+---
-| 阶段 | 目标 | 交付物 | 验收标准 | 预估工时 |
-|------|------|--------|----------|----------|
-| **Phase 1** | 核心引擎构建 | - Master Planner
- DAG 调度器
- 基础 API | - 可生成任务 DAG
- 支持任务启动/暂停
- 单元测试通过 | 2 周 |
-| **Phase 2** | 检索能力实现 | - Retrieval Agent
- 网页解析器
- L1/L2 缓存 | - 支持 3+ 数据源
- 缓存命中率 >30%
- 解析成功率 >85% | 2 周 |
-| **Phase 3** | MCP 集成 | - MCP Executor
- 沙盒环境
- 权限确认流程 | - 连接 2+ MCP 服务
- 安全确认机制可用 | 1.5 周 |
-| **Phase 4** | 质量控制 | - Analyst Agent
- SNA 算法
- 冲突检测 | - 数值冲突检出率 >90%
- 评分机制可用 | 1.5 周 |
-| **Phase 5** | 文档生成 | - Writer Agent
- 增量生成
- 引用管理 | - 可生成 Markdown 报告
- 支持段落锁定 | 1 周 |
-| **Phase 6** | 前端开发 | - 任务树组件
- 证据看板
- 状态控制 | - 完整交互流程可用
- WebSocket 实时更新 | 2 周 |
-| **Phase 7** | 集成测试 | - E2E 测试
- 性能测试
- 安全测试 | - 核心场景覆盖
- 无 P0/P1 缺陷 | 1 周 |
+## 6. 测试覆盖(按当前用例)
-**总计:约 11 周**
+## 集成测试
+- `test_task_lifecycle.py`
+ - 任务创建/启动/完成/快照恢复
+ - evidence/conflict/mcp 接口可用性
+- `test_conversation_lifecycle.py`
+ - 会话创建、重命名、改计划、运行、下载、重跑、批量删除
-### **10.2 里程碑**
+## 单元测试
+- `test_planner.py`:DAG 边界与无反向环
+- `test_state_machine.py`:状态迁移合法性
+- `test_retrieval.py`:查询扩展形状
+- `test_analyst.py`:冲突检测
+- `test_writer.py`:报告与 Bib 生成、标题清洗
+- `test_report_format_agent.py`:体裁识别
+- `test_report_review_agent.py`:审稿规则、修订循环
+- `test_conversation_repository.py`:进度分组复用/隔离
+- `test_conversation_agent.py`:front matter、报告改写、重跑、事件聚合、删除中断
-| 里程碑 | 日期 | 标志性成果 |
-|--------|------|-----------|
-| M1 | W2 | MVP 可运行,单任务检索 |
-| M2 | W4 | 完整检索链路 + 缓存 |
-| M3 | W6 | MCP 集成完成 |
-| M4 | W8 | 质量控制 + 文档生成 |
-| M5 | W11 | Alpha 版本发布 |
+`tests/conftest.py` 默认设置:`DR_USE_MOCK_SOURCES=true`,保证测试可重复。
---
-## **附录 A:配置文件模板**
+## 7. 配置与环境变量总表
+
+后端主要读取(`DR_` 前缀):
+- 应用:`APP_NAME/API_PREFIX/DB_PATH/LOG_LEVEL`
+- 模式:`USE_MOCK_SOURCES`
+- 模型路由:`DEFAULT_LLM_PROVIDER/DEFAULT_LLM_MODEL`
+- Provider:`OPENROUTER_* / DEEPSEEK_* / OPENAI_* / ANTHROPIC_*`
+- Search:`SERPER_API_KEY / SERPAPI_API_KEY / TAVILY_API_KEY / ...`
-### **A.1 MCP 服务器配置**
+前端主要读取:
+- `VITE_API_BASE`(默认 `http://127.0.0.1:8000`)
+- `VITE_API_TIMEOUT_MS`
+- `VITE_PLAN_API_TIMEOUT_MS`
+- `VITE_CONVERSATION_REFRESH_MS`
-**配置结构**:
-* **mcpServers**:MCP 服务器集合,键名为服务器标识
+---
+
+## 8. 当前已知边界与后续扩展位
-**服务器类型**:
-* **filesystem**:文件系统访问服务器
- * command:npx
- * args:指定允许访问的路径
- * disabled:是否禁用
-* **brave-search**:Brave 搜索服务器
- * command:npx
- * args:启动 brave-search 包
- * env:BRAVE_API_KEY 环境变量
-* **python-executor**:Python 代码执行器
- * command:uv
- * args:指定工作目录和启动命令
+- 无鉴权、无租户隔离、无权限体系。
+- `MCPExecutor` 仅最小占位,未对接真实 tool registry。
+- 执行引擎当前单任务内部串行节点处理。
+- 检索源可扩展,但目前只实现 Tavily/arXiv/S2。
+- 意图路由依赖关键词规则,可替换为分类模型。
-### **A.2 日志配置**
+---
-**配置结构**:
+## 9. 建议阅读顺序(源码)
-**格式化器 (Formatters)**:
-* **default**:基础格式,包含时间、模块名、日志级别、消息
-* **detailed**:详细格式,额外包含文件名和行号
+1. `backend/app/services/conversation_agent.py`
+2. `backend/app/services/execution_engine.py`
+3. `backend/app/services/agents.py`
+4. `backend/app/services/retrieval.py`
+5. `backend/app/services/writer.py`
+6. `backend/app/repositories/*.py`
+7. `frontend/src/App.tsx`
+8. `frontend/src/components/ChatTimeline.tsx`
+9. `tests/integration/*.py`
-**处理器 (Handlers)**:
-* **console**:控制台输出,级别 INFO,使用 default 格式
-* **file**:文件输出,级别 DEBUG,使用 detailed 格式
- * 文件路径:logs/app.log
- * 单文件最大 10MB
- * 保留 5 个备份文件
+---
-**日志记录器 (Loggers)**:
-* **app**:应用主日志记录器,级别 DEBUG,同时输出到控制台和文件
+如果你要继续扩展,请先看 `WORKFLOW.md`(端到端时序)再改代码。
diff --git a/docs/LOCAL_RELEASE.md b/docs/LOCAL_RELEASE.md
index 68549e5..0b7113c 100644
--- a/docs/LOCAL_RELEASE.md
+++ b/docs/LOCAL_RELEASE.md
@@ -12,6 +12,13 @@
./scripts/run_backend.sh
```
+If startup shows `No supported WebSocket library detected`, reinstall backend deps:
+
+```bash
+source .venv/bin/activate
+uv pip install -e 'backend[dev]'
+```
+
Backend URL: `http://127.0.0.1:8000`
OpenAPI: `http://127.0.0.1:8000/docs`
@@ -38,3 +45,7 @@ cd frontend && npm run build
- This release is single-user local mode only.
- Queue services, Docker deployment, and multi-user access are intentionally deferred.
+- Frontend is now a chat-driven workspace (conversation sidebar + timeline + Markdown plan editor).
+- Research report downloads are available at:
+ - `GET /api/v1/conversations/{conversation_id}/report/download`
+ - `GET /api/v1/tasks/{task_id}/report/download`
diff --git a/docs/UI_REGRESSION_CHECKLIST.md b/docs/UI_REGRESSION_CHECKLIST.md
new file mode 100644
index 0000000..2ed8ac9
--- /dev/null
+++ b/docs/UI_REGRESSION_CHECKLIST.md
@@ -0,0 +1,39 @@
+# UI Regression Checklist
+
+## Build Baseline
+
+- Run `cd frontend && npm run build` and confirm success.
+
+## Core Workflow
+
+- Create a new conversation, send the first topic, and verify draft plan appears.
+- Save plan, start research, and ensure progress group can expand/collapse.
+- Download report after completion.
+
+## Keyboard Accessibility
+
+- Use `Tab` to navigate top-level controls, sidebar menu buttons, composer, and editor actions.
+- Confirm focused controls have a visible focus ring.
+- In composer, press `Enter` to send and `Shift+Enter` to insert a newline.
+
+## Dialog Consistency
+
+- Trigger single delete, bulk delete, and rename actions.
+- Confirm all actions use in-app dialogs (no browser native `confirm/prompt`).
+- Validate cancel, close (`Esc`), and confirm behavior.
+
+## Time and Typography
+
+- Check conversation list and timeline timestamps render local time (e.g. `HH:MM:SS`).
+- Confirm invalid timestamps degrade gracefully and do not break rendering.
+- Verify small labels (status/role/mono text) remain readable on desktop and mobile.
+
+## Responsive and Drawer Behavior
+
+- In `375x812`, open/close conversation drawer and plan drawer with dedicated close buttons.
+- Confirm opening one drawer closes the other.
+- Confirm background page does not scroll while a mobile drawer or modal is open.
+
+## Reduced Motion
+
+- Enable system-level reduced motion and verify pulsing/loading animations are suppressed.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 416a215..876daaf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,256 +1,818 @@
-import type { CSSProperties } from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
- abortTask,
- connectProgressWs,
- createTask,
- getDag,
- getReport,
- getTask,
- listConflicts,
- listEvidence,
- pauseTask,
- resumeTask,
- startTask,
- voteConflict
+ createConversation,
+ deleteAllConversations,
+ deleteConversation,
+ downloadConversationReport,
+ getConversation,
+ listConversations,
+ renameConversation,
+ reviseConversationPlan,
+ runConversation,
+ updateConversationPlan
} from "./api";
-import type { ConflictRecord, Evidence, ProgressEvent, TaskResponse } from "./types";
+import { ChatTimeline } from "./components/ChatTimeline";
+import { Composer } from "./components/Composer";
+import { ConversationSidebar } from "./components/ConversationSidebar";
+import { Dialog } from "./components/Dialog";
+import { PlanEditorPane } from "./components/PlanEditorPane";
+import type { ConversationDetail, ConversationMessage, ConversationStatus, ConversationSummary } from "./types";
-const initialForm = {
- title: "大语言模型幻觉问题研究",
- description: "调研幻觉成因、检测方法、缓解策略与评测基准。",
+const FIRST_MESSAGE_LIMIT = 500;
+const REFRESH_INTERVAL_MS = Number(import.meta.env.VITE_CONVERSATION_REFRESH_MS ?? "2500");
+const LEFT_SIDEBAR_KEY = "dr:left-sidebar-visible";
+const RIGHT_SIDEBAR_KEY = "dr:right-sidebar-visible";
+
+const DEFAULT_CONFIG = {
maxDepth: 2,
maxNodes: 8,
- sources: "arXiv,Semantic Scholar",
+ searchSources: ["arXiv", "Semantic Scholar"],
priority: 4
};
+const STATUS_LABEL: Record = {
+ DRAFTING_PLAN: "草稿生成中",
+ PLAN_READY: "方案可执行",
+ RUNNING: "处理中",
+ COMPLETED: "研究完成",
+ FAILED: "执行失败"
+};
+
+function toErrorText(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ return String(err);
+}
+
+function readStoredFlag(key: string, fallback: boolean): boolean {
+ if (typeof window === "undefined") return fallback;
+ const raw = window.localStorage.getItem(key);
+ if (raw === "1") return true;
+ if (raw === "0") return false;
+ return fallback;
+}
+
+interface PendingAssistantBubble {
+ conversationId: string | null;
+ content: string;
+}
+
+type ConfirmDialogState =
+ | {
+ kind: "deleteConversation";
+ conversationId: string;
+ topic: string;
+ }
+ | {
+ kind: "deleteAll";
+ total: number;
+ };
+
+interface RenameDialogState {
+ conversationId: string;
+ value: string;
+}
+
export function App() {
- const [form, setForm] = useState(initialForm);
- const [task, setTask] = useState(null);
- const [dag, setDag] = useState(null);
- const [evidence, setEvidence] = useState([]);
- const [conflicts, setConflicts] = useState([]);
- const [progress, setProgress] = useState(0);
- const [report, setReport] = useState("");
- const [events, setEvents] = useState([]);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState("");
-
- const canControl = Boolean(task?.taskId);
- const stateLabel = task?.status ?? "READY";
- const openConflicts = useMemo(() => conflicts.filter((c) => c.resolutionStatus === "OPEN"), [conflicts]);
- const progressStyle = { "--value": `${progress}%` } as CSSProperties;
+ const [summaries, setSummaries] = useState([]);
+ const [activeConversationId, setActiveConversationId] = useState(null);
+ const [activeDetail, setActiveDetail] = useState(null);
+ const [draftMode, setDraftMode] = useState(false);
+ const [draftMessages, setDraftMessages] = useState([]);
+ const [pendingAssistantBubble, setPendingAssistantBubble] = useState(null);
+
+ const [composerText, setComposerText] = useState("");
+ const [planDraft, setPlanDraft] = useState("");
+ const [planVersion, setPlanVersion] = useState(0);
+ const [draftDirty, setDraftDirty] = useState(false);
+ const [editorMode, setEditorMode] = useState<"edit" | "preview">("edit");
+
+ const [sending, setSending] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [starting, setStarting] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+ const [deletingConversationId, setDeletingConversationId] = useState(null);
+ const [renamingConversationId, setRenamingConversationId] = useState(null);
+ const [deletingAll, setDeletingAll] = useState(false);
+ const [refreshingList, setRefreshingList] = useState(false);
+ const [error, setError] = useState("");
+ const [confirmDialog, setConfirmDialog] = useState(null);
+ const [renameDialog, setRenameDialog] = useState(null);
+
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
+ const [mobileEditorOpen, setMobileEditorOpen] = useState(false);
+ const [leftSidebarVisible, setLeftSidebarVisible] = useState(() => readStoredFlag(LEFT_SIDEBAR_KEY, true));
+ const [rightSidebarVisible, setRightSidebarVisible] = useState(() => readStoredFlag(RIGHT_SIDEBAR_KEY, false));
+
+ const composerRef = useRef(null);
+
+ const activeStatus = activeDetail?.status ?? null;
+ const statusLabel = draftMode ? "等待输入研究主题" : activeStatus ? STATUS_LABEL[activeStatus] : "未选择会话";
+ const composerDisabled = activeStatus === "RUNNING" || activeStatus === "DRAFTING_PLAN" || (!activeConversationId && !draftMode);
+
+ const activeSummary = useMemo(
+ () => summaries.find((item) => item.conversationId === activeConversationId) ?? null,
+ [summaries, activeConversationId]
+ );
+ const timelineMessages = useMemo(() => {
+ if (activeDetail) return activeDetail.messages;
+ if (draftMode && !activeConversationId) return draftMessages;
+ return [];
+ }, [activeDetail, draftMode, activeConversationId, draftMessages]);
+ const pendingAssistantText = useMemo(() => {
+ if (!pendingAssistantBubble) return null;
+ if (pendingAssistantBubble.conversationId === null) {
+ return draftMode && !activeConversationId ? pendingAssistantBubble.content : null;
+ }
+ return pendingAssistantBubble.conversationId === activeConversationId ? pendingAssistantBubble.content : null;
+ }, [pendingAssistantBubble, draftMode, activeConversationId]);
+
+ useEffect(() => {
+ void refreshConversations({ autoSelectFirst: true });
+ }, []);
+
+ useEffect(() => {
+ if (!activeConversationId) return;
+ void refreshConversationDetail(activeConversationId, { syncDraft: true });
+ }, [activeConversationId]);
+
+ useEffect(() => {
+ if (!activeConversationId || (activeStatus !== "RUNNING" && activeStatus !== "DRAFTING_PLAN")) return;
+ const timer = window.setInterval(() => {
+ void refreshConversationDetail(activeConversationId, { syncDraft: false });
+ void refreshConversations();
+ }, REFRESH_INTERVAL_MS);
+ return () => window.clearInterval(timer);
+ }, [activeConversationId, activeStatus]);
useEffect(() => {
- if (!task?.taskId) return;
- const ws = connectProgressWs(task.taskId, (msg) => {
- const payload = JSON.parse(msg.data) as ProgressEvent;
- setEvents((prev) => [`${payload.timestamp} ${payload.event}`, ...prev].slice(0, 30));
- if (payload.event === "TASK_PROGRESS" && typeof payload.data.progress === "number") {
- setProgress(payload.data.progress);
+ window.localStorage.setItem(LEFT_SIDEBAR_KEY, leftSidebarVisible ? "1" : "0");
+ }, [leftSidebarVisible]);
+
+ useEffect(() => {
+ window.localStorage.setItem(RIGHT_SIDEBAR_KEY, rightSidebarVisible ? "1" : "0");
+ }, [rightSidebarVisible]);
+
+ useEffect(() => {
+ const drawerOpen = mobileSidebarOpen || mobileEditorOpen;
+ if (!drawerOpen) return;
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ };
+ }, [mobileSidebarOpen, mobileEditorOpen]);
+
+ async function refreshConversations(options?: { autoSelectFirst?: boolean }) {
+ setRefreshingList(true);
+ try {
+ const items = await listConversations();
+ setSummaries(items);
+ if (options?.autoSelectFirst && !activeConversationId && !draftMode && items.length > 0) {
+ setActiveConversationId(items[0].conversationId);
}
- if (payload.event === "TASK_COMPLETED") {
- refreshAll(task.taskId);
+ if (activeConversationId && !items.some((item) => item.conversationId === activeConversationId)) {
+ setActiveConversationId(null);
+ setActiveDetail(null);
+ setPendingAssistantBubble((prev) => (prev?.conversationId === activeConversationId ? null : prev));
}
- });
- return () => ws.close();
- }, [task?.taskId]);
-
- async function refreshAll(taskId: string) {
- const [nextTask, nextDag, nextEvidence, nextConflicts] = await Promise.all([
- getTask(taskId),
- getDag(taskId),
- listEvidence(taskId),
- listConflicts(taskId)
- ]);
- setTask(nextTask);
- setDag(nextDag ?? null);
- setEvidence(nextEvidence);
- setConflicts(nextConflicts);
- if (nextTask.reportPath) {
- try {
- setReport(await getReport(taskId));
- } catch {
- setReport("");
+ } catch (err) {
+ setError(toErrorText(err));
+ } finally {
+ setRefreshingList(false);
+ }
+ }
+
+ async function refreshConversationDetail(
+ conversationId: string,
+ options?: { syncDraft?: boolean; forceDraft?: boolean }
+ ) {
+ try {
+ const detail = await getConversation(conversationId);
+ setActiveDetail(detail);
+ setPendingAssistantBubble((prev) => {
+ if (detail.status === "DRAFTING_PLAN") {
+ if (!prev || prev.conversationId === conversationId || prev.conversationId === null) {
+ return {
+ conversationId,
+ content: prev?.content ?? "正在规划中,请等待,方案生成后会自动显示。"
+ };
+ }
+ return prev;
+ }
+ if (!prev) return prev;
+ if (prev.conversationId === conversationId || prev.conversationId === null) {
+ return null;
+ }
+ return prev;
+ });
+ const currentPlan = detail.currentPlan;
+ if (!currentPlan) return;
+ const shouldSync =
+ Boolean(options?.forceDraft) ||
+ Boolean(options?.syncDraft) ||
+ !draftDirty ||
+ currentPlan.version !== planVersion;
+ if (shouldSync) {
+ setPlanDraft(currentPlan.markdown);
+ setPlanVersion(currentPlan.version);
+ setDraftDirty(false);
}
+ } catch (err) {
+ setError(toErrorText(err));
}
}
- async function onCreateTask() {
- setBusy(true);
+ async function recoverDraftConversation(
+ topic: string,
+ previousConversationIds: Set
+ ): Promise {
+ try {
+ const items = await listConversations();
+ setSummaries(items);
+ const recovered =
+ items.find((item) => !previousConversationIds.has(item.conversationId)) ??
+ items.find((item) => item.topic.trim() === topic);
+ if (!recovered) return null;
+ setDraftMode(false);
+ setActiveConversationId(recovered.conversationId);
+ const detail = await getConversation(recovered.conversationId);
+ setActiveDetail(detail);
+ setPlanDraft(detail.currentPlan?.markdown ?? "");
+ setPlanVersion(detail.currentPlan?.version ?? 0);
+ setDraftDirty(false);
+ return detail;
+ } catch {
+ return null;
+ }
+ }
+
+ function onCreateDraftConversation() {
+ setDraftMode(true);
+ setActiveConversationId(null);
+ setActiveDetail(null);
+ setComposerText("");
+ setPlanDraft("");
+ setPlanVersion(0);
+ setDraftDirty(false);
+ setDraftMessages([]);
+ setPendingAssistantBubble(null);
+ setRightSidebarVisible(false);
+ setMobileSidebarOpen(false);
+ setMobileEditorOpen(false);
+ setError("");
+ }
+
+ async function onSendInstruction() {
+ const text = composerText.trim();
+ if (!text) return;
+ const submittingDraftTopic = !activeConversationId && draftMode;
+ if (submittingDraftTopic && text.length > FIRST_MESSAGE_LIMIT) {
+ setError(`首条研究主题最多 ${FIRST_MESSAGE_LIMIT} 字,请精简后再发送。`);
+ return;
+ }
+ const previousConversationIds = submittingDraftTopic
+ ? new Set(summaries.map((item) => item.conversationId))
+ : undefined;
+
+ let optimisticMessage: ConversationMessage | null = null;
+ setSending(true);
+ setComposerText("");
+ if (submittingDraftTopic) {
+ optimisticMessage = {
+ messageId: `temp-user-${Date.now()}`,
+ conversationId: "__draft__",
+ role: "user",
+ kind: "USER_TEXT",
+ content: text,
+ metadata: { optimistic: true, draft: true },
+ collapsed: false,
+ createdAt: new Date().toISOString()
+ };
+ setDraftMessages([optimisticMessage]);
+ setPendingAssistantBubble({
+ conversationId: null,
+ content: "正在规划中,请等待,方案生成后会自动显示。"
+ });
+ } else if (activeConversationId) {
+ optimisticMessage = {
+ messageId: `temp-user-${Date.now()}`,
+ conversationId: activeConversationId,
+ role: "user",
+ kind: "USER_TEXT",
+ content: text,
+ metadata: { optimistic: true },
+ collapsed: false,
+ createdAt: new Date().toISOString()
+ };
+ const pendingMessage = optimisticMessage;
+ setActiveDetail((prev) => {
+ if (!prev || prev.conversationId !== activeConversationId) return prev;
+ return { ...prev, messages: [...prev.messages, pendingMessage] };
+ });
+ setPendingAssistantBubble({
+ conversationId: activeConversationId,
+ content: activeStatus === "COMPLETED" ? "正在修改中,请等待。" : "正在生成中,请等待。"
+ });
+ }
setError("");
try {
- const created = await createTask({
- title: form.title,
- description: form.description,
- config: {
- maxDepth: Number(form.maxDepth),
- maxNodes: Number(form.maxNodes),
- searchSources: form.sources.split(",").map((s) => s.trim()).filter(Boolean),
- priority: Number(form.priority)
+ if (submittingDraftTopic) {
+ const detail = await createConversation({
+ topic: text,
+ config: DEFAULT_CONFIG
+ });
+ setSummaries((prev) => [detail, ...prev.filter((item) => item.conversationId !== detail.conversationId)]);
+ setDraftMode(false);
+ setActiveConversationId(detail.conversationId);
+ setActiveDetail(detail);
+ setPlanDraft(detail.currentPlan?.markdown ?? "");
+ setPlanVersion(detail.currentPlan?.version ?? 0);
+ setDraftDirty(false);
+ setDraftMessages([]);
+ setPendingAssistantBubble(null);
+ await refreshConversations();
+ return;
+ }
+
+ if (!activeConversationId) return;
+ await reviseConversationPlan(activeConversationId, text);
+ setPendingAssistantBubble(null);
+ await refreshConversationDetail(activeConversationId, { syncDraft: true, forceDraft: true });
+ await refreshConversations();
+ } catch (err) {
+ const errorText = toErrorText(err);
+ if (submittingDraftTopic && previousConversationIds && errorText.includes("请求超时")) {
+ const recoveredDetail = await recoverDraftConversation(text, previousConversationIds);
+ if (recoveredDetail) {
+ setDraftMessages([]);
+ if (recoveredDetail.status === "DRAFTING_PLAN") {
+ setPendingAssistantBubble({
+ conversationId: recoveredDetail.conversationId,
+ content: "正在规划中,请等待,方案生成后会自动显示。"
+ });
+ } else {
+ setPendingAssistantBubble(null);
+ }
+ await refreshConversations();
+ return;
}
- });
- setTask(created);
- await refreshAll(created.taskId);
+ }
+ if (submittingDraftTopic) {
+ setComposerText(text);
+ setDraftMessages([]);
+ setPendingAssistantBubble(null);
+ } else {
+ setComposerText(text);
+ setPendingAssistantBubble(null);
+ if (optimisticMessage) {
+ setActiveDetail((prev) => {
+ if (!prev || prev.conversationId !== optimisticMessage.conversationId) return prev;
+ return {
+ ...prev,
+ messages: prev.messages.filter((message) => message.messageId !== optimisticMessage?.messageId)
+ };
+ });
+ }
+ }
+ setError(errorText);
+ } finally {
+ setSending(false);
+ }
+ }
+
+ async function onSavePlan() {
+ if (!activeConversationId || !planDraft.trim()) return;
+ setSaving(true);
+ setError("");
+ try {
+ await updateConversationPlan(activeConversationId, planDraft);
+ setDraftDirty(false);
+ await refreshConversationDetail(activeConversationId, { syncDraft: true });
+ await refreshConversations();
} catch (err) {
- setError(String(err));
+ setError(toErrorText(err));
} finally {
- setBusy(false);
+ setSaving(false);
}
}
- async function onAction(action: "start" | "pause" | "resume" | "abort") {
- if (!task?.taskId) return;
- setBusy(true);
+ async function onStartResearch() {
+ if (!activeConversationId) return;
+ setStarting(true);
setError("");
try {
- if (action === "start") await startTask(task.taskId);
- if (action === "pause") await pauseTask(task.taskId);
- if (action === "resume") await resumeTask(task.taskId);
- if (action === "abort") await abortTask(task.taskId);
- await refreshAll(task.taskId);
+ await runConversation(activeConversationId);
+ await refreshConversationDetail(activeConversationId, { syncDraft: false });
+ await refreshConversations();
} catch (err) {
- setError(String(err));
+ setError(toErrorText(err));
} finally {
- setBusy(false);
+ setStarting(false);
}
}
- async function onResolveConflict(conflict: ConflictRecord) {
- const selected = conflict.disputedValues[0]?.evidenceId;
- if (!selected) return;
- await voteConflict({
- evidenceId: selected,
- conflictId: conflict.conflictId,
- selectedEvidenceId: selected,
- reason: "Single-user default decision."
+ async function onDownloadReport() {
+ if (!activeConversationId) return;
+ setDownloading(true);
+ setError("");
+ try {
+ await downloadConversationReport(activeConversationId);
+ } catch (err) {
+ setError(toErrorText(err));
+ } finally {
+ setDownloading(false);
+ }
+ }
+
+ function onOpenPlanDrawer() {
+ setRightSidebarVisible(true);
+ if (window.matchMedia("(max-width: 1120px)").matches) {
+ setMobileEditorOpen(true);
+ }
+ }
+
+ function onApplyPlan(markdown: string) {
+ setPlanDraft(markdown);
+ setDraftDirty(true);
+ setEditorMode("edit");
+ onOpenPlanDrawer();
+ }
+
+ function onFocusComposer() {
+ composerRef.current?.focus();
+ }
+
+ function onRequestDeleteConversation(conversationId: string) {
+ const summary = summaries.find((item) => item.conversationId === conversationId);
+ setConfirmDialog({
+ kind: "deleteConversation",
+ conversationId,
+ topic: summary?.topic ?? "未命名会话"
});
- await refreshAll(task!.taskId);
}
+ async function onConfirmDeleteConversation(conversationId: string) {
+ setDeletingConversationId(conversationId);
+ setError("");
+ try {
+ const deletingActive = activeConversationId === conversationId;
+ await deleteConversation(conversationId);
+ setConfirmDialog(null);
+ if (deletingActive) {
+ setActiveConversationId(null);
+ setActiveDetail(null);
+ setPlanDraft("");
+ setPlanVersion(0);
+ setDraftDirty(false);
+ setDraftMessages([]);
+ setPendingAssistantBubble((prev) => (prev?.conversationId === conversationId ? null : prev));
+ setRightSidebarVisible(false);
+ }
+ await refreshConversations({ autoSelectFirst: deletingActive });
+ } catch (err) {
+ setError(toErrorText(err));
+ } finally {
+ setDeletingConversationId(null);
+ }
+ }
+
+ function onRequestRenameConversation(conversationId: string) {
+ const current = summaries.find((item) => item.conversationId === conversationId);
+ setRenameDialog({
+ conversationId,
+ value: current?.topic ?? ""
+ });
+ }
+
+ async function onConfirmRenameConversation() {
+ if (!renameDialog) return;
+ const topic = renameDialog.value.trim();
+ if (!topic) {
+ setError("会话名称不能为空。");
+ return;
+ }
+ if (topic.length > FIRST_MESSAGE_LIMIT) {
+ setError(`会话名称最多 ${FIRST_MESSAGE_LIMIT} 字。`);
+ return;
+ }
+
+ const { conversationId } = renameDialog;
+ setRenamingConversationId(conversationId);
+ setError("");
+ try {
+ const detail = await renameConversation(conversationId, { topic, syncCurrentPlan: true });
+ setSummaries((prev) =>
+ prev.map((item) =>
+ item.conversationId === conversationId
+ ? {
+ conversationId: detail.conversationId,
+ topic: detail.topic,
+ status: detail.status,
+ taskId: detail.taskId,
+ createdAt: detail.createdAt,
+ updatedAt: detail.updatedAt
+ }
+ : item
+ )
+ );
+ if (activeConversationId === conversationId) {
+ setActiveDetail(detail);
+ setPlanDraft(detail.currentPlan?.markdown ?? "");
+ setPlanVersion(detail.currentPlan?.version ?? 0);
+ setDraftDirty(false);
+ }
+ setRenameDialog(null);
+ await refreshConversations();
+ } catch (err) {
+ setError(toErrorText(err));
+ } finally {
+ setRenamingConversationId(null);
+ }
+ }
+
+ function onRequestDeleteAllConversations() {
+ if (summaries.length === 0) return;
+ setConfirmDialog({
+ kind: "deleteAll",
+ total: summaries.length
+ });
+ }
+
+ async function onConfirmDeleteAllConversations() {
+ setDeletingAll(true);
+ setError("");
+ try {
+ await deleteAllConversations();
+ setSummaries([]);
+ setActiveConversationId(null);
+ setActiveDetail(null);
+ setDraftMode(false);
+ setComposerText("");
+ setPlanDraft("");
+ setPlanVersion(0);
+ setDraftDirty(false);
+ setDraftMessages([]);
+ setPendingAssistantBubble(null);
+ setRightSidebarVisible(false);
+ setMobileSidebarOpen(false);
+ setMobileEditorOpen(false);
+ setConfirmDialog(null);
+ await refreshConversations();
+ } catch (err) {
+ setError(toErrorText(err));
+ } finally {
+ setDeletingAll(false);
+ }
+ }
+
+ const composerPlaceholder =
+ activeStatus === "RUNNING"
+ ? "正在处理中,完成后可继续补充修改意见。"
+ : activeStatus === "DRAFTING_PLAN"
+ ? "正在生成研究方案,请等待当前规划完成。"
+ : draftMode
+ ? "先输入研究主题(最多 500 字),Agent 会先给出第一版研究方案。"
+ : activeConversationId
+ ? "输入需求,例如:改成演讲稿;补充最新证据并自动重跑。"
+ : "请选择会话,或点击“新建研究”。";
+ const sendLabel = draftMode && !activeConversationId ? "开始规划" : "发送";
+ const confirmDeleteConversationPending =
+ confirmDialog?.kind === "deleteConversation" && deletingConversationId === confirmDialog.conversationId;
+ const confirmDeleteAllPending = confirmDialog?.kind === "deleteAll" && deletingAll;
+ const renamePending = Boolean(renameDialog) && renamingConversationId === renameDialog.conversationId;
+
return (
-
-
- Deep Research Local Console
- State: {stateLabel} | Task: {task?.taskId ?? "未创建"}
-
+
+
+
+
+
+
+
-