diff --git a/frontend-web/docs/hebing-1.md b/frontend-web/docs/hebing-1.md deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend-web/docs/hebing.md b/frontend-web/docs/hebing.md deleted file mode 100644 index 4bbe4153..00000000 --- a/frontend-web/docs/hebing.md +++ /dev/null @@ -1,140 +0,0 @@ -# App.tsx 路由与页面结构重构需求(首页与业务页分离) - -## 一、需求背景 - -当前 `frontend-web/src/App.tsx` 同时承载了: - -1. 登录/首页相关路由(如 `/login`、`/landing`) -2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`) -3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`) - -这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。 - ---- - -## 二、目标(你提出的核心诉求) - -你希望: - -1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。 -2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。 -3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。 - -> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。 - ---- - -## 三、重构范围 - -### 1)保留在 `App.tsx` 的内容 - -- 全局 Provider:`HashRouter`、`AppProviders` -- 登录/首页路由: - - `/` - - `/login` - - `/login/:username` - - `/landing` -- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一) - -### 2)迁移出 `App.tsx` 的内容 - -将以下与工作台有关的路由从 `App.tsx` 抽离: - -- `/workspace`(重定向) -- `/workspace/chats` -- `/workspace/chats/:thread_id` -- `/workspace/agents` -- `/workspace/agents/new` -- `/workspace/agents/:agent_id/chats`(重定向) -- `/workspace/agents/:agent_id/chats/:thread_id` -- `/workspace/scheduled` -- `/workspace/scheduled/tasks` -- `/workspace/scheduled/runs/:run_id` - -以及配套的包装组件职责: - -- `WorkspaceLayout` -- `ChatRuntime` - ---- - -## 四、建议的新结构(方案) - -## 方案 A(推荐):新增“业务路由容器组件” - -在 `pages` 下新增一个路由容器组件,例如: - -- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`) - -职责: -- 专门维护 `/workspace/*` 相关路由 -- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime` -- 输出一个可被 `App.tsx` 挂载的路由节点 - -同时在 `App.tsx` 里只保留: -- 登录/首页/404 -- 一个 `Route path="/workspace/*" element={} />` - ---- - -## 五、“再建立一个页面放置导航”的落地解释 - -你说的“建立一个页面放置这些导航”,可以有两种理解: - -1. **路由容器页(推荐)**: - - 不强调视觉 UI,重点是集中管理导航与路由跳转规则。 - - 即上面的 `WorkspaceRoutes.tsx`。 - -2. **可见的导航页(可选)**: - - 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。 - - 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。 - -当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。 - ---- - -## 六、实施步骤(开发任务拆解) - -1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。 -2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。 -3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。 -4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。 -5. 校验以下访问路径行为一致: - - `/workspace` 重定向 - - chats/agents/scheduled 各页面可正常进入 - - 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常 - - 404 不受影响 -6. 运行前端并做手工回归。 - ---- - -## 七、验收标准 - -1. `App.tsx` 明显瘦身,主要负责顶层入口路由。 -2. `/workspace/*` 全部由新建组件统一管理。 -3. 功能行为与改造前一致(或符合新定义)。 -4. 代码可读性提升:登录入口与业务入口职责分离。 - ---- - -## 八、注意事项 - -1. 当前项目使用 `HashRouter`,路由写法需保持兼容。 -2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。 -3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。 -4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。 - ---- - -## 九、建议的命名(可直接采用) - -- 新增组件文件:`src/pages/WorkspaceRoutes.tsx` -- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx` - ---- - -## 十、后续可选优化 - -1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。 -2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。 -3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。 diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index c04bce2e..e759d19d 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -1,14 +1,65 @@ # 多智能体圆桌研讨 · 后端开发文档 -本文档面向需要维护或扩展"多智能体圆桌研讨"功能后端的开发者。前端 API 速查请看 `multi-agent-api.md`,此文档关注**实现细节、设计取舍、性能特征**。 +本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`;前端实现见 `multi-agent-frontend-dev.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若未提供则当作待补文件)。 + +> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,**当前仓库未提供**这两份文件;Step 1(intent)与 Step 3(artifacts)的接口签名直接参考本页第 4 / 7 节即可。 + +源码内已有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py`、`artifacts.py` 及 `clarification_utils.py`),与本文互为补充。 --- -## 1. 功能定位 +## 0. 速读 -把"任务目标 + 用户约束"丢给一个**协调智能体**,由它通过 `agent_orchestration` 技能调度 6 个固定职能的子智能体(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述)协同产出方案,直到协调智能体认为信息足够、停止派活并输出最终结论为止。 +- 三个圆桌 Router(`intent / recommend / multi_agent`)**通过 httpx ASGITransport 进程内直调本机 ASGI app**,复用既有 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream` 路由——**不走 TCP / 不经过网络栈**。所有 client 都由 `multi_agent._make_loopback_client(request)` 工厂统一构造。 +- 历史包袱:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:`,遇到开发机 `HTTP_PROXY` / `HTTPS_PROXY` 时仍可能把回环走代理(症状 `headers_in=42.160s`)。**改用 ASGITransport 后此问题不再存在**,相关 `trust_env=False` workaround 全部移除。 +- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。 +- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**——任何环境都必须把 `roundtable-intent` + `roundtable-recommender` 两个**功能型 agent** 持久化进发布物,否则 `/api/intent/stream` 报 500。**互联网 demo 还需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。 +- **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。 +- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。 +- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。 +- **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。 +- **后端 picks clamp 2–6,前端用户可手动加到 8**。 +- **不写死任何具体模型名**:`intent / recommend / multi_agent` 三个 router 的 `model_name = str(payload.get("model") or "")`,空串会被下游 `lead_agent._resolve_model_name()` 自动回退到 `config.yaml` 的 `models[0]`(与 `/api/ai-writing` 行为一致)。内网部署只需配 `config.yaml`,前后端均无需改硬编码。 -对应前端页面:`/page/roundtable/planning` 的 **Step 2 多智能体研讨**。 +--- + +## 1. 功能定位与三步数据流 + +对应前端页面:`/page/workspace/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。 + +``` +Step 1 任务意图理解 + POST /api/intent/init → 建 thread,绑定内置 roundtable-intent + POST /api/intent/stream (多轮) → 澄清对话,终态 [INTENT_READY] + summary + + ↓ intentReady { objective, constraints, assumptions } + +推荐弹窗(Step 1 → Step 2 之间,可选) + POST /api/recommend/stream → 从候选池选 2–6 个席位,[RECOMMEND_READY] + picks + + ↓ selectedAgents[] // 前端可再手动调整到 2-8 + +Step 2 多智能体圆桌研讨 + POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(asyncio.as_completed 流式推 seat_ready) + POST /api/multi-agent/run/stream → leader 派活 / special 执行 / 澄清 + + ↓ lastLeaderContent、各席位交付、hasConsensus + +Step 3 最终方案展示 + GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件 + GET /api/threads/{tid}/artifacts/{path} → 预览或下载 + PATCH /api/threads/{tid}/artifacts/{path} → 文本类产物原地编辑(可选) + (总控结论、席位文本主要来自 Step 2 前端内存缓存,一般不再调 run/stream) +``` + +| 步骤 | 前端 `currentStep` | 专属 Router | 内置 Agent | +|------|-------------------|-------------|------------| +| 1 | 1 | `intent.py` | `roundtable-intent`(固定 id,复用) | +| 1→2 | 推荐弹窗 | `recommend.py` | `roundtable-recommender`(每请求新建 thread) | +| 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 | +| 3 | 3 | `artifacts.py`(通用) | 无新 agent | + +所有接口需 `Authorization: Bearer `。Loopback 调用一律由 `_make_loopback_client(request)` 构造(`httpx.ASGITransport(app=request.app)`,base_url 占位 `http://loopback`),**不经 TCP**,因此不再受本机 `HTTP_PROXY` 干扰。 --- @@ -17,343 +68,1113 @@ ``` offline-backend-20260512/backend/ ├── app/gateway/ -│ ├── app.py # 注册 multi_agent.router +│ ├── app.py # include_router: multi_agent, intent, recommend (artifacts 已存在) │ └── routers/ -│ └── multi_agent.py # 本功能唯一新增的 router +│ ├── multi_agent.py # Step 2:init(SSE) + run/stream + 共享的 loopback 工具 +│ ├── intent.py # Step 1:init + stream(import multi_agent 工具) +│ ├── recommend.py # Step 1→2:推荐 stream(import multi_agent 工具) +│ └── artifacts.py # Step 3:列出/下载/编辑 thread 产出(通用,非圆桌专用) +├── packages/harness/deerflow/tools/builtins/ +│ └── clarification_utils.py # resolve_allow_multiple + coerce_bool(Step1/Step2 共享) └── .deer-flow/agents/ - ├── roundtable-intelligence/ # 6 个内置子智能体目录 - │ ├── config.yaml # ├── id / name / description - │ └── SOUL.md # └── 角色 prompt + ├── roundtable-intent/ # Step 1 任务理解(SOUL 含完整工作流,可不挂 Skill) + ├── roundtable-recommender/ # 推荐智能体 + ├── roundtable-intelligence/ # 默认可选席位(6 个,前端可替换) ├── roundtable-environment/ ├── roundtable-solution-design/ ├── roundtable-risk-review/ ├── roundtable-execution-plan/ - └── roundtable-summary/ + ├── roundtable-summary/ + └── roundtable-coordinator-/ # 协调智能体壳,每次会话新建,长期累积 ``` -**零代码改动构造内置 agent**:6 个目录通过既有的 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。 +**内置 agent 注册**:各 `roundtable-*` 目录通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次 `/api/agents` 请求时 upsert 进 `agents` 表,`user_id IS NULL` 表示内置。`recommend.py` 不依赖此表,前端会过滤 `roundtable-coordinator-*` 出候选池。 + +**Router 间共享导出**:`intent.py` 与 `recommend.py` 从 `multi_agent.py` import `_auth_headers / _create_thread / _flatten_content / _loopback_base / _parse_last_messages / _run_payload / _stream_upstream` 等底层工具,**不要**在 intent / recommend 重复实现 loopback 逻辑。 + +**共享工具逻辑**:`clarification_utils.py` 的 `resolve_allow_multiple(args)` 与 `coerce_bool(value, default)` 被 `intent.py` / `multi_agent.py` / `ClarificationMiddleware` 共用: +- `coerce_bool` 把 LLM tool args 里可能出现的 bool / 数字 / 字符串(`"true"`、`"1"`、`"yes"`、`"y"`、`"on"` / `"false"`、`"0"`、`"no"`、`"n"`、`"off"`)统一成 bool; +- `resolve_allow_multiple` 仅 `args["allow_multiple"]` 显式为真时返回 `True`,缺省 / `null` 都视为 `False`(单选)。 --- ## 3. 接口总览 -路由前缀 `/api/multi-agent`,所有接口需要 `Authorization: Bearer `。 +| 方法 | 路径 | 步骤 | 用途 | +|------|------|------|------| +| POST | `/api/intent/init` | 1 | 创建意图澄清 thread | +| POST | `/api/intent/stream` | 1 | SSE,一轮用户对话 | +| POST | `/api/recommend/stream` | 1→2 | SSE,一次性推荐席位 | +| POST | `/api/multi-agent/init` | 2 | SSE,**按完成顺序**推 `seat_ready`,最后 `init_done` | +| POST | `/api/multi-agent/run/stream` | 2 | SSE,leader 或 special 一轮 | +| GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 | +| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出(含 `.skill/` 内文件提取) | +| PATCH | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 编辑文本类产物(白名单后缀) | -| 方法 | 路径 | 用途 | -|---|---|---| -| POST | `/api/multi-agent/init` | 一次性创建协调智能体 + 6 个子席位线程,返回 `thread_ids` 映射 | -| POST | `/api/multi-agent/run/stream` | SSE 流式驱动一次 leader 或 special 调用 | - -字段细节见 `multi-agent-api.md`,这里不重复。 +字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。 --- -## 4. 内部实现详解 +## 4. Step 1:任务理解(`intent.py`) -### 4.1 `/init` 内部流程 +### 4.1 定位 -``` -[阶段1] 并行准备 6 个子智能体席位 (asyncio.gather) - ├─ GET /api/agents/{id} × 6 → 取 name + description - └─ POST /api/threads × 6 → 建新 thread +轻量包装 Lead Agent 运行时,固定跑内置 `roundtable-intent`(`INTENT_AGENT_ID` 常量)。不做 broadcast、不派活;每轮用户消息对应一次 `/stream`。 -[阶段2] 创建协调智能体 - ├─ POST /api/threads → 协调智能体的 thread - └─ POST /api/agents (with model + skills=[agent_orchestration] + soul) - └─ soul 里包含可调度的 6 个 agent_name + description 列表 -``` +### 4.2 `POST /api/intent/init` -**协调智能体 SOUL 是动态拼出来的**(`multi_agent.py:216-224`): +1. `_create_thread()` → LangGraph thread +2. 返回固定 `agent_id: "roundtable-intent"`(**不**每次新建 agent) + +请求体:`{"model": "deepseek-chat"}` — `model` 可选,**此处不写入 thread**,每轮 `/stream` 自己带 model。 + +与 Step 2 `/init` 的区别:无协调智能体、无 `agent_names` 列表。 + +### 4.3 `POST /api/intent/stream` + +**入参**:`{thread_id, message, model?}`,缺 `thread_id` 或 `message` → 400。 + +**上游 run_payload**:见 §6.6;`excluded_tools` 屏蔽**除 `ask_clarification` 外的几乎全部工具**,硬清单: ```python -soul = ( - "#核心定位:\n" - "用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,...\n" - "#调度方法:\n" - "通过使用agent_orchestration技能调用其他智能体干活,...\n" - "#可调度智能体:\n" - f"{descriptions}" # ← 6 个子席位的 name+description -) +[ + "web_search", + "present_files", + "view_image", + "agent_orchestration", + "bash", "ls", "read_file", "write_file", "str_replace", + "memory", "hindsight_recall", "hindsight_reflect", "hindsight_retain", + "task", "write_todos", +] ``` -由于 `main_agent_name` 由前端每次新建(`roundtable-coordinator-{timestamp36}`),所以协调智能体本身是一次性的、用完即弃。 +写死黑名单而不是「只允许 ask_clarification」的原因:SOUL 文字约束不可靠,硬排除更稳;防止模型偏离去写方案文档或派活。 -### 4.2 `/run/stream` 内部流程 +**流式**:透传 LangGraph SSE → `_parse_last_messages(captured)` 从倒数 `values` 快照拿 `messages`。 -接收 `agent_type` 后走两条分支: +**终态帧判定顺序(重要,勿调整)**: -#### `agent_type = "leader"` (协调智能体) +1. **`[INTENT_READY]` 优先**:从最新消息向前扫描每条 AIMessage,正则 `_INTENT_READY_PATTERN = /\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```/s` 命中后 `json.loads` 解析 + - 命中 → `status: "done"`,`summary` 为 JSON、`content` 为含标记的整段 + - **优先于** `ask_clarification`:模型可能在较早的 AIMessage 里已 emit `[INTENT_READY]`,但本轮残留 `ask_clarification` 的 tool_call;不先扫 READY 前端会卡在澄清 UI +2. 否则用 `_find_dispatch_message(messages)` 找最后一条带 `tool_calls` 的 AIMessage,检查是否含 `ask_clarification` + - 命中 → `status: "clarification"`,附结构化字段: + ```json + { + "status": "clarification", + "content": "", + "question": "...", + "clarification_type": "missing_info", + "clarification_context": "...", + "options": ["..."], + "allow_custom": true, + "allow_multiple": false, + "summary": null + } + ``` + - `content` 取 `_find_trailing_ai_text(messages, exclude_text=preamble)`:ClarificationMiddleware 常在 tool 后还有一条纯文本 AIMessage,比 tool_call 旁的短 preamble 更适合作为气泡正文 + - `allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定(缺省 false) +3. 否则 → `status: "asking"`,`content` 为 `_flatten_content(dispatch_msg.content)`,前端继续 chat -``` -1. broadcast「总控收到用户消息」到所有非自己线程 (asyncio.gather) -2. 拼 RunCreateRequest,转发到 POST /api/threads/{coordinator}/runs/stream - - excluded_tools: ["web_search"] - - context.model_name = 用户传的 model -3. 流式透传 LangGraph 事件给前端 -4. 流结束后从最后一帧 values 取最末一条 message -5. 解析 message.tool_calls (agent_orchestration 技能调用): - - 用 _extract_name_task() 提取 [agent_name, task_text] - - 对每个 [agent_name, task]: - broadcast「总控给 X 派活,内容为: ...」到所有非派活双方 -6. 末尾追加状态帧: - { - "status": [["agent_name", "task"], ...], // 空数组 = 共识达成 - "content": "", - "agent_name": "" - } -``` +**异常**:`HTTPException` / 其它异常都返回 `{"status": "error", "content": "...", "summary": null}`,前端展示红色错误气泡。 -#### `agent_type = "special"` (子智能体) - -``` -1. 拼 RunCreateRequest,转发到 POST /api/threads/{sub_agent}/runs/stream - - excluded_tools: ["web_search","ask_clarification","present_files","view_image"] -2. 流式透传 LangGraph 事件 -3. 流结束后从最后一帧 values 取交付内容 (content) -4. broadcast「子智能体 X 完成 Y 工作,交付内容为: ...」到所有非自己线程 -5. 末尾追加状态帧: - { - "status": "子智能体 X 完成 Y 工作", - "content": "<交付内容>", - "agent_name": "" - } -``` - -### 4.3 _broadcast 机制 - -```python -async def _broadcast(client, base, headers, message, agent_threads, skip): - targets = [tid for name, tid in agent_threads.items() if name not in skip] - await asyncio.gather(*(_append_thread_message(...) for tid in targets)) -``` - -`_append_thread_message` 对每个目标 thread: - -``` -GET /api/threads/{id}/state # 拿当前 messages 列表 -state.values.messages.append({"type":"human","content":...}) -POST /api/threads/{id}/state # 写回完整 state -``` - -这是**为了让每个子线程的对话历史里都能看到全局上下文**(其他席位说了什么、协调智能体怎么派活的)。子智能体下次被调用时,模型自然能基于这份"群消息回放"做判断。 - -**关键不变量**:`skip` 列表通常包含发起方和接收方,避免发起方收到回声、接收方收到重复消息。 - ---- - -## 5. 6 个内置 agent SOUL 设计要点 - -每个 `roundtable-*` 的 SOUL.md 遵循统一结构: - -``` -# {席位名} - -## 角色定位 -你是"多智能体圆桌研讨"会议的 **{席位}** 席位。会议由"总控协调智能体"统一调度, -你只在被派到任务时发言,不需要主动推动议程。 - -## 核心职责 -- ...具体职责... - -## 输出风格 -- 每次发言以 {emoji} 开头 -- 第一句直接给结论 -- 不超过 4 句 - -## 边界 -- 只产出 {本席位输出} -- 不替其他席位做 {其他席位输出} -- 任务结束后停下,等待总控派下一个任务 - -## 示例输出 -> ... -``` - -| 席位 | agent_id | emoji | 核心职责 | -|---|---|---|---| -| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险预警 | -| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 量化承载力 | -| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 | -| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 | -| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源排布 | -| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 + 盖印 | - -**为什么不让子智能体自己调度**:所有 SOUL 都明文写了"只在被点到时发言,由总控协调智能体派活"。子智能体不装 `agent_orchestration` 技能。只有协调智能体装。这是单调度源,避免多智能体相互抢调度权造成的死循环。 - ---- - -## 6. 关键设计决策 - -### 6.1 为什么用 HTTP loopback 而不是内部函数调用 - -`multi_agent.py` 调用 `/api/threads`、`/api/agents`、`/api/threads/.../runs/stream` 都是用 `httpx` 走 `http://localhost:8001/...` 自身循环回来,而不是直接 `from app.gateway.services import ...`。 - -**好处**: -- 自动复用 Gateway 的鉴权 + CSRF + permission 中间件,不用再手写 -- 复用既有路由的请求验证(Pydantic)和错误响应 -- `Authorization` 头透传,被调端自然知道是哪个用户在调 - -**代价**: 每次调用都有一次额外的 TCP+JWT 解析开销(~10-50ms)。但因为 init 已经做了并行优化(A)、broadcast 已经省掉(C),目前总体耗时是可接受的。 - -如未来需要进一步优化,可改为直接调用 `app/gateway/services.py` 里的内部函数,绕过 HTTP 一层。 - -### 6.2 为什么协调智能体每次新建 - -前端在每次进入 step 2 时生成 `roundtable-coordinator-{Date.now().toString(36)}`,作为 `main_agent_name`。 - -**好处**: -- 不同会话/不同用户/同一用户不同任务之间完全隔离 -- 协调智能体的对话历史就是本次研讨的全部上下文,不会被上次任务污染 -- SOUL 不会被"上次的可调度列表"覆盖(虽然现在 6 个席位是固定的) - -**代价**: 每次都要在 `agents` 表插一行,`.deer-flow/agents//` 下生成新目录。长期会累积大量协调智能体目录,**未来需要清理脚本**(标记 description 以 "你是总控控制协调智能体" 开头的 agent 自动 GC)。 - -### 6.3 子智能体席位为什么固定 6 个 - -- 圆桌的 6 个角色定位是产品级强约束(情报/环境/方案/风险/执行/总结) -- 写在后端 SOUL.md 一处维护,跟其他内置 agent 同等地位 -- 前端只需要把 6 个 hyphen-case 名作为 `agent_names` 传过来即可 - -如果后续要支持"用户自选席位",在前端加 mapping 面板让用户从 `GET /api/agents` 里挑,不动后端。 - -### 6.4 为什么 SSE 末尾还要追加一个 `{status:...}` 帧 - -LangGraph 原生 SSE 里没有"调度结果"这种语义,前端只能从最后一帧 values 里去翻 tool_calls。如果由前端解析,前后端协议会绑死 LangGraph 内部数据形状,迁移成本高。所以后端在流末尾追加一个**协议层的总结帧**: +**`summary` 形状**(`done` 时): ```json { - "status": [["agent_name", "task"], ...] | "<完成提示>", - "content": "<最终 AI 文本>", - "agent_name": "<本轮 agent>" + "objective": "一句话目标", + "constraints": ["硬约束 1", "..."], + "assumptions": ["可选假设"] } ``` -前端 `streamMultiAgent` helper 通过 `isFinalStatusFrame()` 识别该帧(必须同时有 `status` + `content` 键),与其他 LangGraph 帧区分。 +### 4.4 内置 Agent:`roundtable-intent` + +- 配置:`.deer-flow/agents/roundtable-intent/config.yaml` + `SOUL.md` +- SOUL 内写清:工作流、`ask_clarification` 用法、`[INTENT_READY]` 格式、禁止派活/写文件 +- **可不配置 `skills`**;若配置 Skill,SOUL 要求不得偏离澄清与结束协议 --- -## 7. 性能特征 +## 5. 推荐智能体(`recommend.py`,Step 1 → 2) -### 7.1 已应用的优化 +### 5.1 定位 -| ID | 优化 | 收益 | -|---|---|---| -| **A** | `/init` 阶段 1 并行化(`asyncio.gather`) | 阶段 1 从 2-4s 降到 0.3-0.7s | -| **C** | 删除 `/init` 末尾的 intro 广播(SOUL 已含定位) | 阶段 3 整段消失,省 1-2s | +无状态单次调用:每次请求内部 `_create_thread()`,**不暴露 `/init`**。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(剔除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。 -应用前 `/init` 耗时 **3-8s**,应用后 **0.5-1.2s**。 +### 5.2 `POST /api/recommend/stream` -### 7.2 仍然存在的开销点 +**入参校验**: -| 调用 | 单次耗时 | 主因 | -|---|---|---| -| `GET /api/agents/{id}` | 50-300ms | 每次触发 `_sync_legacy_agents()` 扫整个 `.deer-flow/agents/` 目录(目前 30+ 项) | -| `POST /api/threads` | 100-200ms | LangGraph thread 表插入 + 默认 state 初始化 | -| `POST /api/agents` | 100-300ms | 文件系统写入 SOUL.md + config.yaml + agents 表 | -| `GET/POST /state` | 50-150ms × 2 | checkpointer 读写 | -| `POST /runs/stream` 启动 | 50-100ms | RunManager + StreamBridge 初始化 | -| LLM 推理 | **数百毫秒~数十秒** | 取决于 model + 上下文长度 | +- `intent.objective` 必填(trim 后非空) +- `candidates` 必须是非空数组 +- 至少一个有效 `agent_id`(trim 后非空) +- 三者缺一 → 400 -**LLM 推理才是绝对大头**,基础设施开销在十几个百分点以内。 +**Prompt 拼装**(`_build_prompt`): -### 7.3 未来可探索 +``` +任务目标: +约束:; ; ... +关键假设:; ... -- **B**(已识别未做): broadcast 跳过 GET-state,直接 POST 新建态。但 POST 需要构造完整的 state shape,改造成本不小。 -- **D**(已识别未做): loopback HTTP → 内部函数调用。代码改动大,但能省掉 ~20-30 次 HTTP 解析开销。 +候选智能体池: +- (): +- ... + +请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。 +``` + +格式刻意贴近 SOUL 示例,让模型有清晰的复刻模板。 + +**上游 run_payload**:`agent_name=RECOMMENDER_AGENT_ID`、`excluded_tools=["web_search", "present_files", "view_image", "agent_orchestration", "ask_clarification"]` — 纯判断,不调任何工具。 + +**终态分支**: + +- 取**最后一条非空 AIMessage** 的文本(`last_ai_content`) +- 正则 `_RECOMMEND_READY_PATTERN = /\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```/s` 匹配 picks JSON +- `_validate_picks(parsed.picks, candidate_ids)`: + - 跳过非 dict / 缺 `agent_id` / 不在候选池 / 已 seen 的项 + - 至多保留 `_MAX_PICKS = 6` 个 + - 不足 `_MIN_PICKS = 2` → 返回 `[]`,触发 fallback +- 有效 picks ≥ 2 → `status: "done"`,`picks` 有值 +- 否则 → `status: "asking"`(**不是模型在追问用户**,而是「网关未能产出合法 picks」,前端走 FALLBACK 默认 6 席) + +```json +{ + "status": "done", + "content": "<自然语言推荐理由>", + "picks": [{"agent_id": "roundtable-risk-review", "reason": "..."}] +} +``` + +**picks 数量上下限的不一致**(需要心里有数): + +| 边界 | 后端 | 前端弹窗 | +|------|------|----------| +| 下限 | `_MIN_PICKS = 2`(不足返 asking) | `MIN_SELECTED = 2`(确认按钮校验) | +| 上限 | `_MAX_PICKS = 6`(截断模型输出) | `MAX_SELECTED = 8`(允许用户手加 2 个) | + +后端只对**模型输出**做 clamp,用户可手动追加候选池里其它 agent 到 8 个。 + +**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤(`langgraph_node` 含 `TitleMiddleware` / `tags` 含 `middleware:title`),避免污染推荐理由区。 + +### 5.3 与 SOUL 的契约 + +模型须在自然语言思路后输出: + +``` +[RECOMMEND_READY] +```json +{"picks": [{"agent_id": "...", "reason": "..."}, ...]} +``` +``` + +`agent_id` 必须是候选池内已有的 id,`_validate_picks` 会做白名单校验。 --- -## 8. 依赖与扩展点 +## 6. Step 2:多智能体研讨(`multi_agent.py`) -### 8.1 `agent_orchestration` 技能 - -协调智能体强依赖此技能,SOUL 里硬编码: +### 6.1 `POST /api/multi-agent/init` — SSE,非一次性 JSON ``` -通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活 +请求体: +{ + "agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id + "main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成 + "model": "deepseek-chat" // 可选,写入 coordinator agent +} ``` -技能的实际派活产物是一个形如 `python3 scripts/agent_orchestration.py "agent_name" "task"` 的 shell 调用文本,后端用正则提取: +**早期校验**(同步抛 HTTP 4xx,**不**进 SSE): + +- `agent_names` 必须非空 +- 每个 `agent_names` 元素 + `main_agent_name` 都需通过 `_AGENT_ID_PATTERN = ^[A-Za-z0-9_-]+$`(用 `_validate_agent_id` 提前 400;上游若收到不合规 id 会抛 422,提前拦更友好) +- 所有 id 都 `.lower()` 后比较 / 存储 + +**SSE 内部流程**: + +``` +asyncio.as_completed(各席位 _prepare_sub_agent) + 每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"} + (按真实完成顺序,先完成先发,前端因此能逐个点亮) + +异常时:取消所有未完成的 task,避免泄露到 AsyncClient close 之后 + +协调智能体: + POST /api/threads → coord_thread + POST /api/agents + skills=["agent_orchestration"] + soul=动态拼接(本次 init 的席位列表 + 派活规则) + id=main_agent_name(前端生成的 roundtable-coordinator-) + agent_threads[main_agent_name] = coord_thread // ⚠️ thread_ids 含 coordinator 自身 + → data: {"event":"coordinator_ready","main_agent_name","thread_id"} + +→ data: {"event":"init_done","main_agent_name","thread_ids":{...}} + +失败 → data: {"event":"error","detail":"..."} +``` + +**协调智能体创建幂等**:若 `_create_agent` 返回 (False, "exists ...")(同名 agent 已存在)则继续往下走,不报错。注意这里的「已存在」会复用历史 agent 的 SOUL(与本次动态拼接的不同),通常只在前端用同一个时间戳跑过两遍时触发。 + +**协调智能体 SOUL**(动态拼接,与席位列表一起写入): + +``` +#核心定位: +用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总, +当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果 + +#调度方法: +通过使用 agent_orchestration 技能调用其他智能体干活,使用技能时传入 +agent_name 和需要做的活。 +⚠️ agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence), + 不要把括号里的中文显示名拼进 agent_name 里。 + +#可调度智能体: +- agent_name: roundtable-intelligence(情报收集): +- agent_name: roundtable-environment(环境评估): +... +``` + +中文显示名通过 `GET /api/agents/{id}` 取 `name`、描述取 `description`。**强调英文 id** 是因为部分模型会把整段 `roundtable-x(中文)` 当 `agent_name` 塞回来,靠后端 `_strip_display_suffix` 兜底(详见 §6.4)。 + +### 6.2 `/run/stream` — `agent_type = "leader"`(`_leader_run`) + +``` +1. 并行启动 pre_broadcast_task(不 await):向除自己外所有 thread 追加 + "总控智能体收到用户消息:" human 消息 +2. _run_payload + _stream_upstream(POST /api/threads/{coordinator}/runs/stream) + - excluded_tools: ["web_search"] // 只禁联网,其余工具(含 agent_orchestration)保留 + - skill_stop_names: {"agent_orchestration", *callers_extra} + // 网关自动并入 agent_orchestration,让 SkillStopMiddleware 在工具调用后停图, + // 便于本路由从 AIMessage.tool_calls 解析派活列表 +3. 透传上游 SSE,_parse_last_messages 拿 messages +4. await pre_broadcast_task(LLM 通常已跑完,这里 await 接近 0 成本) +5. _find_dispatch_message(messages) 找带 tool_calls 的 AIMessage +6. 分三支: + + 分支 A — ask_clarification: + {"status": "clarification", "content": follow_up 或 question 或 leader_text, + "question", "clarification_type", "clarification_context", + "options", "allow_custom", "allow_multiple", "agent_name"} + ⚠️ 空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环 + + 分支 B — 有 tool_calls(派活): + _extract_dispatch(tool_call)解析每个 tool_call → (sub_name, task_text) + 并行 _broadcast("总控智能体给 X 派活,内容为:Y", skip=[leader, sub_name]) + 等所有派活广播完成(asyncio.gather) + {"status": [[sub_name, task_text], ...], "content": leader_text, "agent_name": agent_name} + + 分支 C — 无 tool_calls: + {"status": [], "content": leader_text, "agent_name": agent_name} + // 共识达成,前端 hasConsensus = true +``` + +**`_extract_dispatch` 双格式支持**: + +1. **结构化**:`{"name": "agent_orchestration", "args": {"agent_name": "...", "task": "..."}}` + — 大多数 LLM 在 SOUL 引导下走这条 +2. **Legacy bash argv**:`{"name": "bash", "args": {"command": '... .py "X" "Y" ...'}}` + — 正则 `_TOOL_CALL_PATTERN = /\.py\s+"([^"]*)"\s+"([^"]*)"/`,兼容旧 skill;不可移除 + +两条路径都会过 `_strip_display_suffix`:用 `re.split(r"[^A-Za-z0-9_-]", agent_name, maxsplit=1)[0]` 截断第一个非合法 id 字符之后的内容,把 `roundtable-x(中文)` 还原为 `roundtable-x`;剥离后若仍违规,原样返回让上游 422。 + +**时序日志**(grep `[leader-timing]`): + +``` +payload_built — 构造请求体耗时(broadcast 已经在后台跑) +first_upstream_line — 上游首行(LangGraph 启动 + LLM 首字节) +upstream_stream_complete — 整条 SSE 透传完成 +pre_broadcast_await — 必须 await 才能 yield 终态;>0 表示广播比 LLM 还慢 +dispatch_broadcasts_await — 派活广播并行 gather 耗时 +``` + +### 6.3 `/run/stream` — `agent_type = "special"`(`_special_run`) + +``` +1. _run_payload + _stream_upstream(POST /api/threads/{sub}/runs/stream) + - excluded_tools: ["web_search", "ask_clarification", "present_files", + "view_image", "agent_orchestration"] + // 子智能体禁派活/澄清/文件呈现/图片;只做交付 +2. 透传 SSE,_parse_last_messages 拿最后一条 AIMessage 的 content +3. 启动 post_broadcast_task("子智能体 X 完成 Y 工作,交付内容为:..."),不 await +4. yield {"status": "子智能体X完成X工作", "content": "<交付正文>", "agent_name": "..."} + // ⚠️ 在 yield 之后再 await broadcast,让前端先收到终态、关 loading +5. await post_broadcast_task(SSE 连接稍微多保持一会;前端早就拿到结果了) +``` + +**时序日志**(grep `[special-timing]`): + +``` +payload_built +first_upstream_line +upstream_stream_complete +post_broadcast_total — yield 之后 await broadcast 的总耗时 +``` + +### 6.4 `_broadcast` 与 `_append_thread_message` 机制 ```python -_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"') +async def _append_thread_message(client, base, headers, thread_id, content): + # GET /api/threads/{tid}/state → state + # state["values"]["messages"].append({"type": "human", "content": content}) + # POST /api/threads/{tid}/state + # 失败仅打 warning,不阻断主流程 + +async def _broadcast(client, base, headers, message, agent_threads, skip): + targets = [tid for name, tid in agent_threads.items() if name not in skip] + await asyncio.gather(*(_append_thread_message(...) for tid in targets), + return_exceptions=True) ``` -**若该技能被删除/改名**,leader 流程会拿不到 `tool_calls`,直接走"无派活=共识达成"分支。注意 `skills/` 下必须保留此技能。 +**目的**:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文;**`skip` 通常含发起方与接收方**,避免回声。 -### 8.2 模型选择 +**三种触发**: -前端通过 `GET /api/models` 拿到列表,在 `model` 字段传 `Model.name` (与 `config.yaml.models[].name` 对应)。 +| 触发点 | 内容 | skip | +|--------|------|------| +| leader pre_broadcast | `总控智能体收到用户消息:` | `[leader]` | +| leader 派活 | `总控智能体给 X 派活,内容为:` | `[leader, sub_name]` | +| special 后广播 | `子智能体 X 完成 Y 工作,交付内容为:` | `[sub]` | -- `/init` 的 `model` 字段 → 协调智能体的默认模型 (写进 agents 配置) -- `/run/stream` 的 `model` 字段 → 该次具体运行用什么模型 (写进 `context.model_name`) +**注意**:广播是 best-effort,对方 LLM 真正干活仍需前端显式 `/run/stream` 调到对应席位。 -前端允许"逐席位覆盖模型",由前端在 special 调用时按 `agentConfigs[seat].model` 传入。后端不感知"席位"概念,只看到 `model` 字段。 +### 6.5 终态帧协议(leader / special / clarification) -### 8.3 自定义新席位 +前端 `streamMultiAgent` 用 `isFinalStatusFrame()` 识别:**同时存在 `status` 与 `content`**。 -如果要加第 7 个席位(比如"成本测算"): +| `status` 类型 | 含义 | +|---------------|------| +| `[["agent","task"],...]` | 派活列表(数组);`[]` = 共识 | +| `"clarification"` | 总控追问,需用户回复后再 leader | +| `"子智能体 X 完成..."` | special 完成(字符串) | +| `"error"` | 异常 | -1. `.deer-flow/agents/roundtable-cost/` 加 `config.yaml` + `SOUL.md` -2. 前端 `RoundtablePlanningPage.tsx` 的 `ROUNDTABLE_ROLE_TEMPLATES` + `AGENT_NAME_TO_ROLE` 加映射 -3. 圆桌可视化(圆圈环形图)加一个气泡 + 头像 +与 Step 1 的 `clarification` 帧字段对齐(`question`、`options`、`allow_multiple` 等),便于复用选项卡片组件。 -**后端代码完全不需要改**。 +### 6.6 `_run_payload` 完整字段 + +```python +{ + "input": {"messages": [{"type": "human", + "content": [{"type": "text", "text": new_message}], + "additional_kwargs": {}}]}, + "config": {"recursion_limit": 1000}, + "context": { + "agent_name": , + "model_name": "deepseek-chat", + "mode": "pro", + "reasoning_effort": "medium", + "thinking_enabled": True, + "is_plan_mode": False, + "subagent_enabled": False, + "thread_id": , + }, + "stream_mode": ["messages-tuple", "values"], + "stream_subgraphs": True, + "stream_resumable": True, + "assistant_id": "lead_agent", + "on_disconnect": "continue", + "excluded_tools": <按 router 不同>, + "skill_stop_names": , +} +``` + +字段含义: + +- `recursion_limit: 1000` — 防止 LangGraph 循环深度兜底(圆桌实际很难达到) +- `thinking_enabled: True` — 开启模型的扩展思考(CoT),需 model 支持 +- `is_plan_mode: False`、`subagent_enabled: False` — 圆桌内禁用 TodoList / subagent 中间件,避免与多智能体协调冲突 +- `stream_mode: ["messages-tuple", "values"]` — 同时需要逐 token 增量与最后一次完整 values 快照 +- `assistant_id: "lead_agent"` — 走 Lead Agent 工厂 +- `on_disconnect: "continue"` — 客户端断连后上游继续跑完(不丢 token 但客户端拿不到) + +### 6.7 上游 SSE 透传与 `_parse_last_messages` + +`_stream_upstream` 逐行 yield 上游 `/api/threads/{tid}/runs/stream` 的 SSE。返回元组: + +``` +(line, None) — 原样转发给浏览器(含 event: / data: 行) +(None, captured) — 流结束,captured 是所有 data: 行的字符串列表 +``` + +日志 `[upstream-timing] headers_in=<>` / `first_line=<>`:排查上游首字节延迟;改 ASGITransport 后通常 `headers_in` < 10ms,长尾大概率出在路由 handler 内部(不再是网络/代理问题)。 + +`_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。 + +### 6.8 三个 AIMessage 搜索辅助 + +| 函数 | 用途 | +|------|------| +| `_find_dispatch_message(messages)` | 从后向前找带 `tool_calls` 的 AIMessage(跳过 SkillStop / Clarification 注入的 ToolMessage);都没有则返回最后一条 AIMessage | +| `_find_trailing_ai_text(messages, exclude_text)` | 从后向前找**无 tool_calls** 的 AIMessage 的纯文本(用于澄清后的 follow-up,排除等于 preamble 的项) | +| `_flatten_content(content)` | 把 `str` / 结构化 content blocks `[{"text": ...}]` 拍平成单串 | --- -## 9. 故障排查 +## 7. Step 3:产出文件(`artifacts.py`,通用 Thread API) -| 现象 | 可能原因 | 排查 | -|---|---|---| -| `/init` 500 "create coordinator agent failed" | `agent_orchestration` 技能不存在 / config.yaml 写入失败 | 看后端日志 `multi_agent` logger;检查 `skills/` 目录 | -| `/run/stream` 502 "upstream returned HTTP 4xx" | JWT 过期 / 转发到上游时鉴权挂了 | 前端检查 Authorization 头是否带上 | -| leader 永远 `status: []` 立即结束 | 协调智能体没产生 `tool_calls`,直接给文本回答 | 协调智能体 SOUL 不够强、或模型不支持工具调用,换 model | -| special 内容为空 | 子智能体被 `excluded_tools` 限制到无可用工具 / 模型无文本输出 | 看上游 `runs/stream` 的 values 帧 messages.last.content | -| 进入 step 2 卡住转圈 | `/init` 超时或失败 | F12 Network 看 `/api/multi-agent/init` 状态码与响应体 | +Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力,圆桌只是消费者之一。 -### 服务端日志关键字 +### 7.1 `GET /api/threads/{thread_id}/artifacts` + +- 鉴权:`@require_permission("threads", "read", owner_check=True)` +- 扫描:`get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())` + - 解析为 `.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` +- `collect_outputs_listing(outputs_dir)`: + - 递归 `rglob("*")`,过滤非文件 + - 软链接安全检查(`resolved.relative_to(outputs_root)`,越界丢弃) + - 按 `modified_at` 倒序 +- 目录不存在 → `files: []`(**非 404**) + +```json +{ + "thread_id": "...", + "files": [ + { + "path": "mnt/user-data/outputs/report.md", + "name": "report.md", + "size_bytes": 12480, + "mime_type": "text/markdown", + "modified_at": "2026-05-23T14:32:30.123Z" + } + ] +} +``` + +前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 对协调 thread 与各席位 thread 分别拉取,合并为下载清单(实际只列子席位)。 + +### 7.2 `GET /api/threads/{thread_id}/artifacts/{path}` + +- 鉴权同上 +- 虚拟路径前缀 `mnt/user-data/outputs/...`,经 `resolve_thread_virtual_path` 转物理路径并防穿越 +- **特殊:`.skill/` 压缩包透视**:若 `path` 含 `.skill/`,从 ZIP 提取内部文件(如 `xxx.skill/SKILL.md`)。`_extract_file_from_skill_archive` 同时支持「直接路径命中」与「带顶层目录前缀」两种压缩包结构。结果带 `Cache-Control: private, max-age=300` 头避免重复解压 +- `ACTIVE_CONTENT_MIME_TYPES = {"text/html", "application/xhtml+xml", "image/svg+xml"}` 始终强制 `Content-Disposition: attachment`(防止 XSS 与脚本执行) +- 文本类(mime 以 `text/` 开头)→ `PlainTextResponse` +- 后缀判定不到、但 8KB 内无 null 字节 → 当文本返回(`is_text_file_by_content`) +- 其它 → `FileResponse` 或 `Response` 内嵌 +- `?download=true` 对所有类型强制附件下载 + +### 7.3 `PATCH /api/threads/{thread_id}/artifacts/{path}` + +- 鉴权:`@require_permission("threads", "write", owner_check=True)` +- 请求体:`{"content": "<新内容>"}` +- 限制: + - `.skill/` ZIP 内文件只读(400) + - `ACTIVE_CONTENT_MIME_TYPES` 拒绝(400 active artifacts are read-only) + - 文件后缀需在 `EDITABLE_TEXT_SUFFIXES = {.md, .markdown, .txt, .json, .yaml, .yml, .csv, .xml, .log}` 之内,**或** mime 以 `text/` 开头 +- 用 UTF-8 覆写原文件,返回 `PlainTextResponse(body.content)` + +圆桌 Step 3 当前主要消费 GET,PATCH 用于「在线修订交付稿」类场景,前端默认未启用。 + +### 7.4 与 Step 2 内存的关系 + +**总控结论、各席位最终段落**:主要来自 Step 2 前端缓存的 `lastLeaderContent`、`step2RoundtableDialogues`(filter `confidence === "子智能体交付"`),**不**为 Step 3 新增聚合接口;如后续需要后端聚合,可加 `sessions/summary` 草案路由。 + +--- + +## 8. 内置席位 SOUL 设计要点 + +每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。 + +| 席位 | agent_id | emoji | 核心职责 | +|------|----------|-------|----------| +| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险 | +| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 承载力 | +| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 | +| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 | +| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源 | +| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 | + +子智能体**不**安装 `agent_orchestration`;仅协调智能体安装,保证单调度源。 + +--- + +## 9. 关键设计决策 + +### 9.1 ASGITransport 进程内 loopback(替代旧的 HTTP loopback) + +三个 router 通过 `httpx.ASGITransport(app=request.app)` 直调本机 ASGI app,**不走 TCP / 不开 socket / 不经网络栈**,但仍然完整复用 FastAPI 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应。代价仅是路由层的少量 dispatch 开销(< 1ms),相对 LLM run 本身的数百 ms~数十秒可忽略。 + +统一入口:`multi_agent._make_loopback_client(request)`,返回: + +```python +httpx.AsyncClient( + transport=httpx.ASGITransport(app=request.app), + base_url="http://loopback", # 占位 host,ASGITransport 不在意 + timeout=None, # SSE 长连接仍需 None +) +``` + +**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。 + +### 9.2 协调智能体每次新建 + +`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录(实际仓库已有数十个 `roundtable-coordinator-mp*`),需后续 GC 策略: + +- 选项 A:定时任务清理 N 天前无活跃 thread 的 coordinator agent +- 选项 B:每次创建前 LRU 清理(注意:跨服务实例难协调) +- 选项 C:把 coordinator 实例化为「内置 + 动态 SOUL」复用同一个 id + +### 9.3 澄清协议统一 + +Step 1 与 Step 2 leader 均通过 `ask_clarification` + 终态 `clarification` 帧驱动选项卡片;`[INTENT_READY]` / 派活列表 / `[]` 各自表示阶段结束语义,互不混用。 + +### 9.4 选项单选默认 + +`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。`coerce_bool` 容忍 LLM 返回的字符串/数字 bool。 + +### 9.5 SSE 末尾状态帧 + +LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端用 `isFinalStatusFrame()` / `isFinalIntentFrame()` / `isFinalRecommendFrame()` 与 LangGraph 帧区分解析。 + +### 9.6 SkillStopMiddleware 配合 leader 派活 + +leader run 自动并入 `skill_stop_names = {"agent_orchestration", ...}`,让 `SkillStopMiddleware` 在 `agent_orchestration` 工具调用后立刻停图。这样网关能从 `AIMessage.tool_calls` 直接读出派活列表,不必等 LLM 真把任务执行完。 + +### 9.7 init `agent_threads` 含 coordinator 自身 + +`init_done.thread_ids` 是 `{agent_name: thread_id}` 形式,其中 `main_agent_name` 自己的 thread 也在里面。前端 Step 3 列文件时需自行过滤掉 coordinator thread,否则会多调一次 `listSessionArtifacts`(虽然结果会是空,但有冗余 HTTP)。 + +--- + +## 10. 性能特征 + +| 阶段 | 已做优化 | 典型耗时 | +|------|----------|----------| +| Step 2 `/init` | 席位 `asyncio.as_completed` + 流式 `seat_ready` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) | +| LLM `/run/stream` | — | 数百 ms~数十秒(主因) | +| leader pre_broadcast | 与上游 LLM run 并发启动 | 通常已在 LLM 完成前结束 | +| leader 派活广播 | `asyncio.gather` 并行 | < 200ms(同 thread state R/W) | +| special post_broadcast | yield 之后才 await,前端先关 loading | 不阻塞 UX | +| Step 1/推荐 | 单 thread,无 broadcast | 与模型相关 | + +**时序日志关键字**(开发自带): ```bash -# multi_agent 路由的日志 -grep "multi_agent" .runtime/logs/*.log +grep "init-timing" .runtime/logs/*.log # 仅前端打,作参考 +grep "upstream-timing" .runtime/logs/*.log # _stream_upstream 内 headers_in / first_line +grep "leader-timing" .runtime/logs/*.log # payload_built / first_upstream_line / dispatch_broadcasts_await ... +grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_line / post_broadcast_total +``` -# 派活解析失败 -grep "dispatch failed" .runtime/logs/*.log +仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用。 -# 广播失败(通常无害) -grep "update state .* failed" .runtime/logs/*.log +--- + +## 11. 依赖与扩展点 + +### 11.1 `agent_orchestration` 技能 + +协调智能体派活依赖此技能;正则 `_TOOL_CALL_PATTERN` 从 tool 输出提取 `agent_name` 与 task。技能缺失时 leader 易变成「无 tool_calls → 空 status → 前端误判共识」。 + +### 11.2 模型 + +`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型(见前端文档 §5.5 Persona 抽屉)。 + +**绝不在路由里硬编码具体模型名**。三个 router 一律 `str(payload.get("model") or "")`: + +- 前端不传 / 传空串 → 下游 `lead_agent._resolve_model_name("")` 命中 falsy 分支,返回 `app_config.models[0].name`(`config.yaml` 配置顺序里的第一项) +- 前端传了一个不在 `config.yaml` 里的 model 名 → 同样回退到首项,并 `logger.warning("Model 'x' not found ... fallback to ...")` +- 前端传了合法 model 名 → 直接使用 + +这是 `/api/ai-writing` 与圆桌三路由的统一行为,让本地 / 内网部署只需要改 `config.yaml`,不需要触前端常量或路由代码。详见 `deerflow/agents/lead_agent/agent.py:_resolve_model_name`。 + +### 11.3 新增席位 + +1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md` +2. 前端候选池 / 推荐逻辑纳入该 id +3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改** + +### 11.7 内置 agent 部署持久化(必读,关系到内网部署能否启动) + +#### 11.7.1 问题背景 + +圆桌功能涉及的 agent 都靠**磁盘文件**激活(`.deer-flow/agents//{config.yaml,SOUL.md}`),但仓库根 `.gitignore` 第 64 / 74 行有: + +``` +offline-backend-20260512/backend/.deer-flow/* +``` + +整个 `.deer-flow/` 被 git 排除。新机器 `git clone` 后 `.deer-flow/agents/` 不存在 → 圆桌相关 agent 缺失 → 进入 `/api/intent/init` 后 `_create_thread` 本身能成功,但下一步 `/api/intent/stream` 真正调用 LangGraph 时会因为 agent 解析失败抛 500(典型表现:前端 Step 1 init 一次成功,下一句话发出去就 500)。 + +#### 11.7.1.1 哪些 agent **必须**打包,哪些**不需要** + +圆桌涉及的 agent 分**两类**,部署侧需求完全不同: + +| Agent | 角色 | 是否必须打包发布 | +|-------|------|------------------| +| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ **必须**(任何环境) | +| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ **必须**(任何环境) | +| `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 | +| `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 | +| `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 | +| `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 | +| `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 | +| `roundtable-summary` | 示例研讨席位(总结陈述) | ⚠️ 同上 | +| `roundtable-coordinator-*` | 协调智能体壳,每次会话动态新建 | ❌ **不打包**(运行时生成) | + +**关键区分**: +- `roundtable-intent` / `roundtable-recommender` 是**圆桌功能本身的一部分**——没有它们,整个 Step 1 + 推荐流程跑不起来,跟客户业务无关 +- 6 个 `roundtable-<领域>` 席位只是**互联网 demo 用的示例研讨者**,让访客打开页面就能看到完整三步流程。内网部署面对的是客户自己的业务场景,会把客户自有的领域 agent(行业专家、合规审查员、业务规则引擎……)注册成候选 agent;这时 6 个示例席位反而成噪声 + +**对应的部署组合**: + +| 部署场景 | 打包内容 | +|----------|----------| +| **互联网 demo** | `roundtable-intent` + `roundtable-recommender` + **6 个示例席位** | +| **内网客户部署** | `roundtable-intent` + `roundtable-recommender`(仅此 2 个,**不带** 6 个示例席位) | +| **本地开发** | 全要(方便快速跑通) | + +**内网部署的额外注意**(去掉 6 个示例席位时): + +1. **客户自有的候选 agent 必须先存在**:内网部署落地时,客户的业务 agent 应该已经注册进 `.deer-flow/agents/`(手工建目录或通过 `POST /api/agents` 创建)。否则候选池为空,推荐拿不到任何 picks。 +2. **前端 `FALLBACK_AGENT_IDS` 需要适配**:前端 `RecommendAgentsDialog.tsx` 里硬编码了 6 个示例席位 id 作为 fallback;若推荐失败回退路径会拿这 6 个去 `candidates` 里交集,内网部署该交集恒为空,**fallback 会失败**。建议要么: + - **方案 1**(最稳):内网客户的候选池足够丰富 + recommender SOUL 调好,让推荐成功率 ~100%,不依赖 fallback + - **方案 2**:把 `FALLBACK_AGENT_IDS` 改成从后端 `/api/agents` 取前 6 个 `published=true` 的候选 agent(动态 fallback)。改动点见前端文档 §6.2 `RecommendAgentsDialog` +3. **推荐 SOUL(`roundtable-recommender/SOUL.md`)通常无需改**:SOUL 本身没写死候选 id,只描述「从给的候选池里挑」;候选池由前端传入,模型读到客户自有 agent 后会照样工作。 + +下文给出三种修复方案,**推荐方案 A**(最简单,无需写代码)。 + +#### 11.7.2 方案 A:`.gitignore` 白名单(推荐) + +让 git 只跟踪 **`roundtable-intent` + `roundtable-recommender` 两个功能型 agent** 的 `config.yaml` 与 `SOUL.md`,其它一律忽略。6 个示例席位**不进白名单**——互联网 demo 部署机如果需要它们,按 §11.7.2.A 在该分支单独追加白名单;内网部署机直接享受「干净的 `.deer-flow/`」。 + +**步骤 1:编辑仓库根 `.gitignore`** + +在最后追加白名单段(位置很重要,**必须在 `offline-backend-20260512/backend/.deer-flow/*` 之后**,否则被前面的通配符吃掉): + +```gitignore +# === 圆桌功能型 agent 跟随仓库发布(任何部署都需要) === +# 父目录先 unignore,git 才能往下递归 +!offline-backend-20260512/backend/.deer-flow/ +!offline-backend-20260512/backend/.deer-flow/agents/ +# 仅放行 2 个功能型 agent;6 个示例席位由互联网分支自行放行 +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/SOUL.md +``` + +⚠️ **不要**直接写 `!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/**`——`git` 不会进入还在 ignore 的父目录,必须逐层 un-ignore,再精确放行文件。 + +**步骤 2:强制 add 并提交** + +```bash +# 验证白名单生效 +git check-ignore -v offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml +# 期望输出无任何匹配,或最后一条匹配是上面的 ! 行 + +# 加入索引(只加 2 个功能型 agent) +git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent +git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender + +git status # 应只看到 2 × 2 = 4 个新文件,绝不出现 users/、threads/、data/、6 个示例席位 + +git commit -m "chore: 把圆桌功能型 agent(intent + recommender)纳入版本管理" +``` + +**步骤 3:部署机验证** + +```bash +# clone 之后立刻 +ls offline-backend-20260512/backend/.deer-flow/agents/ +# 内网部署:应看到 roundtable-intent/ + roundtable-recommender/(+ 客户自有 agent) +# 互联网 demo:还应看到 6 个 roundtable-<领域>/ 示例席位 +``` + +启动后端,前端打开圆桌规划页,Step 1 第一句话发出去能正常拿到 streaming 响应而不是 500,就说明 OK。 + +##### 11.7.2.A 互联网 demo 分支:追加 6 个示例席位 + +互联网 demo 维护一条独立分支(或独立 `.gitignore` 段,用 build flag 切),在白名单后追加: + +```gitignore +# === 仅互联网 demo 需要的 6 个示例研讨席位 === +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/SOUL.md +``` + +`git add` 对应 6 个目录、提交,互联网分支即拿到全部 8 个内置 agent。两条分支共享同一份 `roundtable-intent / roundtable-recommender`,SOUL 调整对两边都生效,避免双维护。 + +**优点**: + +- 零代码改动;新机器 `git clone` 即可,部署文档不用单独说「记得拷一下 `.deer-flow/agents/`」 +- 修改 SOUL 直接走 git 走 review 流程 +- 多人协作时 SOUL 改动可见 + +**注意事项**: + +- `git status` 检查时确保**只**有 16 个内置文件进了索引,**绝对不能**包括 `users/`、`threads/`、`data/*.db`、`roundtable-coordinator-*/` 这些运行时数据 +- `roundtable-coordinator-mp*/` 这类历史会话残留目录会被 ignore 规则自动忽略(白名单没放行它们),无需手动清理 +- 部署机如果之前手动建过 `.deer-flow/agents/roundtable-intent/`,`git pull` 会把仓库版本覆盖上去——这是期望行为 + +#### 11.7.3 方案 B:源码内置 + 启动时种子化 + +如果不想让 `.deer-flow/` 这种「运行时目录」出现在 git 里,可以把 SOUL/config 放进 `packages/harness/` 里作为只读资源,启动时检查 `.deer-flow/agents//` 不存在就从源拷过去。 + +**步骤大纲**: + +1. 在 `packages/harness/deerflow/agents/builtin_personas/` 下建 8 个目录(`roundtable-intent/` 等),把 SOUL.md + config.yaml 搬过去 +2. 在 `app/gateway/app.py` 的 `lifespan` 启动阶段新增 `_seed_builtin_roundtable_agents()`: + ```python + def _seed_builtin_roundtable_agents() -> None: + from importlib.resources import files + src_root = files("deerflow.agents.builtin_personas") + dst_root = get_paths().agents_dir() # .deer-flow/agents/ + for agent_id in BUILTIN_ROUNDTABLE_AGENT_IDS: # 8 个常量 + dst = dst_root / agent_id + if dst.exists(): + continue # 已有(可能是用户改过的),不覆盖 + dst.mkdir(parents=True) + for fname in ("config.yaml", "SOUL.md"): + (dst / fname).write_bytes((src_root / agent_id / fname).read_bytes()) + ``` +3. 紧接着调一次 `_sync_legacy_agents(store)`,把这些刚落地的目录 upsert 进 `agents` 表 +4. 把 `BUILTIN_ROUNDTABLE_AGENT_IDS` 常量也导出给 `recommend.py` / `multi_agent.py` 共享 + +**优点**: + +- `.deer-flow/` 仍然作为「运行时数据目录」语义干净 +- 运维可以手动改 `.deer-flow/agents/roundtable-intent/SOUL.md` 临时调试,重启不被覆盖(`if dst.exists(): continue`) +- 升级时如果想强制刷新,加个 `--force-reseed-builtins` 启动参数即可 + +**缺点**: + +- 多一段 bootstrap 代码 + 一份 `pyproject.toml` 的 `package-data` 声明(确保 SOUL.md 跟着 wheel 一起发布) +- 「现场改 SOUL 不被覆盖」既是优点也是坑——A 改了 SOUL 但忘了同步源码,下次新机器部署又回到旧版本 + +#### 11.7.4 方案 C:纯运维流程(最弱,不推荐) + +不动代码、不动 .gitignore,发布说明里写一行: + +> 部署前请将 `<开发机>:offline-backend-20260512/backend/.deer-flow/agents/` 整个目录 `scp` 到部署机同位置。 + +只在「不允许改仓库结构」或「初期试跑」时用。长期维护几乎一定会有人忘记拷而部署失败。 + +#### 11.7.5 方案选择决策 + +| 场景 | 推荐方案 | +|------|----------| +| 单仓库多人协作、SOUL 经常调整 | **A**(.gitignore 白名单) | +| SOUL 极少改,希望严格区分代码与运行时数据 | **B**(源码 + lifespan 种子化) | +| 一次性 POC / 即将切换到 B | C | + +无论选哪种,部署机第一次启动后都建议跑这条 smoke test 验证: + +```bash +curl -X POST http://127.0.0.1:8001/api/intent/init \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{}' +# 期望: 200 + {"status":"success","agent_id":"roundtable-intent","thread_id":"..."} + +curl -X POST http://127.0.0.1:8001/api/intent/stream \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"thread_id":"<上一步返回的>","message":"测试一下"}' +# 期望: 200 + SSE 长连接,最后一帧 status 为 asking 或 clarification +# 若 500 → 看后端日志,大概率是 SOUL.md 找不到或 LLM 外网不通(看上一条排查项) +``` + +#### 11.7.6 推荐智能体(`roundtable-recommender`)与研讨席位的关系(注:完整定制流程见 §11.8) + +推荐路由 `recommend.py` 用到的 `roundtable-recommender` 与「研讨席位」是**两件事**: + +- **`roundtable-recommender`**:负责从候选池里挑 picks 的「推荐者」本身,是个 LLM agent,**功能型**,所有部署必须有 +- **研讨席位**(无论是 6 个示例还是客户自有 agent):是「被挑选的候选」,前端 `filterRecommendCandidates` 过滤后送进 `/api/recommend/stream` 的 `candidates` 数组;recommender 在 SOUL 里看到候选池后才能输出 `[RECOMMEND_READY]` JSON + +「推荐改成动态」改的是**从池子里挑哪几个**,不是「池子可以没有」。**池子的来源在两类部署里不同**: + +| 部署 | 候选池来源 | +|------|------------| +| 互联网 demo | 仓库自带的 6 个 `roundtable-<领域>` 示例席位(+ 用户自建的) | +| 内网客户 | 客户自有的业务 agent(通过 `POST /api/agents` 或 `.deer-flow/agents//` 注册)| + +`roundtable-recommender/SOUL.md` 本身没写死任何候选 id(只描述「从给的候选池里挑」),所以内网部署即使候选池从「6 个示例」换成「客户自有 agent」,**recommender 的 SOUL 无需改动**,候选池由前端传入。 + +**前端 fallback 行为差异**: + +互联网 demo 因为有这 6 个内置示例席位垫底,推荐失败时 `FALLBACK_AGENT_IDS ∩ candidates` 还能拿到回退选项;**内网部署该交集恒为空**,回退路径失效——所以内网部署应通过「候选池足够丰富 + recommender SOUL 调好」让推荐成功率接近 100%,或改造前端 `FALLBACK_AGENT_IDS` 为动态从 `/api/agents` 取前 N 个(见前端文档 §6.2)。 + +### 11.4 新增 Step 1 工具 + +若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。 + +### 11.5 改回旧 bash 派活协议 + +后端已**双格式兼容**,前端无需变化;只要 SOUL 改成生成 `bash` + `.py "X" "Y"` argv,`_extract_dispatch` 仍会解析正确。但建议保持新的结构化 `agent_orchestration`,prompt 更稳定。 + +### 11.6 把广播改为推送 + +`_append_thread_message` 通过 `GET state → mutate → POST state` 实现,是为了与 LangGraph checkpoint 一致;如果将来 Gateway 提供 `POST /api/threads/{id}/messages/append` 这类原子接口,可换上,降低并发竞争(虽然圆桌当前不会并发广播同一 thread)。 + +### 11.8 添加 / 替换任务推荐智能体(`roundtable-recommender`) + +推荐智能体 id 在后端是**硬编码**常量(`recommend.py:63` → `RECOMMENDER_AGENT_ID = "roundtable-recommender"`),路由不接收 `agent_id` 参数。因此「添加推荐智能体」**不是新增一个 id**,而是落地这个固定 id 对应的文件(首次部署)或**就地改 SOUL**(定制行为)。 + +#### 11.8.1 何时需要动它 + +| 场景 | 是否需要动 SOUL | +|------|-----------------| +| 全新部署,目录还不存在 | ✅ 必须先把目录搭起来(§11.8.2) | +| 候选池从 6 个互联网示例换成客户自有 agent | ❌ **不用改**——SOUL 没写死候选 id,候选池由前端传入 | +| 想改变推荐风格(更激进 / 更保守 / 偏好某领域) | ✅ 改 SOUL(§11.8.4) | +| 想让模型偏好你新加的领域 agent | ⚠️ **优先**改新 agent 的 `description`(候选池 prompt 用的就是它),其次才改 recommender SOUL | +| 想改输出 JSON 格式 | ❌ **不要**——后端正则 `_RECOMMEND_READY_PATTERN` 写死了 `[RECOMMEND_READY]` + ```json ``` 块;改 SOUL 输出会让后端解析失败走 fallback | + +#### 11.8.2 标准目录结构 + +``` +.deer-flow/agents/roundtable-recommender/ +├── config.yaml # id / name / description(只是 DB 元数据,不进 prompt) +└── SOUL.md # 行为契约,详见 11.8.4 +``` + +**`config.yaml` 模板**(**id 必须严格等于 `roundtable-recommender`**,否则 `recommend.py` 找不到): + +```yaml +description: 智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单 +id: roundtable-recommender +name: 智能体推荐 +``` + +字段说明: +- `id`:**不可改**,与 `RECOMMENDER_AGENT_ID` 常量绑死 +- `name`:DB 显示名,候选池过滤时不参与(`filterRecommendCandidates` 按 id 排除) +- `description`:DB 元数据,不进 prompt;改了不会影响推荐效果 + +**SOUL.md** 内容见 §11.8.4 模板。新增之后按 §11.7.2 把这两个文件加入 git 白名单 + commit 即可。 + +#### 11.8.3 后端给推荐智能体喂的 prompt 长什么样 + +`recommend.py:_build_prompt()` 把前端传的 intent + candidates 拼成下面这段,作为**唯一一条 user message** 投递: + +``` +任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求 +约束:预算 ≤ 500 万元; 工期 ≤ 6 个月; 必须满足等保 2.0 +关键假设:现有系统具备基础数字化能力; 允许预定窗口停机 + +候选智能体池: +- roundtable-intelligence (情报收集): 检索同类历史案例、行业最佳实践与外部情报 +- roundtable-solution-design (方案设计): 产出主备方案与关键路径 +- my-cost-calc (成本测算): 基于预算和资源单价计算总成本 +- ... + +请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。 +``` + +**关键事实**: +- **候选 agent 的 `name` 与 `description` 在 prompt 里就是它的「介绍」**——这是模型判断「这个 agent 适不适合本任务」的全部信息。客户自有 agent 想被推荐准确,`description` 必须写**任务导向**的能力描述,不要写 "由 XX 团队维护" 这种元信息 +- **候选池由前端过滤后传入**,后端不读 DB;前端 `filterRecommendCandidates` 已剔除 `roundtable-intent / roundtable-recommender / roundtable-coordinator-*` +- SOUL 看不到「intent agent 上一轮说了什么 / 用户原始 message」,只看到结构化的 `objective / constraints / assumptions`——这是设计选择,让推荐稳定 + +#### 11.8.4 SOUL 设计要点(模板 + 注解) + +SOUL **必须**满足三件事,其它都可定制: + +1. **输出格式严格**:自然语言思路 → `[RECOMMEND_READY]` 单独成行 → ```json {"picks": [{"agent_id": "...", "reason": "..."}, ...]} ``` +2. **`agent_id` 严格来自候选池**:模型造词出来会被 `_validate_picks` 过滤掉 +3. **2 ≤ picks ≤ 6**:少于 2 → 整轮判 `asking` 走 fallback;多于 6 → 后端截断 + +下面是一份可直接用的最小 SOUL,重点行带注释(**真正落盘时去掉 ``**): + +```markdown +# 智能体推荐专员 + +## 角色定位 +你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成 +「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里, +挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。 + +## 核心职责 +- 仔细阅读用户的任务目标与约束 +- 浏览候选池中每个智能体的 `name` 与 `description` +- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个) +- 为每个推荐的智能体写一句**具体、可验证**的推荐理由 +- 不推荐重复职能 / 不推荐与任务明显无关的智能体(宁缺毋滥) + +## 数量约束 +- **下限**:至少 2 个 / **上限**:最多 6 个 +- 候选池不足 2 个时,有几个推几个 + +## 输出协议(后端用正则识别,严格执行) +1. 先用自然语言简短说明推荐思路(1-3 句话) +2. 输出标记 `[RECOMMEND_READY]` 单独成行 +3. 紧接一个 ```json 代码块: + + ```json + { + "picks": [ + {"agent_id": "<候选池中的 id>", "reason": "<一句推荐理由>"}, + ... + ] + } + ``` + +格式注意: +- `[RECOMMEND_READY]` **单独成行**,前后无其他字符 +- `agent_id` **严格**来自候选池,不要造词 +- `reason` **具体**,不要"很合适"这种空话 + +## 边界 +- 不调用任何工具(已被后端 excluded_tools 屏蔽) +- 不评价候选 agent 优劣,只判断"是否适合本次任务" +- 不发起对话、不反问用户 + +## 示例 + +``` + +##### 定制方向 + +- **偏好新加的 agent**:在「示例」里把它列进候选池并出现在推荐结果中,模型会学到这种 id 也可推荐。比单纯在「核心职责」里加文字约束有效 +- **行业偏向**:在「角色定位」加一句「优先考虑能直接对接 <行业> 合规要求的智能体」 +- **激进 vs 保守**:调「数量约束」——例如「能挑出强相关的 3 个就不要凑 6 个」会让 picks 缩到 3-4 个 +- **不要做的事**: + - ❌ 改 `[RECOMMEND_READY]` 为别的标记 → 解析失败 + - ❌ 把 JSON 结构改成 `{"recommendations": [...]}` 之类 → 解析失败 + - ❌ 让模型只输出 JSON 不带前面的自然语言 → 前端 `parseRecommendStreamContent` 把整段当 `rationale`,弹窗顶部为空白 + +#### 11.8.5 验证流程 + +部署完成后跑 smoke test 确认推荐路径通: + +```bash +# 1. 确认 agent 文件落地 +ls .deer-flow/agents/roundtable-recommender/ +# 期望: SOUL.md config.yaml + +# 2. 确认 DB 已 upsert(任意 /api/agents 请求会触发 _sync_legacy_agents) +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/agents | \ + python -c "import sys,json; ids=[a['id'] for a in json.load(sys.stdin).get('agents',[])]; print('roundtable-recommender' in ids)" +# 期望: True + +# 3. 触发一次推荐(用最小 intent + 候选池) +curl -X POST http://127.0.0.1:8001/api/recommend/stream \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "intent": { + "objective": "测试推荐链路", + "constraints": ["响应时间 < 30s"], + "assumptions": [] + }, + "candidates": [ + {"agent_id": "agent-a", "name": "A", "description": "做 X"}, + {"agent_id": "agent-b", "name": "B", "description": "做 Y"}, + {"agent_id": "agent-c", "name": "C", "description": "做 Z"} + ] + }' +# 期望(SSE 流):最后一帧 data: {"status":"done","content":"...","picks":[...]} +# 若 picks 是空 → SOUL 没产生合法 [RECOMMEND_READY] / agent_id 不在候选池 +# 若整轮 status=error → 看后端 _diagnose_exception 输出的 hint +``` + +#### 11.8.6 故障排查清单 + +| 现象 | 排查方向 | +|------|----------| +| 推荐永远走 fallback(status=asking) | 后端日志 grep `recommend stream`,看 `last_ai_content` 是不是不含 `[RECOMMEND_READY]`——SOUL 没遵守协议 | +| `picks` 数量被截断到 6 | 后端 `_MAX_PICKS=6` 故意限制,前端用户可再手加到 8 | +| 模型推荐了候选池里没有的 id | `_validate_picks` 会丢掉,但若有效项不足 2 个会整轮走 fallback;改 SOUL 强调「严格来自候选池」 | +| 推荐结果不偏向客户自有 agent | 先检查该 agent 的 `description` 是不是写得「任务导向」;再考虑在 SOUL 示例里加它 | +| 推荐稳定性差(同 intent 重跑结果飘) | 推荐 model 用低 temperature(在 `roundtable-recommender/config.yaml` 加 `model: <某个低 temp 模型>` 或直接前端选 deterministic 模型) | +| 前端弹窗顶部 rationale 区空白 | SOUL 让模型只输出 JSON 没有自然语言;恢复成「先思路 + 后 JSON」 | + +--- + +## 12. 故障排查 + +| 现象 | 可能原因 | 排查 | +|------|----------|------| +| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) | +| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) | +| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 | +| Step 2 init 完全卡住(历史症状,ASGITransport 改造后已不应再出现) | 旧版 `httpx` 走 `HTTP_PROXY` 拦回环 → 40s 超时 | 自改 ASGITransport 起此现象物理上不可能复现;若仍卡,看后端日志栈、检查路由 handler 是否阻塞 | +| leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` | +| dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 | +| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 | +| Step 3 无文件 | 子 agent 未写 outputs / 错路径 | `GET .../artifacts` 对子 thread_id 逐个查;确认 sandbox outputs 路径 | +| `seat_ready` 顺序不固定 | `asyncio.as_completed` 按真实完成顺序推送 | 这是设计行为,不是 bug;前端不应按顺序依赖 agent_name | + +### 日志关键字 + +```bash +grep "intent stream" .runtime/logs/*.log +grep "multi_agent\|multi-agent" .runtime/logs/*.log +grep "recommend stream" .runtime/logs/*.log +grep "dispatch failed" .runtime/logs/*.log +grep "upstream-timing" .runtime/logs/*.log +grep "leader-timing" .runtime/logs/*.log +grep "special-timing" .runtime/logs/*.log +grep "headers_in=" .runtime/logs/*.log # 上游首字节延迟;ASGITransport 改造后通常 < 10ms ``` --- -## 10. 测试入口 - -后端目前没有针对本功能的单测。建议补: +## 13. 测试建议 ``` -backend/tests/test_multi_agent.py - - test_init_creates_threads_for_all_subagents - - test_init_creates_coordinator_with_orchestration_skill - - test_init_returns_400_when_agent_names_empty - - test_stream_leader_emits_status_frame_with_content - - test_stream_special_emits_status_frame_with_content - - test_extract_name_task_parses_orchestration_skill_output - - test_broadcast_skips_listed_agents +backend/tests/test_intent.py # init、INTENT_READY 优先级、clarification 字段 +backend/tests/test_recommend.py # picks 校验、候选池过滤、clamp 2-6 +backend/tests/test_multi_agent.py # init SSE 事件顺序、leader/special 终态、clarification ``` -可用 `httpx.MockTransport` 拦截 loopback 请求,不依赖真实 Gateway。 +可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway: + +```python +def mock_handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/api/threads": + return httpx.Response(200, json={"thread_id": "t-1"}) + ... + +transport = httpx.MockTransport(mock_handler) +client = httpx.AsyncClient(transport=transport, base_url=...) +``` + +针对 SSE 上游可用 `respx` 或直接 mock `_stream_upstream` 的字符串列表。 --- -## 11. 路径速查 +## 14. 路径速查 | 资源 | 路径 | -|---|---| -| 路由实现 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` | -| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py`(`include_router(multi_agent.router)`) | -| 6 个内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` | -| `agent_orchestration` 技能定义 | `skills/public/agent_orchestration/`(若存在) | -| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` | +|------|------| +| Step 1 路由 | `offline-backend-20260512/backend/app/gateway/routers/intent.py` | +| 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` | +| Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` | +| Step 3 列出/下载/编辑 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | +| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` | +| 澄清单选/多选 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` | +| 任务理解 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` | +| 推荐 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/` | +| 圆桌席位 | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` | +| 协调智能体壳(累积) | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-coordinator-*/` | +| 前端 Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` | +| 前端推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` | +| 前端 Step 2 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` | | 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | -| 前端 API 速查 | `frontend-web/docs/multi-agent-api.md` | +| 前端开发文档 | `frontend-web/docs/multi-agent-frontend-dev.md` | +| API 速查 | `frontend-web/docs/multi-agent-api.md` | diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md new file mode 100644 index 00000000..88da48a8 --- /dev/null +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -0,0 +1,742 @@ +# 多智能体圆桌研讨 · 前端开发文档 + +本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。 + +> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。 + +--- + +## 0. 速读:你需要先知道的事 + +- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。 +- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。 +- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。 +- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。 +- **`` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `...`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。 +- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。 +- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。 +- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。 +- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。 +- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。 +- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。 + +--- + +## 1. 功能定位 + +**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」) + +**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx` + +**核心能力**: + +| 步骤 | 用户目标 | 真实后端对接 | +|------|----------|--------------| +| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` | +| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` | +| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 | +| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` | + +**步骤门禁**: + +- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。 +- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。 + +--- + +## 2. 目录结构 + +``` +frontend-web/src/roundtable-planning/ +├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport +├── pages/ +│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行) +├── api/ +│ ├── intent.ts # Step 1:initIntent、streamIntent +│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent +│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl +├── components/ +│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器 +│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置 +│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup) +│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props) +│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入) +│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport +│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model) +│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer +│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选) +│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单 +│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览 +│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮 +├── hooks/ +│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随 +│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步 +│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona +│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除 +├── lib/ +│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐 +│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `...` 思考过程 +│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等) +│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染 +├── utils/ +│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName) +└── styles/ + └── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖) +``` + +**模块依赖方向(自上而下)**: + +``` +pages/RoundtablePlanningPage + ├─ uses → hooks/useStep1Intent + ├─ uses → hooks/useStep2Orchestration + ├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate) + ├─ uses → components/Step{1,2,3}Panel (纯 props 驱动) + ├─ uses → components/RecommendAgentsDialog + └─ uses → components/Avatars + +hooks/useStep1Intent → api/intent +hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型 +hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型 +``` + +主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。 + +**路由注册**(`src/pages/WorkspaceRoutes.tsx`): + +```tsx +} /> +``` + +侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。 + +**环境变量**(由 `scripts/start-frontend.sh` 注入): + +- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀 +- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装 + +--- + +## 3. 三步数据流(前端视角) + +``` +[进入 Step 1] + useEffect → runStep1Init() + initIntent(model) → { agent_id, thread_id } + setIntentThreadId、setTimelineEntries + interactiveMessages seed 欢迎语(本地 seed,非 API) + +[用户发送 / 选项卡片提交] + sendIntentMessage(text) + 追加 user 气泡 + agent 占位(streaming + phase) + streamIntent({ onTextDelta, onPhaseChange, signal }) + 终态: + done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗 + clarification → IntentClarificationCard + pendingIntentClarificationMsgId + asking → 继续对话 + error → toast + 红色 (错误) 文案 + +[点击「进入多智能体研讨」且 intentReady] + setIsRecommendDialogOpen(true) + RecommendAgentsDialog + listAgents() → filterRecommendCandidates() + streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange }) + 用户勾选 2–8 个 → onConfirm(agentIds) + handleConfirmAgents + setSelectedAgents(agentIds.map(...)) + step2InitRef.current = false // 复位 Step 2 init 闸门 + setCurrentStep(2) + +[进入 Step 2] + useEffect(step2InitRef 闸门)→ initMultiAgent(SSE) + onSeatReady:右侧席位面板逐个「在线」 + onCoordinatorReady / init_done → threadIds + coordinatorName + runOrchestration(buildInitialIntent(), threadIds, coordinatorName) + while !cancelled && cycles<8: + leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条 + 否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合… + dispatched.length === 0 → hasConsensus = true,可进 Step 3 + +[进入 Step 3] + HighFidelityReport + consensusText ← lastLeaderContent + seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") + Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) + 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) + ``` + + --- + + ## 4. API 层 + + ### 4.1 `api/intent.ts` — Step 1 + +| 函数 | 说明 | +|------|------| +| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` | +| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 | + +**`StreamIntentRequest` 重要字段**: + +- `threadId`:必填,跨多轮对话保持 +- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消) +- `onTextDelta(text, messageId)`:每个 AI 文本增量 +- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling` + +**`StreamIntentResult`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 | +| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) | +| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 | +| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 | + +**SSE 解析要点**: + +- `event: metadata` → phase `connected` +- `event: messages` + `extractAiTextDelta` → `onTextDelta` +- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名) +- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`) + +**Abort 行为**: + +- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef` +- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft` +- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」 + +### 4.2 `api/recommend.ts` — 推荐弹窗 + +| 函数 | 说明 | +|------|------| +| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` | +| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) | +| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 | + +**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见): + +- `roundtable-intent` +- `roundtable-recommender` +- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳) + +**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似): + +- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"` +- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象 +- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示) +- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id) +- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`) + +**终态语义**: + +- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选 +- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集 + +### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件 + +| 函数 | 说明 | +|------|------| +| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` | +| `streamMultiAgent(req)` | leader / special 一轮 | +| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` | +| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL | + +**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段): + +```ts +{ event: "seat_ready", agent_name, thread_id, display_name, description } +{ event: "coordinator_ready", main_agent_name, thread_id } +{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record +{ event: "error", detail } +``` + +**`streamMultiAgent` 终态**(`isFinalStatusFrame`): + +| `status` | 含义 | 前端处理 | +|----------|------|----------| +| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 | +| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 | +| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 | +| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence | +| `"error"` | 失败 | toast + 红色气泡 | + +**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。 + +**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。 + +**调试开关**: + +```js +window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total) +window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream::] +``` + +> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。 + +--- + +## 5. 主页面状态(`RoundtablePlanningPage`) + +> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。 + +| 维度 | 所有者 | 暴露给主页面的方式 | +|------|--------|-------------------| +| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState | +| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 | +| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 | +| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 | +| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref | + +### 5.1 步骤与门禁 + +- `currentStep`: `1 | 2 | 3` +- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」 +- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用 + +### 5.2 Step 1 关键 state + +| 状态 | 用途 | +|------|------| +| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 | +| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 | +| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) | +| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) | +| `intentClarificationSelections` | `Map`,多选 / 多消息时各自维护 | +| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) | +| `intentAborterRef` | 当前 stream 的 AbortController | +| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 | +| `step1InitRef` | 闸门:true 即认为已 init 过 | +| `step1InitGenRef` | **单调递增**的代数标记,详见下文 | +| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) | + +**`step1InitGenRef` 的存在原因**: + +React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。 + +**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则): + +- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块 +- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染 +- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐 + +### 5.3 推荐与 Step 2 席位 + +| 状态 | 用途 | +|------|------| +| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) | +| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) | +| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName | +| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` | +| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 | +| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) | +| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 | +| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) | +| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) | +| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 | +| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) | +| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 | +| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 | +| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set | +| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) | +| `agentConfigs` | `Map` Persona 覆盖;下一轮该席位的派活会用此 model | +| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 | + +**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。 + +### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)` + +伪代码: + +``` +isOrchestrating = true +orchestrationCancelRef = false +nextLeaderTask = seedMessage +cycles = 0 +MAX_CYCLES = 8 + +while !cancelled && cycles < MAX_CYCLES: + cycles += 1 + highlight(speakerId=2) // 总控 + leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中") + res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange) + + if res.clarification: + finalize(leaderBubble, content, "等待用户澄清") + setPendingClarification(res.clarification) + break // 等用户回复后由 submitClarification 重启循环 + + finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明") + if res.content: setLastLeaderContent(res.content) + + if res.dispatched.length === 0: + setHasConsensus(true); setConsensusPercentage(100); break + + for [subName, task] of res.dispatched: // **顺序**,不是并行 + meta = agentNameToRole[subName.toLowerCase()] + markSpeakerStreaming(meta.speakerId) + perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL + subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中") + specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter) + finalize(subBubble, specRes.content, "子智能体交付") + markSpeakerIdle(meta.speakerId) + if cancelled: break + + setConsensusPercentage(prev => min(95, prev + 8)) + nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..." + +if cycles >= MAX_CYCLES: + toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环") + +clearAllStreamingSpeakers() +isOrchestrating = false +``` + +要点: + +- **`buildInitialIntent()`**: + - 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语 + - 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字 + - 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活 +- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)` +- **挂起 / 恢复**: + - 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()` + - 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)` +- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread + +### 5.5 Persona 抽屉(Step 2 圆桌可视化点击) + +- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉 +- 抽屉控件: + - **System Prompt**:仅前端记录(后端覆写未开放) + - **Temp**:仅前端记录 + - **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel` +- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`: + - 把新的 `(seatId, model)` 写入 `agentConfigs` + - 追加一条「⚡ [模型偏好已保存] ...」对话气泡 + - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数 + +### 5.6 全局模型选择器(右侧 sidebar 顶部) + +- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段) +- **state**:`const [selectedModel, setSelectedModel] = useState("")` +- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项 +- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖 +- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」 +- **可改换为 Radix Select**:当前用原生 `