From 4d688ddbd898f3041255f0477d6ad2d38f3353bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 14 Aug 2026 12:56:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/gpt-researcher-deerflow-集成开发方案.md | 1725 +++++++++++++++++ .../src/components/workspace/input-box.tsx | 674 +++---- .../workspace/messages/message-list-item.tsx | 1240 ++++++------ .../workspace/messages/message-list.tsx | 263 ++- frontend-web/src/pages/ChatPage.tsx | 6 +- offline-backend-20260512/backend/config.yaml | 12 +- 6 files changed, 2906 insertions(+), 1014 deletions(-) create mode 100644 docs/gpt-researcher-deerflow-集成开发方案.md diff --git a/docs/gpt-researcher-deerflow-集成开发方案.md b/docs/gpt-researcher-deerflow-集成开发方案.md new file mode 100644 index 00000000..8491e367 --- /dev/null +++ b/docs/gpt-researcher-deerflow-集成开发方案.md @@ -0,0 +1,1725 @@ +# GPT Researcher 接入 DeerFlow 的可实施开发方案 + +> 文档状态:设计稿,可进入任务拆分与开发评审 +> 编写日期:2026-08-14 +> GPT Researcher 核对基线:[`5d84d2f5553e70a2765a8ff3a0d2672d60437ce8`](https://github.com/assafelovic/gpt-researcher/tree/5d84d2f5553e70a2765a8ff3a0d2672d60437ce8) +> 上游本地代码:`F:\react01\deeflow-code\gpt-researcher`(当前 HEAD 与上述基线一致) +> DeerFlow 基线:当前工作区代码 +> 本文只描述新增的「深度研究」功能,不改造、不依赖现有 AI 写作功能。 + +## 1. 文档目标 + +本文回答以下工程问题: + +1. GPT Researcher 的哪些功能可以在 DeerFlow 中实现。 +2. 哪些上游代码适合直接复用,哪些代码只能复用设计,哪些代码不应接入。 +3. 如何让复用代码运行在 DeerFlow 当前的 LangGraph 1.x、模型、检索、MCP、技能、用户体系和网关体系中。 +4. 后端需要新增哪些包、类、数据表、API、后台任务和测试。 +5. 前端新页面需要新增哪些路由、组件、状态和交互。 +6. 如何分阶段交付,并用明确验收标准避免“页面有了,但能力对不上”。 + +本文是实现级设计,不是简单功能介绍。文中提到的文件名、类名、接口和状态均作为后续开发的默认方案;实际编码时如需调整,应同步更新本文。 + +--- + +## 2. 结论摘要 + +### 2.1 可行性结论 + +可以做,而且不需要安装 GPT Researcher 锁定的旧版 LangGraph,更不需要把 DeerFlow 降级。 + +GPT Researcher 当前真正直接使用 LangGraph 的生产代码主要在: + +- [`multi_agents/agents/orchestrator.py`](https://github.com/assafelovic/gpt-researcher/blob/5d84d2f5553e70a2765a8ff3a0d2672d60437ce8/multi_agents/agents/orchestrator.py) +- [`multi_agents/agents/editor.py`](https://github.com/assafelovic/gpt-researcher/blob/5d84d2f5553e70a2765a8ff3a0d2672d60437ce8/multi_agents/agents/editor.py) + +其中使用的 `StateGraph`、`END`、`add_node`、`add_edge`、`add_conditional_edges`、`compile`、`ainvoke` 在 DeerFlow 当前 LangGraph 1.x 中仍有对应能力。需要适配的重点不是图 API,而是: + +- 把上游阻塞式人工反馈改成 LangGraph 1.x 的 `interrupt()` 与 `Command(resume=...)`。 +- 给图注入 DeerFlow checkpointer 和线程配置。 +- 把上游 LLM Provider 替换为 DeerFlow 的 `create_chat_model()`。 +- 把上游搜索、抓取、MCP 和文档加载全部替换为 DeerFlow 的“研究材料提供器”。 +- 把内存中的运行过程改为 DeerFlow 可恢复、可取消、可多实例接管的后台任务。 + +### 2.2 推荐接入方式 + +不建议把整个 GPT Researcher 仓库复制进来,也不建议以子进程、独立服务或 Git submodule 的形式运行。 + +推荐方式是: + +1. 将经过筛选的上游核心代码 vendoring 到 DeerFlow 的独立 `deep_research` 包。 +2. 保持上游研究流程和报告生成方法的主体结构,减少功能偏差。 +3. 在四个边界上使用 DeerFlow Adapter:模型、研究材料、事件、产物。 +4. 搜索结果一律由 DeerFlow 提供完整正文和元数据,使 GPT Researcher 不再自行联网、抓取或调用 MCP。 +5. 新建独立前端入口 `/page/deep-research`,不进入 `open-canvas`,不修改 AI 写作状态机。 + +### 2.3 第一版的能力范围 + +第一版建议支持: + +- 普通研究报告。 +- 详细报告,按子主题并行研究和合并。 +- Deep Research,按宽度和深度递归研究。 +- 多智能体报告编审,包括计划、研究、写作、事实核查、修订、发布。 +- DeerFlow 搜索、知识库、技能和 MCP 作为统一信息来源。 +- 研究计划、实时进度、来源库、引用、Markdown/HTML 报告。 +- 后台执行、刷新恢复、断线重连、取消、重试、人工审核后继续。 +- 历史会话、报告追问、产物下载。 + +PDF、DOCX 自动导出建议做成可选能力。原因不是 LangGraph,而是 DeerFlow 当前依赖中没有上游导出使用的全部库。核心研究功能可以保持零 LangGraph 依赖变更;如果部署方需要服务端 PDF/DOCX,再启用独立的 `deep-research-export` 可选依赖组。 + +--- + +## 3. 范围与非目标 + +### 3.1 本期范围 + +- 新增独立「深度研究」功能和页面。 +- 复用 GPT Researcher 的研究规划、上下文处理、报告生成和多智能体编审逻辑。 +- 使用 DeerFlow 当前的信息收集能力,不使用 GPT Researcher 自带检索链路。 +- 支持 LangGraph 1.x。 +- 接入 DeerFlow 当前登录用户、权限、模型配置、持久化、后台作业、SSE 和产物体系。 +- 上游代码以固定 commit 为基线,保留来源、许可证和本地补丁说明。 + +### 3.2 明确非目标 + +- 不复用或改造 AI 写作页面、上下文、状态机和接口。 +- 不启动 GPT Researcher 自带 FastAPI/Next.js 服务。 +- 不接入 GPT Researcher 自带搜索 Provider、scraper、MCP、向量库和本地文档加载器。 +- 不在运行期动态 `pip install` 模型 Provider。 +- 不为兼容上游而降低 DeerFlow 的 LangGraph 或 LangChain 版本。 +- 不使用上游 AG2 目录的另一套多智能体实现。 +- 不在第一期提供任意用户自定义 MCP 配置或任意 URL 抓取,以免绕过 DeerFlow 安全边界。 + +--- + +## 4. 目标功能清单 + +### 4.1 研究模式 + +| 模式 | 用户用途 | GPT Researcher 基础 | DeerFlow 中的实现 | +|---|---|---|---| +| 快速研究 | 快速得到带来源的短答案 | `quick_search()` | 可同步或短后台任务,仍由 DeerFlow 收集材料 | +| 标准报告 | 一次规划、检索、压缩、成文 | `GPTResearcher.conduct_research()` + `write_report()` | 第一阶段主路径 | +| 详细报告 | 总体研究后按子主题并行展开 | `DetailedReport` | 子主题任务并行,最终合并引言和结论 | +| 深度研究 | 按 breadth/depth 递归探索 | `DeepResearchSkill` | 有界递归、分支事件、统一去重和取消 | +| 多智能体编审 | 有计划、复核、修订、事实核查 | `ChiefEditor` + `EditorAgent` | LangGraph 1.x 图,支持暂停和恢复 | +| 自定义报告 | 用户给定报告结构或提示 | upstream custom report/prompt | 配置白名单与模板变量,不允许任意系统指令覆盖 | + +### 4.2 用户可见能力 + +1. 创建研究课题。 +2. 选择报告模式、语言、语气、模型和研究强度。 +3. 查看自动生成的研究计划。 +4. 在开启人工审核时,修改或批准研究计划。 +5. 实时查看:当前阶段、搜索问题、材料数量、子主题、递归分支、写作和核查进度。 +6. 查看来源列表、来源正文摘要、来源类型、时间、相关度和被引用情况。 +7. 查看流式报告预览。 +8. 下载 Markdown、HTML,以及部署已启用时的 PDF、DOCX。 +9. 研究完成后继续针对本次报告和材料追问。 +10. 取消正在运行的任务;刷新页面后恢复状态;服务重启后自动续跑或重新领取。 +11. 查看、重命名、删除历史研究会话。 + +### 4.3 管理和运维能力 + +- 并发、最大搜索次数、材料正文长度、递归深度和宽度限制。 +- 每用户同时运行任务数限制。 +- 模型调用和 Token 使用记录。 +- 任务租约、心跳、失败原因和重试次数。 +- 来源可追溯,报告引用能回到具体材料。 +- 不可用能力的显式降级,例如未安装 PDF 导出依赖时只展示 MD/HTML。 + +--- + +## 5. 总体架构 + +```mermaid +flowchart LR + UI["深度研究新页面"] --> API["Deep Research Gateway API"] + API --> SS["Session Store"] + API --> JS["Durable Job Store"] + API --> SSE["SSE 事件流"] + JS --> DISP["Job Dispatcher"] + DISP --> EXEC["Job Executor"] + EXEC --> ENGINE["DeepResearchEngine"] + + ENGINE --> BASIC["GPT Researcher 核心流程"] + ENGINE --> MULTI["LangGraph 1.x 多智能体图"] + + BASIC --> LLM["DeerFlow LLM Adapter"] + MULTI --> LLM + BASIC --> MATERIAL["DeerFlow Material Provider"] + MULTI --> MATERIAL + + MATERIAL --> SEARCH["现有 web_search"] + MATERIAL --> KB["现有知识库/技能"] + MATERIAL --> MCP["现有 MCP 工具"] + MATERIAL --> FETCH["现有正文获取工具"] + + ENGINE --> EVENT["Event Sink"] + EVENT --> ES["Event Store"] + ES --> SSE + ENGINE --> ART["Artifact Adapter"] + ART --> OUTPUTS["DeerFlow thread outputs"] +``` + +核心原则是“研究算法可以复用,基础设施必须由 DeerFlow 接管”。上游算法不能直接持有网络、模型、文件系统或 WebSocket,它只能依赖明确的协议接口。 + +--- + +## 6. 上游代码复用清单 + +### 6.0 上游本地源码定位 + +上游代码已克隆到: + +```text +F:\react01\deeflow-code\gpt-researcher +``` + +当前版本固定为 `5d84d2f5553e70a2765a8ff3a0d2672d60437ce8`。本章所有 `gpt_researcher/...`、`multi_agents/...` 和 `backend/...` 路径均相对于该目录;实施时以该本地目录作为 vendoring 的唯一来源,不从浮动分支重新下载文件。 + +最关键的源码位置如下: + +| 用途 | 本地文件 | +|---|---| +| 研究总入口 `GPTResearcher` | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\agent.py` | +| 研究规划、并行子查询、上下文聚合 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\researcher.py` | +| 报告、引言、结论和子主题写作 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\writer.py` | +| breadth/depth 递归深度研究 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\deep_research.py` | +| 来源策展 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\curator.py` | +| 上游 LLM 调用总入口(需改成 DeerFlow Adapter) | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\utils\llm.py` | +| 多智能体总图 | `F:\react01\deeflow-code\gpt-researcher\multi_agents\agents\orchestrator.py` | +| 多智能体子主题编审图 | `F:\react01\deeflow-code\gpt-researcher\multi_agents\agents\editor.py` | +| 标准报告调用顺序 | `F:\react01\deeflow-code\gpt-researcher\backend\report_type\basic_report\basic_report.py` | +| 详细报告调用顺序 | `F:\react01\deeflow-code\gpt-researcher\backend\report_type\detailed_report\detailed_report.py` | + +后续复制到 `deerflow/agents/deep_research/vendor/` 时,必须在 `vendor/manifest.json` 保存上表中每个源文件的相对路径、上游 commit 和 SHA-256,保证可追溯和可升级。 + +### 6.1 直接 vendoring 后适配的代码 + +下表中的“复用”表示复制到 DeerFlow 仓库并保留主体算法,不表示原文件零修改。 + +| 上游路径 | 复用内容 | 本地适配点 | +|---|---|---| +| `gpt_researcher/agent.py` | `GPTResearcher` 总调度、研究、报告、子主题、引用、图像入口 | 构造函数注入 LLM、材料、事件、产物和取消令牌;移除环境变量驱动 | +| `gpt_researcher/skills/researcher.py` | `ResearchConductor` 的规划、子查询并行、正文处理、摘要和上下文聚合 | 搜索和抓取改调 `MaterialProvider`;保留完整 DeerFlow 来源元数据 | +| `gpt_researcher/skills/writer.py` | `ReportGenerator` 的报告、引言、结论、子主题和标题生成 | 调 DeerFlow LLM Adapter;事件化输出;引用绑定 source id | +| `gpt_researcher/skills/deep_research.py` | 搜索计划、递归深挖、学习点合并、访问 URL 去重 | 修复空查询变量初始化问题;注入 Adapter;增加全局预算和取消检查 | +| `gpt_researcher/skills/curator.py` | 来源筛选和排序 | 使用 DeerFlow 元数据;策展前后均记录选择原因 | +| `gpt_researcher/skills/context_manager.py` | 上下文管理接口与调用关系 | 压缩器换成 DeerFlow 实现 | +| `gpt_researcher/prompts.py` | 报告类型、研究和引用提示词族 | 保留上游语义;配置默认中文;禁止请求直接覆盖系统安全提示 | +| `gpt_researcher/actions/agent_creator.py` | 研究代理角色生成 | LLM 调用换 Adapter | +| `gpt_researcher/actions/query_processing.py` | 查询生成、子主题和查询清洗 | LLM 调用换 Adapter,输出做 Pydantic 校验 | +| `gpt_researcher/actions/report_generation.py` | 报告段落、完整报告和结论生成 | LLM 调用换 Adapter,支持分块事件 | +| `gpt_researcher/actions/markdown_processing.py` | Markdown 整理、引用和链接处理 | 加 source id 绑定,过滤危险 HTML | +| `gpt_researcher/utils/enum.py` | 报告类型、语气等枚举 | 转换成 DeerFlow 配置可序列化枚举 | +| `gpt_researcher/utils/rate_limiter.py` | 限流算法 | 接入每任务/每用户预算,不在全进程使用隐式全局 | +| `gpt_researcher/utils/workers.py` | 并发任务辅助 | 接收统一 semaphore 和取消令牌 | +| `gpt_researcher/utils/validators.py` | 部分文本和 URL 校验 | 与 DeerFlow 安全规则合并 | +| `backend/report_type/basic_report/basic_report.py` | 标准报告的调用顺序 | 改成 `BasicResearchRunner` | +| `backend/report_type/detailed_report/detailed_report.py` | 子主题拆分、并行报告、总报告拼接 | 改成 `DetailedResearchRunner`,并发受任务预算限制 | + +### 6.2 多智能体可复用代码 + +| 上游路径 | 复用内容 | 必改内容 | +|---|---|---| +| `multi_agents/agents/orchestrator.py` | 总图节点和条件路由 | LangGraph 1.x checkpointer、thread id、暂停恢复、事件输出 | +| `multi_agents/agents/editor.py` | 子主题 research-review-revise 图 | 1.x 编译、并发上限、取消和错误传播 | +| `multi_agents/agents/researcher.py` | 子主题研究代理 | DeerFlow 材料提供器 | +| `multi_agents/agents/reviewer.py` | 草稿审阅 | DeerFlow LLM Adapter,结构化输出校验 | +| `multi_agents/agents/reviser.py` | 根据评审修改 | 版本化草稿和事件记录 | +| `multi_agents/agents/writer.py` | 章节和全文写作 | DeerFlow LLM Adapter,引用校验 | +| `multi_agents/agents/fact_checker.py` | 事实核查及回环判断 | 来源必须来自 session source store;限制最大回环次数 | +| `multi_agents/agents/visualizer.py` | 图表/图片建议和位置 | 第一阶段可关闭;启用时调用 DeerFlow 图像能力 | +| `multi_agents/agents/publisher.py` | 最终拼接和发布 | 产物路径换成 Artifact Adapter | +| `multi_agents/agents/human.py` | 人工审核所处流程位置 | 不复用阻塞输入实现,重写为 `interrupt()` | +| `multi_agents/memory/research.py` | 研究状态结构 | 改成 TypedDict/Pydantic 可持久化状态 | +| `multi_agents/memory/draft.py` | 草稿和修订状态结构 | 加 revision、source ids、序列化约束 | + +多智能体流程保持如下语义: + +```mermaid +flowchart TD + B["浏览/初始材料"] --> P["生成研究计划"] + P --> H{"需要人工审核?"} + H -->|修改计划| P + H -->|批准| R["研究各子主题"] + H -->|关闭人工审核| R + R --> W["写作"] + W --> F{"事实核查通过?"} + F -->|否且未超过回环上限| W + F -->|是| V["图表与插图"] + V --> PUB["发布报告与产物"] + + subgraph SUB["每个子主题内部"] + SR["Researcher"] --> REV["Reviewer"] + REV --> OK{"通过?"} + OK -->|否| FIX["Reviser"] + FIX --> REV + end +``` + +### 6.3 只复用协议或思路,不复制实现 + +| 上游路径 | 可借鉴内容 | 不直接复制原因 | +|---|---|---| +| `gpt_researcher/retrievers/custom/custom.py` | `{url, raw_content}` 的预取材料契约 | 使用同步 `requests` 和环境变量 endpoint,缺少认证、元数据和取消 | +| `gpt_researcher/actions/retriever.py` | 检索器工厂的边界概念 | 内含大量上游 Provider 分支,与 DeerFlow 重复 | +| `gpt_researcher/context/compression.py` | 分块、相似度筛选、上下文压缩顺序 | 依赖当前 DeerFlow 未声明的 `langchain_classic`、`langchain_text_splitters` | +| `gpt_researcher/skills/image_generator.py` | 生成图片提示和插入位置 | 图片执行应走 DeerFlow 已配置工具,不能直接持有上游 Provider | +| `backend/utils.py` | MD、PDF、DOCX 转换步骤 | 原始输出目录与依赖不适合 DeerFlow 部署 | +| `multi_agents/agents/utils/file_formats.py` | 输出格式转换概念 | 需要统一路径防护和能力探测 | + +### 6.4 明确不复用的代码 + +| 上游目录 | 原因 | +|---|---| +| `gpt_researcher/retrievers/*` | 信息收集由 DeerFlow 负责,避免 Provider 重复和配置分裂 | +| `gpt_researcher/scraper/*` | 抓取、反爬、SSRF 和网络策略必须由 DeerFlow 控制 | +| `gpt_researcher/mcp/*` | DeerFlow 已有 MCP 生命周期、缓存、认证和工具白名单 | +| `gpt_researcher/document/*` | 本地文件和网页材料统一进入 DeerFlow 材料协议 | +| `gpt_researcher/vector_store/*` | 第一阶段不引入第二套向量存储;需要时接 DeerFlow 知识库 | +| `gpt_researcher/llm_provider/generic/base.py` | 存在动态 import/安装 Provider 的设计,不适合离线可重复部署 | +| `backend/server/app.py`、`server_utils.py`、`websocket_manager.py` | 是上游演示服务,认证、多租户、持久化和并发模型均不符合 DeerFlow 网关 | +| 上游 Next.js 前端 | 当前项目是 Vite + React 19,直接复制会引入第二套路由和组件体系 | +| `multi_agents/ag2/*` | 与 LangGraph 实现重复,并引入额外 Agent 框架和依赖 | +| 上游 Docker Compose | DeerFlow 已有离线部署与统一启动脚本 | + +--- + +## 7. 建议新增目录和文件 + +### 7.1 后端 Harness 层 + +```text +offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/ +├── __init__.py +├── config.py +├── types.py +├── events.py +├── cancellation.py +├── engine.py +├── runners/ +│ ├── __init__.py +│ ├── basic.py +│ ├── detailed.py +│ ├── deep.py +│ └── multi_agent.py +├── adapters/ +│ ├── __init__.py +│ ├── llm.py +│ ├── material.py +│ ├── context.py +│ ├── image.py +│ ├── artifacts.py +│ └── event_sink.py +└── vendor/ + ├── UPSTREAM.md + ├── PATCHES.md + ├── LICENSE + ├── manifest.json + ├── gpt_researcher/ + │ └── ...仅选定文件... + └── multi_agents/ + └── ...仅 LangGraph 版本选定文件... +``` + +职责边界: + +- `engine.py`:只决定使用哪个 runner,并组装 Adapter。 +- `runners/*`:把统一输入映射到上游流程,不能直接访问 FastAPI 或数据库。 +- `adapters/*`:隔离 DeerFlow 基础设施和上游代码。 +- `vendor/*`:尽量保留上游算法,所有 DeerFlow 特有代码放在 vendor 外。 +- Harness 层严禁 `import app.*`,必须继续通过 `tests/test_harness_boundary.py`。 + +### 7.2 持久化层 + +```text +offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research/ +├── __init__.py +├── models.py +└── sql.py +``` + +同时需要: + +- 在 `deerflow/persistence/models/__init__.py` 导入新 ORM model,保证 `create_all` 可发现。 +- 为正式部署增加 Alembic migration;不能只依赖开发环境的 `create_all`。 +- 表字段使用项目已有的 `PortableLongText`、`BeijingDateTime` 等兼容类型。 + +### 7.3 App/Gateway 层 + +```text +offline-backend-20260512/backend/app/gateway/ +├── routers/deep_research.py +├── deep_research_job_executor.py +└── deep_research_job_dispatcher.py +``` + +修改点: + +- `app/gateway/app.py`:注册 `deep_research.router`。 +- `app/gateway/deps.py`:创建 session/job/event/source/message repository,注册 dispatcher 和 executor 生命周期。 +- 如使用隐藏 runtime thread:线程列表接口需要排除 `metadata.hidden == true` 的内部线程。 + +### 7.4 前端 + +```text +frontend-web/src/deep-research/ +├── routes/DeepResearchRoutes.tsx +├── pages/DeepResearchHomePage.tsx +├── pages/DeepResearchSessionPage.tsx +├── api/deep-research.ts +├── lib/types.ts +├── lib/event-reducer.ts +├── hooks/useDeepResearchSession.ts +├── hooks/useDeepResearchStream.ts +└── components/ + ├── ResearchSetupPanel.tsx + ├── SessionHistoryPanel.tsx + ├── ResearchProgressTimeline.tsx + ├── ResearchPlanPanel.tsx + ├── SourceLibraryPanel.tsx + ├── SourceDetailDrawer.tsx + ├── ReportPreview.tsx + ├── ArtifactDownloads.tsx + └── ResearchChatPanel.tsx +``` + +路由修改: + +- 在 `frontend-web/src/pages/PageRoutes.tsx` 增加: + +```tsx +} /> +``` + +- 在 `frontend-web/src/core/page-layout/sidebar-menu.ts` 的「问答管理」下增加: + +```ts +{ + id: "qa-deep-research", + label: "深度研究", + icon: Search, + path: "/page/deep-research", +} +``` + +该菜单继续经过现有动态菜单覆盖、权限过滤和敏感词展示逻辑,不单独实现另一套侧边栏。 + +--- + +## 8. 核心领域模型 + +### 8.1 研究配置 `DeepResearchConfig` + +文件:`deerflow/agents/deep_research/config.py` + +```python +class DeepResearchConfig(BaseModel): + mode: Literal["quick", "basic", "detailed", "deep", "multi_agent"] + language: str = "zh-CN" + tone: Literal["objective", "formal", "analytical", "persuasive"] = "objective" + report_format: Literal["markdown", "apa", "mla", "chicago"] = "markdown" + + fast_model: str | None = None + smart_model: str | None = None + strategic_model: str | None = None + thinking_enabled: bool = False + + max_search_results_per_query: int = 8 + max_iterations: int = 3 + max_subtopics: int = 8 + deep_breadth: int = 4 + deep_depth: int = 2 + max_concurrency: int = 4 + max_context_words: int = 24_000 + + curate_sources: bool = True + generate_images: bool = False + include_human_feedback: bool = False + max_revision_loops: int = 2 + + allowed_material_channels: list[str] = ["web_search"] + allowed_domains: list[str] = [] + custom_outline: str | None = None +``` + +约束: + +- 每个任务开始时把完整配置保存为 `config_snapshot`,任务运行中不读取可变全局配置。 +- 所有数值字段设置服务端上下限,前端限制不能代替服务端限制。 +- 模型名只允许来自 DeerFlow 已注册模型列表。 +- `allowed_material_channels` 只允许管理员配置的工具组,不接受任意 MCP server URL。 +- 不使用 `os.environ` 保存每次请求的模型、报告类型或检索器设置。 + +### 8.2 统一研究材料 `ResearchMaterial` + +文件:`deerflow/agents/deep_research/types.py` + +```python +class ResearchMaterial(BaseModel): + id: str + query: str + title: str + url: str | None = None + raw_content: str + snippet: str | None = None + source: str + source_type: Literal["web", "knowledge", "skill", "mcp", "file", "other"] + published_at: datetime | None = None + relevance_score: float | None = None + rec_uuid: str | None = None + content_hash: str + metadata: dict[str, Any] = {} +``` + +该类型比上游 `{url, raw_content}` 更完整。写入上游上下文时至少保留: + +```python +{ + "id": material.id, + "title": material.title, + "url": material.url, + "raw_content": material.raw_content, + "source": material.source, + "source_type": material.source_type, + "published_at": material.published_at, + "relevance_score": material.relevance_score, + "recUuid": material.rec_uuid, + "metadata": material.metadata, +} +``` + +必须修改上游 `ResearchConductor._search_relevant_source_urls()`:当前上游识别到 `raw_content` 后只保留 URL 和正文,会丢失标题、来源和业务记录 ID。DeerFlow 版本不得丢弃这些字段,否则报告引用无法回到当前信息收集系统。 + +### 8.3 材料提供协议 + +```python +class MaterialProvider(Protocol): + async def search( + self, + query: str, + *, + domains: list[str] | None, + limit: int, + user_id: str, + session_id: str, + cancellation: CancellationToken, + ) -> list[ResearchMaterial]: ... +``` + +`DeerFlowMaterialProvider` 的第一版实现步骤: + +1. 复用 `deerflow/community/configurable_search/tools.py` 中的 `ConfigurableSearchSettings`、`execute_search()` 和结果规范化结构。 +2. 将现有结果字段 `recUuid/content/title/url/source/time/publish_time` 映射成 `ResearchMaterial`。 +3. 如果 `content` 是完整正文,直接作为 `raw_content`,GPT Researcher 不再抓取此 URL。 +4. 如果只有简短 snippet,调用 DeerFlow 当前被允许的正文获取工具补齐;该步骤仍发生在 Adapter 内。 +5. 如配置允许知识库、技能或 MCP,则通过 `deerflow/tools/tools.py:get_available_tools()` 获取白名单工具,分别调用后归一化。 +6. 按优先级使用 `rec_uuid`、canonical URL、`content_hash` 去重。 +7. 写入 `deep_research_sources`,再把材料交给上游算法。 + +第二阶段可把材料渠道做成 registry,但不要让 vendor 代码知道渠道名称或工具实现。 + +### 8.4 研究输入与输出 + +```python +class DeepResearchRequest(BaseModel): + session_id: str + user_id: str + query: str + config: DeepResearchConfig + request_id: str + + +class DeepResearchResult(BaseModel): + report_markdown: str + report_html: str | None + source_ids: list[str] + artifact_paths: dict[str, str] + usage: dict[str, Any] + plan: dict[str, Any] | None +``` + +--- + +## 9. DeerFlow 模型适配 + +### 9.1 复用入口 + +复用当前: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py +└── create_chat_model(...) +``` + +不要复用 GPT Researcher 的 `GenericLLMProvider`。后者按 Provider 动态加载依赖,不符合本项目离线和锁定依赖的方式。 + +### 9.2 Adapter 设计 + +文件:`deerflow/agents/deep_research/adapters/llm.py` + +```python +class DeerFlowCompletionBackend: + def __init__(self, config: DeepResearchConfig, event_sink: EventSink): ... + + async def complete( + self, + *, + model_role: Literal["fast", "smart", "strategic"], + messages: list[dict[str, str]], + temperature: float | None = None, + response_schema: type[BaseModel] | None = None, + operation: str, + ) -> CompletionResult: ... +``` + +内部映射: + +```python +model_name = resolve_model_name(config, model_role) +model = create_chat_model( + name=model_name, + thinking_enabled=config.thinking_enabled, +) +response = await model.ainvoke(to_langchain_messages(messages)) +``` + +适配上游的最佳切入点是 `gpt_researcher/utils/llm.py`:保留 `get_llm()`、`create_chat_completion()`、`construct_subtopics()` 等方法签名,使大多数上游 action/skill 不需要修改;其内部委托给当前请求上下文中的 `DeerFlowCompletionBackend`。 + +### 9.3 上下文注入 + +不得通过模块全局变量保存当前用户、任务或模型。可选方案: + +- 首选:显式构造函数注入到 `GPTResearcher`、`ResearchConductor`、`ReportGenerator`。 +- 对不适合大范围改签名的上游函数:使用只在当前 async task 生效的 `contextvars.ContextVar` 保存 Adapter bundle。 + +如果使用 `ContextVar`,必须提供 `bind_adapters()` context manager,并在 executor 的 `finally` 中 reset,避免协程复用造成串任务。 + +### 9.4 Token 与费用 + +- 优先读取 LangChain `AIMessage.usage_metadata`。 +- 记录 prompt、completion、total tokens 和模型名,不记录完整系统提示或敏感材料。 +- GPT Researcher 的静态价格表只作为缺省估算;DeerFlow 没有价格配置时显示 Token,不伪造金额。 +- 每次调用产生 `model_usage` 事件,同时累加到 session 和 job snapshot。 + +### 9.5 重试规则 + +- 只保留一个明确的重试层,避免 DeerFlow Provider 重试和上游重试相乘。 +- 结构化输出解析失败可进行一次“修复 JSON”模型调用;不新增 `json-repair` 强制依赖。 +- 取消请求、权限错误、配置错误不重试。 + +--- + +## 10. 上下文压缩适配 + +GPT Researcher 上游压缩器依赖本项目未声明的旧式拆分包,因此第一版新增 `DeerFlowContextCompressor`,保持上游调用接口但不引入旧依赖。 + +建议接口: + +```python +class DeerFlowContextCompressor: + async def async_get_context( + self, + query: str, + max_results: int = 10, + cost_callback: Callable | None = None, + ) -> list[dict[str, Any]]: ... +``` + +算法: + +1. 按标题、段落和字符上限分块,不破坏 Markdown 代码块。 +2. 对每个分块保留 source id,不把多个来源拼成无法追溯的大块。 +3. 若部署已配置 embedding,按 query 相似度初筛。 +4. 没有 embedding 时,使用关键词/BM25 风格分值和来源相关度进行初筛。 +5. 超过上下文预算时,再用 `fast_model` 逐来源摘要。 +6. 摘要结果必须带 source id,禁止产生没有出处的新事实。 +7. 最终按来源多样性、发布时间和相关度组合,避免一个域名占满上下文。 + +上下文预算应同时考虑字符数、估算 token 和模型窗口,不只使用单一 word count。 + +--- + +## 11. LangGraph 1.x 适配方案 + +### 11.1 可以保留的图 API + +以下上游写法可在实现阶段通过兼容性测试确认并尽量保留: + +```python +graph = StateGraph(ResearchState) +graph.add_node("planner", planner) +graph.add_edge("browser", "planner") +graph.add_conditional_edges("fact_checker", route_after_fact_check) +graph.set_entry_point("browser") +compiled = graph.compile(checkpointer=checkpointer) +``` + +如果项目统一风格要求,可将 `set_entry_point` 改为 `START -> browser`,但不是功能必要条件,不应为了重写而扩大补丁。 + +### 11.2 人工审核的必要改造 + +上游 `HumanAgent.review_plan()` 会等待 WebSocket 或控制台 `input()`,不能在 FastAPI 后台任务中使用。改造后节点应返回 LangGraph interrupt: + +```python +from langgraph.types import interrupt + +async def human_review_node(state: MultiAgentState): + decision = interrupt({ + "kind": "research_plan_review", + "plan": state["research_plan"], + "allowed_actions": ["approve", "revise", "cancel"], + }) + return apply_review_decision(state, decision) +``` + +网关恢复任务: + +```python +from langgraph.types import Command + +await graph.ainvoke( + Command(resume=validated_feedback), + config={"configurable": {"thread_id": graph_thread_id}}, +) +``` + +规则: + +- `thread_id` 使用稳定的 `deep-research:{session_id}:{job_id}`。 +- graph 必须注入 `deerflow.runtime.checkpointer` 当前配置的 checkpointer。 +- 进入 interrupt 时,job 状态更新为 `awaiting_input`,写入 `awaiting_input` 事件,释放执行租约。 +- `/resume` 验证用户是 session owner、job 正处于等待状态、action 在本次 interrupt 白名单中。 +- 恢复请求使用 `request_id` 幂等,重复点击不能执行两次。 + +### 11.3 最大回环与取消 + +- `planner -> human -> planner` 有最大修改次数。 +- `writer -> fact_checker -> writer` 受 `max_revision_loops` 限制。 +- 子主题内部 `reviewer -> reviser -> reviewer` 同样受限制。 +- 每个节点开始、每次外部调用前后都检查 `CancellationToken`。 +- LangGraph recursion limit 必须由服务端计算并设置,不能完全接受前端参数。 + +### 11.4 兼容性测试 + +新增 `tests/test_deep_research_langgraph_1x.py`: + +1. 用 fake planner/researcher/writer 编译总图。 +2. 运行到 interrupt,断言 checkpoint 已写入。 +3. 使用 `Command(resume=...)` 恢复并完成。 +4. 测试 fact-check 回环和最大回环。 +5. 在测试中断言运行环境的 LangGraph 主版本为 1,不允许测试通过隐式旧虚拟环境。 + +该测试是 Phase 0 的硬门槛。静态核对表明上游核心图 API 可迁移,但必须用当前项目锁定环境实际执行一次 contract test 后才能合并 vendor 代码。 + +--- + +## 12. 研究引擎与 Runner + +### 12.1 统一入口 + +文件:`deerflow/agents/deep_research/engine.py` + +```python +class DeepResearchEngine: + async def run( + self, + request: DeepResearchRequest, + *, + adapters: AdapterBundle, + cancellation: CancellationToken, + ) -> DeepResearchResult: + runner = self._runners[request.config.mode] + return await runner.run(request, adapters, cancellation) +``` + +`AdapterBundle` 包含: + +```python +class AdapterBundle(NamedTuple): + llm: DeerFlowCompletionBackend + materials: MaterialProvider + context: DeerFlowContextCompressor + artifacts: ArtifactWriter + events: EventSink + images: ImageProvider | None +``` + +### 12.2 Basic runner + +顺序: + +1. `phase_changed(planning)`。 +2. 构造注入 Adapter 的 `GPTResearcher`。 +3. `conduct_research()`。 +4. 研究过程中把每个 query 和 material 写入事件/数据库。 +5. `phase_changed(writing)`。 +6. `write_report()`。 +7. Markdown 引用后处理和引用完整性校验。 +8. 写 Markdown/HTML 产物。 +9. `report_completed` 和 `artifact_created`。 + +### 12.3 Detailed runner + +复用 `DetailedReport` 的主要步骤: + +- `_initial_research` +- `_get_all_subtopics` +- `_generate_subtopic_reports` +- `_get_subtopic_report` +- `_construct_detailed_report` + +适配要求: + +- 子主题任务共享 session 来源库和全局 URL 去重集。 +- 子主题并发受统一 semaphore 限制,不为每个子主题再创建无限并发。 +- 单个子主题失败时记录 warning;是否允许部分成功由配置决定。 +- 合并时对重复引用进行稳定编号,不能每个章节独立编号后直接拼接。 + +### 12.4 Deep runner + +复用 `DeepResearchSkill` 的: + +- `generate_search_queries` +- `generate_research_plan` +- `process_research_results` +- `deep_research` +- `run` + +必须包含的本地补丁: + +1. 上游空 `serp_queries` 的提前返回分支在若干累计变量初始化前引用变量,先初始化累计变量再判断空查询。 +2. 所有嵌套 `GPTResearcher` 实例继续传递同一个 LLM、MaterialProvider、EventSink、session scope 和 CancellationToken。 +3. breadth、depth、总 query 数、总材料字符数、总模型调用数同时受硬预算限制。 +4. 每个分支产生 `deep_branch_started`、`deep_branch_completed` 或 `warning` 事件。 +5. `visited_urls` 不只按原始 URL 比较,使用 canonical URL 和 content hash。 + +### 12.5 Multi-agent runner + +复用 `ChiefEditor` 与 `EditorAgent` 的角色和图语义,执行方式改为: + +```python +graph = build_multi_agent_graph( + adapters=adapters, + checkpointer=checkpointer, + config=request.config, +) +await graph.ainvoke( + initial_state, + config={ + "configurable": {"thread_id": graph_thread_id}, + "recursion_limit": computed_limit, + }, +) +``` + +节点不得自行创建新的上游 LLM Provider 或 retriever。所有节点通过图构建函数闭包或状态依赖拿到 Adapter。 + +--- + +## 13. 持久化设计 + +### 13.1 `deep_research_sessions` + +| 字段 | 类型建议 | 说明 | +|---|---|---| +| `id` | string/UUID PK | 会话 ID | +| `user_id` | string + index | 所有者 | +| `title` | string | 默认从 query 生成,可重命名 | +| `query` | long text | 原始课题 | +| `mode` | string | basic/detailed/deep/multi_agent | +| `status` | string + index | draft/running/awaiting_input/completed/failed/cancelled | +| `config_snapshot` | JSON/long text | 创建/启动时的完整配置 | +| `runtime_thread_id` | string unique | 复用 DeerFlow thread outputs 的内部线程 | +| `plan_snapshot` | JSON/long text | 最新研究计划 | +| `report_markdown` | long text | 最新报告 | +| `report_html` | long text nullable | 清洗后的 HTML | +| `source_count` | int | 来源数快照 | +| `usage_snapshot` | JSON/long text | Token/费用统计 | +| `active_job_id` | string nullable | 当前任务 | +| `error` | long text nullable | 面向用户的错误摘要 | +| `created_at/updated_at` | BeijingDateTime | 时间 | + +### 13.2 `deep_research_jobs` + +该表复用 `deerflow.persistence.roundtable_jobs` 的成熟算法和字段思路,但第一版不直接继承或修改正在运行的 roundtable 表。 + +关键字段: + +- `id`, `session_id`, `user_id` +- `request_id`, `request_hash` +- `active_dedupe_key`,数据库唯一约束保证同一会话只有一个 active job +- `input_snapshot` +- `status`, `phase`, `progress`, `current_query` +- `lease_owner`, `lease_until` +- `attempt`, `version` +- `cancel_requested`, `cancel_requested_at` +- `error_code`, `error_message` +- `created_at`, `started_at`, `updated_at`, `finished_at` + +需要复用/等价实现的方法: + +- `compute_active_dedupe_key()` +- `try_create_or_get_active()` +- `claim_lease()` / `renew_lease()` / `release_lease()` +- 基于 `version` 的 CAS 状态更新 +- `request_cancel()` +- 过期 lease 回收 + +如果后续多个领域都需要相同作业能力,可单独提取通用 `DurableJobRepository`;首期不要为了抽象而重构现有 roundtable 代码。 + +### 13.3 `deep_research_events` + +| 字段 | 说明 | +|---|---| +| `id` | 数据库主键 | +| `job_id`, `session_id` | 索引 | +| `seq` | job 内严格递增序号,唯一约束 `(job_id, seq)` | +| `event_type` | 事件类型 | +| `phase` | 当前阶段 | +| `payload` | JSON/long text | +| `created_at` | 时间 | + +事件必须先持久化再推送 SSE。SSE 客户端断线后用 `after=seq` 或 `Last-Event-ID` 补发,不能只依赖内存队列。 + +### 13.4 `deep_research_sources` + +保存 `ResearchMaterial` 的完整字段,并增加: + +- `session_id`, `job_id` +- `selected`:是否进入最终上下文 +- `selection_reason` +- `citation_key` +- `first_seen_query` +- `created_at`, `updated_at` + +正文可很大,使用 `PortableLongText`。如果后续规模需要对象存储,可保持数据库里元数据和正文 locator,但第一版先使用现有数据库能力。 + +### 13.5 `deep_research_messages` + +用于研究完成后的追问: + +- `id`, `session_id`, `user_id` +- `role` +- `content` +- `citation_source_ids` +- `usage_snapshot` +- `created_at` + +追问只读取本 session 报告和来源;若允许再次搜索,必须通过一个显式 `allow_new_research` 参数创建新 job,不能静默联网。 + +### 13.6 产物存储 + +推荐为每个 session 创建一个隐藏的 DeerFlow runtime thread: + +- `runtime_thread_id` 写入 session。 +- thread metadata:`thread_type=deep_research`、`hidden=true`、`session_id=...`。 +- 产物写到现有线程 sandbox 的 `user-data/outputs`。 +- 下载复用 `app/gateway/routers/artifacts.py` 的用户鉴权、路径保护、列表和文件读取。 +- 普通聊天记录列表必须过滤 `hidden=true`,避免内部线程出现在聊天页。 + +固定文件名建议: + +```text +outputs/ +├── report.md +├── report.html +├── sources.json +├── research-plan.json +├── report.pdf # 可选能力 +└── report.docx # 可选能力 +``` + +--- + +## 14. 后台任务与恢复 + +### 14.1 为什么必须后台执行 + +详细报告、Deep Research 和多智能体报告可能执行数分钟,不能把完整流程绑定在单个 FastAPI 请求生命周期中。必须复用 roundtable 作业的工程模式:数据库作业、dispatcher、lease、executor、轮询式 SSE。 + +### 14.2 状态机 + +```mermaid +stateDiagram-v2 + [*] --> queued + queued --> running: dispatcher 领取 + running --> awaiting_input: LangGraph interrupt + awaiting_input --> queued: 用户批准或修改 + running --> completed: 报告与必要产物成功 + running --> failed: 不可恢复错误 + queued --> cancelled: 取消 + running --> cancelled: 检查到取消令牌 + awaiting_input --> cancelled: 取消 + running --> queued: lease 过期且允许重试 + completed --> [*] + failed --> [*] + cancelled --> [*] +``` + +`cancel_requested` 是内部标志,不是最终状态。executor 检查后清理并写成 `cancelled`。 + +### 14.3 阶段枚举 + +```text +initializing +planning +collecting +curating +compressing +deepening +writing +reviewing +exporting +done +``` + +### 14.4 Dispatcher + +`deep_research_job_dispatcher.py` 负责: + +1. 周期性扫描 `queued` 或 lease 过期的可恢复任务。 +2. 用数据库 CAS 领取,不依赖单进程锁。 +3. 设置 `lease_owner` 和 `lease_until`。 +4. 把 job 交给本进程 executor。 +5. 达到全局/每用户并发限制时保持 queued。 + +### 14.5 Executor + +`deep_research_job_executor.py` 负责: + +1. 读取不可变 `input_snapshot`。 +2. 组装 repository、Adapter、checkpointer 和取消令牌。 +3. 启动 lease heartbeat。 +4. 调用 `DeepResearchEngine.run()`。 +5. 持续更新 job phase/progress,但事件正文写入 event store。 +6. 捕获 interrupt,保存 `awaiting_input` 状态。 +7. 成功时原子更新 session、job 和最终事件。 +8. 失败时分类:配置错误、模型错误、材料错误、预算超限、内部错误。 +9. `finally` 中释放资源、重置 ContextVar、停止 heartbeat。 + +禁止把正在运行的 `asyncio.Task` 作为唯一真相保存在内存字典;内存映射只能用于本进程快速取消,数据库状态才是恢复依据。 + +--- + +## 15. 事件协议与 SSE + +### 15.1 统一事件包 + +```json +{ + "seq": 42, + "sessionId": "drs_xxx", + "jobId": "drj_xxx", + "type": "source_added", + "phase": "collecting", + "timestamp": "2026-08-14T10:00:00+08:00", + "payload": { + "sourceId": "src_xxx", + "title": "...", + "url": "https://...", + "sourceType": "web" + } +} +``` + +### 15.2 事件类型 + +| 类型 | 主要 payload | +|---|---| +| `job_status` | status、progress | +| `phase_changed` | from、to、message | +| `plan_created` | plan、subtopics | +| `query_started` | query、branch、iteration | +| `query_completed` | query、resultCount、duration | +| `source_added` | source 摘要 | +| `sources_curated` | selectedIds、removedIds、reason | +| `context_compressed` | before/after token、sourceCount | +| `deep_branch_started` | depth、breadth、query | +| `deep_branch_completed` | learnings、newQueries | +| `report_chunk` | sectionId、delta | +| `report_completed` | reportVersion、sourceCount | +| `artifact_created` | format、artifact path/name | +| `awaiting_input` | interruptId、kind、payload、allowedActions | +| `model_usage` | model、operation、token usage | +| `warning` | code、message、recoverable | +| `error` | code、message、retryable | +| `heartbeat` | server time | + +不要把模型原始思维链写入事件或返回前端。进度说明只能是系统定义的操作摘要。 + +### 15.3 SSE 实现复用点 + +前端和后端分别参考: + +- `app/gateway/routers/roundtable_jobs.py`:作业查询、取消、恢复和数据库轮询 SSE。 +- `frontend-web/src/roundtable-planning/api/roundtable-jobs.ts`:`fetch()` body reader、`AbortController` 和流式解析。 + +新功能应复制通用模式或提取无领域依赖的流解析辅助函数,不能让 deep-research API import roundtable 领域类型。 + +--- + +## 16. Gateway API 设计 + +统一前缀:`/api/deep-research` + +所有接口均使用 DeerFlow 当前认证方式,并校验 `session.user_id == current_user.id`。管理员也不默认跨用户读取报告,除非已有明确审计权限。 + +### 16.1 创建会话 + +`POST /api/deep-research/sessions` + +请求: + +```json +{ + "query": "分析国内工业大模型在设备预测性维护中的落地情况", + "title": "工业大模型预测性维护研究", + "config": { + "mode": "deep", + "language": "zh-CN", + "tone": "analytical", + "deepBreadth": 4, + "deepDepth": 2, + "curateSources": true, + "includeHumanFeedback": false, + "allowedMaterialChannels": ["web_search", "knowledge"] + } +} +``` + +响应: + +```json +{ + "id": "drs_xxx", + "status": "draft", + "title": "工业大模型预测性维护研究", + "query": "...", + "config": {}, + "createdAt": "..." +} +``` + +### 16.2 会话 CRUD + +- `GET /api/deep-research/sessions?limit=20&cursor=...&status=...` +- `GET /api/deep-research/sessions/{session_id}` +- `PATCH /api/deep-research/sessions/{session_id}`:仅 draft 可修改影响运行的配置;运行后只允许改 title。 +- `DELETE /api/deep-research/sessions/{session_id}`:运行中先拒绝或要求先取消;删除需要清理隐藏 thread 和产物。 + +列表使用游标分页,不返回完整 report 和 source 正文。 + +### 16.3 启动任务 + +`POST /api/deep-research/sessions/{session_id}/jobs` + +请求: + +```json +{ + "requestId": "client-generated-uuid" +} +``` + +响应: + +```json +{ + "jobId": "drj_xxx", + "sessionId": "drs_xxx", + "status": "queued", + "reused": false +} +``` + +幂等规则: + +- 同用户、同 session、同 requestId 和相同请求 hash 返回原 job。 +- 同 session 已有 active job 时返回该 job 并标记 `reused=true`。 +- 同 requestId 但请求 hash 不同返回 409。 + +### 16.4 Job 查询和流 + +- `GET /api/deep-research/jobs/{job_id}` +- `GET /api/deep-research/jobs/{job_id}/stream?after=42` +- `POST /api/deep-research/jobs/{job_id}/cancel` +- `POST /api/deep-research/jobs/{job_id}/resume` + +resume 请求示例: + +```json +{ + "requestId": "uuid", + "interruptId": "int_xxx", + "action": "revise", + "payload": { + "plan": [ + {"title": "市场现状", "questions": ["..."]} + ] + } +} +``` + +### 16.5 来源接口 + +- `GET /api/deep-research/sessions/{session_id}/sources?selected=all&cursor=...` +- `GET /api/deep-research/sessions/{session_id}/sources/{source_id}` +- `PATCH /api/deep-research/sessions/{session_id}/sources/{source_id}`:只允许在人工计划/材料审核阶段切换 selected。 + +正文接口需要大小限制;列表只返回摘要。 + +### 16.6 报告追问 + +`POST /api/deep-research/sessions/{session_id}/chat` + +请求: + +```json +{ + "message": "报告中哪些案例已经进入规模化生产?", + "allowNewResearch": false +} +``` + +响应使用 SSE,引用返回 `sourceIds`。`allowNewResearch=false` 时只允许使用 session 报告和来源。 + +### 16.7 导出 + +`POST /api/deep-research/sessions/{session_id}/export` + +```json +{"format": "html"} +``` + +- Markdown:总是支持。 +- HTML:总是支持,输出前清洗危险标签和链接。 +- PDF/DOCX:若 capability 未启用返回 422 + `EXPORT_FORMAT_UNAVAILABLE`,前端不展示对应按钮。 +- 可增加 `GET /api/deep-research/capabilities` 返回模式、模型、材料渠道和导出格式。 + +--- + +## 17. 前端页面设计 + +### 17.1 路由 + +```text +/page/deep-research 创建页 + 历史列表 +/page/deep-research/:sessionId 研究工作台 +``` + +`DeepResearchRoutes.tsx`: + +```tsx + + } /> + } /> + +``` + +### 17.2 创建页 + +主表单: + +- 课题/问题,必填。 +- 模式:标准、详细、深度、多智能体。 +- 输出语言和语气。 +- 模型:默认使用系统配置,可展开选择 fast/smart/strategic。 +- 材料渠道:只显示后端 capabilities 返回的渠道。 +- 高级设置:每次结果数、最大子主题、深度、宽度、并发、来源策展、人工审核、图片。 +- 创建并开始。 + +历史区: + +- 标题、模式、状态、进度、来源数、更新时间。 +- 支持状态筛选、重命名、删除、继续查看。 +- 运行中的会话进入后自动连接 SSE。 + +### 17.3 工作台布局 + +桌面端建议三栏: + +```text +┌──────────────┬────────────────────────────────┬──────────────────┐ +│ 历史/阶段 │ 计划、进度时间线、报告预览 │ 来源库/来源详情 │ +│ │ │ │ +│ 新建研究 │ 底部:停止/批准/导出/报告追问 │ 引用定位 │ +└──────────────┴────────────────────────────────┴──────────────────┘ +``` + +移动端改为 tabs,不强制三栏挤压。 + +### 17.4 组件职责 + +- `ResearchProgressTimeline`:仅消费 event reducer 的 view model,不直接请求 API。 +- `ResearchPlanPanel`:展示/编辑 interrupt 中的计划;批准、修改、取消均调用 resume/cancel API。 +- `SourceLibraryPanel`:分页和筛选来源,区分 web/knowledge/skill/MCP。 +- `SourceDetailDrawer`:显示受限正文、元数据、相关查询和引用章节。 +- `ReportPreview`:渲染 Markdown,点击引用跳到 SourceDetailDrawer。 +- `ArtifactDownloads`:根据 capabilities 和 artifact 事件显示下载。 +- `ResearchChatPanel`:完成后追问,不复用 AI 写作 Composer 状态。 + +### 17.5 数据和流状态 + +- TanStack Query 管 session、job、sources 和 messages 的服务器状态。 +- `useDeepResearchStream` 只负责连接、重连、last seq 和事件派发。 +- `event-reducer.ts` 将事件转为 timeline、report delta、phase、progress、interrupt 等 UI 状态。 +- `report_completed` 后以服务端 session 内容覆盖流式拼接内容,避免断线丢 chunk。 +- SSE 断线采用有限退避;页面不可见时可以降低轮询,但不能取消后台 job。 +- 刷新页面先 GET job/session,再从 `lastEventSeq` 补流。 + +### 17.6 前端类型 + +`lib/types.ts` 至少定义: + +```ts +type ResearchMode = "quick" | "basic" | "detailed" | "deep" | "multi_agent"; +type ResearchJobStatus = + | "queued" + | "running" + | "awaiting_input" + | "completed" + | "failed" + | "cancelled"; + +interface DeepResearchEvent { + seq: number; + sessionId: string; + jobId: string; + type: DeepResearchEventType; + phase: ResearchPhase; + timestamp: string; + payload: T; +} +``` + +后端 snake_case 到前端 camelCase 的转换在 API client 层集中处理,不散落到组件。 + +--- + +## 18. 产物与导出 + +### 18.1 核心零新增导出依赖 + +- `report.md`:保存最终 Markdown。 +- `report.html`:使用项目现有 Markdown 能力或轻量转换生成并清洗。 +- 浏览器打印:前端为 HTML 报告提供打印样式,让用户另存 PDF。 + +### 18.2 完整服务端导出 + +上游转换涉及 `python-docx`、`htmldocx`、`md2pdf`/WeasyPrint 等依赖,当前 DeerFlow 环境不能假定已经存在。建议在 backend `pyproject.toml` 增加可选组,而不是主依赖: + +```toml +deep-research-export = [ + "python-docx>=...", + "htmldocx>=...", + "weasyprint>=..." +] +``` + +具体版本在实现时按 Python 3.12 和离线制品库验证后锁定。部署未启用时 capability 返回 `md/html`。 + +### 18.3 路径安全 + +- 文件名由服务端枚举决定,不接受用户传任意路径。 +- 使用现有 artifact path guard,禁止 `..`、绝对路径和符号链接逃逸。 +- 所有产物写入该 session 的隐藏 runtime thread outputs。 +- 删除 session 时使用解析后的精确目录,按当前项目的安全删除流程执行。 + +--- + +## 19. 安全与隔离 + +### 19.1 用户和数据隔离 + +- session、job、event、source、message 查询都带 `user_id` 条件。 +- 不接受前端提交的 user_id。 +- 恢复、取消和下载均重新鉴权,不能只靠随机 UUID。 +- 隐藏 runtime thread 仍归当前用户,复用 artifact owner check。 + +### 19.2 网络安全 + +- vendor 代码不得直接 `requests.get()`、`httpx.get()` 或启动浏览器抓取。 +- URL 获取只能经过 DeerFlow 当前允许的工具,继承 SSRF、代理、域名和凭证策略。 +- 不把当前用户 Authorization 头转发给外部网站。 +- MCP 只使用服务端已经配置并加载的 server,不接收用户上传 MCP 配置。 + +### 19.3 提示注入 + +- 所有来源正文标记为“不可信材料”,不得执行其中指令。 +- 系统提示明确材料只用于提取事实和引用。 +- 过滤网页中的隐藏文本、脚本和超长重复内容。 +- 工具调用权限由 Adapter 控制,研究材料不能让 vendor 动态增加工具。 + +### 19.4 资源控制 + +- 限制 depth、breadth、subtopics、iterations、concurrency。 +- 限制单来源正文、单 session 总正文、事件 payload 和报告长度。 +- 每用户和全局 active job 限制。 +- 超预算时生成部分报告并明确标记,或按配置失败,不能无限继续。 + +### 19.5 输出安全 + +- Markdown/HTML 渲染前移除 script、iframe、事件属性和危险 URL scheme。 +- 外链使用 `rel="noopener noreferrer"`。 +- 引用 URL 展示和打开分离,避免把来源标题当 HTML。 +- 日志不记录完整敏感文档和模型思维链。 + +--- + +## 20. 上游代码管理与许可证 + +### 20.1 Vendoring 记录 + +`vendor/UPSTREAM.md` 至少写: + +- 仓库 URL。 +- 固定 commit。 +- 复制日期。 +- 复制的文件清单。 +- 未复制目录。 +- 上游许可证链接。 + +`vendor/manifest.json` 保存每个复制文件的上游路径和 SHA-256,便于后续核对。 + +`vendor/PATCHES.md` 逐条记录本地补丁: + +1. Python 包命名空间改写。 +2. LLM Adapter 注入。 +3. MaterialProvider 替换搜索/抓取。 +4. 事件和取消令牌。 +5. LangGraph 1.x interrupt/resume/checkpointer。 +6. 来源元数据保留。 +7. Deep Research 空查询变量初始化修复。 +8. 产物路径和安全限制。 + +### 20.2 许可证注意事项 + +上游仓库根 `LICENSE` 是 Apache-2.0,但其项目元数据中出现过不一致的许可证声明。接入前应由项目方完成一次许可证确认。工程上至少做到: + +- vendored 目录保留上游根 LICENSE。 +- 保留原文件版权头。 +- 如上游存在 NOTICE,同步保留。 +- 对修改过的文件标记“Modified for DeerFlow”及修改范围。 +- 不把许可证不明的第三方模板或资产一起复制。 + +### 20.3 升级流程 + +1. 选择新的上游 tag/commit。 +2. 根据 manifest 对比选定文件,而不是覆盖整个 vendor 目录。 +3. 重放 `PATCHES.md` 中的小补丁。 +4. 运行 Adapter contract、LangGraph 1.x、引用和全模式测试。 +5. 人工比较同一 fake 数据下报告章节、引用和事件是否出现能力回退。 + +不建议 Git submodule:当前项目是离线部署,submodule 容易造成制品不完整,也不利于维护本地补丁。 + +--- + +## 21. 测试方案 + +本项目后端要求 TDD。每个阶段先写 fake 驱动的单元/契约测试,再接真实模型或搜索。 + +### 21.1 Harness 单元测试 + +```text +tests/test_deep_research_langgraph_1x.py +tests/test_deep_research_material_provider.py +tests/test_deep_research_prefetched_content.py +tests/test_deep_research_material_metadata.py +tests/test_deep_research_llm_adapter.py +tests/test_deep_research_context.py +tests/test_deep_research_basic_runner.py +tests/test_deep_research_detailed_runner.py +tests/test_deep_research_deep_runner.py +tests/test_deep_research_multi_agent_runner.py +tests/test_deep_research_citations.py +``` + +重点断言: + +- MaterialProvider 已返回 `raw_content` 时,vendor BrowserManager/scraper 从未被调用。 +- DeerFlow 的 `recUuid/source/published_at` 经过研究流程后仍存在。 +- 相同材料去重但不丢失命中它的多个 query。 +- fake LLM 下 basic/detailed/deep/multi_agent 都产生预期章节和引用。 +- 取消在搜索、压缩、递归和写作阶段均能停止。 +- 不存在 `app.*` 从 Harness 导入。 + +### 21.2 Persistence/Job 测试 + +```text +tests/test_deep_research_sessions.py +tests/test_deep_research_jobs.py +tests/test_deep_research_job_leases.py +tests/test_deep_research_job_cancellation.py +tests/test_deep_research_event_replay.py +tests/test_deep_research_user_isolation.py +``` + +场景: + +- 20 个并发启动请求只产生一个 active job。 +- 相同 requestId 幂等,不同 payload 冲突。 +- 两个 dispatcher 只有一个能领取 lease。 +- executor 崩溃后 lease 过期,另一个实例接管。 +- SSE 从 seq N 准确补发,无重复或前端 reducer 可幂等去重。 +- 用户 A 无法获取、取消、恢复、下载用户 B 的任务。 + +### 21.3 Gateway 测试 + +- session CRUD 和校验。 +- start/get/stream/cancel/resume。 +- resume action 白名单和 interruptId 校验。 +- source 正文大小和权限。 +- export capability 降级。 +- 隐藏 runtime thread 不出现在普通聊天列表。 + +### 21.4 前端测试 + +- 路由与侧边栏入口。 +- 创建配置序列化。 +- event reducer 对乱序、重复和补发事件幂等。 +- SSE 断线重连使用 last seq。 +- awaiting_input 正确显示审核按钮。 +- source 引用点击定位。 +- completed 时以 session 最终报告校正流式文本。 +- 不支持 PDF/DOCX 时不展示按钮。 + +### 21.5 无外网验收夹具 + +建立固定 fake 材料集和 fake LLM 响应,使 CI 不联网也能验收: + +- 至少 6 个来源,包含重复 URL、相同正文不同 URL、无 URL 知识库材料。 +- 至少 3 个子主题。 +- 一个事实核查失败后修订成功的路径。 +- 一个人工计划修改后恢复的路径。 + +--- + +## 22. 分阶段开发计划 + +### Phase 0:兼容性尖峰与边界测试 + +目标:用最少代码消除最大技术风险,不做页面。 + +任务: + +1. 添加 LangGraph 1.x fake graph contract test。 +2. 添加预取 `raw_content` 不触发上游 scraper 的 contract test。 +3. 验证 `create_chat_model()` 可通过 Adapter 完成上游一次结构化输出。 +4. 核对并锁定 vendoring 文件清单和许可证。 + +验收: + +- 当前 LangGraph 1.x 环境测试通过。 +- 不安装 `langgraph<0.3`。 +- fake MaterialProvider 能完成一次 basic research 最小闭环。 + +### Phase 1:标准报告后端 MVP + +任务: + +1. 新增 config/types/cancellation/events。 +2. vendor 核心 `gpt_researcher` 选定代码。 +3. 实现 LLM、Material、Context、Artifact Adapter。 +4. 实现 Basic runner。 +5. 新增 session/source/job/event 表。 +6. 实现 session、start、get、stream、cancel API。 +7. 复用隐藏 runtime thread 产物。 + +验收: + +- DeerFlow 当前 `web_search` 材料可以生成带引用报告。 +- 上游任何 retriever/scraper/MCP 均未运行。 +- 刷新页面/API 断线后能继续获取进度。 +- 可取消,产出 MD/HTML。 + +### Phase 2:独立前端页面 + +任务: + +1. 增加路由和菜单。 +2. 创建页、历史列表和工作台。 +3. SSE hook、event reducer、进度时间线。 +4. 来源库、报告预览和产物下载。 + +验收: + +- 不进入 AI 写作页面或 context。 +- 可以完整创建、运行、刷新恢复、取消和下载。 +- 菜单覆盖、权限和敏感词展示仍正常。 + +### Phase 3:详细报告与 Deep Research + +任务: + +1. 接入 Detailed runner。 +2. 接入 Deep runner并修复已知空查询问题。 +3. 增加全局预算、分支事件和来源去重。 +4. 增加高级配置 UI。 + +验收: + +- 同一来源跨子主题稳定去重和引用。 +- 并发不超过配置。 +- depth/breadth 到限后确定性停止。 +- 部分分支失败有明确告警,不造成任务永不结束。 + +### Phase 4:LangGraph 多智能体与人工审核 + +任务: + +1. vendor LangGraph 多智能体选定代码。 +2. 改造状态、checkpointer、interrupt/resume。 +3. 接事实核查与修订回环。 +4. 前端加入计划审核和修订交互。 + +验收: + +- 服务重启后仍可恢复等待中的人工审核。 +- 重复批准不会执行两次。 +- 所有回环有上限。 +- 多智能体同样只使用 DeerFlow 材料和模型。 + +### Phase 5:追问、图片和完整导出 + +任务: + +1. 报告追问与消息持久化。 +2. DeerFlow ImageProvider 适配和图片插入。 +3. 可选 PDF/DOCX 导出依赖与 capability。 +4. 完善配额、监控和管理配置。 + +验收: + +- 追问引用来源可回溯。 +- 图片能力未配置时不影响报告主流程。 +- 导出依赖未安装时优雅降级。 + +--- + +## 23. 代码级实施顺序 + +建议按以下提交顺序实施,便于评审和回退: + +1. `test: add deep research adapter and langgraph contracts` +2. `feat: add deep research domain types and adapters` +3. `vendor: add selected gpt-researcher core at pinned commit` +4. `feat: add basic deep research runner` +5. `feat: add deep research persistence and durable jobs` +6. `feat: add deep research gateway APIs and SSE` +7. `feat: add deep research frontend route and setup page` +8. `feat: add research workbench, sources and report preview` +9. `feat: add detailed and recursive deep research modes` +10. `feat: adapt multi-agent graph to langgraph 1.x interrupts` +11. `feat: add report follow-up and optional exports` + +每个提交都应保持: + +- `uv run --no-sync pytest` 相关测试通过。 +- `uv run --no-sync ruff check` 通过。 +- `pnpm typecheck` 通过(涉及前端时)。 +- 不修改或降级 LangGraph 依赖。 +- 不引入 Harness → App 反向依赖。 + +--- + +## 24. 功能对齐检查表 + +为防止“复用了代码但功能对不上”,开发完成后逐项确认: + +| GPT Researcher 能力 | DeerFlow 对齐结果 | +|---|---| +| 自动生成研究问题 | query processing 复用,结构化校验 | +| 自动选择/创建研究角色 | agent creator 复用 | +| 多来源并行研究 | ResearchConductor 复用,来源由 DeerFlow 提供 | +| 网页抓取 | 不复用;由 DeerFlow 当前信息收集完成 | +| 来源压缩 | 接口复用,压缩器适配 | +| 来源策展 | SourceCurator 复用并保留 DeerFlow 元数据 | +| 标准报告 | Basic runner | +| 详细报告 | Detailed runner | +| 递归深度研究 | Deep runner | +| 子主题并行 | 复用,增加统一并发预算 | +| 多智能体研究、审阅、修订 | LangGraph 1.x Multi-agent runner | +| 人工计划审核 | `interrupt/Command(resume)` | +| 事实核查回环 | 复用,增加最大回环和来源约束 | +| 引言、正文、结论 | ReportGenerator 复用 | +| 引用和参考链接 | Markdown processing 复用并绑定 source id | +| 图片生成与插入 | 后续接 DeerFlow ImageProvider | +| Markdown | 核心支持 | +| HTML | 核心支持并清洗 | +| PDF/DOCX | 可选依赖能力 | +| 实时进度 | EventSink + DB events + SSE | +| 历史任务 | DeerFlow session/job 持久化 | +| 中断、恢复、取消 | DeerFlow durable job + LangGraph checkpointer | +| 模型切换 | DeerFlow `create_chat_model()` | +| 搜索 Provider | 不暴露上游 Provider,使用 DeerFlow 渠道 | +| MCP | 使用 DeerFlow 已配置 MCP,不复用上游 MCP | +| 前端 | 新建 DeerFlow React 页面,不复用上游 Next.js UI | + +--- + +## 25. 已知风险与处理 + +### 25.1 上游升级造成大补丁 + +处理:vendor 只保留核心文件,Adapter 放在 vendor 外,用 manifest 和 contract test 管理升级。 + +### 25.2 材料正文质量不一致 + +处理:MaterialProvider 区分 snippet 和正文;低于正文阈值时由 DeerFlow 正文工具补齐;记录 material quality,报告不得把 snippet 当完整证据。 + +### 25.3 递归研究费用不可控 + +处理:depth/breadth 之外增加总 query、总正文、总模型调用和 Token 硬预算;预算快到上限时先合成已有结论。 + +### 25.4 SSE 消息量过大 + +处理:正文不放事件;report chunk 做节流/合并;事件只存摘要和 locator;支持 seq 补发。 + +### 25.5 多实例重复执行 + +处理:数据库唯一 active key、lease、heartbeat、version CAS;产物写入使用临时文件后原子替换;最终状态更新幂等。 + +### 25.6 上游阻塞代码卡住事件循环 + +处理:优先异步化;无法立即改造的纯 CPU/同步转换使用 `asyncio.to_thread`;网络请求不得用同步 requests。 + +### 25.7 导出依赖影响离线部署 + +处理:MD/HTML 为核心能力,PDF/DOCX 是显式可选依赖和 capability,不因为导出阻塞核心功能上线。 + +### 25.8 许可证元数据不一致 + +处理:合入 vendor 前法律/项目方确认;保留根 LICENSE、版权头、修改说明,不复制无关资产。 + +--- + +## 26. 开发前最终确认项 + +以下选择不会阻塞本文方案,但 Phase 1 开工前应在配置层确认: + +1. 第一批允许的材料渠道是仅 `web_search`,还是同时包含知识库、技能、MCP。 +2. 默认模型角色映射:fast/smart/strategic 是否都使用当前默认模型,还是管理员分别配置。 +3. session 删除是否同步删除隐藏 runtime thread 和所有产物。 +4. 默认最大研究预算和每用户并发数。 +5. 是否在首个版本就启用人工计划审核;即使不启用,后端状态和表结构仍应预留。 +6. PDF/DOCX 是否作为首发硬需求;如果是,需要提前准备离线系统依赖和 Python wheel。 + +推荐默认值:第一版先开放 `web_search` 和已存在的内部知识材料;模型角色都映射到当前默认模型;删除会话同步删除内部产物;每用户最多 2 个 active job;人工审核随 multi-agent 阶段上线;首发只保证 MD/HTML。 + +--- + +## 27. 最终判断 + +该功能可以在当前 DeerFlow 上实现,且总体风险可控。最重要的工程决策不是“是否复用 GPT Researcher”,而是把复用边界划清: + +- 复用其已经成熟的研究规划、递归研究、报告生成和多智能体编审算法。 +- 不复用其与 DeerFlow 重叠且治理较弱的搜索、抓取、MCP、模型 Provider、演示服务和前端。 +- 用 Adapter 保证研究算法只消费 DeerFlow 提供的材料、模型、事件和产物能力。 +- 用 LangGraph 1.x 原生 interrupt/checkpointer 和 DeerFlow durable job 解决暂停、恢复、多实例与刷新续接。 +- 用独立页面和独立领域包避免影响现有 AI 写作和聊天主链路。 + +按本文 Phase 0 到 Phase 4 实施后,可以覆盖 GPT Researcher 的主要生产功能,同时保留 DeerFlow 当前信息收集体系和 LangGraph 1.x 依赖,不需要再维护一套旧运行时。 diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 6b9f815d..cd61334e 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -22,10 +22,10 @@ import { useEffect, useMemo, useRef, - useState, - type ComponentProps, - type ReactNode, -} from "react"; + useState, + type ComponentProps, + type ReactNode, +} from "react"; import { PromptInput, @@ -62,10 +62,10 @@ import { } from "@/components/ui/dropdown-menu"; import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; -import { useI18n } from "@/core/i18n/hooks"; -import { isHiddenFromUIMessage } from "@/core/messages/utils"; -import { useModels } from "@/core/models/hooks"; -import type { Model } from "@/core/models/types"; +import { useI18n } from "@/core/i18n/hooks"; +import { isHiddenFromUIMessage } from "@/core/messages/utils"; +import { useModels } from "@/core/models/hooks"; +import type { Model } from "@/core/models/types"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; @@ -134,11 +134,11 @@ export function InputBox({ onFollowupsVisibilityChange, onSubmit, onStop, - promptPrefix, - agentSelector, - knowledgeSelector, - extraModels = [], - assistantId, + promptPrefix, + agentSelector, + knowledgeSelector, + extraModels = [], + assistantId, hideAdminRecommendedQuestions, compact, ...props @@ -194,28 +194,28 @@ export function InputBox({ * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the * thread is already bound to one agent. */ - agentSelector?: { - selectedAgentId: string | null; - onAgentChange: (agentId: string | null) => void; - }; - /** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */ - knowledgeSelector?: ReactNode; - /** Page-scoped model choices implemented outside the DeerFlow model API. */ - extraModels?: Model[]; -}) { + agentSelector?: { + selectedAgentId: string | null; + onAgentChange: (agentId: string | null) => void; + }; + /** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */ + knowledgeSelector?: ReactNode; + /** Page-scoped model choices implemented outside the DeerFlow model API. */ + extraModels?: Model[]; +}) { const { t } = useI18n(); const [localSettings, setLocalSettings] = useLocalSettings(); const searchParams = useSearchParams(); - const [modelDialogOpen, setModelDialogOpen] = useState(false); - const { models } = useModels(); - const singleRoundRagActive = - context.execution_profile === "single_round_rag"; - const multiRoundRagActive = - context.execution_profile === "multi_round_rag"; - const frontendRagActive = singleRoundRagActive || multiRoundRagActive; - const modelOptions = useMemo(() => { - return singleRoundRagActive ? extraModels : models; - }, [extraModels, models, singleRoundRagActive]); + const [modelDialogOpen, setModelDialogOpen] = useState(false); + const { models } = useModels(); + const singleRoundRagActive = + context.execution_profile === "single_round_rag"; + const multiRoundRagActive = + context.execution_profile === "multi_round_rag"; + const frontendRagActive = singleRoundRagActive || multiRoundRagActive; + const modelOptions = useMemo(() => { + return singleRoundRagActive ? extraModels : models; + }, [extraModels, models, singleRoundRagActive]); const { thread, isMock } = useThread(); const { textInput } = usePromptInputController(); const promptRootRef = useRef(null); @@ -238,46 +238,46 @@ export function InputBox({ !userPreferences.hide_admin_prompt_prefix && recommendedQuestions.length > 0; - useEffect(() => { - // Multi-round RAG is a frontend execution profile, not a model option. - // Do not let the normal model fallback clear it on every render. - if (multiRoundRagActive) { - return; - } - if (modelOptions.length === 0) { - return; - } - const currentModel = modelOptions.find((m) => m.name === context.model_name); - const fallbackModel = currentModel ?? modelOptions[0]!; + useEffect(() => { + // Multi-round RAG is a frontend execution profile, not a model option. + // Do not let the normal model fallback clear it on every render. + if (multiRoundRagActive) { + return; + } + if (modelOptions.length === 0) { + return; + } + const currentModel = modelOptions.find((m) => m.name === context.model_name); + const fallbackModel = currentModel ?? modelOptions[0]!; const supportsThinking = fallbackModel.supports_thinking ?? false; const nextModelName = fallbackModel.name; const nextMode = getResolvedMode(context.mode, supportsThinking); - if ( - context.model_name === nextModelName && - context.mode === nextMode && - context.execution_profile === fallbackModel.execution_profile - ) { - return; - } + if ( + context.model_name === nextModelName && + context.mode === nextMode && + context.execution_profile === fallbackModel.execution_profile + ) { + return; + } onContextChange?.({ - ...context, - model_name: nextModelName, - mode: nextMode, - execution_profile: fallbackModel.execution_profile, - }); - }, [context, modelOptions, multiRoundRagActive, onContextChange]); - - const selectedModel = useMemo(() => { - if (modelOptions.length === 0) { - return undefined; - } - return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0]; - }, [context.model_name, modelOptions]); + ...context, + model_name: nextModelName, + mode: nextMode, + execution_profile: fallbackModel.execution_profile, + }); + }, [context, modelOptions, multiRoundRagActive, onContextChange]); - const resolvedModelName = selectedModel?.name; - const selectedSingleRoundRag = singleRoundRagActive; + const selectedModel = useMemo(() => { + if (modelOptions.length === 0) { + return undefined; + } + return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0]; + }, [context.model_name, modelOptions]); + + const resolvedModelName = selectedModel?.name; + const selectedSingleRoundRag = singleRoundRagActive; const supportThinking = useMemo( () => selectedModel?.supports_thinking ?? false, @@ -291,72 +291,72 @@ export function InputBox({ const handleModelSelect = useCallback( (model_name: string) => { - const model = modelOptions.find((m) => m.name === model_name); + const model = modelOptions.find((m) => m.name === model_name); if (!model) { return; } onContextChange?.({ ...context, model_name, - mode: getResolvedMode(context.mode, model.supports_thinking ?? false), - reasoning_effort: context.reasoning_effort, - execution_profile: model.execution_profile, - }); - setModelDialogOpen(false); - }, - [onContextChange, context, modelOptions], + mode: getResolvedMode(context.mode, model.supports_thinking ?? false), + reasoning_effort: context.reasoning_effort, + execution_profile: model.execution_profile, + }); + setModelDialogOpen(false); + }, + [onContextChange, context, modelOptions], ); - const handleModeSelect = useCallback( - (mode: InputMode) => { - const ordinaryModel = - models.find((model) => model.name === context.model_name) ?? models[0]; - onContextChange?.({ - ...context, - model_name: ordinaryModel?.name ?? context.model_name, - mode: getResolvedMode( - mode, - ordinaryModel?.supports_thinking ?? supportThinking, - ), - reasoning_effort: - mode === "ultra" - ? "high" + const handleModeSelect = useCallback( + (mode: InputMode) => { + const ordinaryModel = + models.find((model) => model.name === context.model_name) ?? models[0]; + onContextChange?.({ + ...context, + model_name: ordinaryModel?.name ?? context.model_name, + mode: getResolvedMode( + mode, + ordinaryModel?.supports_thinking ?? supportThinking, + ), + reasoning_effort: + mode === "ultra" + ? "high" : mode === "pro" ? "medium" : mode === "thinking" - ? "low" - : "minimal", - execution_profile: undefined, - }); - }, - [onContextChange, context, models, supportThinking], - ); - - const handleSingleRoundRagSelect = useCallback(() => { - const ragModel = - extraModels.find((model) => model.name === context.model_name) ?? - extraModels[0]; - if (!ragModel) { - return; - } - - onContextChange?.({ - ...context, - model_name: ragModel.name, - mode: getResolvedMode( - context.mode, - ragModel.supports_thinking ?? false, - ), - execution_profile: "single_round_rag", - }); - }, [context, extraModels, onContextChange]); - - const handleMultiRoundRagSelect = useCallback(() => { - onContextChange?.({ - ...context, - execution_profile: "multi_round_rag", - }); - }, [context, onContextChange]); + ? "low" + : "minimal", + execution_profile: undefined, + }); + }, + [onContextChange, context, models, supportThinking], + ); + + const handleSingleRoundRagSelect = useCallback(() => { + const ragModel = + extraModels.find((model) => model.name === context.model_name) ?? + extraModels[0]; + if (!ragModel) { + return; + } + + onContextChange?.({ + ...context, + model_name: ragModel.name, + mode: getResolvedMode( + context.mode, + ragModel.supports_thinking ?? false, + ), + execution_profile: "single_round_rag", + }); + }, [context, extraModels, onContextChange]); + + const handleMultiRoundRagSelect = useCallback(() => { + onContextChange?.({ + ...context, + execution_profile: "multi_round_rag", + }); + }, [context, onContextChange]); const handleReasoningEffortSelect = useCallback( (effort: "minimal" | "low" | "medium" | "high") => { @@ -422,11 +422,11 @@ export function InputBox({ onContextChange?.({ ...context, model_name: resolvedModelName, - mode: getResolvedMode( - context.mode, - selectedModel?.supports_thinking ?? false, - ), - execution_profile: selectedModel?.execution_profile, + mode: getResolvedMode( + context.mode, + selectedModel?.supports_thinking ?? false, + ), + execution_profile: selectedModel?.execution_profile, }); setTimeout(() => onSubmit?.(message), 0); return; @@ -441,8 +441,8 @@ export function InputBox({ onStop, resolvedModelName, ragModeEnabled, - selectedModel?.supports_thinking, - selectedModel?.execution_profile, + selectedModel?.supports_thinking, + selectedModel?.execution_profile, status, threadId, ], @@ -500,9 +500,9 @@ export function InputBox({ }, [pendingSuggestion, requestFormSubmit, textInput]); const showFollowups = - !disabled && - !isNewThread && - !selectedSingleRoundRag && + !disabled && + !isNewThread && + !selectedSingleRoundRag && !followupsHidden && (followupsLoading || followups.length > 0); @@ -528,19 +528,19 @@ export function InputBox({ return; } - if (disabled || isMock || selectedSingleRoundRag) { + if (disabled || isMock || selectedSingleRoundRag) { return; } - const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m)); - const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai"); + const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m)); + const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai"); const lastAiId = lastAi?.id ?? null; if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { return; } lastGeneratedForAiIdRef.current = lastAiId; - const recent = visibleMessages + const recent = visibleMessages .filter((m) => m.type === "human" || m.type === "ai") .map((m) => { const role = m.type === "human" ? "user" : "assistant"; @@ -590,7 +590,7 @@ export function InputBox({ }); return () => controller.abort(); - }, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]); + }, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]); return (
@@ -677,7 +677,7 @@ export function InputBox({ : "*:data-[slot='input-group']:rounded-3xl", // 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边) compact && isNewThread - ? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800" + ? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800" : // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边 // 对话态:常规白卡 + 中性边框 + 轻阴影 isNewThread @@ -727,16 +727,16 @@ export function InputBox({ /> */} - {!frontendRagActive && ( - - )} - {!frontendRagActive && + )} + {!frontendRagActive && - {context.writing_mode && ( - - )} - - 写作 - - } - {!frontendRagActive && + {context.writing_mode && ( + + )} + + 写作 + + } + {!frontendRagActive && - {memoryInjectionEnabled && ( - - )} - - 记忆 - - } - {!frontendRagActive && + {memoryInjectionEnabled && ( + + )} + + 记忆 + + } + {!frontendRagActive && 参考文献 - } - {!frontendRagActive && } + {!frontendRagActive && 上下文压缩 - } + } - + ? context.mode + : "flash" + } + tooltipContent={ + singleRoundRagActive + ? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料" + : multiRoundRagActive + ? "多轮 RAG 问答:多次检索并展示检索过程与参考资料" + : undefined + } + > -
- {frontendRagActive ? ( - - ) : context.mode === "flash" ? ( - - ) : context.mode === "thinking" ? ( - - ) : context.mode === "pro" ? ( - - ) : context.mode === "ultra" ? ( - - ) : null} -
+ > +
+ {frontendRagActive ? ( + + ) : context.mode === "flash" ? ( + + ) : context.mode === "thinking" ? ( + + ) : context.mode === "pro" ? ( + + ) : context.mode === "ultra" ? ( + + ) : null} +
- {frontendRagActive - ? multiRoundRagActive - ? "多轮 RAG" - : "单轮 RAG" - : (context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && - t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} -
+ {frontendRagActive + ? multiRoundRagActive + ? "多轮 RAG" + : "单轮 RAG" + : (context.mode === "flash" && t.inputBox.flashMode) || + (context.mode === "thinking" && + t.inputBox.reasoningMode) || + (context.mode === "pro" && t.inputBox.proMode) || + (context.mode === "ultra" && t.inputBox.ultraMode)} +
@@ -918,11 +918,11 @@ export function InputBox({ {t.inputBox.mode} - handleModeSelect("flash")} > @@ -930,9 +930,9 @@ export function InputBox({
{t.inputBox.flashMode} @@ -941,7 +941,7 @@ export function InputBox({ {t.inputBox.flashModeDescription}
- {!frontendRagActive && context.mode === "flash" ? ( + {!frontendRagActive && context.mode === "flash" ? ( ) : (
@@ -949,9 +949,9 @@ export function InputBox({ {supportThinking && ( handleModeSelect("thinking")} @@ -960,9 +960,9 @@ export function InputBox({
{t.inputBox.reasoningMode} @@ -971,7 +971,7 @@ export function InputBox({ {t.inputBox.reasoningModeDescription}
- {!frontendRagActive && context.mode === "thinking" ? ( + {!frontendRagActive && context.mode === "thinking" ? ( ) : (
@@ -979,8 +979,8 @@ export function InputBox({ )} @@ -1001,15 +1001,15 @@ export function InputBox({ {t.inputBox.proModeDescription}
- {!frontendRagActive && context.mode === "pro" ? ( + {!frontendRagActive && context.mode === "pro" ? ( ) : (
)}
{t.inputBox.ultraMode} @@ -1039,78 +1039,78 @@ export function InputBox({ {t.inputBox.ultraModeDescription}
- {!frontendRagActive && context.mode === "ultra" ? ( + {!frontendRagActive && context.mode === "ultra" ? ( ) : (
)} - - {extraModels.length > 0 && ( - <> - - -
-
- - 单轮 RAG 问答 -
-
- 基于知识库检索生成答案,并展示思考内容与参考资料 -
-
- {selectedSingleRoundRag ? ( - - ) : ( -
- )} - - - )} - -
-
- - 多轮 RAG 问答 -
-
- 多次检索、展示过程与参考资料,适合复杂问题 -
-
- {multiRoundRagActive ? ( - - ) : ( -
- )} - - + + {extraModels.length > 0 && ( + <> + + +
+
+ + 单轮 RAG 问答 +
+
+ 基于知识库检索生成答案,并展示思考内容与参考资料 +
+
+ {selectedSingleRoundRag ? ( + + ) : ( +
+ )} + + + )} + +
+
+ + 多轮 RAG 问答 +
+
+ 多次检索、展示过程与参考资料,适合复杂问题 +
+
+ {multiRoundRagActive ? ( + + ) : ( +
+ )} + + - {!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && ( + {!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
@@ -1226,10 +1226,9 @@ export function InputBox({ )} - - - {!frontendRagActive && agentSelector && ( - {!frontendRagActive && knowledgeSelector} + + + {!frontendRagActive && agentSelector && ( )} - {!frontendRagActive && promptPrefix && ( + {!frontendRagActive && knowledgeSelector} + {!frontendRagActive && promptPrefix && ( )} - {!multiRoundRagActive && @@ -1292,7 +1292,7 @@ export function InputBox({ - {modelOptions.map((m) => ( + {modelOptions.map((m) => (
{m.display_name} - - {selectedSingleRoundRag - ? `单轮知识库问答 · ${m.name}` - : m.model} - + + {selectedSingleRoundRag + ? `单轮知识库问答 · ${m.name}` + : m.model} +
{m.name === context.model_name ? ( @@ -1312,15 +1312,15 @@ export function InputBox({
)} - ))} - + ))} + - } + } ; - onEditResubmit?: (text: string) => void; - onEditSave?: (messageId: string, text: string) => void | Promise; - onMessageDeleted?: (deletedIds: string[]) => void; + displaySources?: RuntimeDisplaySource[]; + extraRehypePlugins?: ReturnType; + onEditResubmit?: (text: string) => void; + onEditSave?: (messageId: string, text: string) => void | Promise; + onMessageDeleted?: (deletedIds: string[]) => void; /** * Durable ask time of the turn this message belongs to (the preceding * question's `client_ts`). Used to time-stamp the AI answer, which carries no @@ -185,102 +191,102 @@ export function MessageListItem({ * wall clock at render time and appear to "drift" to now on every reload. */ turnTime?: string; -}) { - const { t } = useI18n(); - const isHuman = message.type === 'human'; - const messageMarkdown = - extractContentFromMessage(message) ?? - extractReasoningContentFromMessage(message) ?? - ''; - const [assistantEditing, setAssistantEditing] = useState(false); - const [assistantDraft, setAssistantDraft] = useState(messageMarkdown); - const [assistantSaving, setAssistantSaving] = useState(false); - - useEffect(() => { - if (!assistantEditing) { - setAssistantDraft(messageMarkdown); - } - }, [assistantEditing, messageMarkdown]); - - const canSaveAssistantEdit = - !isLoading && - !isHuman && - !!onEditSave && - !!message.id && - !message.id.startsWith('opt-') && - !!messageMarkdown; - const confirmAssistantEdit = useCallback(async () => { - const value = assistantDraft.trim(); - if (!value || !message.id || !onEditSave || assistantSaving) { - return; - } - setAssistantSaving(true); - try { - await onEditSave(message.id, value); - setAssistantEditing(false); - } catch { - // The page-level save callback owns user-facing error toasts. Keep the - // editor open so the user can adjust and retry. - } finally { - setAssistantSaving(false); - } - }, [assistantDraft, assistantSaving, message.id, onEditSave]); - - if (!isHuman && assistantEditing) { - return ( - - -
-