Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
2f7f07467e
28 files changed
+3277
-5368
No files matched your search
Whitespace-only changes.
@@ -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={<WorkspaceRoutes />} />`
|
||||
|
||||
---
|
||||
|
||||
## 五、“再建立一个页面放置导航”的落地解释
|
||||
|
||||
你说的“建立一个页面放置这些导航”,可以有两种理解:
|
||||
|
||||
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`,减少硬编码路径。
|
||||
@@ -1,14 +1,47 @@
|
||||
# 多智能体圆桌研讨 · 后端开发文档
|
||||
|
||||
本文档面向需要维护或扩展"多智能体圆桌研讨"功能后端的开发者。前端 API 速查请看 `multi-agent-api.md`,此文档关注**实现细节、设计取舍、性能特征**。
|
||||
本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`、`multi-agent-step1-api.md`、`multi-agent-step3-api.md`;推荐弹窗见 `recommend-agents-progress.md`;前端实现见 `multi-agent-frontend-dev.md`。本文关注**实现细节、协议帧、设计取舍**。
|
||||
|
||||
源码内另有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py` 及 `clarification_utils.py`),与本文互为补充。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定位
|
||||
## 1. 功能定位与三步数据流
|
||||
|
||||
把"任务目标 + 用户约束"丢给一个**协调智能体**,由它通过 `agent_orchestration` 技能调度 6 个固定职能的子智能体(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述)协同产出方案,直到协调智能体认为信息足够、停止派活并输出最终结论为止。
|
||||
对应前端页面:`/page/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。
|
||||
|
||||
对应前端页面:`/page/roundtable/planning` 的 **Step 2 多智能体研讨**。
|
||||
```
|
||||
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[]
|
||||
|
||||
Step 2 多智能体圆桌研讨
|
||||
POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(席位列表由前端传入)
|
||||
POST /api/multi-agent/run/stream → leader 派活 / special 执行 / 澄清
|
||||
|
||||
↓ lastLeaderContent、各席位交付、hasConsensus
|
||||
|
||||
Step 3 最终方案展示
|
||||
GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件
|
||||
GET /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 <JWT>`。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:单次请求卡住 40s+)。
|
||||
|
||||
---
|
||||
|
||||
@@ -17,13 +50,16 @@
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── app/gateway/
|
||||
│ ├── app.py # 注册 multi_agent.router
|
||||
│ ├── app.py # include_router: multi_agent, intent, recommend
|
||||
│ └── routers/
|
||||
│ └── multi_agent.py # 本功能唯一新增的 router
|
||||
│ ├── multi_agent.py # Step 2:init(SSE) + run/stream
|
||||
│ ├── intent.py # Step 1:init + stream
|
||||
│ ├── recommend.py # Step 1→2:推荐 stream
|
||||
│ └── artifacts.py # Step 3:列出/下载 thread 产出
|
||||
└── .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/
|
||||
@@ -31,329 +67,371 @@ offline-backend-20260512/backend/
|
||||
└── roundtable-summary/
|
||||
```
|
||||
|
||||
**零代码改动构造内置 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` 表示内置。
|
||||
|
||||
**共享工具逻辑**:`packages/harness/deerflow/tools/builtins/clarification_utils.py` 中的 `resolve_allow_multiple()` 被 `intent.py`、`multi_agent.py` 与 `ClarificationMiddleware` 共用,决定选项卡片默认单选还是多选。
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口总览
|
||||
|
||||
路由前缀 `/api/multi-agent`,所有接口需要 `Authorization: Bearer <JWT>`。
|
||||
| 方法 | 路径 | 步骤 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 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,并行拉起席位 + 协调智能体 |
|
||||
| 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 | 预览/下载单个产出 |
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| 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`。不做 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`
|
||||
|
||||
1. `_create_thread()` → LangGraph thread
|
||||
2. 返回固定 `agent_id: "roundtable-intent"`(**不**每次新建 agent)
|
||||
|
||||
与 Step 2 `/init` 的区别:无协调智能体、无 `agent_names` 列表。
|
||||
|
||||
### 4.3 `POST /api/intent/stream`
|
||||
|
||||
**上游**:`_run_payload(agent_name=roundtable-intent, excluded_tools=[...])`,硬排除 `agent_orchestration`、`write_file`、`web_search` 等,**仅保留** `ask_clarification` 等澄清能力,防止模型写方案文档。
|
||||
|
||||
**流式**:透传 LangGraph SSE → 解析 `_parse_last_messages(captured)`。
|
||||
|
||||
**终态帧判定顺序(重要)**:
|
||||
|
||||
1. **先**扫描全部 AI 消息中的 `[INTENT_READY]\n\`\`\`json {...}\`\`\``(正则 `_INTENT_READY_PATTERN`)
|
||||
- 命中 → `status: "done"`,`summary` 为解析后的 JSON,`content` 为含标记的整段文本
|
||||
- **优先于** `ask_clarification`,避免「已汇总仍被当成待澄清」
|
||||
|
||||
2. 否则查最后一则带 `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
|
||||
}
|
||||
```
|
||||
|
||||
**协调智能体 SOUL 是动态拼出来的**(`multi_agent.py:216-224`):
|
||||
`allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定:**默认单选**(点击即发送);仅模型显式传 `allow_multiple: true` 时为多选。
|
||||
|
||||
```python
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,...\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,...\n"
|
||||
"#可调度智能体:\n"
|
||||
f"{descriptions}" # ← 6 个子席位的 name+description
|
||||
)
|
||||
3. 否则 → `status: "asking"`(继续对话,无结构化澄清)
|
||||
|
||||
**`summary` 形状**(`done` 时):
|
||||
|
||||
```json
|
||||
{
|
||||
"objective": "一句话目标",
|
||||
"constraints": ["硬约束 1", "..."],
|
||||
"assumptions": ["可选假设"]
|
||||
}
|
||||
```
|
||||
|
||||
由于 `main_agent_name` 由前端每次新建(`roundtable-coordinator-{timestamp36}`),所以协调智能体本身是一次性的、用完即弃。
|
||||
### 4.4 内置 Agent:`roundtable-intent`
|
||||
|
||||
### 4.2 `/run/stream` 内部流程
|
||||
- 配置:`.deer-flow/agents/roundtable-intent/config.yaml` + `SOUL.md`
|
||||
- SOUL 内写清:工作流、`ask_clarification` 用法、`[INTENT_READY]` 格式、禁止派活/写文件
|
||||
- **可不配置 `skills`**;若配置 Skill,SOUL 要求不得偏离澄清与结束协议
|
||||
|
||||
接收 `agent_type` 后走两条分支:
|
||||
---
|
||||
|
||||
#### `agent_type = "leader"` (协调智能体)
|
||||
## 5. 推荐智能体(`recommend.py`,Step 1 → 2)
|
||||
|
||||
### 5.1 定位
|
||||
|
||||
无状态单次调用:每次请求内部 `_create_thread()`,不暴露 `/init`。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(排除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。
|
||||
|
||||
### 5.2 `POST /api/recommend/stream`
|
||||
|
||||
**入参校验**:
|
||||
|
||||
- `intent.objective` 必填
|
||||
- `candidates` 非空,且至少一个有效 `agent_id`
|
||||
|
||||
**Prompt 拼装**(`_build_prompt`):把 objective/constraints/assumptions 与候选池格式化为一条 user message。
|
||||
|
||||
**上游**:`roundtable-recommender`,`excluded_tools` 含 `ask_clarification`、`agent_orchestration` 等(纯判断,不调工具)。
|
||||
|
||||
**终态**:
|
||||
|
||||
- 解析 `[RECOMMEND_READY]\n\`\`\`json {"picks":[{"agent_id","reason"},...]}\`\`\``
|
||||
- `_validate_picks()`:只保留候选池内的 id、去重、数量 clamp 到 2–6
|
||||
- 有效 picks ≥ 2 → `status: "done"`,`picks` 有值
|
||||
- 否则 → `status: "asking"`(前端回退默认席位列表)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "done",
|
||||
"content": "<自然语言推荐理由>",
|
||||
"picks": [{"agent_id": "roundtable-risk-review", "reason": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤,避免污染推荐理由区。
|
||||
|
||||
---
|
||||
|
||||
## 6. Step 2:多智能体研讨(`multi_agent.py`)
|
||||
|
||||
### 6.1 `/init` 内部流程(SSE,非一次性 JSON)
|
||||
|
||||
当前实现为 **流式 init**,便于前端按席位点亮 UI:
|
||||
|
||||
```
|
||||
1. broadcast「总控收到用户消息」到所有非自己线程 (asyncio.gather)
|
||||
2. 拼 RunCreateRequest,转发到 POST /api/threads/{coordinator}/runs/stream
|
||||
asyncio.as_completed(各席位 _prepare_sub_agent)
|
||||
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
|
||||
|
||||
协调智能体:
|
||||
POST /api/threads → coord_thread
|
||||
POST /api/agents(skills=[agent_orchestration],SOUL 动态拼入可调度列表)
|
||||
→ data: {"event":"coordinator_ready","main_agent_name","thread_id"}
|
||||
|
||||
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}}
|
||||
失败 → data: {"event":"error","detail":"..."}
|
||||
```
|
||||
|
||||
**席位列表**:由前端 `agent_names` 传入(推荐弹窗确认后的 2–6 个 id),不再写死 6 个。
|
||||
|
||||
**协调智能体 SOUL**(动态拼接)包含:只负责协调汇总、通过 `agent_orchestration` 派活、`agent_name` 必须用英文 id(不要用中文显示名)。
|
||||
|
||||
### 6.2 `/run/stream` — `agent_type = "leader"`
|
||||
|
||||
```
|
||||
1. (可选)pre_broadcast:总控收到用户消息 → 广播到非自己的 thread
|
||||
2. 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": "<leader 最终文本>",
|
||||
"agent_name": "<coordinator>"
|
||||
}
|
||||
3. 透传 LangGraph 事件
|
||||
4. 解析 messages → tool_calls
|
||||
|
||||
分支 A — ask_clarification:
|
||||
status: "clarification"
|
||||
+ question / options / allow_multiple / clarification_type / clarification_context
|
||||
(空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环)
|
||||
|
||||
分支 B — 有 agent_orchestration 派活:
|
||||
并发 _broadcast 派活消息到相关 thread
|
||||
status: [["sub_agent_id", "task_text"], ...]
|
||||
|
||||
分支 C — 无 tool_calls:
|
||||
status: [] // 共识达成,content 为总控最终文本
|
||||
```
|
||||
|
||||
#### `agent_type = "special"` (子智能体)
|
||||
### 6.3 `/run/stream` — `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": "<sub agent>"
|
||||
}
|
||||
1. POST /api/threads/{sub}/runs/stream
|
||||
- excluded_tools: web_search, ask_clarification, present_files, view_image, agent_orchestration
|
||||
2. 透传 + 解析最后 AI 文本
|
||||
3. post_broadcast:子智能体完成交付 → 广播到非自己的 thread
|
||||
4. status: "<完成提示字符串>" 或业务自定义;content 为交付正文;agent_name 为子 agent id
|
||||
```
|
||||
|
||||
### 4.3 _broadcast 机制
|
||||
### 6.4 `_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))
|
||||
# 对每个 target: GET state → append human message → POST state
|
||||
```
|
||||
|
||||
`_append_thread_message` 对每个目标 thread:
|
||||
目的:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文。`skip` 通常含发起方与接收方,避免回声。
|
||||
|
||||
```
|
||||
GET /api/threads/{id}/state # 拿当前 messages 列表
|
||||
state.values.messages.append({"type":"human","content":...})
|
||||
POST /api/threads/{id}/state # 写回完整 state
|
||||
```
|
||||
### 6.5 终态帧协议(leader / special / clarification)
|
||||
|
||||
这是**为了让每个子线程的对话历史里都能看到全局上下文**(其他席位说了什么、协调智能体怎么派活的)。子智能体下次被调用时,模型自然能基于这份"群消息回放"做判断。
|
||||
前端 `streamMultiAgent` 用 `isFinalStatusFrame()` 识别:**同时存在 `status` 与 `content`**。
|
||||
|
||||
**关键不变量**:`skip` 列表通常包含发起方和接收方,避免发起方收到回声、接收方收到重复消息。
|
||||
| `status` 类型 | 含义 |
|
||||
|---------------|------|
|
||||
| `[["agent","task"],...]` | 派活列表;`[]` = 共识 |
|
||||
| `"clarification"` | 总控追问,需用户回复后再 leader |
|
||||
| `"子智能体 X 完成..."` | special 完成(字符串) |
|
||||
| `"error"` | 异常 |
|
||||
|
||||
与 Step 1 的 `clarification` 帧字段对齐(`question`、`options`、`allow_multiple` 等),便于复用选项卡片组件。
|
||||
|
||||
---
|
||||
|
||||
## 5. 6 个内置 agent SOUL 设计要点
|
||||
## 7. Step 3:产出文件(`artifacts.py`,通用 Thread API)
|
||||
|
||||
每个 `roundtable-*` 的 SOUL.md 遵循统一结构:
|
||||
Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力。
|
||||
|
||||
```
|
||||
# {席位名}
|
||||
### 7.1 `GET /api/threads/{thread_id}/artifacts`
|
||||
|
||||
## 角色定位
|
||||
你是"多智能体圆桌研讨"会议的 **{席位}** 席位。会议由"总控协调智能体"统一调度,
|
||||
你只在被派到任务时发言,不需要主动推动议程。
|
||||
|
||||
## 核心职责
|
||||
- ...具体职责...
|
||||
|
||||
## 输出风格
|
||||
- 每次发言以 {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/<id>/` 下生成新目录。长期会累积大量协调智能体目录,**未来需要清理脚本**(标记 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 内部数据形状,迁移成本高。所以后端在流末尾追加一个**协议层的总结帧**:
|
||||
- 鉴权:`threads:read`,owner 校验
|
||||
- 扫描:`users/{user_id}/threads/{thread_id}/user-data/outputs/`(`collect_outputs_listing`)
|
||||
- 目录不存在 → `files: []`(非 404)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": [["agent_name", "task"], ...] | "<完成提示>",
|
||||
"content": "<最终 AI 文本>",
|
||||
"agent_name": "<本轮 agent>"
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
前端 `streamMultiAgent` helper 通过 `isFinalStatusFrame()` 识别该帧(必须同时有 `status` + `content` 键),与其他 LangGraph 帧区分。
|
||||
前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 可对协调 thread 与各席位 thread 分别拉取,合并为下载清单。
|
||||
|
||||
### 7.2 `GET /api/threads/{thread_id}/artifacts/{path}`
|
||||
|
||||
- 虚拟路径前缀 `mnt/user-data/outputs/...`
|
||||
- 路径穿越防护;`download=true` 强制附件下载
|
||||
- HTML/XHTML/SVG 等 active content 始终按下载处理
|
||||
|
||||
**总控结论、各席位最终段落**:主要来自 Step 2 已缓存的 `lastLeaderContent`、`step2RoundtableDialogues`;无需为 Step 3 新增聚合接口(可选优化见 `multi-agent-step3-api.md` 中的 `sessions/summary` 草案)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 性能特征
|
||||
## 8. 内置席位 SOUL 设计要点
|
||||
|
||||
### 7.1 已应用的优化
|
||||
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
|
||||
|
||||
| ID | 优化 | 收益 |
|
||||
|---|---|---|
|
||||
| **A** | `/init` 阶段 1 并行化(`asyncio.gather`) | 阶段 1 从 2-4s 降到 0.3-0.7s |
|
||||
| **C** | 删除 `/init` 末尾的 intro 广播(SOUL 已含定位) | 阶段 3 整段消失,省 1-2s |
|
||||
| 席位 | agent_id | emoji | 核心职责 |
|
||||
|------|----------|-------|----------|
|
||||
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险 |
|
||||
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 承载力 |
|
||||
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
|
||||
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
|
||||
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源 |
|
||||
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 |
|
||||
|
||||
应用前 `/init` 耗时 **3-8s**,应用后 **0.5-1.2s**。
|
||||
|
||||
### 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 + 上下文长度 |
|
||||
|
||||
**LLM 推理才是绝对大头**,基础设施开销在十几个百分点以内。
|
||||
|
||||
### 7.3 未来可探索
|
||||
|
||||
- **B**(已识别未做): broadcast 跳过 GET-state,直接 POST 新建态。但 POST 需要构造完整的 state shape,改造成本不小。
|
||||
- **D**(已识别未做): loopback HTTP → 内部函数调用。代码改动大,但能省掉 ~20-30 次 HTTP 解析开销。
|
||||
子智能体**不**安装 `agent_orchestration`;仅协调智能体安装,保证单调度源。
|
||||
|
||||
---
|
||||
|
||||
## 8. 依赖与扩展点
|
||||
## 9. 关键设计决策
|
||||
|
||||
### 8.1 `agent_orchestration` 技能
|
||||
### 9.1 HTTP loopback
|
||||
|
||||
协调智能体强依赖此技能,SOUL 里硬编码:
|
||||
三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。
|
||||
|
||||
```
|
||||
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
|
||||
```
|
||||
### 9.2 协调智能体每次新建
|
||||
|
||||
技能的实际派活产物是一个形如 `python3 scripts/agent_orchestration.py "agent_name" "task"` 的 shell 调用文本,后端用正则提取:
|
||||
`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录,需后续 GC 策略。
|
||||
|
||||
```python
|
||||
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
|
||||
```
|
||||
### 9.3 澄清协议统一
|
||||
|
||||
**若该技能被删除/改名**,leader 流程会拿不到 `tool_calls`,直接走"无派活=共识达成"分支。注意 `skills/` 下必须保留此技能。
|
||||
Step 1 与 Step 2 leader 均通过 `ask_clarification` + 终态 `clarification` 帧驱动选项卡片;`[INTENT_READY]` / 派活列表 / `[]` 各自表示阶段结束语义,互不混用。
|
||||
|
||||
### 8.2 模型选择
|
||||
### 9.4 选项单选默认
|
||||
|
||||
前端通过 `GET /api/models` 拿到列表,在 `model` 字段传 `Model.name` (与 `config.yaml.models[].name` 对应)。
|
||||
`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。
|
||||
|
||||
- `/init` 的 `model` 字段 → 协调智能体的默认模型 (写进 agents 配置)
|
||||
- `/run/stream` 的 `model` 字段 → 该次具体运行用什么模型 (写进 `context.model_name`)
|
||||
### 9.5 SSE 末尾状态帧
|
||||
|
||||
前端允许"逐席位覆盖模型",由前端在 special 调用时按 `agentConfigs[seat].model` 传入。后端不感知"席位"概念,只看到 `model` 字段。
|
||||
|
||||
### 8.3 自定义新席位
|
||||
|
||||
如果要加第 7 个席位(比如"成本测算"):
|
||||
|
||||
1. `.deer-flow/agents/roundtable-cost/` 加 `config.yaml` + `SOUL.md`
|
||||
2. 前端 `RoundtablePlanningPage.tsx` 的 `ROUNDTABLE_ROLE_TEMPLATES` + `AGENT_NAME_TO_ROLE` 加映射
|
||||
3. 圆桌可视化(圆圈环形图)加一个气泡 + 头像
|
||||
|
||||
**后端代码完全不需要改**。
|
||||
LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端与 LangGraph 帧区分解析。
|
||||
|
||||
---
|
||||
|
||||
## 9. 故障排查
|
||||
## 10. 性能特征
|
||||
|
||||
| 阶段 | 已做优化 | 典型耗时 |
|
||||
|------|----------|----------|
|
||||
| Step 2 `/init` | 席位 `as_completed` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) |
|
||||
| LLM `/run/stream` | — | 数百 ms~数十秒(主因) |
|
||||
| Step 1/推荐 | 单 thread,无 broadcast | 与模型相关 |
|
||||
|
||||
仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用(见原 B/D 优化项)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 依赖与扩展点
|
||||
|
||||
### 11.1 `agent_orchestration` 技能
|
||||
|
||||
协调智能体派活依赖此技能;正则 `_TOOL_CALL_PATTERN` 从 tool 输出提取 `agent_name` 与 task。技能缺失时 leader 易变成「无 tool_calls → 空 status → 前端误判共识」。
|
||||
|
||||
### 11.2 模型
|
||||
|
||||
`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型。
|
||||
|
||||
### 11.3 新增席位
|
||||
|
||||
1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md`
|
||||
2. 前端候选池 / 推荐逻辑纳入该 id
|
||||
3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改**
|
||||
|
||||
### 11.4 新增 Step 1 工具
|
||||
|
||||
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 白名单逻辑(当前为黑名单,仅 `ask_clarification` 实际可用)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查 |
|
||||
|---|---|---|
|
||||
| `/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 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY |
|
||||
| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple` |
|
||||
| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 | Network + 后端 `multi_agent` 日志 |
|
||||
| leader 立即 `status: []` | 未调用 orchestration 技能 | 换模型或加强 SOUL |
|
||||
| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧 |
|
||||
| Step 3 无文件 | 子 agent 未写 outputs | `GET .../artifacts` 对子 thread_id 逐个查 |
|
||||
|
||||
### 服务端日志关键字
|
||||
### 日志关键字
|
||||
|
||||
```bash
|
||||
# multi_agent 路由的日志
|
||||
grep "multi_agent" .runtime/logs/*.log
|
||||
|
||||
# 派活解析失败
|
||||
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 "update state .* failed" .runtime/logs/*.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 校验、候选池过滤
|
||||
backend/tests/test_multi_agent.py # init SSE 事件、leader/special 终态、clarification
|
||||
```
|
||||
|
||||
可用 `httpx.MockTransport` 拦截 loopback 请求,不依赖真实 Gateway。
|
||||
可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway。
|
||||
|
||||
---
|
||||
|
||||
## 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` |
|
||||
| 澄清单选/多选 | `packages/harness/deerflow/tools/builtins/clarification_utils.py` |
|
||||
| 任务理解 Agent | `.deer-flow/agents/roundtable-intent/` |
|
||||
| 推荐 Agent | `.deer-flow/agents/roundtable-recommender/` |
|
||||
| 圆桌席位 | `.deer-flow/agents/roundtable-*/` |
|
||||
| 前端 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` |
|
||||
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
|
||||
| Step 1 API 说明 | `frontend-web/docs/multi-agent-step1-api.md` |
|
||||
| Step 3 API 说明 | `frontend-web/docs/multi-agent-step3-api.md` |
|
||||
@@ -0,0 +1,659 @@
|
||||
# 多智能体圆桌研讨 · 前端开发文档
|
||||
|
||||
本文档面向维护 `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. 速读:你需要先知道的事
|
||||
|
||||
- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。
|
||||
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
|
||||
- **唯一默认模型**:`DEFAULT_MODEL = "deepseek-chat"`(页面常量,写在 `RoundtablePlanningPage.tsx`)。`useModels()` 只用于 Persona 抽屉的下拉选项。
|
||||
- **席位区间硬约束**:推荐弹窗要求 **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 # 三步主页面 + 状态机 + 编排循环(≈3600 行)
|
||||
├── api/
|
||||
│ ├── intent.ts # Step 1:initIntent、streamIntent
|
||||
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
|
||||
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
|
||||
├── components/
|
||||
│ ├── 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 # 滚动跟随 + 用户上滑暂停跟随
|
||||
├── lib/
|
||||
│ └── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
|
||||
├── utils/
|
||||
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
|
||||
└── styles/
|
||||
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
|
||||
```
|
||||
|
||||
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
|
||||
|
||||
```tsx
|
||||
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
|
||||
```
|
||||
|
||||
侧栏入口:`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<agent_name, thread_id>
|
||||
{ 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:<agentType>:<agentName>]
|
||||
```
|
||||
|
||||
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
|
||||
|
||||
---
|
||||
|
||||
## 5. 主页面状态(`RoundtablePlanningPage`)
|
||||
|
||||
### 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<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
|
||||
| `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<seatId, {model, weight, prompt, temp}>` 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()`,默认 `DEFAULT_MODEL`
|
||||
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
|
||||
- 把新的 `(seatId, model)` 写入 `agentConfigs`
|
||||
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
|
||||
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数
|
||||
|
||||
### 5.6 草稿持久化(`utils/drafts.ts`)
|
||||
|
||||
- Key:`roundtable-planning:drafts:v1`(`localStorage`)
|
||||
- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
|
||||
- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字
|
||||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep`
|
||||
- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave`
|
||||
- **加载草稿**:`handleLoadDraft`
|
||||
- 中断所有 in-flight(`abortAllInFlight + stopIntentStream`)
|
||||
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉
|
||||
- `step1InitGenRef` 自增,淘汰可能晚归的旧 init
|
||||
- `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init
|
||||
- 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件说明
|
||||
|
||||
### 6.1 `IntentClarificationCard`
|
||||
|
||||
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`
|
||||
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
|
||||
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
|
||||
- 仅选项 → `我选择:A、B`
|
||||
- 仅自定义 → 直接发自定义文本
|
||||
- 同时存在 → `我选择:A、B;补充:…`
|
||||
- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片)
|
||||
|
||||
### 6.2 `RecommendAgentsDialog`
|
||||
|
||||
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
|
||||
- 阶段 UI(`InternalState.phase`):
|
||||
- `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
|
||||
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
|
||||
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
|
||||
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
|
||||
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
|
||||
|
||||
### 6.3 `HighFidelityReport`(Step 3)
|
||||
|
||||
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
|
||||
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
|
||||
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
|
||||
- **文件预览策略**(`shouldPreviewInModal`):
|
||||
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
|
||||
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
|
||||
- 命中规则:
|
||||
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
|
||||
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
|
||||
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
|
||||
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
|
||||
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
|
||||
|
||||
### 6.4 `ArtifactPreviewModal`
|
||||
|
||||
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
|
||||
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
|
||||
- 其它文本类 → `<pre>` 直出
|
||||
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
|
||||
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
|
||||
|
||||
### 6.5 共享 UI 模式与辅助
|
||||
|
||||
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
|
||||
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
|
||||
- `connecting` → 「调度中」
|
||||
- `connected` → 「已连接,等待响应」
|
||||
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
|
||||
- `tool_result` → 「处理工具结果」
|
||||
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
|
||||
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
|
||||
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
|
||||
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
|
||||
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
|
||||
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
|
||||
|
||||
---
|
||||
|
||||
## 7. 澄清交互(与主聊天对齐)
|
||||
|
||||
| 能力 | 实现位置 |
|
||||
|------|----------|
|
||||
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
|
||||
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
|
||||
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
|
||||
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
|
||||
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
|
||||
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
|
||||
|
||||
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
|
||||
|
||||
**关键差异**:
|
||||
|
||||
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
|
||||
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与主聊天的差异
|
||||
|
||||
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|
||||
|------|-----------------------------------|----------|
|
||||
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
|
||||
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
|
||||
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
|
||||
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
|
||||
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
|
||||
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
|
||||
|
||||
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键交互清单
|
||||
|
||||
| 交互 | 行为 |
|
||||
|------|------|
|
||||
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
|
||||
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
|
||||
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
|
||||
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
|
||||
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
|
||||
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
|
||||
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
|
||||
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
|
||||
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
|
||||
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
|
||||
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 类型与常量速查
|
||||
|
||||
```ts
|
||||
// Step 1 输出 → Step 2 输入
|
||||
interface IntentSummary {
|
||||
objective: string;
|
||||
constraints: string[];
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
// Step 2 线程映射(init_done.thread_ids)
|
||||
type ThreadIdMap = Record<string, string>;
|
||||
|
||||
// 派活
|
||||
type LeaderDispatch = [string, string]; // [agent_name, task]
|
||||
|
||||
// 当前选中的研讨席位
|
||||
type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
// Step 1 单条澄清问题
|
||||
interface ClarificationPayload {
|
||||
question: string;
|
||||
clarificationType: string;
|
||||
context: string;
|
||||
options: unknown[];
|
||||
allowCustom: boolean;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
// Step 2 stream 终态
|
||||
interface StreamMultiAgentResult {
|
||||
dispatched: LeaderDispatch[];
|
||||
message: string | null;
|
||||
content: string;
|
||||
agentName: string | null;
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
```
|
||||
|
||||
**核心常量**:
|
||||
|
||||
| 常量 | 值 | 位置 |
|
||||
|------|----|------|
|
||||
| `DEFAULT_MODEL` | `"deepseek-chat"` | RoundtablePlanningPage |
|
||||
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
|
||||
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
|
||||
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
|
||||
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
|
||||
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
|
||||
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
|
||||
| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
|
||||
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
|
||||
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
|
||||
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
|
||||
|
||||
---
|
||||
|
||||
## 11. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查 |
|
||||
|------|----------|------|
|
||||
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
|
||||
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
|
||||
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
|
||||
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
|
||||
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
|
||||
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
|
||||
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
|
||||
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
|
||||
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
|
||||
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
|
||||
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
|
||||
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
|
||||
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
|
||||
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
|
||||
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
|
||||
|
||||
**本地调试开关**:
|
||||
|
||||
```js
|
||||
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
|
||||
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 扩展指南
|
||||
|
||||
### 12.1 新增默认研讨席位
|
||||
|
||||
1. 后端 `.deer-flow/agents/roundtable-xxx/`
|
||||
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
|
||||
3. 无需改 API 层(`agent_names` 由推荐结果传入)
|
||||
|
||||
### 12.2 调整 Step 1 澄清轮次上限
|
||||
|
||||
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
|
||||
|
||||
### 12.3 调整 Step 2 编排上限
|
||||
|
||||
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
|
||||
|
||||
### 12.4 把 special 改回并行
|
||||
|
||||
不推荐;若一定要做:
|
||||
|
||||
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
|
||||
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
|
||||
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
|
||||
|
||||
### 12.5 接入真实 Persona 覆盖
|
||||
|
||||
当前 prompt / temp 只在前端记录;要落地需:
|
||||
|
||||
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
|
||||
- 前端:`agentConfigs[meta.speakerId]` 一并传入
|
||||
|
||||
### 12.6 拆分页面
|
||||
|
||||
当前刻意单文件维护三步。若拆分建议:
|
||||
|
||||
- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
|
||||
- 保持 `api/*` 不变
|
||||
- 保持 `roundtable-planning.css` 主题变量
|
||||
|
||||
### 12.7 文档协同
|
||||
|
||||
`@/roundtable-planning/` 内一切公共 API 都应当:
|
||||
|
||||
1. 在 `api/*.ts` 文件级注释里说明 endpoint
|
||||
2. 在本页第 4 节 / 第 10 节同步对照
|
||||
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 13. 路径速查
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
|
||||
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
|
||||
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
|
||||
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||||
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
|
||||
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
|
||||
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
|
||||
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
|
||||
| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
|
||||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
|
||||
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
|
||||
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
|
||||
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
|
||||
@@ -1,492 +0,0 @@
|
||||
# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现
|
||||
|
||||
> Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。
|
||||
>
|
||||
> 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
### ✅ 要做的事
|
||||
- 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位
|
||||
- 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream`
|
||||
- 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper)
|
||||
- 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration`
|
||||
|
||||
### ❌ 不做的事
|
||||
- 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样)
|
||||
- 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可)
|
||||
- 不做时间线(timelineEntries)的后端持久化(前端临时状态够用)
|
||||
- 不做"评分/置信度"等假指标
|
||||
- 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL`
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据流概览
|
||||
|
||||
```
|
||||
[Step 1 入口]
|
||||
↓
|
||||
前端:POST /api/intent/init
|
||||
← {agent_id: "roundtable-intent", thread_id: "..."}
|
||||
|
||||
用户输入第一句话:"帮我规划一个生产系统安全改造方案"
|
||||
↓
|
||||
前端:POST /api/intent/stream (SSE)
|
||||
← (流式 AI 文本) "好的,预算大致是多少?"
|
||||
← {status: "asking", content: "..."} ← 智能体反问中,需要用户继续
|
||||
|
||||
用户回:"500 万左右"
|
||||
↓
|
||||
前端再次 POST /api/intent/stream
|
||||
← (流式) "明白,还有什么时间约束吗?"
|
||||
← {status: "asking", ...}
|
||||
|
||||
...对话进行几轮 ...
|
||||
|
||||
用户:可以了
|
||||
↓
|
||||
前端再 POST /api/intent/stream
|
||||
← (流式) "好,我理解的任务是: <汇总>"
|
||||
← {status: "done", content: "<最终汇总文本>", summary: {...}} ← 信息充分,可进入 step 2
|
||||
|
||||
[Step 1 完成 → Step 2 init]
|
||||
前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口设计
|
||||
|
||||
跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。
|
||||
|
||||
### 3.1 `POST /api/intent/init`
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"model": "deepseek-chat" // 可选,默认全局 DEFAULT_MODEL
|
||||
}
|
||||
```
|
||||
|
||||
**响应 200**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"agent_id": "roundtable-intent",
|
||||
"thread_id": "<langgraph_thread_id>"
|
||||
}
|
||||
```
|
||||
|
||||
**行为**:
|
||||
1. 鉴权(透传 `Authorization`)
|
||||
2. 调上游 `POST /api/threads` 建 1 个 thread
|
||||
3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**)
|
||||
|
||||
**与 step 2 `/init` 的区别**:
|
||||
- 没有"协调智能体"概念,只有 1 个 agent
|
||||
- 没有 6 个 sub 席位,只有 1 个 thread
|
||||
- 不需要传 `agent_names` 数组
|
||||
- `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的)
|
||||
|
||||
### 3.2 `POST /api/intent/stream`
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"thread_id": "<from /init>",
|
||||
"message": "用户这一轮要说的话",
|
||||
"model": "deepseek-chat" // 可选
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:`text/event-stream`,SSE 流式。
|
||||
|
||||
**SSE 帧组成**:
|
||||
1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量
|
||||
2. 流末追加一个**状态帧**(跟 step 2 同样模式):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "asking" | "done" | "error",
|
||||
"content": "<本轮 AI 文本>",
|
||||
"summary": {
|
||||
"objective": "...", // 仅 status=done 时有: 任务目标
|
||||
"constraints": ["...", ...], // 仅 status=done 时有: 约束清单
|
||||
"assumptions": ["...", ...] // 仅 status=done 时有: 关键假设
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`status` 取值**:
|
||||
- `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框
|
||||
- `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent
|
||||
- `"error"` —— 上游异常,`content` 里是错误信息
|
||||
|
||||
**判定 done/asking 的简单策略**(后端实现):
|
||||
- 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧
|
||||
- 否则一律是 `asking`
|
||||
|
||||
> 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取:
|
||||
>
|
||||
> ```
|
||||
> [INTENT_READY]
|
||||
> ```json
|
||||
> {"objective": "...", "constraints": ["..."], "assumptions": ["..."]}
|
||||
> ```
|
||||
> ```
|
||||
>
|
||||
> 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 内置智能体配置
|
||||
|
||||
**位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/`
|
||||
|
||||
### 4.1 `config.yaml`
|
||||
```yaml
|
||||
id: roundtable-intent
|
||||
name: 任务理解智能体
|
||||
description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。
|
||||
```
|
||||
|
||||
### 4.2 `SOUL.md`
|
||||
|
||||
```markdown
|
||||
# 任务理解智能体
|
||||
|
||||
## 角色定位
|
||||
你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
|
||||
通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。
|
||||
|
||||
## 核心职责
|
||||
- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
|
||||
- 不要追问已经清楚的事
|
||||
- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
|
||||
- 信息足够后,立即停止追问,产出结构化汇总
|
||||
|
||||
## 信息充分性判定
|
||||
当满足以下条件时,认为信息已充分:
|
||||
1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
|
||||
2. 至少 1 条硬约束(预算/时间/合规/范围之一)
|
||||
3. 没有明显的歧义点
|
||||
|
||||
## 信息汇总产出
|
||||
信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):
|
||||
|
||||
```
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "用一句话写出用户最终要做的事",
|
||||
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
|
||||
"assumptions": ["假设 1", "假设 2", ...]
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。
|
||||
|
||||
## 输出风格
|
||||
- 一次回复不超过 3 句话
|
||||
- 提问要具体,不要"还有什么补充吗"这种空问
|
||||
- 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总)
|
||||
|
||||
## 边界
|
||||
- 不调用 agent_orchestration(这是 step 2 的协调智能体的事)
|
||||
- 不写文件、不做计算、不联网
|
||||
- 用户消息歧义大时,优先调 ask_clarification 工具引导
|
||||
```
|
||||
|
||||
### 4.3 注册方式
|
||||
|
||||
跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端实现要点
|
||||
|
||||
### 5.1 新建 router 文件
|
||||
**位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py`
|
||||
|
||||
```python
|
||||
"""任务理解智能体接口 - Step 1 的极简包装。
|
||||
|
||||
跟 multi_agent.py 同构但简化:
|
||||
- 没有 broadcast(单 thread,无需广播)
|
||||
- 没有派活逻辑
|
||||
- 没有 leader/special 分支,只有一种调用方式
|
||||
- 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx, json, re, asyncio, logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 复用 multi_agent.py 里已有的工具函数
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_loopback_base, _auth_headers,
|
||||
_create_thread, _stream_upstream, _parse_last_messages,
|
||||
_flatten_content, _run_payload,
|
||||
)
|
||||
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
|
||||
_INTENT_READY_PATTERN = re.compile(
|
||||
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_intent(request: Request, payload: dict = Body(...)) -> dict:
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return {
|
||||
"status": "success",
|
||||
"agent_id": INTENT_AGENT_ID,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
thread_id = str(payload.get("thread_id") or "").strip()
|
||||
message = str(payload.get("message") or "").strip()
|
||||
model_name = str(payload.get("model") or "deepseek-chat")
|
||||
if not thread_id or not message:
|
||||
raise HTTPException(status_code=400, detail="thread_id and message required")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def gen():
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
try:
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=message,
|
||||
excluded_tools=["web_search", "present_files",
|
||||
"view_image", "agent_orchestration"],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
captured = []
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
yield (line if line.endswith("\n") else line + "\n")
|
||||
elif done is not None:
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
last_ai = {}
|
||||
for m in reversed(messages):
|
||||
if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
last_ai = m
|
||||
break
|
||||
content = _flatten_content(last_ai.get("content", ""))
|
||||
|
||||
# 检测 [INTENT_READY] 标记
|
||||
match = _INTENT_READY_PATTERN.search(content)
|
||||
if match:
|
||||
try:
|
||||
summary = json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
summary = None
|
||||
frame = {"status": "done", "content": content, "summary": summary}
|
||||
else:
|
||||
frame = {"status": "asking", "content": content}
|
||||
yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
|
||||
except HTTPException as exc:
|
||||
yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("intent stream failure")
|
||||
yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2 在 `app/gateway/app.py` 注册
|
||||
```python
|
||||
from app.gateway.routers import intent
|
||||
app.include_router(intent.router)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要点
|
||||
|
||||
### 6.1 新增 API helper
|
||||
**位置**:`frontend-web/src/roundtable-planning/api/intent.ts`
|
||||
|
||||
```ts
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface IntentInitResponse {
|
||||
status: "success";
|
||||
agent_id: string;
|
||||
thread_id: string;
|
||||
}
|
||||
|
||||
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface IntentSummary {
|
||||
objective: string;
|
||||
constraints: string[];
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
export interface StreamIntentRequest {
|
||||
threadId: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
onTextDelta?: (text: string, id: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamIntentResult {
|
||||
status: "asking" | "done" | "error";
|
||||
content: string;
|
||||
summary: IntentSummary | null;
|
||||
}
|
||||
|
||||
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
|
||||
// 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
|
||||
// {status, content, summary?} 格式。
|
||||
// ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染
|
||||
|
||||
替换 `handleSendMessage`:
|
||||
|
||||
```ts
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 1 && !intentThreadId) {
|
||||
initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id));
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputText.trim() || !intentThreadId) return;
|
||||
const userText = inputText.trim();
|
||||
// (push user msg to interactiveMessages 略)
|
||||
setInputText("");
|
||||
|
||||
// 占位 agent 气泡
|
||||
const agentMsgId = pushAgentPlaceholder("任务理解智能体");
|
||||
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: userText,
|
||||
model: DEFAULT_MODEL,
|
||||
onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta),
|
||||
});
|
||||
|
||||
finalizeAgentMsg(agentMsgId, res.content);
|
||||
if (res.status === "done" && res.summary) {
|
||||
setIntentReady(res.summary);
|
||||
triggerToast("✅ 任务理解完成,可以进入圆桌研讨");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 6.3 "进入 step 2"按钮的联动
|
||||
|
||||
- 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`)
|
||||
- 改造:只有 `intentReady !== null` 时该按钮才 enabled
|
||||
- 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑)
|
||||
|
||||
```ts
|
||||
const buildInitialIntent = useCallback(() => {
|
||||
if (intentReady) {
|
||||
return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||
}
|
||||
// fallback: 旧逻辑保留兜底
|
||||
const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
|
||||
const userIntent = lastUserMessage?.content ?? "请围绕给定任务展开圆桌研讨";
|
||||
const constraints = `预算硬约束:${budgetLimit} 万元`;
|
||||
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||
}, [interactiveMessages, budgetLimit, intentReady]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施清单
|
||||
|
||||
| 优先级 | 项 | 文件 |
|
||||
|---|---|---|
|
||||
| P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` |
|
||||
| P0 | 新 router | `backend/app/gateway/routers/intent.py` |
|
||||
| P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) |
|
||||
| P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) |
|
||||
| P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 |
|
||||
| P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) |
|
||||
| P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 与 step 2 的衔接
|
||||
|
||||
```
|
||||
step 1 输出: intentReady.objective + intentReady.constraints
|
||||
↓
|
||||
step 2 入口: buildInitialIntent() 拼成 leader 的 new_message
|
||||
↓
|
||||
step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了),
|
||||
除非模型遇到完全意料之外的歧义
|
||||
↓
|
||||
研讨更聚焦,减少 step2 阶段的 clarification 轮次
|
||||
```
|
||||
|
||||
副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。
|
||||
|
||||
---
|
||||
|
||||
## 9. 路径速查
|
||||
|
||||
| 资源 | 位置 |
|
||||
|---|---|
|
||||
| Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) |
|
||||
| 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
|
||||
| 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
|
||||
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
|
||||
| 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收清单
|
||||
|
||||
- [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id
|
||||
- [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout)
|
||||
- [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary
|
||||
- [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态
|
||||
- [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话)
|
||||
- [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)
|
||||
@@ -1,264 +0,0 @@
|
||||
# 多智能体圆桌 · Step 3 · 接口与对接说明
|
||||
|
||||
> Step 3(`/page/roundtable/planning` 的"最终方案展示"页)对应 `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx`。当前页面是产品早期的"展厅效果",绝大多数内容是硬编码 mock。本文档梳理**真正需要的数据**、**用哪些接口**、**前后端怎么对接**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 改造方向(在做什么,不做什么)
|
||||
|
||||
### ✅ 保留并对接真实数据
|
||||
|
||||
| 区块 | 现状 | 改造后 |
|
||||
|---|---|---|
|
||||
| 标题栏 + 生成时间 | 硬编码"2026-05-21 11:01:12" | 用本地 `Date.now()` 或 init/最后一次 leader 完成时刻 |
|
||||
| "已达成共识"徽章 | 静态显示 | step2 自然收敛(`leaderRes.dispatched.length === 0`)后才显示 |
|
||||
| **总控最终结论** | 完全是 mock 文本 | 取最后一次 `leaderRes.content`(已经在 step2 拿到了) |
|
||||
| **各席位最终交付(折叠卡片)** | 当前 step3 没渲染 | 取每个 sub 线程的最后一条 AIMessage 文本 |
|
||||
| **生成的报告文件清单** | 5 张假"档案" | 真实列出 `outputs/` 目录里的所有产出(markdown / pdf / csv 等) |
|
||||
| **文件下载** | 假 toast | 用现有 `GET /api/threads/{tid}/artifacts/{path}` |
|
||||
| "重新起草新规划" | 调用 `setCurrentStep(1)` 重置 | **保留**,含义就是回到 step1 重新开始 |
|
||||
|
||||
### ❌ 不做(直接删 / 不接口)
|
||||
|
||||
| 区块 | 原因 |
|
||||
|---|---|
|
||||
| 4 张 metric 卡片(设计全要素通过率 99.4% / Tier III+ / 终审共识度 42 轮研商 / 90.3分)| 全是按 `budgetLimit` 编出来的"展厅指标",没有真实数据源 |
|
||||
| 三阶段推进路线图(0-24h / 24-60h / 60-72h 三张卡)| 假设业务必然是 72 小时交付,写死场景,不通用 |
|
||||
| "采纳理由与最终裁定判词"段 | 整段都是模板话术,没接口能算出"90.3 分总评" |
|
||||
| "12 轮极限对抗研商" 数据 | 当前没记录"轮次"语义,临时就用 leader 实际轮数代替即可,不值得为它出接口 |
|
||||
| "签发起草成果并投产运行" 按钮 | 无对应业务动作(不是要部署、不是要审批),删掉 |
|
||||
| "执行摘要" 段写死的 3 个 bullet | 模板话术,跟实际任务无关 |
|
||||
|
||||
最终 step3 页面只保留四块:**头部状态 + 总控结论 + 席位交付列表 + 文件下载清单**,再加底部"重新起草"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据来源全景
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ step2 全程结束时,前端手里已经有的本地状态 │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ coordinatorName: "roundtable-coordinator-xxx" │
|
||||
│ threadIds: { [agent_name]: thread_id, ... } │ ← /init 返回
|
||||
│ step2RoundtableDialogues: [...所有气泡, 含每个 sub 最终 │
|
||||
│ 交付的 content...] │
|
||||
│ leaderRes.content (最后一次 leader 的 final text) │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
|
||||
后端权威数据(需要时从后端拉):
|
||||
- 每个 sub 线程的完整 state → GET /api/threads/{tid}/state
|
||||
- 单个 outputs 文件内容/下载 → GET /api/threads/{tid}/artifacts/{path}
|
||||
- outputs 目录文件清单 ❗暂无 → 新增接口(见 §3)
|
||||
```
|
||||
|
||||
**关键判断**:席位最终交付文本 + 总控结论文本 **已经在 step2 内存里**,无需重新调接口。文件清单是**目前唯一缺失**的数据源。
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口需求
|
||||
|
||||
### 3.1 新增(**必需**):列出某 thread 的产出文件
|
||||
|
||||
```
|
||||
GET /api/multi-agent/sessions/artifacts?thread_id=<sub_thread_id>
|
||||
```
|
||||
|
||||
或者更通用、放进 artifacts 路由下:
|
||||
|
||||
```
|
||||
GET /api/threads/{thread_id}/artifacts
|
||||
```
|
||||
|
||||
**为什么需要**:现有 artifacts endpoint 只能"按已知路径下载文件",没有"列出目录"功能。Step 3 需要知道 sub-agent 写到 `/mnt/user-data/outputs/` 下哪些文件,才能在前端列出可下载清单。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"thread_id": "<sub_thread_id>",
|
||||
"files": [
|
||||
{
|
||||
"path": "mnt/user-data/outputs/方案设计报告_生产系统安全改造技术方案.md",
|
||||
"name": "方案设计报告_生产系统安全改造技术方案.md",
|
||||
"size_bytes": 12480,
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-05-23T14:32:30.123Z"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**实现建议**:
|
||||
- 列 `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` 下的文件
|
||||
- 鉴权复用 `@require_permission("threads", "read", owner_check=True)`
|
||||
- 路径要做 traversal 保护(虚拟路径仅限 `mnt/user-data/outputs/`)
|
||||
|
||||
### 3.2 新增(**可选,体感优化**):一次性聚合 step3 数据
|
||||
|
||||
```
|
||||
POST /api/multi-agent/sessions/summary
|
||||
Body: {
|
||||
"main_agent_name": "roundtable-coordinator-xxx",
|
||||
"thread_ids": { "<agent_name>": "<thread_id>", ... }
|
||||
}
|
||||
```
|
||||
|
||||
**为什么可选**:不加这个接口的话,前端需要为每个席位发一次 `GET /state` + 一次 `GET /artifacts`(共 7-13 次 round-trip)。加了这个接口,**一次返回所有数据**。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"main_agent_name": "roundtable-coordinator-xxx",
|
||||
"coordinator": {
|
||||
"thread_id": "...",
|
||||
"final_text": "...", // 最后一条 AIMessage content
|
||||
"dispatch_rounds": 3 // leader 派活的轮数(可选展示)
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"agent_name": "roundtable-intelligence",
|
||||
"thread_id": "...",
|
||||
"display_name": "情报收集",
|
||||
"final_text": "...", // 最后一条 AIMessage content
|
||||
"files": [ // 该席位写到 outputs/ 的文件
|
||||
{ "path": "...", "name": "...", "size_bytes": ..., "mime_type": "..." }
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**取舍**:
|
||||
- 优点:1 次请求 vs 13 次;体验更顺
|
||||
- 缺点:等于把 3.1 + N 次 state 读放到一个 endpoint,实现稍复杂
|
||||
- 推荐:**先做 3.1,3.2 暂缓**;前端用现有 endpoint 串起来够用
|
||||
|
||||
### 3.3 复用(**无需改动**)
|
||||
|
||||
| Endpoint | 用途 |
|
||||
|---|---|
|
||||
| `GET /api/threads/{tid}/state` | 取席位 / 总控线程的完整状态,提取最后一条 AIMessage content |
|
||||
| `GET /api/threads/{tid}/artifacts/{path}` | 点击文件名预览或下载(已有 `?download=true` 支持) |
|
||||
|
||||
> Note: 这两个 endpoint 已经在 `app.gateway.routers.threads` / `app.gateway.routers.artifacts` 里实现,前端 `fetch-client` 也有现成调用方式。
|
||||
|
||||
---
|
||||
|
||||
## 4. 前后端对接流程
|
||||
|
||||
### 4.1 进入 step3 时
|
||||
|
||||
```ts
|
||||
// 触发条件:runOrchestration 自然结束(leaderRes.dispatched.length === 0
|
||||
// 且无 clarification),用户点"达成共识生成全案成果"按钮 / 自动跳转。
|
||||
|
||||
// 前端手里已有:threadIds, coordinatorName, 最后的 leaderRes
|
||||
// 1. 总控结论文本 — 已在内存,直接用
|
||||
const consensusText = lastLeaderRes.content;
|
||||
|
||||
// 2. 各席位交付 — 已在 step2RoundtableDialogues 里,过滤"子智能体交付"的卡片
|
||||
const seatDeliveries = step2RoundtableDialogues
|
||||
.filter(d => d.confidence === "子智能体交付")
|
||||
.map(d => ({ sender: d.sender, content: d.content }));
|
||||
|
||||
// 3. 文件清单 — 调新接口
|
||||
const filesPerSeat = await Promise.all(
|
||||
Object.entries(threadIds)
|
||||
.filter(([name]) => name !== coordinatorName)
|
||||
.map(async ([name, tid]) => ({
|
||||
agentName: name,
|
||||
files: (await fetch(`/api/threads/${tid}/artifacts`).then(r => r.json())).files,
|
||||
})),
|
||||
);
|
||||
```
|
||||
|
||||
### 4.2 用户点击文件时
|
||||
|
||||
```ts
|
||||
// 在线预览(mime 是 markdown/text/pdf 自动 inline)
|
||||
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}`);
|
||||
|
||||
// 强制下载
|
||||
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}?download=true`);
|
||||
```
|
||||
|
||||
### 4.3 "重新起草新规划" 按钮
|
||||
|
||||
```ts
|
||||
// 跟现在一样,纯前端:清掉 dialogues + 跳 step1
|
||||
setCurrentStep(1);
|
||||
setInteractiveMessages(...); // 已有逻辑
|
||||
setStep2RoundtableDialogues([]);
|
||||
// 不需要调任何接口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 改造前端的具体文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` | **大改**。删除四张 metric 卡 / 三阶段路线图 / 论证要件 / 裁定判词;新增结论卡 + 席位折叠列表 + 文件清单 |
|
||||
| `frontend-web/src/roundtable-planning/api/multi-agent.ts` | 新增 `listSessionArtifacts(threadId)` helper(封装 §3.1 接口) |
|
||||
| `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 把 `threadIds` / `coordinatorName` / `lastLeaderContent` 传进 `HighFidelityReport` 的 props(目前没传) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 改造后端的具体文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | 新增 `GET /api/threads/{tid}/artifacts`(不带 path,列目录) |
|
||||
| `offline-backend-20260512/backend/CLAUDE.md` | 在 artifacts 路由表里增加新条目说明 |
|
||||
| `offline-backend-20260512/backend/tests/test_artifacts_list.py` | 列目录新接口的单测(覆盖:空目录返回 [];traversal 路径返回 403;不存在 thread 返回 404)|
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施优先级
|
||||
|
||||
| 优先级 | 项 | 备注 |
|
||||
|---|---|---|
|
||||
| P0 | 新增 `GET /api/threads/{tid}/artifacts` 列文件接口 | 必需,没它前端无法列文件 |
|
||||
| P0 | step3 大改:删 mock 区块、加结论/席位/文件三块 | 主要价值改造 |
|
||||
| P1 | 前端 helper `listSessionArtifacts` | 封装 fetch + 类型 |
|
||||
| P2 | 单测 `test_artifacts_list.py` | 上线后跟进 |
|
||||
| P3 | `POST /api/multi-agent/sessions/summary` 聚合接口 | 13 次→1 次,体感优化,**非必需** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 显式不做的事 ❌
|
||||
|
||||
1. **不做"PDF 导出"**:后端没有现成 PDF 渲染管线,要做就是单独的项目。当前页面那个 `导出方案 PDF` 按钮直接删掉,或者保留按钮但点击只触发 `window.print()`(让浏览器自己生成 PDF)。
|
||||
2. **不做"安全分享"**:项目里已有 `/api/thread-shares`(per-thread 分享)和 `/api/public/shares`(公开页面),但那是针对单个 thread 的,不是多 thread 会话的。要做"多 thread 整合分享"是新功能,远超 step3 的范围。
|
||||
3. **不做"指标雷达图"**(什么 90.3 分总评):没有真实数据源,强行加只会变成下一个 mock。
|
||||
4. **不做"签发投产"**:纯展示按钮,不接业务系统,删。
|
||||
5. **不做"轮次/对抗次数"显示**:除非前端记一下 `dispatch_count`,否则后端没现成统计。如果真要展示,用现成的"派活了几次"代替"对抗 N 轮"。
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收清单
|
||||
|
||||
step3 改造完成后应该能:
|
||||
|
||||
- [ ] 看到当前 session 的总控最终结论,markdown 渲染
|
||||
- [ ] 看到 6 个席位各自的最终交付内容(默认折叠,点击展开)
|
||||
- [ ] 看到所有 sub-agent 写到 outputs 的文件列表,含文件名、大小、类型 icon
|
||||
- [ ] 点击文件名能在线预览(markdown / pdf / 图片)
|
||||
- [ ] 点击下载按钮能下载(带正确文件名)
|
||||
- [ ] 点"重新起草新规划"能回到 step1 且 step2 内存清空
|
||||
- [ ] 页面不再包含任何来自 `budgetLimit` 的"假指标"
|
||||
|
||||
---
|
||||
|
||||
## 10. 路径速查
|
||||
|
||||
| 资源 | 位置 |
|
||||
|---|---|
|
||||
| Step 3 组件 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||||
| 父页面 (state 持有者) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||
| 后端 artifacts 路由 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
|
||||
| 后端 multi-agent 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
|
||||
| 物理 outputs 目录 | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,312 +0,0 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 最后更新:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
### Canvas 系统(双栏写作/编码工作区)
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
|
||||
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
|
||||
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
|
||||
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
|
||||
| 阶段七 | 版本历史导航 | ✅ 已完成 |
|
||||
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
|
||||
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
|
||||
|
||||
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
|
||||
|
||||
| 步骤 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
|
||||
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
|
||||
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
|
||||
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
|
||||
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
|
||||
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
|
||||
|
||||
**当前状态:全部功能已完成 🎉**
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端核心文件结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
├── api/
|
||||
│ ├── client.ts # LangGraph SDK 客户端实例
|
||||
│ ├── models.ts # 模型列表 API
|
||||
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
|
||||
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
|
||||
│ └── threads.ts # createCanvasThread、getCanvasThread
|
||||
├── components/
|
||||
│ ├── artifact/
|
||||
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
|
||||
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
|
||||
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
|
||||
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
|
||||
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
|
||||
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
|
||||
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
|
||||
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
|
||||
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
|
||||
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
|
||||
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
|
||||
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
|
||||
│ ├── chat/
|
||||
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
|
||||
│ ├── layout/
|
||||
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
|
||||
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
|
||||
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
|
||||
│ └── selectors/
|
||||
│ └── CanvasModelSelector.tsx # 模型下拉选择器
|
||||
├── contexts/
|
||||
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
|
||||
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
|
||||
│ ├── CanvasThreadContext.tsx # threadId、modelName
|
||||
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
|
||||
├── editor/
|
||||
│ ├── TiptapEditor.tsx # EditorContent 包装器
|
||||
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
|
||||
├── hooks/
|
||||
│ ├── useCanvasArtifact.ts # artifact 初始化工具
|
||||
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
|
||||
│ ├── useCanvasSelection.ts # 文字/代码选区状态
|
||||
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
|
||||
│ ├── useCanvasThread.ts # 线程加载工具
|
||||
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
|
||||
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
|
||||
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
|
||||
├── pages/
|
||||
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
|
||||
│ └── CanvasThreadPage.tsx # Provider 装配层
|
||||
├── routes/
|
||||
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
|
||||
├── types/
|
||||
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
│ ├── model.ts # CanvasModel
|
||||
│ ├── quick-action.ts # QuickAction、QuickActionId
|
||||
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
│ └── thread.ts # CanvasMessage、CanvasThreadValues
|
||||
└── utils/
|
||||
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
|
||||
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
|
||||
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
|
||||
├── selection.ts # buildMessageWithSelection
|
||||
├── stream.ts # 流式工具
|
||||
└── thread.ts # 线程工具
|
||||
```
|
||||
|
||||
### 2.2 后端核心文件结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── packages/harness/deerflow/agents/
|
||||
│ ├── canvas_agent/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
|
||||
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
|
||||
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
|
||||
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
|
||||
└── app/gateway/
|
||||
├── app.py # 注册 writing router
|
||||
└── routers/
|
||||
└── writing.py # POST /api/writing/rewrite(SSE 流式)
|
||||
```
|
||||
|
||||
**langgraph.json** 注册了两个 graph:
|
||||
- `lead_agent` → 主聊天 Agent
|
||||
- `canvas_agent` → Canvas 写作 Agent
|
||||
|
||||
### 2.3 关键 Context 层级
|
||||
|
||||
```
|
||||
CanvasThreadPage
|
||||
├── CanvasThreadProvider # threadId、modelName
|
||||
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
|
||||
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
|
||||
│ │ └── CanvasShell
|
||||
│ │ └── CanvasResizableLayout
|
||||
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
|
||||
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
|
||||
│ │ └── CanvasArtifactPanel
|
||||
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、关键架构决策与注意事项
|
||||
|
||||
### 3.1 流式架构:单例流(重要)
|
||||
|
||||
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
|
||||
|
||||
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
|
||||
|
||||
```
|
||||
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
|
||||
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
|
||||
```
|
||||
|
||||
### 3.2 artifact 内容注入机制(重要)
|
||||
|
||||
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
|
||||
|
||||
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
|
||||
|
||||
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
|
||||
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
|
||||
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
|
||||
|
||||
```typescript
|
||||
// utils/quick-actions.ts
|
||||
export function buildQuickActionMessage(id, content) {
|
||||
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
|
||||
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
|
||||
}
|
||||
|
||||
// core/messages/utils.ts
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ThinkTagParser(AI 改写思考过程解析)
|
||||
|
||||
后端支持两种思考格式:
|
||||
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
|
||||
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
|
||||
|
||||
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
|
||||
|
||||
### 3.4 版本历史:本地切换
|
||||
|
||||
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
|
||||
|
||||
### 3.5 Writing Editor 流程(三阶段状态机)
|
||||
|
||||
```
|
||||
idle → menu → preview → idle
|
||||
↑ ↓
|
||||
openAiMenu closeAll
|
||||
```
|
||||
|
||||
- **idle**:正常编辑
|
||||
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
|
||||
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
|
||||
|
||||
---
|
||||
|
||||
## 四、已解决的技术难题
|
||||
|
||||
| 问题 | 解法 |
|
||||
|------|------|
|
||||
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
|
||||
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
|
||||
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
|
||||
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
|
||||
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
|
||||
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
|
||||
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
|
||||
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
|
||||
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
|
||||
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
|
||||
|
||||
---
|
||||
|
||||
## 五、当前已知限制(非 bug,设计取舍)
|
||||
|
||||
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
|
||||
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
|
||||
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
|
||||
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
|
||||
|
||||
---
|
||||
|
||||
## 六、快速开始下一次工作
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 后端(从 offline-backend-20260512/backend/)
|
||||
make dev # 启动 Gateway API on port 8001
|
||||
|
||||
# 前端(从 frontend-web/)
|
||||
pnpm dev # 启动 Vite on port 5174
|
||||
```
|
||||
|
||||
### 访问路径
|
||||
|
||||
- 主页:`http://localhost:5174`
|
||||
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
|
||||
|
||||
### 类型检查
|
||||
|
||||
```bash
|
||||
cd frontend-web && pnpm typecheck
|
||||
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、如需继续开发的潜在方向
|
||||
|
||||
| 方向 | 描述 | 优先级 |
|
||||
|------|------|------|
|
||||
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
|
||||
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
|
||||
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
|
||||
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
|
||||
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
|
||||
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键文件速查
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
|
||||
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
|
||||
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
|
||||
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
|
||||
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
|
||||
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
|
||||
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
|
||||
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
|
||||
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
|
||||
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
|
||||
|
||||
---
|
||||
|
||||
## 九、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
|
||||
详细进度见:frontend-web/docs/openCanvas-progress.md
|
||||
|
||||
当前全部功能已完成,包括:
|
||||
- Canvas 双栏布局、canvas_agent、版本历史
|
||||
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
|
||||
- 快捷动作(通过 CanvasStreamContext 统一流)
|
||||
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
|
||||
|
||||
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
|
||||
```
|
||||
@@ -1,950 +0,0 @@
|
||||
# Open Canvas 模式需求拆解文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
|
||||
|
||||
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
|
||||
|
||||
- 前端:基于 `React 19 + Vite + react-router-dom`
|
||||
- 后端:基于 `Python + FastAPI + LangGraph`
|
||||
- 模型层:支持云模型与本地模型自由配置
|
||||
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
|
||||
|
||||
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终目标
|
||||
|
||||
### 2.1 用户视角目标
|
||||
|
||||
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
|
||||
|
||||
- 左侧是聊天区,右侧是 Canvas 编辑区
|
||||
- 可以从空白文本或代码模板快速开始
|
||||
- 可以通过聊天让 AI 生成一份结构化 artifact
|
||||
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
|
||||
- artifact 支持版本历史切换
|
||||
- artifact 支持文本和代码两种主要类型
|
||||
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
|
||||
- 支持启用 Web 搜索增强
|
||||
- 支持切换模型
|
||||
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
|
||||
|
||||
### 2.2 工程视角目标
|
||||
|
||||
新增的 Canvas 模式需要满足以下工程原则:
|
||||
|
||||
- 不破坏现有 DeerFlow 工作区
|
||||
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
|
||||
- 可逐阶段上线
|
||||
- 前后端状态协议清晰
|
||||
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
### 3.1 本次需求包含
|
||||
|
||||
- 前端新增 `open-canvas` 专用目录
|
||||
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
|
||||
- 前端新增 Canvas 页面、上下文、组件、状态管理
|
||||
- 后端新增 Canvas 专用线程状态字段
|
||||
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
|
||||
- 模型配置统一为 DeerFlow 后端配置驱动
|
||||
- 支持本地大模型配置与模型列表动态下发
|
||||
- 支持结构化 artifact 的保存、读取、更新、版本切换
|
||||
|
||||
### 3.2 本次需求不包含
|
||||
|
||||
- 一次性复刻 open-canvas 全部 UI 细节
|
||||
- 完整照搬 Supabase 鉴权体系
|
||||
- 直接复用 open-canvas 的 Next.js API Route
|
||||
- 直接运行 open-canvas 的 LangGraph.js 后端
|
||||
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
|
||||
|
||||
### 3.3 迁移原则
|
||||
|
||||
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
|
||||
- 前端尽量复用其交互设计和数据结构
|
||||
- 后端以 Python 实现同等能力
|
||||
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体设计思路
|
||||
|
||||
### 4.1 前端设计思路
|
||||
|
||||
在现有 `frontend-web/src` 下新增一个专用目录,例如:
|
||||
|
||||
```text
|
||||
src/
|
||||
open-canvas/
|
||||
pages/
|
||||
routes/
|
||||
components/
|
||||
contexts/
|
||||
hooks/
|
||||
types/
|
||||
api/
|
||||
utils/
|
||||
constants/
|
||||
```
|
||||
|
||||
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
|
||||
|
||||
建议路由形式:
|
||||
|
||||
- `/canvas`
|
||||
- `/canvas/:threadId`
|
||||
|
||||
也可以挂在现有页面布局下,例如:
|
||||
|
||||
- `/workspace/canvas`
|
||||
- `/workspace/canvas/:threadId`
|
||||
|
||||
推荐优先采用独立一级功能路由:
|
||||
|
||||
- 逻辑更清晰
|
||||
- 状态隔离更容易
|
||||
- 不容易污染现有 `workspace` 功能
|
||||
|
||||
### 4.2 后端设计思路
|
||||
|
||||
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
|
||||
|
||||
#### 方案 A:新增独立 Canvas Assistant
|
||||
|
||||
- 新增 `canvas_agent`
|
||||
- 使用单独的 LangGraph 状态定义
|
||||
- 前端调用时通过 `assistant_id=canvas_agent`
|
||||
|
||||
优点:
|
||||
|
||||
- 与现有 `lead_agent` 隔离
|
||||
- 风险更低
|
||||
- 更容易对齐 open-canvas 的图结构
|
||||
|
||||
缺点:
|
||||
|
||||
- 会产生一套新的 Agent 图维护成本
|
||||
|
||||
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
|
||||
|
||||
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
|
||||
- Agent 根据模式进入 Canvas 路径
|
||||
|
||||
优点:
|
||||
|
||||
- 统一入口
|
||||
|
||||
缺点:
|
||||
|
||||
- 会显著增加现有 Agent 复杂度
|
||||
- 容易影响现有聊天和文件产物模式
|
||||
|
||||
推荐先做方案 A。
|
||||
|
||||
### 4.3 模型配置设计思路
|
||||
|
||||
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
|
||||
|
||||
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
|
||||
|
||||
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `provider`
|
||||
- `supports_thinking`
|
||||
- `supports_reasoning_effort`
|
||||
- `supports_vision`
|
||||
- `supports_tool_calling`
|
||||
- `supports_artifact_editing`
|
||||
- `supports_code_editing`
|
||||
- `temperature_range`
|
||||
- `max_tokens_range`
|
||||
- `base_url_source` 或 provider 标识
|
||||
- `is_local`
|
||||
|
||||
---
|
||||
|
||||
## 5. 与当前项目的关键差异
|
||||
|
||||
### 5.1 当前 DeerFlow 现状
|
||||
|
||||
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
|
||||
|
||||
- `artifacts: string[]`
|
||||
|
||||
这更接近“生成文件产物”的工作模式。
|
||||
|
||||
前端当前 artifact 面板也主要是:
|
||||
|
||||
- 文件列表
|
||||
- 文件详情
|
||||
- 文件下载/预览
|
||||
|
||||
### 5.2 open-canvas 的关键能力
|
||||
|
||||
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
|
||||
|
||||
- 一个 conversation 对应一个主 artifact
|
||||
- artifact 具有类型、标题、内容、版本
|
||||
- 用户可以选中部分内容让 AI 改写
|
||||
- 每次改写都会生成新版本
|
||||
|
||||
### 5.3 需要新增的核心能力
|
||||
|
||||
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
|
||||
|
||||
- 当前 artifact
|
||||
- 当前版本索引
|
||||
- 历史版本列表
|
||||
- 选区信息
|
||||
- 当前编辑模式
|
||||
- 快速动作状态
|
||||
- Web 搜索结果状态
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施方案
|
||||
|
||||
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段一:前端路由与目录骨架
|
||||
|
||||
### 7.1 目标
|
||||
|
||||
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
|
||||
|
||||
### 7.2 需求
|
||||
|
||||
- 新增 `src/open-canvas/` 目录
|
||||
- 新增 Canvas 页面路由
|
||||
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
|
||||
- 提供 Canvas 首页和 Canvas Thread 页
|
||||
- 支持空状态展示
|
||||
|
||||
### 7.3 建议目录
|
||||
|
||||
```text
|
||||
src/open-canvas/
|
||||
api/
|
||||
threads.ts
|
||||
runs.ts
|
||||
models.ts
|
||||
components/
|
||||
layout/
|
||||
chat/
|
||||
canvas/
|
||||
artifacts/
|
||||
quick-actions/
|
||||
selectors/
|
||||
contexts/
|
||||
CanvasThreadContext.tsx
|
||||
CanvasGraphContext.tsx
|
||||
CanvasUIContext.tsx
|
||||
hooks/
|
||||
useCanvasThread.ts
|
||||
useCanvasStream.ts
|
||||
useCanvasArtifact.ts
|
||||
pages/
|
||||
CanvasHomePage.tsx
|
||||
CanvasThreadPage.tsx
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx
|
||||
types/
|
||||
artifact.ts
|
||||
thread.ts
|
||||
model.ts
|
||||
utils/
|
||||
artifact.ts
|
||||
selection.ts
|
||||
stream.ts
|
||||
constants/
|
||||
index.ts
|
||||
```
|
||||
|
||||
### 7.4 路由建议
|
||||
|
||||
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
|
||||
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
|
||||
|
||||
建议形式:
|
||||
|
||||
```text
|
||||
/canvas
|
||||
/canvas/:threadId
|
||||
```
|
||||
|
||||
### 7.5 验收标准
|
||||
|
||||
- 能访问 Canvas 首页
|
||||
- 能访问指定 thread 的 Canvas 页面
|
||||
- 不影响现有 `workspace/*` 与 `strategy/*`
|
||||
- 页面有独立布局骨架
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段二:前端 Canvas 布局与交互容器
|
||||
|
||||
### 8.1 目标
|
||||
|
||||
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
|
||||
|
||||
### 8.2 需求
|
||||
|
||||
- 左侧聊天区
|
||||
- 右侧画布区
|
||||
- 支持面板拖拽或折叠
|
||||
- 初始空状态
|
||||
- 支持“快速开始”:
|
||||
- 新建空白文本 artifact
|
||||
- 新建代码 artifact
|
||||
|
||||
### 8.3 组件拆分建议
|
||||
|
||||
- `CanvasShell`
|
||||
- `CanvasSidebar`
|
||||
- `CanvasChatPanel`
|
||||
- `CanvasArtifactPanel`
|
||||
- `CanvasEmptyState`
|
||||
- `QuickStartActions`
|
||||
|
||||
### 8.4 快速开始能力
|
||||
|
||||
用户进入空线程时可以选择:
|
||||
|
||||
- 开始写文章
|
||||
- 开始写代码
|
||||
- 从模板开始
|
||||
|
||||
首版模板可只做最小集:
|
||||
|
||||
- 空白文档
|
||||
- HTML 模板
|
||||
- React 组件模板
|
||||
- Python 脚本模板
|
||||
|
||||
### 8.5 验收标准
|
||||
|
||||
- 页面可稳定展示双栏布局
|
||||
- 快速开始能创建前端本地 artifact 状态
|
||||
- 代码和文本可分别进入不同渲染器
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段三:结构化 Artifact 数据模型
|
||||
|
||||
### 9.1 目标
|
||||
|
||||
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
|
||||
|
||||
### 9.2 核心要求
|
||||
|
||||
需要支持以下能力:
|
||||
|
||||
- 文本 artifact
|
||||
- 代码 artifact
|
||||
- 标题
|
||||
- 当前版本索引
|
||||
- 版本历史
|
||||
- 当前选中版本切换
|
||||
- 后续支持局部高亮编辑
|
||||
|
||||
### 9.3 建议数据结构
|
||||
|
||||
```ts
|
||||
type CanvasArtifact = {
|
||||
currentIndex: number;
|
||||
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
|
||||
};
|
||||
|
||||
type CanvasArtifactText = {
|
||||
index: number;
|
||||
type: "text";
|
||||
title: string;
|
||||
fullMarkdown: string;
|
||||
};
|
||||
|
||||
type CanvasArtifactCode = {
|
||||
index: number;
|
||||
type: "code";
|
||||
title: string;
|
||||
language: string;
|
||||
code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.4 线程状态建议新增字段
|
||||
|
||||
前端与后端需共同支持:
|
||||
|
||||
- `artifact`
|
||||
- `highlighted_text`
|
||||
- `highlighted_code`
|
||||
- `canvas_mode`
|
||||
- `web_search_enabled`
|
||||
- `web_search_results`
|
||||
- `quick_action_id`
|
||||
|
||||
### 9.5 与现有 `artifacts: string[]` 的关系
|
||||
|
||||
建议保留现有 `artifacts: string[]` 不动。
|
||||
|
||||
新增字段:
|
||||
|
||||
- `artifact` 用于 Canvas 主文档
|
||||
- `artifacts` 继续用于文件产物
|
||||
|
||||
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
|
||||
|
||||
### 9.6 验收标准
|
||||
|
||||
- 前端类型定义完成
|
||||
- 后端状态字段设计完成
|
||||
- 确认与现有线程状态兼容
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段四:后端线程状态与 API 扩展
|
||||
|
||||
### 10.1 目标
|
||||
|
||||
让后端线程状态可以安全存储和返回 Canvas Artifact。
|
||||
|
||||
### 10.2 需求
|
||||
|
||||
- 扩展 Python `ThreadState`
|
||||
- 扩展状态序列化逻辑
|
||||
- 确保 `/api/threads/*` 返回结构化 `artifact`
|
||||
- 确保 LangGraph 流中可带上 `artifact` 更新
|
||||
|
||||
### 10.3 后端改造点
|
||||
|
||||
建议关注以下模块:
|
||||
|
||||
- `packages/harness/deerflow/agents/thread_state.py`
|
||||
- 线程 state serializer
|
||||
- `app/gateway/routers/threads.py`
|
||||
- 流事件适配层
|
||||
|
||||
### 10.4 注意事项
|
||||
|
||||
- 旧线程没有 `artifact` 字段时必须兼容
|
||||
- 状态更新不能破坏现有消息结构
|
||||
- 必须确保 JSON 可序列化
|
||||
|
||||
### 10.5 验收标准
|
||||
|
||||
- 新建线程可写入 `artifact`
|
||||
- 读取线程时可返回 `artifact`
|
||||
- 不影响现有线程读取
|
||||
- 前端能从线程 state 中恢复 Canvas 内容
|
||||
|
||||
---
|
||||
|
||||
## 11. 阶段五:Canvas 专用 Agent 图
|
||||
|
||||
### 11.1 目标
|
||||
|
||||
实现与 open-canvas 类似的 AI 路由能力。
|
||||
|
||||
### 11.2 推荐方案
|
||||
|
||||
新增 `canvas_agent`,单独维护图逻辑。
|
||||
|
||||
### 11.3 节点拆分建议
|
||||
|
||||
首版建议至少拆成这些节点:
|
||||
|
||||
- `route_user_intent`
|
||||
- `reply_general_input`
|
||||
- `generate_artifact`
|
||||
- `rewrite_artifact`
|
||||
- `update_highlighted_text`
|
||||
- `update_highlighted_code`
|
||||
- `generate_followup`
|
||||
|
||||
二期可加:
|
||||
|
||||
- `rewrite_artifact_theme`
|
||||
- `rewrite_code_theme`
|
||||
- `change_length`
|
||||
- `change_reading_level`
|
||||
- `translate_artifact`
|
||||
- `add_comments`
|
||||
- `add_logs`
|
||||
- `fix_bugs`
|
||||
- `port_language`
|
||||
- `web_search_enrich`
|
||||
|
||||
### 11.4 路由逻辑要求
|
||||
|
||||
系统需要能判断:
|
||||
|
||||
- 用户是在闲聊
|
||||
- 用户要新生成 artifact
|
||||
- 用户要整体重写当前 artifact
|
||||
- 用户要局部修改当前 artifact
|
||||
- 用户要执行某个快捷动作
|
||||
|
||||
### 11.5 状态输入要求
|
||||
|
||||
节点需要读取:
|
||||
|
||||
- 当前消息历史
|
||||
- 当前 artifact
|
||||
- 当前选区
|
||||
- 当前模型配置
|
||||
- Web 搜索开关
|
||||
|
||||
### 11.6 输出要求
|
||||
|
||||
节点输出必须能更新:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `web_search_results`
|
||||
- `followup`
|
||||
|
||||
### 11.7 验收标准
|
||||
|
||||
- 能根据输入创建新 artifact
|
||||
- 能根据输入整体改写 artifact
|
||||
- 能保留版本历史
|
||||
- 能在改写后返回自然语言 followup
|
||||
|
||||
---
|
||||
|
||||
## 12. 阶段六:前端流式同步与版本管理
|
||||
|
||||
### 12.1 目标
|
||||
|
||||
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
|
||||
|
||||
### 12.2 需求
|
||||
|
||||
- 复用现有 LangGraph stream client
|
||||
- 在 Canvas 模式中解析 `artifact` 更新
|
||||
- 支持流式文本更新
|
||||
- 支持版本切换
|
||||
- 支持断线后恢复线程状态
|
||||
|
||||
### 12.3 前端状态层建议
|
||||
|
||||
- `CanvasThreadContext`:线程基础信息
|
||||
- `CanvasGraphContext`:消息、artifact、streaming 状态
|
||||
- `CanvasUIContext`:面板、编辑态、选中态
|
||||
|
||||
### 12.4 关键能力
|
||||
|
||||
- 当前版本切换
|
||||
- 新版本创建后自动定位到最新版本
|
||||
- 手动编辑内容后保存
|
||||
- 流结束后线程状态持久化
|
||||
|
||||
### 12.5 验收标准
|
||||
|
||||
- 发送消息时聊天和画布可同步更新
|
||||
- 版本数会增加
|
||||
- 切换历史版本稳定
|
||||
- 刷新页面后线程状态能恢复
|
||||
|
||||
---
|
||||
|
||||
## 13. 阶段七:局部选区改写
|
||||
|
||||
### 13.1 目标
|
||||
|
||||
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
|
||||
|
||||
### 13.2 文本选区需求
|
||||
|
||||
- 支持在 Markdown 文本中选择一段文本
|
||||
- 记录选区位置与原始文本
|
||||
- 用户发出修改请求后,仅重写该选区
|
||||
- 将选区结果合并为新版本 artifact
|
||||
|
||||
### 13.3 代码选区需求
|
||||
|
||||
- 支持在代码编辑器中选择一段代码
|
||||
- 记录 start/end index 或行列信息
|
||||
- AI 只返回替换片段
|
||||
- 前端或后端负责将新片段拼接回完整代码
|
||||
|
||||
### 13.4 实现建议
|
||||
|
||||
首版建议:
|
||||
|
||||
- 文本:前端记录纯文本字符位置
|
||||
- 代码:使用 CodeMirror selection 信息
|
||||
|
||||
不建议首版就做复杂块级富文本局部编辑。
|
||||
|
||||
### 13.5 风险
|
||||
|
||||
- Markdown 渲染态与源码态位置映射复杂
|
||||
- 富文本块级位置难维护
|
||||
- 流式更新中选区同步容易错位
|
||||
|
||||
### 13.6 首版折中方案
|
||||
|
||||
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
|
||||
|
||||
### 13.7 验收标准
|
||||
|
||||
- 文本局部改写成功
|
||||
- 代码局部改写成功
|
||||
- 原内容未改动部分保持不变
|
||||
- 新版本正确入历史
|
||||
|
||||
---
|
||||
|
||||
## 14. 阶段八:快捷动作系统
|
||||
|
||||
### 14.1 目标
|
||||
|
||||
实现 open-canvas 风格的快捷动作体系。
|
||||
|
||||
### 14.2 首批推荐动作
|
||||
|
||||
文本类:
|
||||
|
||||
- 扩写
|
||||
- 精简
|
||||
- 改正式语气
|
||||
- 改口语语气
|
||||
- 改阅读难度
|
||||
- 翻译
|
||||
|
||||
代码类:
|
||||
|
||||
- 加注释
|
||||
- 加日志
|
||||
- 修复 bug
|
||||
- 优化代码
|
||||
- 转换语言
|
||||
|
||||
### 14.3 设计方式
|
||||
|
||||
快捷动作不要直接写死在 UI,建议配置化:
|
||||
|
||||
- `id`
|
||||
- `label`
|
||||
- `type`
|
||||
- `applicable_to`
|
||||
- `prompt_template`
|
||||
|
||||
### 14.4 后端设计
|
||||
|
||||
快捷动作可通过:
|
||||
|
||||
- 前端传 `quick_action_id`
|
||||
- 后端路由节点根据动作 id 选择 prompt
|
||||
|
||||
### 14.5 验收标准
|
||||
|
||||
- 文本动作可执行
|
||||
- 代码动作可执行
|
||||
- 结果进入新版本
|
||||
- 不同动作提示词可扩展
|
||||
|
||||
---
|
||||
|
||||
## 15. 阶段九:模型配置统一与本地模型支持
|
||||
|
||||
### 15.1 目标
|
||||
|
||||
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
|
||||
|
||||
### 15.2 原则
|
||||
|
||||
- 后端 `config.yaml` 为真源
|
||||
- 前端只展示后端暴露的模型信息
|
||||
- 不维护一份脱离后端的前端常量模型表
|
||||
|
||||
### 15.3 本地模型支持范围
|
||||
|
||||
建议优先支持两类:
|
||||
|
||||
- `Ollama`
|
||||
- OpenAI-compatible 本地服务
|
||||
- LM Studio
|
||||
- vLLM
|
||||
- LocalAI
|
||||
- one-api 风格代理
|
||||
|
||||
### 15.4 后端配置建议
|
||||
|
||||
后端模型配置应至少支持:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `use`
|
||||
- `model`
|
||||
- `api_key`
|
||||
- `base_url`
|
||||
- `supports_vision`
|
||||
- `supports_thinking`
|
||||
- `supports_tool_calling`
|
||||
- `is_local`
|
||||
- `canvas_capabilities`
|
||||
|
||||
### 15.5 Canvas 模式额外要求
|
||||
|
||||
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
|
||||
|
||||
- `supports_canvas_generation`
|
||||
- `supports_canvas_rewrite`
|
||||
- `supports_canvas_code_edit`
|
||||
- `supports_streaming_text`
|
||||
- `supports_streaming_tool_calls`
|
||||
|
||||
### 15.6 与 open-canvas 的关系
|
||||
|
||||
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
|
||||
|
||||
### 15.7 验收标准
|
||||
|
||||
- 前端模型选择器可显示后端返回模型
|
||||
- 可切换本地模型
|
||||
- Canvas 模式能用本地模型生成和改写
|
||||
|
||||
---
|
||||
|
||||
## 16. 阶段十:Web 搜索增强
|
||||
|
||||
### 16.1 目标
|
||||
|
||||
实现类似 open-canvas 的可选 Web 搜索增强。
|
||||
|
||||
### 16.2 需求
|
||||
|
||||
- UI 提供 Web 搜索开关
|
||||
- 后端在需要时走搜索节点
|
||||
- 搜索结果写入线程状态
|
||||
- 结果可在画布旁侧栏展示
|
||||
|
||||
### 16.3 首版建议
|
||||
|
||||
只做:
|
||||
|
||||
- 开关
|
||||
- 搜索结果列表
|
||||
- 搜索结果用于生成 artifact
|
||||
|
||||
暂不做复杂网页抓取编辑链路。
|
||||
|
||||
### 16.4 验收标准
|
||||
|
||||
- 打开搜索后可返回搜索结果
|
||||
- 搜索结果可参与 artifact 生成
|
||||
- 结果展示不阻塞主画布
|
||||
|
||||
---
|
||||
|
||||
## 17. 阶段十一:持久化、导出与文件产物联动
|
||||
|
||||
### 17.1 目标
|
||||
|
||||
让 Canvas 文档与 DeerFlow 原有文件能力协同。
|
||||
|
||||
### 17.2 需求
|
||||
|
||||
- artifact 持久化在线程 state 中
|
||||
- 可手动导出为文件
|
||||
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
|
||||
- Canvas artifact 与 `artifacts: string[]` 可双向关联
|
||||
|
||||
### 17.3 推荐能力
|
||||
|
||||
- 导出 Markdown
|
||||
- 导出代码文件
|
||||
- 另存为 artifact file
|
||||
- 从 artifact file 回填到 Canvas
|
||||
|
||||
### 17.4 验收标准
|
||||
|
||||
- 当前版本可导出
|
||||
- 导出文件可在现有文件面板查看
|
||||
- 文件型 artifact 和 Canvas artifact 能共存
|
||||
|
||||
---
|
||||
|
||||
## 18. 推荐开发顺序
|
||||
|
||||
建议严格按以下顺序推进:
|
||||
|
||||
1. 路由和目录骨架
|
||||
2. Canvas 页面布局
|
||||
3. 前端 artifact 数据模型
|
||||
4. 后端线程状态扩展
|
||||
5. 新建 Canvas Agent 图
|
||||
6. 前后端流式同步
|
||||
7. 整体生成与整体改写
|
||||
8. 局部选区改写
|
||||
9. 快捷动作
|
||||
10. 本地模型支持完善
|
||||
11. Web 搜索增强
|
||||
12. 导出与文件联动
|
||||
|
||||
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
|
||||
|
||||
---
|
||||
|
||||
## 19. 里程碑定义
|
||||
|
||||
### M1:页面可进入
|
||||
|
||||
- 路由接通
|
||||
- 双栏布局完成
|
||||
- 本地 mock artifact 可渲染
|
||||
|
||||
### M2:后端状态打通
|
||||
|
||||
- 线程可保存结构化 artifact
|
||||
- 页面刷新后能恢复
|
||||
|
||||
### M3:AI 生成可用
|
||||
|
||||
- 可新建文本/代码 artifact
|
||||
- 可整体改写
|
||||
- 可产生版本历史
|
||||
|
||||
### M4:核心体验可用
|
||||
|
||||
- 选区改写可用
|
||||
- 快捷动作可用
|
||||
- 模型切换可用
|
||||
|
||||
### M5:增强能力完整
|
||||
|
||||
- 本地模型支持完善
|
||||
- Web 搜索增强
|
||||
- 导出和文件联动
|
||||
|
||||
---
|
||||
|
||||
## 20. 风险与难点
|
||||
|
||||
### 20.1 最大难点
|
||||
|
||||
- 结构化 artifact 状态从无到有
|
||||
- 局部选区改写的精确性
|
||||
- 流式状态与版本管理的一致性
|
||||
- 不破坏现有 DeerFlow 文件型 artifact 体系
|
||||
|
||||
### 20.2 技术风险
|
||||
|
||||
- 线程 state JSON 结构过大
|
||||
- 大模型对局部改写指令遵循不稳定
|
||||
- 本地模型 tool calling 与流式能力差异大
|
||||
- 文本渲染态与源码态位置映射复杂
|
||||
|
||||
### 20.3 规避建议
|
||||
|
||||
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
|
||||
- 首版只支持文本和代码两种 artifact 类型
|
||||
- 首版先做整体生成和整体改写,再做局部改写
|
||||
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
|
||||
|
||||
---
|
||||
|
||||
## 21. 验收总清单
|
||||
|
||||
### 21.1 前端
|
||||
|
||||
- 有独立 Canvas 路由
|
||||
- 有专用目录和模块边界
|
||||
- 双栏布局可用
|
||||
- 文本和代码两类 artifact 可渲染
|
||||
- 历史版本可切换
|
||||
- 选区改写可交互
|
||||
- 快捷动作可触发
|
||||
- 模型可切换
|
||||
|
||||
### 21.2 后端
|
||||
|
||||
- 线程状态支持 `artifact`
|
||||
- 新增 `canvas_agent` 或等价模式
|
||||
- 生成、重写、局部改写可用
|
||||
- 流式返回稳定
|
||||
- 模型列表接口支持 Canvas 能力元数据
|
||||
|
||||
### 21.3 本地模型
|
||||
|
||||
- 可配置 Ollama
|
||||
- 可配置 OpenAI-compatible 本地服务
|
||||
- 前端可见并可切换
|
||||
- 至少能完成文本生成和整体改写
|
||||
|
||||
---
|
||||
|
||||
## 22. 推荐首版最小可交付范围
|
||||
|
||||
如果要控制风险,建议首版只交付以下内容:
|
||||
|
||||
- 新增 `canvas/*` 路由
|
||||
- 双栏布局
|
||||
- 结构化 `artifact`
|
||||
- `canvas_agent`
|
||||
- 文本/代码 artifact 新建
|
||||
- 整体生成
|
||||
- 整体改写
|
||||
- 历史版本切换
|
||||
- 后端模型动态加载
|
||||
- Ollama 或本地 OpenAI-compatible 至少一种可用
|
||||
|
||||
先不要把以下内容放进首版强制目标:
|
||||
|
||||
- 富文本块级精确编辑
|
||||
- 全量快捷动作
|
||||
- 复杂 Web 搜索增强
|
||||
- 多 artifact 并行管理
|
||||
|
||||
---
|
||||
|
||||
## 23. 后续实施建议
|
||||
|
||||
建议下一步基于本文档继续输出两份落地文档:
|
||||
|
||||
### 23.1 前端实施清单
|
||||
|
||||
逐文件列出:
|
||||
|
||||
- 要新建哪些文件
|
||||
- 哪些组件可复用
|
||||
- 哪些页面要接入路由
|
||||
- 哪些状态要从现有 hooks 抽离
|
||||
|
||||
### 23.2 后端实施清单
|
||||
|
||||
逐模块列出:
|
||||
|
||||
- 要改哪些 Python 文件
|
||||
- 新增哪些状态字段
|
||||
- 新增哪些 Agent 节点
|
||||
- 哪些 API 需要扩展
|
||||
|
||||
---
|
||||
|
||||
## 24. 结论
|
||||
|
||||
这个需求很大,但完全可拆。
|
||||
|
||||
正确做法不是“先抄 UI”,而是按下面的主线推进:
|
||||
|
||||
- 先建前端 Canvas 子系统
|
||||
- 再建结构化 artifact 状态
|
||||
- 再建 Canvas Agent 图
|
||||
- 再补局部改写和快捷动作
|
||||
- 最后统一本地模型能力
|
||||
|
||||
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
|
||||
@@ -1,243 +0,0 @@
|
||||
# 素材专家检索 —— 内网接口对接说明
|
||||
|
||||
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
|
||||
涉及的代码文件,以及常见错误的排查方式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 检索来源切换
|
||||
|
||||
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
|
||||
search_provider: internet
|
||||
# 以下两项仅在 search_provider=intranet 时生效
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples
|
||||
```
|
||||
|
||||
| `search_provider` 取值 | 含义 |
|
||||
|---|---|
|
||||
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
|
||||
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
|
||||
| `tavily` | Tavily 互联网检索 |
|
||||
| `demo` | 内置演示素材,仅用于本地调试 |
|
||||
|
||||
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
|
||||
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 检索关键词数量
|
||||
|
||||
素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
|
||||
范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
|
||||
|
||||
- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
|
||||
- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
|
||||
(`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
|
||||
- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
|
||||
规整到 `[1, 10]`,越界回退默认 `6`。
|
||||
- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
|
||||
|
||||
---
|
||||
|
||||
## 1.6 素材总量上限
|
||||
|
||||
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 素材总量上限,范围 1~100,非法值回退 20
|
||||
max_materials: 20
|
||||
```
|
||||
|
||||
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
|
||||
|
||||
| `max_materials` | 检索词数 | 每词检索条数 |
|
||||
|---|---|---|
|
||||
| 20 | 1 | 20 |
|
||||
| 20 | 2 | 10 |
|
||||
| 20 | 3 | 7(共 21,合并后截断到 20) |
|
||||
| 20 | 5 | 4 |
|
||||
|
||||
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
|
||||
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
|
||||
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
|
||||
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
|
||||
|
||||
> `config.yaml` 支持热加载,改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内网接口规格
|
||||
|
||||
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
|
||||
- **方法**:`POST`
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "检索关键词",
|
||||
"knowledge_base_name": "samples",
|
||||
"ts": "yes"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应体**:一个数组,每个元素结构如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"page_content": "正文片段",
|
||||
"metadata": {
|
||||
"m_title": "标题",
|
||||
"content": "内容",
|
||||
"recUuid": "原文id",
|
||||
"m_publish":"发布时间",
|
||||
"source1": "来源"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
我们需要的字段都在 `metadata` 里。
|
||||
|
||||
### 字段映射
|
||||
|
||||
后端把内网响应映射为统一的 `SearchResult` 结构:
|
||||
|
||||
| 内网响应字段 | SearchResult 字段 | 说明 |
|
||||
|--------------------------|--------------------|------|
|
||||
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
|
||||
| `metadata.content` | `content` | 内容,截断到 500 字 |
|
||||
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
|
||||
| `metadata.m_publish` | `published_at` | 发布时间 |
|
||||
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
|
||||
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
|
||||
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
|
||||
| — | `source_type` | 固定为 `"intranet"` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 对接逻辑与涉及文件
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
|
||||
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
|
||||
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
|
||||
|
||||
调用链:
|
||||
|
||||
```
|
||||
ai_writing.py(_make_config 注入 ai_writing_config)
|
||||
→ researcher_node
|
||||
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
|
||||
→ IntranetSearchProvider.search() # POST search_docs_es
|
||||
→ SearchResult[] # 字段映射
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
|
||||
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
|
||||
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
|
||||
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
|
||||
|
||||
「查看原文」链接拼接规则:
|
||||
|
||||
```
|
||||
INTRANET_SOURCE_URL_BASE + recUuid
|
||||
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
|
||||
```
|
||||
|
||||
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
|
||||
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理与排查
|
||||
|
||||
### 4.1 内网检索返回空 / 调不通
|
||||
|
||||
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
|
||||
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
|
||||
|
||||
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
|
||||
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
|
||||
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
|
||||
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
|
||||
**接口报错不影响写作流程继续**。
|
||||
|
||||
> 容错相关代码:
|
||||
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
|
||||
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
|
||||
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
|
||||
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
|
||||
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. **确认网络可达**:在后端所在机器执行
|
||||
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
|
||||
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
|
||||
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
|
||||
|
||||
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
|
||||
用户可以选择「重新搜索」或「无数据继续写作」。
|
||||
|
||||
### 4.2 字段缺失 / 响应结构变化
|
||||
|
||||
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
|
||||
单条数据字段缺失不会导致整批失败。
|
||||
|
||||
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
|
||||
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
|
||||
|
||||
### 4.3 「查看原文」按钮不显示
|
||||
|
||||
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
|
||||
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
|
||||
|
||||
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
|
||||
|
||||
### 4.4 原文链接打不开 / 地址不对
|
||||
|
||||
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
|
||||
若内网原文详情页地址有变,只改这一个常量即可。
|
||||
|
||||
### 4.5 检索到的素材全是无关内容
|
||||
|
||||
**现象**:素材卡片内容与写作意图完全不沾边。
|
||||
|
||||
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
|
||||
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
|
||||
|
||||
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
|
||||
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
|
||||
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调上线步骤
|
||||
|
||||
1. 确认后端机器能访问 `10.106.57.180:7863`。
|
||||
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
|
||||
3. 后端配置热加载生效(或重启后端)。
|
||||
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
|
||||
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
|
||||
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
|
||||
@@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
@@ -102,7 +103,9 @@ export function MessageList({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmitAllClarifications = useCallback(async () => {
|
||||
const handleSubmitAllClarifications = useCallback(async (
|
||||
selectionOverrides?: Map<string, ClarificationSelection>,
|
||||
) => {
|
||||
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +114,7 @@ export function MessageList({
|
||||
const id = pendingClarificationIds[index]!;
|
||||
const message = messages.find((msg) => msg.id === id);
|
||||
const payload = message ? getClarificationPayload(message) : null;
|
||||
const selection = clarificationSelections.get(id) ?? {
|
||||
const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
@@ -221,7 +224,12 @@ export function MessageList({
|
||||
}
|
||||
submitting={submittingClarification}
|
||||
canSubmit={hasAnyClarificationAnswer}
|
||||
onSubmitAll={handleSubmitAllClarifications}
|
||||
onSubmitAll={(override) => {
|
||||
const overrides = override
|
||||
? new Map([[messageId, override]])
|
||||
: undefined;
|
||||
void handleSubmitAllClarifications(overrides);
|
||||
}}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -412,6 +420,7 @@ type ClarificationPayload = {
|
||||
clarification_type?: string;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
};
|
||||
|
||||
type ClarificationSelection = {
|
||||
@@ -600,7 +609,7 @@ function ClarificationCard({
|
||||
onSelectionChange: (next: ClarificationSelection) => void;
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onSubmitAll: () => void | Promise<void>;
|
||||
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
|
||||
@@ -624,11 +633,21 @@ function ClarificationCard({
|
||||
|
||||
const disabled = !isPending || submitting;
|
||||
const multiPending = (pendingCount ?? 0) > 1;
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allow_multiple,
|
||||
payload.clarification_type,
|
||||
);
|
||||
|
||||
function toggleOption(optionId: string) {
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (!allowMultiple) {
|
||||
const next = { selectedIds: [optionId], custom: "" };
|
||||
onSelectionChange(next);
|
||||
void onSubmitAll(next);
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
@@ -666,7 +685,7 @@ function ClarificationCard({
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可多选(再次点击取消选择)
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
@@ -675,10 +694,10 @@ function ClarificationCard({
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
variant={isSelected && allowMultiple ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleOption(optionId)}
|
||||
onClick={() => handleOptionClick(optionId)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
@@ -695,7 +714,7 @@ function ClarificationCard({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
{isSelected && allowMultiple ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</Button>
|
||||
@@ -720,10 +739,10 @@ function ClarificationCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? (
|
||||
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending ? (
|
||||
{multiPending && allowMultiple ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
@@ -735,14 +754,14 @@ function ClarificationCard({
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : allowMultiple ? (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ClarificationPayload } from "./multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type { ClarificationPayload };
|
||||
|
||||
/**
|
||||
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
|
||||
* built-in agent and no per-call coordinator: one /init creates a thread
|
||||
@@ -73,12 +78,17 @@ export interface StreamIntentRequest {
|
||||
}
|
||||
|
||||
export interface StreamIntentResult {
|
||||
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */
|
||||
status: "asking" | "done" | "error";
|
||||
/** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */
|
||||
status: "asking" | "clarification" | "done" | "error";
|
||||
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
|
||||
content: string;
|
||||
/** Populated only when status === "done". */
|
||||
summary: IntentSummary | null;
|
||||
/**
|
||||
* Populated when the intent agent called ask_clarification — render option
|
||||
* cards + custom input instead of treating the turn as plain text only.
|
||||
*/
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,14 +97,25 @@ export interface StreamIntentResult {
|
||||
* is optional.
|
||||
*/
|
||||
function isFinalIntentFrame(value: unknown): value is {
|
||||
status: "asking" | "done" | "error";
|
||||
status: "asking" | "clarification" | "done" | "error";
|
||||
content?: string;
|
||||
summary?: IntentSummary | null;
|
||||
question?: string;
|
||||
clarification_type?: string;
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
return status === "asking" || status === "done" || status === "error";
|
||||
return (
|
||||
status === "asking" ||
|
||||
status === "clarification" ||
|
||||
status === "done" ||
|
||||
status === "error"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +187,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
req.onPhaseChange?.(next, toolName);
|
||||
};
|
||||
|
||||
const result: StreamIntentResult = { status: "asking", content: "", summary: null };
|
||||
const result: StreamIntentResult = {
|
||||
status: "asking",
|
||||
content: "",
|
||||
summary: null,
|
||||
clarification: null,
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -205,6 +231,22 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
if (parsed.summary && typeof parsed.summary === "object") {
|
||||
result.summary = parsed.summary as IntentSummary;
|
||||
}
|
||||
if (parsed.status === "clarification") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType,
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
export interface SeatReadyEvent {
|
||||
@@ -222,6 +224,8 @@ export interface ClarificationPayload {
|
||||
options: unknown[];
|
||||
/** Whether the user is allowed to type a custom answer. */
|
||||
allowCustom: boolean;
|
||||
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamMultiAgentResult {
|
||||
@@ -257,6 +261,7 @@ function isFinalStatusFrame(value: unknown): value is {
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
@@ -447,14 +452,19 @@ export async function streamMultiAgent(
|
||||
// a question (or chip choices) instead of mistaking the run for
|
||||
// a consensus.
|
||||
if (parsed.status === "clarification") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType:
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
|
||||
clarificationType,
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { Agent } from "@/core/agents";
|
||||
|
||||
import type { IntentSummary } from "./intent";
|
||||
|
||||
/**
|
||||
* Agent-recommendation API. Sits between Step 1 (intent clarification) and
|
||||
* Step 2 (multi-agent roundtable): given the IntentSummary and a candidate
|
||||
* agent pool, the built-in `roundtable-recommender` picks 2-6 agents most
|
||||
* relevant to the task and explains why.
|
||||
*
|
||||
* One-shot, stateless: no /init, no thread_id, each call spins up a fresh
|
||||
* backend-managed thread. Use {@link filterRecommendCandidates} to clean the
|
||||
* `/api/agents` list before passing it as `candidates`.
|
||||
*/
|
||||
|
||||
/** Single agent pick returned by the recommender. */
|
||||
export interface RecommendPick {
|
||||
agent_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Final status frame the backend appends after the upstream stream finishes. */
|
||||
export interface StreamRecommendResult {
|
||||
/**
|
||||
* `done` → picks parsed and validated, ready to populate the dialog as
|
||||
* default-checked selections.
|
||||
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
|
||||
* caller should apply its fallback list (typically the 6 roundtable-* seats).
|
||||
* `error` → upstream / network failure.
|
||||
*/
|
||||
status: "done" | "asking" | "error";
|
||||
/** Full final AI text (includes the natural-language rationale). */
|
||||
content: string;
|
||||
/** Populated only when status === "done"; null otherwise. */
|
||||
picks: RecommendPick[] | null;
|
||||
}
|
||||
|
||||
/** Phase mirror of streamIntent so the UI can reuse one loading-label mapper. */
|
||||
export type RecommendStreamPhase = "connecting" | "connected" | "streaming";
|
||||
|
||||
export interface StreamRecommendRequest {
|
||||
/** Step 1 output. `objective` is required by the backend. */
|
||||
intent: IntentSummary;
|
||||
/** Pre-filtered candidate pool (use {@link filterRecommendCandidates}). */
|
||||
candidates: RecommendCandidate[];
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
/** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */
|
||||
onRationaleUpdate?: (rationale: string) => void;
|
||||
/** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */
|
||||
onPicksUpdate?: (picks: RecommendPick[]) => void;
|
||||
/** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
/** Fires whenever the inferred SSE phase changes. */
|
||||
onPhaseChange?: (phase: RecommendStreamPhase) => void;
|
||||
}
|
||||
|
||||
/** Shape the backend expects in `candidates[]`. */
|
||||
export interface RecommendCandidate {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent ids that must never enter the candidate pool. Centralized here so
|
||||
* every caller (recommend dialog, future tools) applies the same rule.
|
||||
*
|
||||
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
|
||||
* - `roundtable-recommender`— the recommender itself, can't recommend itself.
|
||||
* - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB
|
||||
* accumulates one per session); meaningless as roundtable participants.
|
||||
*/
|
||||
const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-";
|
||||
const EXCLUDED_AGENT_IDS = new Set<string>(["roundtable-intent", "roundtable-recommender"]);
|
||||
|
||||
/**
|
||||
* Drop excluded ids from a raw `GET /api/agents` list and project each entry
|
||||
* to the slim shape the backend expects. Returns a fresh array; original
|
||||
* objects are not mutated.
|
||||
*/
|
||||
export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] {
|
||||
const out: RecommendCandidate[] = [];
|
||||
for (const a of agents) {
|
||||
if (!a || !a.id) continue;
|
||||
if (EXCLUDED_AGENT_IDS.has(a.id)) continue;
|
||||
if (a.id.startsWith(EXCLUDED_AGENT_ID_PREFIX)) continue;
|
||||
out.push({
|
||||
agent_id: a.id,
|
||||
name: a.name ?? a.id,
|
||||
description: a.description ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isFinalRecommendFrame(value: unknown): value is {
|
||||
status: "done" | "asking" | "error";
|
||||
content?: string;
|
||||
picks?: RecommendPick[] | null;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
return status === "done" || status === "asking" || status === "error";
|
||||
}
|
||||
|
||||
const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]";
|
||||
|
||||
/** Match one complete pick object inside a partially streamed JSON block. */
|
||||
const PICK_OBJECT_PATTERN =
|
||||
/\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g;
|
||||
|
||||
function unescapeJsonString(raw: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${raw}"`) as string;
|
||||
} catch {
|
||||
return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split accumulated recommender output into visible rationale text and any
|
||||
* pick objects that have fully arrived in the JSON section.
|
||||
*/
|
||||
export function parseRecommendStreamContent(raw: string): {
|
||||
rationale: string;
|
||||
picks: RecommendPick[];
|
||||
} {
|
||||
const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER);
|
||||
let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw;
|
||||
rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd();
|
||||
|
||||
const jsonSection =
|
||||
markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : "";
|
||||
|
||||
const picks: RecommendPick[] = [];
|
||||
const seen = new Set<string>();
|
||||
PICK_OBJECT_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) {
|
||||
const agent_id = unescapeJsonString(match[1]).trim();
|
||||
const reason = unescapeJsonString(match[2]).trim();
|
||||
if (!agent_id || seen.has(agent_id)) continue;
|
||||
seen.add(agent_id);
|
||||
picks.push({ agent_id, reason });
|
||||
}
|
||||
|
||||
return { rationale, picks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an AI text delta from one LangGraph `messages-tuple` frame. Same
|
||||
* shape as the helper in intent.ts; duplicated rather than imported to keep
|
||||
* each helper independently readable.
|
||||
*/
|
||||
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
|
||||
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||
|
||||
if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") {
|
||||
const meta = parsed[1] as Record<string, unknown>;
|
||||
const node = String(meta.langgraph_node ?? "");
|
||||
if (node.includes("TitleMiddleware")) return null;
|
||||
const tags = meta.tags;
|
||||
if (Array.isArray(tags) && tags.includes("middleware:title")) return null;
|
||||
}
|
||||
|
||||
const chunk = parsed[0];
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
const obj = chunk as Record<string, unknown>;
|
||||
const type = String(obj.type ?? "").toLowerCase();
|
||||
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
|
||||
const content = obj.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") text += item;
|
||||
else if (item && typeof item === "object") {
|
||||
const block = item as Record<string, unknown>;
|
||||
const blockText = block.text;
|
||||
if (typeof blockText === "string") text += blockText;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text) return null;
|
||||
const id = typeof obj.id === "string" ? obj.id : "";
|
||||
return { text, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to `/api/recommend/stream` and resolve once the
|
||||
* upstream stream finishes. Each call is one recommendation round — the
|
||||
* recommender is stateless, so calling twice in a row produces two
|
||||
* independent runs.
|
||||
*/
|
||||
export async function streamRecommend(req: StreamRecommendRequest): Promise<StreamRecommendResult> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/recommend/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent: req.intent,
|
||||
candidates: req.candidates,
|
||||
model: req.model,
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`recommend/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let lastEvent = "";
|
||||
|
||||
let currentPhase: RecommendStreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: RecommendStreamPhase) => {
|
||||
if (next === currentPhase) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next);
|
||||
};
|
||||
|
||||
const result: StreamRecommendResult = { status: "asking", content: "", picks: null };
|
||||
let rawContent = "";
|
||||
let lastRationale = "";
|
||||
let lastPickCount = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("event:")) {
|
||||
lastEvent = line.slice(6).trim();
|
||||
if (lastEvent === "metadata" && currentPhase === "connecting") {
|
||||
setPhase("connected");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFinalRecommendFrame(parsed)) {
|
||||
result.status = parsed.status;
|
||||
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||
if (Array.isArray(parsed.picks)) {
|
||||
result.picks = parsed.picks as RecommendPick[];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastEvent === "messages") {
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
setPhase("streaming");
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
rawContent += delta.text;
|
||||
const { rationale, picks } = parseRecommendStreamContent(rawContent);
|
||||
if (rationale !== lastRationale) {
|
||||
lastRationale = rationale;
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
lastPickCount = picks.length;
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.content) {
|
||||
const { rationale, picks } = parseRecommendStreamContent(result.content);
|
||||
if (rationale !== lastRationale) {
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type IntentClarificationSelection = {
|
||||
selectedIds: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
function normalizeOptions(raw: unknown[]): ClarificationOption[] {
|
||||
return raw
|
||||
.map((opt, index) => {
|
||||
if (typeof opt === "string") {
|
||||
const label = opt.trim();
|
||||
return label ? { id: `option-${index}`, label } : null;
|
||||
}
|
||||
if (opt && typeof opt === "object" && "label" in opt) {
|
||||
const obj = opt as { id?: unknown; label?: unknown; description?: unknown };
|
||||
const label = String(obj.label ?? "").trim();
|
||||
if (!label) return null;
|
||||
const id = String(obj.id ?? label).trim() || `option-${index}`;
|
||||
const description =
|
||||
typeof obj.description === "string" && obj.description.trim()
|
||||
? obj.description.trim()
|
||||
: undefined;
|
||||
return { id, label, description };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((opt): opt is ClarificationOption => opt !== null);
|
||||
}
|
||||
|
||||
export function formatIntentClarificationAnswer(
|
||||
payload: ClarificationPayload,
|
||||
selection: IntentClarificationSelection,
|
||||
): string | null {
|
||||
const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []);
|
||||
const selectedLabels = selection.selectedIds
|
||||
.map((sid) => options.find((option) => option.id === sid)?.label)
|
||||
.filter((label): label is string => Boolean(label && label.trim()));
|
||||
const trimmedCustom = selection.custom.trim();
|
||||
|
||||
if (selectedLabels.length > 0 && trimmedCustom) {
|
||||
return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
||||
}
|
||||
if (selectedLabels.length > 0) {
|
||||
return `我选择:${selectedLabels.join("、")}`;
|
||||
}
|
||||
if (trimmedCustom) {
|
||||
return trimmedCustom;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface IntentClarificationCardProps {
|
||||
payload: ClarificationPayload;
|
||||
fallbackContent?: string;
|
||||
selection: IntentClarificationSelection;
|
||||
onSelectionChange: (next: IntentClarificationSelection) => void;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
/** Multi-select: submit current selection. Single-select: also used for custom text. */
|
||||
onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
|
||||
}
|
||||
|
||||
export function IntentClarificationCard({
|
||||
payload,
|
||||
fallbackContent,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
}: IntentClarificationCardProps) {
|
||||
const options = useMemo(
|
||||
() => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
|
||||
[payload.options],
|
||||
);
|
||||
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allowMultiple,
|
||||
payload.clarificationType,
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
|
||||
}, [selection.custom, selection.selectedIds]);
|
||||
|
||||
const showSubmitButton = allowMultiple || payload.allowCustom !== false;
|
||||
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled || submitting) return;
|
||||
if (!allowMultiple) {
|
||||
onSubmit({ selectedIds: [optionId], custom: "" });
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
onSelectionChange({ ...selection, selectedIds: next });
|
||||
}
|
||||
|
||||
function updateCustom(value: string) {
|
||||
if (disabled || submitting) return;
|
||||
onSelectionChange({ ...selection, custom: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
需要你的确认
|
||||
</span>
|
||||
{payload.clarificationType ? (
|
||||
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
|
||||
{payload.clarificationType}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{payload.context ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-xs font-medium leading-relaxed text-slate-800">
|
||||
{payload.question || fallbackContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const isSelected = selection.selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={disabled || submitting}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
|
||||
isSelected && allowMultiple
|
||||
? "border-primary bg-primary text-white shadow-sm"
|
||||
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
|
||||
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
|
||||
(disabled || submitting) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span className="text-[11px] font-bold">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-snug",
|
||||
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && allowMultiple ? (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allowCustom !== false ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={selection.custom}
|
||||
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||
placeholder={
|
||||
options.length > 0
|
||||
? allowMultiple
|
||||
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||
}
|
||||
disabled={disabled || submitting}
|
||||
className="min-h-16 resize-none border-slate-200 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSubmitButton ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || submitting || !canSubmit}
|
||||
onClick={() => onSubmit()}
|
||||
className="h-8 rounded-xl px-4 text-xs font-bold"
|
||||
>
|
||||
{submitting ? "发送中…" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listAgents, type Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
parseRecommendStreamContent,
|
||||
streamRecommend,
|
||||
type RecommendCandidate,
|
||||
type RecommendPick,
|
||||
type RecommendStreamPhase,
|
||||
} from "../api/recommend";
|
||||
|
||||
/**
|
||||
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
|
||||
* (roundtable). Opens with a streaming recommendation from
|
||||
* `roundtable-recommender`, defaults its picks to checked, and lets the user
|
||||
* trim or extend the selection from the rest of the agent pool before
|
||||
* confirming.
|
||||
*
|
||||
* Selection bounds (2 ≤ N ≤ 8) are enforced on the confirm button — the
|
||||
* backend also clamps but UI feedback is faster here.
|
||||
*/
|
||||
|
||||
export interface RecommendAgentsDialogProps {
|
||||
open: boolean;
|
||||
intent: IntentSummary | null;
|
||||
/** Falls back to the recommender's own default if omitted. */
|
||||
model?: string;
|
||||
onCancel: () => void;
|
||||
/** Called with the user's final selection of agent ids (in display order). */
|
||||
onConfirm: (agentIds: string[]) => void;
|
||||
}
|
||||
|
||||
const MIN_SELECTED = 2;
|
||||
const MAX_SELECTED = 8;
|
||||
|
||||
/**
|
||||
* Default selection when the recommender fails or returns < 2 valid picks.
|
||||
* Intersected with the live candidate pool at runtime so missing built-ins
|
||||
* don't break the dialog.
|
||||
*/
|
||||
const FALLBACK_AGENT_IDS = [
|
||||
"roundtable-intelligence",
|
||||
"roundtable-environment",
|
||||
"roundtable-solution-design",
|
||||
"roundtable-risk-review",
|
||||
"roundtable-execution-plan",
|
||||
"roundtable-summary",
|
||||
];
|
||||
|
||||
type DialogPhase =
|
||||
| "idle"
|
||||
| "loading_agents"
|
||||
| "recommending"
|
||||
| "ready"
|
||||
| "fallback"
|
||||
| "error";
|
||||
|
||||
interface InternalState {
|
||||
phase: DialogPhase;
|
||||
/** Streaming + final rationale text from the recommender. */
|
||||
rationale: string;
|
||||
/** Streaming SSE phase from the recommender helper. */
|
||||
streamPhase: RecommendStreamPhase | null;
|
||||
/** Pre-filtered candidate pool fetched from `/api/agents`. */
|
||||
candidates: RecommendCandidate[];
|
||||
/** Reason-by-agent_id, only for picks returned by the recommender. */
|
||||
reasons: Record<string, string>;
|
||||
/** Agent ids the recommender returned (in order). */
|
||||
recommendedIds: string[];
|
||||
/** User-visible warning ("推荐失败,已使用默认席位") for the fallback case. */
|
||||
warning: string | null;
|
||||
/** Latest error message; only shown when `phase === "error"`. */
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InternalState = {
|
||||
phase: "idle",
|
||||
rationale: "",
|
||||
streamPhase: null,
|
||||
candidates: [],
|
||||
reasons: {},
|
||||
recommendedIds: [],
|
||||
warning: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which candidate ids should be checked by default given a set of
|
||||
* recommended ids. The result preserves recommendation order and silently
|
||||
* drops ids that aren't in the candidate pool.
|
||||
*/
|
||||
function intersectInOrder(preferred: string[], pool: RecommendCandidate[]): string[] {
|
||||
const poolIds = new Set(pool.map((c) => c.agent_id));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of preferred) {
|
||||
if (!poolIds.has(id) || seen.has(id)) continue;
|
||||
out.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function RecommendAgentsDialog({
|
||||
open,
|
||||
intent,
|
||||
model,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RecommendAgentsDialogProps) {
|
||||
const [state, setState] = useState<InternalState>(INITIAL_STATE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Latest stream abort controller, so closing or re-running cancels the
|
||||
// in-flight request instead of leaking it.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Generation token guards against stale completion handlers writing into
|
||||
// state after a re-run (e.g. user clicks "重新推荐" mid-stream).
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const cancelInflight = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The full pipeline: fetch agents → run recommendation → seed selection.
|
||||
* Wrapped so the user can re-trigger it via the "重新推荐" button.
|
||||
*/
|
||||
const runRecommendation = useCallback(async () => {
|
||||
if (!intent || !intent.objective) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "缺少任务目标,无法启动推荐",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cancelInflight();
|
||||
const myGen = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({ ...INITIAL_STATE, phase: "loading_agents" });
|
||||
setSelectedIds(new Set());
|
||||
|
||||
let agents: Agent[];
|
||||
try {
|
||||
agents = await listAgents();
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
if (candidates.length === 0) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "recommending",
|
||||
candidates,
|
||||
rationale: "",
|
||||
streamPhase: "connecting",
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await streamRecommend({
|
||||
intent,
|
||||
candidates,
|
||||
model,
|
||||
signal: controller.signal,
|
||||
onRationaleUpdate: (rationale) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, rationale }));
|
||||
},
|
||||
onPicksUpdate: (picks) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
for (const p of picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of seed) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
}));
|
||||
},
|
||||
onPhaseChange: (phase) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, streamPhase: phase }));
|
||||
},
|
||||
});
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
if (result.picks) {
|
||||
for (const p of result.picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "ready",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: status === "asking" or picks too few. Use the 6 built-in
|
||||
// roundtable seats, intersected against the live pool.
|
||||
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
|
||||
const seed = fallback.length >= MIN_SELECTED
|
||||
? fallback
|
||||
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "fallback",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds: seed,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
|
||||
}));
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
// AbortError fires when the user closes the dialog mid-stream — no
|
||||
// state update needed because the dialog is going away.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
streamPhase: null,
|
||||
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
|
||||
}));
|
||||
}
|
||||
}, [intent, model, cancelInflight]);
|
||||
|
||||
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
|
||||
// in-flight stream so the next open starts clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
cancelInflight();
|
||||
return;
|
||||
}
|
||||
void runRecommendation();
|
||||
return () => {
|
||||
cancelInflight();
|
||||
};
|
||||
}, [open, runRecommendation, cancelInflight]);
|
||||
|
||||
const handleToggle = useCallback((agentId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(agentId)) next.delete(agentId);
|
||||
else next.add(agentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Split candidates into "recommended" and "other" — order within each
|
||||
// group matches the recommender's pick order / the API's list order so the
|
||||
// dialog is deterministic between renders.
|
||||
const { recommendedCards, otherCards } = useMemo(() => {
|
||||
const recSet = new Set(state.recommendedIds);
|
||||
const byId = new Map(state.candidates.map((c) => [c.agent_id, c]));
|
||||
const recommended: RecommendCandidate[] = [];
|
||||
for (const id of state.recommendedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) recommended.push(c);
|
||||
}
|
||||
const others = state.candidates.filter((c) => !recSet.has(c.agent_id));
|
||||
return { recommendedCards: recommended, otherCards: others };
|
||||
}, [state.candidates, state.recommendedIds]);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const canConfirm =
|
||||
(state.phase === "ready" || state.phase === "fallback") &&
|
||||
selectedCount >= MIN_SELECTED &&
|
||||
selectedCount <= MAX_SELECTED;
|
||||
|
||||
const selectionHint = (() => {
|
||||
if (selectedCount < MIN_SELECTED) {
|
||||
return `至少再选 ${MIN_SELECTED - selectedCount} 个`;
|
||||
}
|
||||
if (selectedCount > MAX_SELECTED) {
|
||||
return `已超出上限 ${selectedCount - MAX_SELECTED} 个`;
|
||||
}
|
||||
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
|
||||
})();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canConfirm) return;
|
||||
// Order: recommended-and-selected first (in recommender order), then
|
||||
// user-added picks (in pool order). Keeps the leader's "可调度智能体"
|
||||
// list visually stable.
|
||||
const ordered: string[] = [];
|
||||
for (const c of recommendedCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
}
|
||||
for (const c of otherCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
}
|
||||
onConfirm(ordered);
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-purple-500" />
|
||||
选择参会智能体
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Rationale + status block */}
|
||||
<div className="border rounded-md bg-muted/30 p-3 text-sm">
|
||||
<RationaleBlock state={state} />
|
||||
</div>
|
||||
|
||||
{/* Selection area: scrollable, takes remaining height */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
|
||||
{state.phase === "error" ? (
|
||||
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
||||
{state.errorMessage ?? "未知错误"}
|
||||
</div>
|
||||
) : state.phase === "recommending" ? (
|
||||
<>
|
||||
{recommendedCards.length > 0 ? (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
streaming
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 py-2">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
等待推荐结果...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (state.phase === "ready" || state.phase === "fallback") ? (
|
||||
<>
|
||||
{state.warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
|
||||
{state.warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recommendedCards.length > 0 && (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{otherCards.length > 0 && (
|
||||
<Section title={`其他候选 (${otherCards.length})`}>
|
||||
{otherCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={undefined}
|
||||
recommended={false}
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
selectedCount < MIN_SELECTED || selectedCount > MAX_SELECTED
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selectionHint}
|
||||
</span>
|
||||
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void runRecommendation()}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon className="size-3 mr-1" />
|
||||
重新推荐
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||||
进入研讨
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RationaleBlock({ state }: { state: InternalState }) {
|
||||
if (state.phase === "loading_agents") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载候选智能体...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "recommending") {
|
||||
const label =
|
||||
state.streamPhase === "connecting"
|
||||
? "调度推荐智能体..."
|
||||
: state.streamPhase === "connected"
|
||||
? "已连接,等待响应..."
|
||||
: "正在生成推荐...";
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
{state.rationale ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
|
||||
{state.rationale}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "ready" || state.phase === "fallback") {
|
||||
if (!state.rationale) {
|
||||
return <div className="text-xs text-muted-foreground">推荐完成。</div>;
|
||||
}
|
||||
return (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
|
||||
{state.rationale}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.phase === "error") {
|
||||
return <div className="text-xs text-destructive">推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。</div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</div>
|
||||
<div className="grid gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentCardProps {
|
||||
candidate: RecommendCandidate;
|
||||
reason: string | undefined;
|
||||
recommended: boolean;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
/** Card is still being filled in from the SSE stream. */
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
function AgentCard({
|
||||
candidate,
|
||||
reason,
|
||||
recommended,
|
||||
checked,
|
||||
onToggle,
|
||||
streaming = false,
|
||||
}: AgentCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
|
||||
checked
|
||||
? "border-primary bg-primary/5 hover:bg-primary/10"
|
||||
: "border-border hover:bg-muted/40",
|
||||
streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300",
|
||||
)}
|
||||
>
|
||||
{/* Visual check box — simulated, since the project ships no Checkbox primitive */}
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 size-4 rounded border flex items-center justify-center shrink-0",
|
||||
checked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
|
||||
{recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
|
||||
推荐
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{candidate.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{candidate.description}
|
||||
</div>
|
||||
)}
|
||||
{reason ? (
|
||||
<div className="text-xs text-primary/80 mt-1 italic">
|
||||
<span className="font-medium not-italic">推荐理由:</span> {reason}
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,10 @@
|
||||
/**
|
||||
* 圆桌规划(Roundtable Planning)模块入口。
|
||||
*
|
||||
* 详细架构 / API 字段速查见 `frontend-web/docs/multi-agent-frontend-dev.md`。
|
||||
* 这里只暴露两个外部使用的组件:
|
||||
* - `RoundtablePlanningPage`:三步合一的主页面(任务理解 → 多智能体研讨 → 方案展示)。
|
||||
* - `HighFidelityReport`:Step 3 报告视图,可单独嵌入其它面板(如未来的「方案对比」)。
|
||||
*/
|
||||
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
|
||||
export { HighFidelityReport } from "./components/HighFidelityReport";
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolve whether option cards should allow multi-select.
|
||||
* Default is single-select (click to send); only multi when explicitly requested.
|
||||
*/
|
||||
export function resolveAllowMultiple(
|
||||
allowMultiple: boolean | undefined,
|
||||
_clarificationType?: string | undefined,
|
||||
): boolean {
|
||||
if (typeof allowMultiple === "boolean") {
|
||||
return allowMultiple;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,4 +1,38 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
/**
|
||||
* 圆桌规划主页面(三步合一)。
|
||||
*
|
||||
* 这是一个约 3 600 行的单文件页面,通过 `currentStep ∈ {1,2,3}` 在同一容器内切换
|
||||
* 三步 UI。架构 / 状态机 / SSE 终态语义请先读 `frontend-web/docs/multi-agent-frontend-dev.md`,
|
||||
* 这里只补充阅读源码时最容易忽略的几条规则:
|
||||
*
|
||||
* 1) **不要并行派活**:编排循环 `runOrchestration` 内 `for (const [subName, task] of dispatched)`
|
||||
* 是**顺序**而非并行——历史上并行触发 N 路 SSE,每路 token 级 setState 整表会卡顿;保持顺序。
|
||||
*
|
||||
* 2) **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅增量)→
|
||||
* `finalizeDialogue`(最终文本 + confidence + `streaming=false`)。三个函数都按 `id`
|
||||
* 匹配,绝不要用数组下标。
|
||||
*
|
||||
* 3) **Step 1 init 防 StrictMode 双 mount**:用单调代数 `step1InitGenRef` 而不是闭包 cancelled
|
||||
* 标志。任何 setState 完成前都要校验 `myGen === step1InitGenRef.current`。同样模式见
|
||||
* `RecommendAgentsDialog` 的 `generationRef`。
|
||||
*
|
||||
* 4) **离开 Step 2 不销毁线程**:`step2InitRef`/`threadIds` 保留,允许用户回到 Step 2 继续看
|
||||
* 完整讨论。只有「新建任务 / 加载草稿 / 推荐确认」会复位 `step2InitRef`。
|
||||
*
|
||||
* 5) **草稿自动保存只在前进切换时触发**(1→2、2→3),后退不保存,避免覆盖 `furthestStep`。
|
||||
*
|
||||
* 6) **顶栏门禁**:Step 2 需 `intentReady`;Step 3 需 `hasConsensus`(leader.dispatched===[])。
|
||||
*
|
||||
* 7) **Persona 抽屉**改的 model 不会立即重启编排,下一轮该席位被派活时由 `runOrchestration`
|
||||
* 读 `agentConfigs[meta.speakerId]?.model` 传入 special。prompt / temp 当前**仅前端记录**。
|
||||
*
|
||||
* 8) **常量**:`DEFAULT_MODEL`、`MAX_CYCLES=8`、`COMMANDER_SEAT_ID=1`、`INTENT_READY_RE`。
|
||||
*
|
||||
* 调试开关(默认开启):
|
||||
* window.__MULTI_AGENT_INIT_TIMING__ // /api/multi-agent/init 各阶段耗时
|
||||
* window.__MULTI_AGENT_DEBUG__ // streamMultiAgent 帧计数 + lastEvent
|
||||
*/
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
@@ -21,6 +55,12 @@ import {
|
||||
type IntentStreamPhase,
|
||||
type IntentSummary,
|
||||
} from "@/roundtable-planning/api/intent";
|
||||
import {
|
||||
IntentClarificationCard,
|
||||
formatIntentClarificationAnswer,
|
||||
type IntentClarificationSelection,
|
||||
} from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
|
||||
import {
|
||||
deleteDraft,
|
||||
deriveDraftName,
|
||||
@@ -159,42 +199,111 @@ type RoleEntry = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
const ROUNDTABLE_ROLE_TEMPLATES: ReadonlyArray<Omit<RoleEntry, "status">> = [
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human" },
|
||||
{ id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent" },
|
||||
{ id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent" },
|
||||
{ id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent" },
|
||||
{ id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent" },
|
||||
{ id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent" },
|
||||
{ id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent" },
|
||||
{ id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent" },
|
||||
];
|
||||
/** Stable seat id reserved for the human commander row. Agent seats start at 2. */
|
||||
const COMMANDER_SEAT_ID = 1;
|
||||
|
||||
function makeRoles(defaultStatus: string, overrides: Record<number, string> = {}): RoleEntry[] {
|
||||
return ROUNDTABLE_ROLE_TEMPLATES.map((t) => ({
|
||||
/** One entry per agent the user picked for this round of Step 2. */
|
||||
type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
/**
|
||||
* Display metadata for the 6 built-in roundtable seats — kept so the
|
||||
* familiar emoji/color/display name survives when the user keeps the
|
||||
* defaults. Custom agents fall through to {@link FALLBACK_AVATAR_TYPES}.
|
||||
*/
|
||||
const BUILTIN_AGENT_META: Record<string, { avatarType: string; displayName: string }> = {
|
||||
"roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" },
|
||||
"roundtable-environment": { avatarType: "emerald", displayName: "环境评估" },
|
||||
"roundtable-solution-design": { avatarType: "human", displayName: "方案设计" },
|
||||
"roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" },
|
||||
"roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" },
|
||||
"roundtable-summary": { avatarType: "blue", displayName: "总结陈述" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Visual rotation for non-built-in agents. Each entry must be a key
|
||||
* recognized by {@link getAvatar} — anything else falls back to the plain
|
||||
* `RobotAvatar`.
|
||||
*/
|
||||
const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const;
|
||||
|
||||
function getAvatarTypeFor(agentId: string, index: number): string {
|
||||
return (
|
||||
BUILTIN_AGENT_META[agentId]?.avatarType ??
|
||||
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayNameFor(agent: SelectedAgent): string {
|
||||
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the seat-template list shown in the right-rail role panel.
|
||||
* The commander always sits at id=1; agents take id=2..N+1, in the order
|
||||
* the user picked them so the panel mirrors the recommend dialog.
|
||||
*/
|
||||
function buildRoleTemplates(
|
||||
agents: SelectedAgent[],
|
||||
): ReadonlyArray<Omit<RoleEntry, "status">> {
|
||||
const out: Omit<RoleEntry, "status">[] = [
|
||||
{ id: COMMANDER_SEAT_ID, name: "指挥员 / 主持人", type: "human" },
|
||||
];
|
||||
agents.forEach((a, i) => {
|
||||
out.push({
|
||||
id: i + 2,
|
||||
name: getDisplayNameFor(a),
|
||||
agentId: a.agent_id,
|
||||
type: "agent",
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeRolesFromTemplates(
|
||||
templates: ReadonlyArray<Omit<RoleEntry, "status">>,
|
||||
defaultStatus: string,
|
||||
overrides: Record<number, string> = {},
|
||||
): RoleEntry[] {
|
||||
return templates.map((t) => ({
|
||||
...t,
|
||||
status: overrides[t.id] ?? defaultStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
// Subset of role templates that map to real backend agents (excludes the
|
||||
// human commander seat) — used as the `agent_names` payload on init.
|
||||
const ROUNDTABLE_AGENT_IDS = Array.from(
|
||||
new Set(
|
||||
ROUNDTABLE_ROLE_TEMPLATES.filter((r) => r.agentId).map((r) => r.agentId as string),
|
||||
),
|
||||
);
|
||||
/**
|
||||
* Map `agent_name` (lowercase) → bubble meta. Used while streaming output so
|
||||
* the right speaker bubble lights up and the dialogue card picks the right
|
||||
* avatar. Speaker ids stay in sync with {@link buildRoleTemplates} (agents
|
||||
* are id 2..N+1, indexed by selection order).
|
||||
*/
|
||||
function buildAgentNameToRole(
|
||||
agents: SelectedAgent[],
|
||||
): Record<string, { speakerId: number; avatarType: string; displayName: string }> {
|
||||
const out: Record<string, { speakerId: number; avatarType: string; displayName: string }> = {};
|
||||
agents.forEach((a, i) => {
|
||||
out[a.agent_id] = {
|
||||
speakerId: i + 2,
|
||||
avatarType: getAvatarTypeFor(a.agent_id, i),
|
||||
displayName: getDisplayNameFor(a),
|
||||
};
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// agent_name (lowercase) → bubble meta. Used when streaming output, so we can
|
||||
// light up the right speaker bubble and pick an avatar for the dialogue card.
|
||||
const AGENT_NAME_TO_ROLE: Record<string, { speakerId: number; avatarType: string; displayName: string }> = {
|
||||
"roundtable-summary": { speakerId: 2, avatarType: "blue", displayName: "总结陈述" },
|
||||
"roundtable-intelligence": { speakerId: 3, avatarType: "purple", displayName: "情报收集" },
|
||||
"roundtable-environment": { speakerId: 4, avatarType: "emerald", displayName: "环境评估" },
|
||||
"roundtable-solution-design": { speakerId: 5, avatarType: "human", displayName: "方案设计" },
|
||||
"roundtable-risk-review": { speakerId: 6, avatarType: "amber", displayName: "风险审查" },
|
||||
"roundtable-execution-plan": { speakerId: 7, avatarType: "pink", displayName: "执行规划" },
|
||||
};
|
||||
/**
|
||||
* Default Step-2 roster when the user hasn't picked anything yet (e.g. the
|
||||
* recommend dialog was skipped or hasn't run). Matches the original
|
||||
* hand-coded 6 roundtable seats so the page renders identically until the
|
||||
* recommend flow overrides it.
|
||||
*/
|
||||
const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [
|
||||
{ agent_id: "roundtable-summary", name: "总结陈述" },
|
||||
{ agent_id: "roundtable-intelligence", name: "情报收集" },
|
||||
{ agent_id: "roundtable-environment", name: "环境评估" },
|
||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
||||
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
|
||||
];
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
@@ -334,24 +443,33 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
]);
|
||||
|
||||
// Roles in roundtable state (updates as we switch steps!)
|
||||
const [roles, setRoles] = useState<RoleEntry[]>([
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human", status: "等待激活" },
|
||||
{ id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待激活" },
|
||||
{ id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent", status: "等待激活" },
|
||||
{ id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent", status: "等待激活" },
|
||||
{ id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent", status: "等待激活" },
|
||||
{ id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent", status: "等待激活" },
|
||||
{ id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent", status: "等待激活" },
|
||||
{ id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待陈述" },
|
||||
]);
|
||||
// The user's chosen Step-2 participants. Defaults to the 6 built-in
|
||||
// roundtable seats so behavior is unchanged before the recommend dialog
|
||||
// overrides it. Step 6 will replace this via the dialog's onConfirm.
|
||||
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(DEFAULT_SELECTED_AGENTS);
|
||||
|
||||
// Derived: template list for the seat panel + dispatch-time name lookup +
|
||||
// the `agent_names` payload for /api/multi-agent/init. All three regenerate
|
||||
// together whenever the selection changes, so seat ids stay in sync.
|
||||
const roleTemplates = useMemo(() => buildRoleTemplates(selectedAgents), [selectedAgents]);
|
||||
const agentNameToRole = useMemo(() => buildAgentNameToRole(selectedAgents), [selectedAgents]);
|
||||
const roundtableAgentIds = useMemo(() => selectedAgents.map((a) => a.agent_id), [selectedAgents]);
|
||||
|
||||
// Roles in roundtable state (updates as we switch steps!). Seeded from
|
||||
// `roleTemplates`'s initial value (selectedAgents starts at the default
|
||||
// roster), so the panel renders identically to the previous hand-coded list.
|
||||
const [roles, setRoles] = useState<RoleEntry[]>(() =>
|
||||
makeRolesFromTemplates(buildRoleTemplates(DEFAULT_SELECTED_AGENTS), "等待激活"),
|
||||
);
|
||||
|
||||
// Dialog: recommend agents before entering Step 2.
|
||||
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
||||
|
||||
// Step 2 dialogue list — populated by the real orchestration loop once the
|
||||
// user enters the roundtable. Starts empty so we never flash mock content.
|
||||
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<any[]>([]);
|
||||
|
||||
// Inputs for Step 2 conversation simulation controls
|
||||
const [roundTableSpeed, setRoundTableSpeed] = useState<string>("正常");
|
||||
const [isRoundTableRunning, setIsRoundTableRunning] = useState<boolean>(true);
|
||||
const [customAgentInput, setCustomAgentInput] = useState<string>("");
|
||||
// Whether the roundtable visualizer + constraint console panel is collapsed.
|
||||
@@ -367,6 +485,10 @@ export default function RoundtablePlanningPage() {
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null);
|
||||
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
|
||||
Map<number, IntentClarificationSelection>
|
||||
>(new Map());
|
||||
const step1InitRef = useRef<boolean>(false);
|
||||
// Monotonic counter incremented every time Step 1 init starts. The in-flight
|
||||
// async checks this on completion and bails if a newer init has begun (e.g.
|
||||
@@ -376,6 +498,7 @@ export default function RoundtablePlanningPage() {
|
||||
// early-returned because the ref was already set; net result: the async
|
||||
// landed but its setState got skipped, leaving intentThreadId null forever).
|
||||
const step1InitGenRef = useRef<number>(0);
|
||||
const intentAborterRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Multi-agent orchestration state (lives only for one step-2 visit).
|
||||
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
|
||||
@@ -427,6 +550,17 @@ export default function RoundtablePlanningPage() {
|
||||
activeAbortersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const stopIntentStream = useCallback(() => {
|
||||
if (intentAborterRef.current) {
|
||||
try {
|
||||
intentAborterRef.current.abort();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Toast auto-dismiss logic
|
||||
useEffect(() => {
|
||||
if (showToast) {
|
||||
@@ -435,6 +569,11 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 1) return;
|
||||
stopIntentStream();
|
||||
}, [currentStep, stopIntentStream]);
|
||||
|
||||
// Auto-follow the streamed conversations: track scroll position, stop
|
||||
// following when the user scrolls up, expose a `回到底部` button that
|
||||
// re-engages auto-follow. Same hook for both steps.
|
||||
@@ -442,6 +581,22 @@ export default function RoundtablePlanningPage() {
|
||||
step1ScrollRef,
|
||||
[interactiveMessages],
|
||||
);
|
||||
|
||||
// Sync intentReady when [INTENT_READY] appears in chat but the stream frame
|
||||
// missed it (e.g. clarification returned before summary was parsed).
|
||||
useEffect(() => {
|
||||
if (intentReady) return;
|
||||
for (let i = interactiveMessages.length - 1; i >= 0; i--) {
|
||||
const msg = interactiveMessages[i];
|
||||
if (msg.sender === "user") continue;
|
||||
const { summary } = splitIntentReadyBlock(msg.content ?? "");
|
||||
if (summary) {
|
||||
setIntentReady(summary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [interactiveMessages, intentReady]);
|
||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(
|
||||
step2ScrollRef,
|
||||
[step2RoundtableDialogues],
|
||||
@@ -737,9 +892,13 @@ export default function RoundtablePlanningPage() {
|
||||
// parallel meant N concurrent SSE streams each triggering a full-list
|
||||
// setState on every token delta, which made the browser stutter badly
|
||||
// when the leader 派活给多个席位。改为顺序后,同一时刻只有一个 SSE 在跑。
|
||||
//
|
||||
// ⚠️ 不要改回 Promise.all 并行 —— 详见 multi-agent-frontend-dev.md §5.4
|
||||
// 与 §12.4。如果未来要并行,先把 step2RoundtableDialogues 换成 Map<id, Bubble>
|
||||
// 或基于 ref 的局部更新,再放开 N 路 SSE。
|
||||
for (const [subName, task] of leaderRes.dispatched) {
|
||||
if (orchestrationCancelRef.current) break;
|
||||
const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()];
|
||||
const meta = agentNameToRole[subName.toLowerCase()];
|
||||
if (meta) markSpeakerStreaming(meta.speakerId);
|
||||
const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
|
||||
|
||||
@@ -806,7 +965,23 @@ export default function RoundtablePlanningPage() {
|
||||
clearAllStreamingSpeakers();
|
||||
setIsOrchestrating(false);
|
||||
}
|
||||
}, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs]);
|
||||
}, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]);
|
||||
|
||||
// Called by RecommendAgentsDialog onConfirm: update the participant roster,
|
||||
// reset step-2 init so the new coordinator is created for the new roster,
|
||||
// then navigate to Step 2.
|
||||
const handleConfirmAgents = useCallback((agentIds: string[]) => {
|
||||
setSelectedAgents(
|
||||
agentIds.map((id) => ({
|
||||
agent_id: id,
|
||||
name: BUILTIN_AGENT_META[id]?.displayName ?? id,
|
||||
})),
|
||||
);
|
||||
step2InitRef.current = false;
|
||||
setIsRecommendDialogOpen(false);
|
||||
setCurrentStep(2);
|
||||
setShowToast("成功确认!已切换至第二阶段圆桌研讨系统");
|
||||
}, []);
|
||||
|
||||
// Public stop handler used by the pause/stop button and the step-2 teardown.
|
||||
// Sets the cancel flag and aborts every in-flight stream so the loop can
|
||||
@@ -849,7 +1024,7 @@ export default function RoundtablePlanningPage() {
|
||||
const coord = newCoordinatorName();
|
||||
try {
|
||||
const initRes = await initMultiAgent({
|
||||
agentNames: ROUNDTABLE_AGENT_IDS,
|
||||
agentNames: roundtableAgentIds,
|
||||
mainAgentName: coord,
|
||||
model: DEFAULT_MODEL,
|
||||
// As each sub-agent's thread comes online, flip every role that
|
||||
@@ -918,7 +1093,11 @@ export default function RoundtablePlanningPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRoundTableRunning]);
|
||||
|
||||
// Adjust budget constraint dynamically
|
||||
// Budget slider — UI demo only.
|
||||
// 这是一段**未对接后端**的演示逻辑:滑动预算条只会本地 setBudgetLimit 并 1.5 秒后追加一条
|
||||
// 模拟智能体口吻的气泡(根据档位选话术)。真实研讨里预算属于约束条件,应由 Step 1 的
|
||||
// intent.summary.constraints 承载、由总控派活时透传给 sub-agent,而不是这条单滑块。
|
||||
// 重写此功能前请同步考虑:1) 是否真的需要可调约束;2) 若需要,改成走 runOrchestration 注入新 prompt。
|
||||
const handleBudgetChange = (newVal: number) => {
|
||||
setBudgetLimit(newVal);
|
||||
triggerToast(`💰 研商经费调优约束变更为 ${newVal} 万!各大智能体启动资源重算...`);
|
||||
@@ -961,6 +1140,10 @@ export default function RoundtablePlanningPage() {
|
||||
// Save per-seat agent config overrides. We persist locally only — the next
|
||||
// time the orchestration loop dispatches to this seat it will read the
|
||||
// chosen model out of `agentConfigs` and pass it on the streaming call.
|
||||
//
|
||||
// 目前 prompt / temp 两个字段**仅前端保留**,后端尚未开放 per-agent override 接口。
|
||||
// 当后端开放时,应在 streamMultiAgent 的 special 调用上同时传 prompt_override / temperature,
|
||||
// 而不是改这里的本地存储结构(后者会被 useState 重置)。
|
||||
const handleSaveAgentAndRecalculate = (id: number) => {
|
||||
const role = roles.find(r => r.id === id);
|
||||
const roleName = role?.name || "专家智能体";
|
||||
@@ -1009,6 +1192,8 @@ export default function RoundtablePlanningPage() {
|
||||
setIntentReady(null);
|
||||
setIntentInitError(null);
|
||||
setIntentThreadId(null);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setTimelineEntries([]);
|
||||
setInteractiveMessages([
|
||||
{
|
||||
@@ -1049,6 +1234,7 @@ export default function RoundtablePlanningPage() {
|
||||
// Stop anything in flight before swapping state out.
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
stopIntentStream();
|
||||
setIsOrchestrating(false);
|
||||
setIsInitializing(false);
|
||||
setPendingClarification(null);
|
||||
@@ -1078,7 +1264,7 @@ export default function RoundtablePlanningPage() {
|
||||
// doesn't change and the effect wouldn't re-fire.
|
||||
runStep1Init();
|
||||
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
|
||||
}, [abortAllInFlight, runStep1Init]);
|
||||
}, [abortAllInFlight, runStep1Init, stopIntentStream]);
|
||||
|
||||
// ── Draft helpers ──────────────────────────────────────────────────────
|
||||
// Build a fresh snapshot of the current in-memory session. Called by every
|
||||
@@ -1184,6 +1370,7 @@ export default function RoundtablePlanningPage() {
|
||||
// Cancel anything in-flight before swapping state out from under it.
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
stopIntentStream();
|
||||
setIsOrchestrating(false);
|
||||
setIsInitializing(false);
|
||||
setPendingClarification(null);
|
||||
@@ -1224,7 +1411,7 @@ export default function RoundtablePlanningPage() {
|
||||
setCurrentStep(draft.furthestStep);
|
||||
triggerToast(`📂 已加载草稿:${draft.name}`);
|
||||
},
|
||||
[drafts, abortAllInFlight],
|
||||
[drafts, abortAllInFlight, stopIntentStream],
|
||||
);
|
||||
|
||||
const handleDeleteDraft = useCallback(
|
||||
@@ -1277,10 +1464,11 @@ export default function RoundtablePlanningPage() {
|
||||
// Chat sender logic for Step 1 interaction — drives the real intent agent
|
||||
// via /api/intent/stream. The agent streams its reply into a placeholder
|
||||
// bubble; if its final status is "done" we capture the structured summary
|
||||
// for the Step 2 handoff.
|
||||
const handleSendMessage = async () => {
|
||||
const userText = inputText.trim();
|
||||
if (!userText) return;
|
||||
// for the Step 2 handoff. When status is "clarification", render option
|
||||
// cards like the main chat ClarificationCard.
|
||||
const sendIntentMessage = async (userText: string) => {
|
||||
const trimmed = userText.trim();
|
||||
if (!trimmed) return;
|
||||
if (!intentThreadId) {
|
||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||
return;
|
||||
@@ -1290,33 +1478,31 @@ export default function RoundtablePlanningPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
|
||||
const userMsgTime = nowTime();
|
||||
const userMsgId = Date.now() + Math.random();
|
||||
const userMsg = {
|
||||
id: userMsgId,
|
||||
sender: "user",
|
||||
senderName: "用户",
|
||||
content: userText,
|
||||
content: trimmed,
|
||||
time: userMsgTime,
|
||||
};
|
||||
setInteractiveMessages(prev => [...prev, userMsg]);
|
||||
setInputText("");
|
||||
setCharactersLeft(0);
|
||||
|
||||
// Right-side timeline gets a short summary too.
|
||||
setTimelineEntries(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: userMsgTime,
|
||||
title: "用户实时反馈",
|
||||
desc: userText.length > 25 ? userText.substring(0, 25) + "..." : userText,
|
||||
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
|
||||
},
|
||||
]);
|
||||
|
||||
// Placeholder agent bubble — text deltas accumulate into it. `phase`
|
||||
// tracks the inferred SSE stage so the "in flight" caption can flip from
|
||||
// "调度中" → "已连接,等待响应" → "正在思考问题" as the run progresses.
|
||||
const agentMsgId = Date.now() + Math.random() + 0.01;
|
||||
setInteractiveMessages(prev => [
|
||||
...prev,
|
||||
@@ -1331,9 +1517,6 @@ export default function RoundtablePlanningPage() {
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Map an inferred phase + (optional) tool name to a human-readable label
|
||||
// shown next to the spinner. Tool-call phases highlight ask_clarification
|
||||
// explicitly since that's the only tool the intent agent normally uses.
|
||||
const intentPhaseLabel = (phase: IntentStreamPhase, toolName?: string): string => {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
@@ -1351,11 +1534,14 @@ export default function RoundtablePlanningPage() {
|
||||
};
|
||||
|
||||
setIsIntentSending(true);
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
try {
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: userText,
|
||||
message: trimmed,
|
||||
model: DEFAULT_MODEL,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)),
|
||||
@@ -1368,47 +1554,105 @@ export default function RoundtablePlanningPage() {
|
||||
);
|
||||
},
|
||||
});
|
||||
// Finalize the bubble with the authoritative final text from the
|
||||
// status frame (covers cases where deltas didn't accumulate cleanly).
|
||||
const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
|
||||
const resolvedSummary = res.summary ?? parsedFromContent;
|
||||
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: res.content || m.content || "(无文本输出)", streaming: false } as any
|
||||
? {
|
||||
...m,
|
||||
content: res.content || m.content || "(无文本输出)",
|
||||
streaming: false,
|
||||
clarification: resolvedSummary ? undefined : res.clarification ?? undefined,
|
||||
} as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
if (res.status === "done" && res.summary) {
|
||||
setIntentReady(res.summary);
|
||||
|
||||
if (resolvedSummary) {
|
||||
setIntentReady(resolvedSummary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setTimelineEntries(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: nowTime(),
|
||||
title: "任务意图已明确",
|
||||
desc: res.summary!.objective.length > 25
|
||||
? res.summary!.objective.substring(0, 25) + "..."
|
||||
: res.summary!.objective,
|
||||
desc: resolvedSummary.objective.length > 25
|
||||
? resolvedSummary.objective.substring(0, 25) + "..."
|
||||
: resolvedSummary.objective,
|
||||
},
|
||||
]);
|
||||
triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨");
|
||||
} else if (res.status === "clarification" && res.clarification) {
|
||||
setPendingIntentClarificationMsgId(agentMsgId);
|
||||
setIntentClarificationSelections(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentMsgId, { selectedIds: [], custom: "" });
|
||||
return next;
|
||||
});
|
||||
} else if (res.status === "error") {
|
||||
triggerToast(`❌ 智能体调用失败: ${res.content}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("intent stream failed:", err);
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast(`❌ 发送失败: ${(err as Error).message}`);
|
||||
if ((err as Error).name === "AbortError" || aborter.signal.aborted) {
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: (m.content ?? "").trim()
|
||||
? `${m.content ?? ""}\n\n(已暂停)`
|
||||
: "(已暂停)",
|
||||
streaming: false,
|
||||
} as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast("⏸️ 已暂停任务理解回复");
|
||||
} else {
|
||||
console.error("intent stream failed:", err);
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast(`❌ 发送失败: ${(err as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
if (intentAborterRef.current === aborter) {
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
setIsIntentSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
void sendIntentMessage(inputText);
|
||||
};
|
||||
|
||||
const submitIntentClarification = (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => {
|
||||
const selection =
|
||||
overrideSelection ??
|
||||
intentClarificationSelections.get(msgId) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const answer = formatIntentClarificationAnswer(payload, selection);
|
||||
if (!answer) {
|
||||
triggerToast("请先选择选项或输入回答");
|
||||
return;
|
||||
}
|
||||
void sendIntentMessage(answer);
|
||||
};
|
||||
|
||||
// Skip step logic
|
||||
const handleSkipQuestion = () => {
|
||||
const systemSkipMsg = {
|
||||
@@ -1473,11 +1717,11 @@ export default function RoundtablePlanningPage() {
|
||||
// seat to 在线 — assume all seats are online so the panel doesn't get
|
||||
// stuck at "正在上线".
|
||||
const baseStatus = loadedFromDraftRef.current ? "在线" : "正在上线";
|
||||
setRoles(makeRoles(baseStatus, { 1: "在线" }));
|
||||
setRoles(makeRolesFromTemplates(roleTemplates, baseStatus, { 1: "在线" }));
|
||||
} else {
|
||||
setRoles(makeRoles("已完成"));
|
||||
setRoles(makeRolesFromTemplates(roleTemplates, "已完成"));
|
||||
}
|
||||
}, [currentStep, intentThreadId, intentInitError]);
|
||||
}, [currentStep, intentThreadId, intentInitError, roleTemplates]);
|
||||
|
||||
// Inject user input to the roundtable room — sends it as a new leader call.
|
||||
const handleInjectToRoundtable = () => {
|
||||
@@ -1544,6 +1788,14 @@ export default function RoundtablePlanningPage() {
|
||||
>
|
||||
|
||||
{/* Toast Notice Banner */}
|
||||
<RecommendAgentsDialog
|
||||
open={isRecommendDialogOpen}
|
||||
intent={intentReady}
|
||||
model={DEFAULT_MODEL}
|
||||
onCancel={() => setIsRecommendDialogOpen(false)}
|
||||
onConfirm={handleConfirmAgents}
|
||||
/>
|
||||
|
||||
{showToast && (
|
||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
|
||||
<Sparkles className="h-4 w-4 shrink-0" />
|
||||
@@ -2009,16 +2261,10 @@ export default function RoundtablePlanningPage() {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Avatar chooser based on role.id */}
|
||||
{role.type === "human" ? (
|
||||
<Man1Avatar size="h-7 w-7" />
|
||||
) : (
|
||||
role.id === 2 ? <RobotAvatar headColor="#f5222d" size="h-7 w-7" /> :
|
||||
role.id === 3 ? <PurpleRobotAvatar size="h-7 w-7" /> :
|
||||
role.id === 4 ? <EmeraldRobotAvatar size="h-7 w-7" /> :
|
||||
role.id === 6 ? <AmberRobotAvatar size="h-7 w-7" /> :
|
||||
role.id === 7 ? <PinkRobotAvatar size="h-7 w-7" /> :
|
||||
<BlueRobotAvatar size="h-7 w-7" />
|
||||
getAvatar(role.agentId ? (agentNameToRole[role.agentId]?.avatarType ?? "blue") : "blue", "h-7 w-7")
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
@@ -2146,10 +2392,7 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
<button
|
||||
id="confirm-roundtable-btn"
|
||||
onClick={() => {
|
||||
setCurrentStep(2);
|
||||
triggerToast("成功确认!已切换至第二阶段圆桌研讨系统");
|
||||
}}
|
||||
onClick={() => setIsRecommendDialogOpen(true)}
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white font-bold text-xs px-4 py-2 rounded-xl transition shadow-[0_2px_8px_rgba(245,34,45,0.2)] active:scale-95 shrink-0"
|
||||
>
|
||||
用户确认,进入多智能体研讨
|
||||
@@ -2229,12 +2472,26 @@ export default function RoundtablePlanningPage() {
|
||||
const isStreaming = (msg as { streaming?: boolean }).streaming === true;
|
||||
const hasContent = (msg.content ?? "").length > 0;
|
||||
const isAgent = msg.sender !== "user";
|
||||
// Only agent replies carry markdown / [INTENT_READY]; user
|
||||
// messages stay as plain text so casing/whitespace are
|
||||
// preserved verbatim.
|
||||
const clarification = (msg as { clarification?: ClarificationPayload }).clarification;
|
||||
const parts = isAgent
|
||||
? splitIntentReadyBlock(msg.content ?? "")
|
||||
: { before: msg.content ?? "", summary: null, after: "" };
|
||||
const isPendingClarification =
|
||||
isAgent &&
|
||||
clarification &&
|
||||
!intentReady &&
|
||||
!parts.summary &&
|
||||
pendingIntentClarificationMsgId === msg.id;
|
||||
const clarificationSelection =
|
||||
intentClarificationSelections.get(msg.id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const introText =
|
||||
clarification &&
|
||||
parts.before.trim() === (clarification.question || "").trim()
|
||||
? ""
|
||||
: parts.before;
|
||||
return (
|
||||
<div key={msg.id} className="flex items-start justify-between gap-3 border-b border-slate-100/50 pb-2.5 last:border-0 last:pb-0">
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
@@ -2257,7 +2514,7 @@ export default function RoundtablePlanningPage() {
|
||||
</span>
|
||||
) : isAgent ? (
|
||||
<div className="text-xs text-slate-700 leading-normal font-normal break-words space-y-2">
|
||||
{parts.before && (
|
||||
{introText && (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
@@ -2293,7 +2550,7 @@ export default function RoundtablePlanningPage() {
|
||||
),
|
||||
}}
|
||||
>
|
||||
{parts.before}
|
||||
{introText}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{parts.summary && (
|
||||
@@ -2338,6 +2595,25 @@ export default function RoundtablePlanningPage() {
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />
|
||||
)}
|
||||
{isPendingClarification && clarification && (
|
||||
<IntentClarificationCard
|
||||
payload={clarification}
|
||||
fallbackContent={parts.before || undefined}
|
||||
selection={clarificationSelection}
|
||||
onSelectionChange={(next) => {
|
||||
setIntentClarificationSelections(prev => {
|
||||
const map = new Map(prev);
|
||||
map.set(msg.id, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
disabled={!isPendingClarification}
|
||||
submitting={isIntentSending && isPendingClarification}
|
||||
onSubmit={(override) =>
|
||||
submitIntentClarification(msg.id, clarification, override)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words">
|
||||
@@ -2373,7 +2649,11 @@ export default function RoundtablePlanningPage() {
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder="请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
|
||||
placeholder={
|
||||
pendingIntentClarificationMsgId
|
||||
? "也可在此输入自定义回答 (Enter 快速发送)"
|
||||
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
|
||||
}
|
||||
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
|
||||
maxLength={500}
|
||||
/>
|
||||
@@ -2410,6 +2690,18 @@ export default function RoundtablePlanningPage() {
|
||||
{isIntentSending ? "发送中" : "发送"}
|
||||
</button>
|
||||
|
||||
{isIntentSending && (
|
||||
<button
|
||||
id="pause-intent-btn"
|
||||
type="button"
|
||||
onClick={() => stopIntentStream()}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition border border-amber-200/40 shadow-sm active:scale-95"
|
||||
>
|
||||
<Pause className="w-3 h-3" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
onClick={handleAgentFollowUp}
|
||||
@@ -2435,7 +2727,7 @@ export default function RoundtablePlanningPage() {
|
||||
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
||||
return;
|
||||
}
|
||||
setCurrentStep(2);
|
||||
setIsRecommendDialogOpen(true);
|
||||
}}
|
||||
disabled={!intentReady}
|
||||
title={intentReady ? undefined : "请先完成任务意图澄清"}
|
||||
@@ -2483,23 +2775,6 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Interactive speed controls */}
|
||||
<div className="flex items-center bg-slate-50 border border-slate-200 rounded-xl p-0.5 text-[10px] shrink-0 font-bold font-sans shadow-inner">
|
||||
<span className="px-2 text-slate-400 font-semibold selection:bg-transparent">研商步长</span>
|
||||
<button
|
||||
onClick={() => { setRoundTableSpeed("慢速"); triggerToast("🐌 已切换为 0.5x 慢速拟合监听模式"); }}
|
||||
className={`px-1.5 py-0.5 rounded transition ${roundTableSpeed === "慢速" ? "bg-white text-primary font-extrabold shadow-sm" : "text-slate-500 hover:text-slate-800"}`}
|
||||
>慢速</button>
|
||||
<button
|
||||
onClick={() => { setRoundTableSpeed("正常"); triggerToast("⏱️ 已恢复为 1.0x 标准协商状态值"); }}
|
||||
className={`px-1.5 py-0.5 rounded transition ${roundTableSpeed === "正常" ? "bg-white text-primary font-extrabold shadow-sm" : "text-slate-500 hover:text-slate-800"}`}
|
||||
>正常</button>
|
||||
<button
|
||||
onClick={() => { setRoundTableSpeed("极速"); triggerToast("🚀 已并行为 2.5x 极速算力重算模式"); }}
|
||||
className={`px-1.5 py-0.5 rounded transition ${roundTableSpeed === "极速" ? "bg-white text-primary font-extrabold shadow-sm" : "text-slate-500 hover:text-slate-800"}`}
|
||||
>极速</button>
|
||||
</div>
|
||||
|
||||
{/* Suspend / resume button visibility:
|
||||
- Show "挂起协商" only while a run is actually in flight
|
||||
(isOrchestrating + still nominally running). When the
|
||||
@@ -2744,7 +3019,14 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Agent Persona Config Drawer / Drawer dialog box */}
|
||||
{/* Selected Agent Persona Config Drawer / Drawer dialog box
|
||||
────────────────────────────────────────────────────────
|
||||
点击圆桌可视化里的某个智能体头像后展开,允许覆写该席位的:
|
||||
- System Prompt (本地记录,后端覆写未开放)
|
||||
- 推理模型 (下一轮派活时 runOrchestration 会读 agentConfigs[id].model)
|
||||
- Temperature (本地记录,后端覆写未开放)
|
||||
保存按钮 = handleSaveAgentAndRecalculate,不会立即重启编排,只更新
|
||||
agentConfigs;真正生效在下一次该席位被 leader 派活时。 */}
|
||||
{selectedAgentId && (
|
||||
<div className="mx-6 mb-4 p-4.5 bg-gradient-to-r from-[var(--brand-200)]/50 via-[var(--brand-200)]/15 to-white border-2 border-primary rounded-2xl shadow-[0_8px_30px_rgba(245,34,45,0.06)] relative animate-fade-in">
|
||||
<button
|
||||
@@ -3123,7 +3405,7 @@ export default function RoundtablePlanningPage() {
|
||||
content: String(d.content ?? ""),
|
||||
}))}
|
||||
seatNameMap={Object.fromEntries(
|
||||
Object.entries(AGENT_NAME_TO_ROLE).map(([k, v]) => [k, v.displayName]),
|
||||
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
|
||||
)}
|
||||
onReset={handleStartNewTask}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ from app.gateway.routers import (
|
||||
multi_agent,
|
||||
notifications,
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
@@ -862,14 +863,13 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||||
app.include_router(runs.router)
|
||||
|
||||
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
|
||||
app.include_router(multi_agent.router)
|
||||
|
||||
# Task-intent agent (Step 1 of the roundtable flow): single built-in
|
||||
# agent runs a 2-5 turn clarification dialogue, then hands a structured
|
||||
# {objective, constraints, assumptions} summary off to the multi-agent
|
||||
# roundtable as its initial intent.
|
||||
app.include_router(intent.router)
|
||||
# --- 圆桌规划三步流(前端 /page/workspace/roundtable/planning)---
|
||||
# 实现见 app/gateway/routers/{multi_agent,intent,recommend}.py;
|
||||
# 文档见 frontend-web/docs/multi-agent-backend-dev.md
|
||||
app.include_router(multi_agent.router) # Step 2:init(SSE) + run/stream(leader|special)
|
||||
app.include_router(intent.router) # Step 1:/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
|
||||
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
|
||||
@@ -142,11 +142,18 @@ def collect_outputs_listing(outputs_dir: Path) -> list[dict]:
|
||||
)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def list_artifacts(thread_id: str, request: Request) -> dict:
|
||||
"""List artifact files generated by the agent under outputs/.
|
||||
"""列出 thread 在 ``outputs/`` 下生成的产物文件。
|
||||
|
||||
Walks ``backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/``
|
||||
recursively. Returns an empty list if the directory does not exist (e.g. the
|
||||
thread has not produced any artifacts yet).
|
||||
圆桌 Step 3(``HighFidelityReport``)对每个**子席位** thread_id 调用本接口,
|
||||
展示各智能体写入沙箱 ``mnt/user-data/outputs/`` 的文件;协调智能体 thread
|
||||
通常无独立交付文件。
|
||||
|
||||
遍历路径::
|
||||
|
||||
.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/
|
||||
|
||||
返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
|
||||
目录不存在时 ``files`` 为空数组(非 404)。
|
||||
"""
|
||||
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())
|
||||
return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
"""Task-intent agent endpoints (Step 1 of the roundtable flow).
|
||||
"""圆桌规划 · 第一步「任务理解」Gateway 路由。
|
||||
|
||||
Lightweight wrapper around the lead-agent runtime that runs a single built-in
|
||||
agent (``roundtable-intent``) on its own thread. The agent's job is to do 2-5
|
||||
clarifying turns with the user and then emit a structured
|
||||
``{objective, constraints, assumptions}`` summary the frontend hands off to
|
||||
the multi-agent roundtable as its initial intent.
|
||||
前端页面:``/page/workspace/roundtable/planning`` Step 1
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/intent.ts``
|
||||
|
||||
Same SSE shape as ``multi_agent.py`` (transparent passthrough + appended
|
||||
status frame), so the frontend can reuse the same reader pattern.
|
||||
职责
|
||||
----
|
||||
在独立 thread 上运行内置智能体 ``roundtable-intent``(SOUL 见
|
||||
``.deer-flow/agents/roundtable-intent/SOUL.md``),与用户进行 2–5 轮澄清,
|
||||
最终输出结构化意图 ``{objective, constraints, assumptions}``,供 Step 2
|
||||
总控协调智能体作为初始 prompt。
|
||||
|
||||
与 ``multi_agent.py`` 的关系
|
||||
---------------------------
|
||||
- **复用**同一套 loopback 工具:``_stream_upstream``、``_run_payload``、
|
||||
``_parse_last_messages`` 等,保证 SSE 透传 + 末尾状态帧的解析逻辑一致。
|
||||
- **不复用** ``/init`` 的协调智能体创建逻辑:intent 是长期注册的内置 agent,
|
||||
只需 ``POST /api/intent/init`` 创建 thread 即可。
|
||||
|
||||
SSE 协议(与前端 ``streamIntent`` 对齐)
|
||||
--------------------------------------
|
||||
1. 透传 LangGraph ``/api/threads/{id}/runs/stream`` 的全部 ``data:`` 行
|
||||
(含 ``messages-tuple`` 增量,供 UI 流式打字)。
|
||||
2. 流结束后追加**一条** JSON 状态帧(``data: {...}\\n\\n``),字段:
|
||||
|
||||
``status`` — 终态枚举:
|
||||
- ``asking``:本轮无 ``ask_clarification``,模型仍在追问或闲聊;
|
||||
- ``clarification``:本轮以澄清工具结束,需用户选选项/自定义输入;
|
||||
- ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
|
||||
- ``error``:上游或网关异常。
|
||||
|
||||
``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。
|
||||
|
||||
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
||||
``question``、``clarification_type``、``clarification_context``、
|
||||
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
|
||||
|
||||
终态判定优先级(实现细节,勿随意调整)
|
||||
------------------------------------
|
||||
1. **``[INTENT_READY]`` 优先**:从最新消息向前扫描 AIMessage,正则匹配
|
||||
SOUL 规定的 ``[INTENT_READY]\\n```json {...} `````;即使最后一条消息仍带
|
||||
过期的 ``ask_clarification`` tool_call,也返回 ``done``(避免前端卡在澄清 UI)。
|
||||
2. 否则看最后一条带 ``tool_calls`` 的 AIMessage 是否含 ``ask_clarification``。
|
||||
3. 否则 ``asking``。
|
||||
|
||||
工具白名单
|
||||
----------
|
||||
``excluded_tools`` 屏蔽除 ``ask_clarification`` 外的一切工具,防止模型偏离
|
||||
澄清任务去写文件、派活等(SOUL 文字约束不可靠,硬排除更稳)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,15 +74,16 @@ from app.gateway.routers.multi_agent import (
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
|
||||
# Matches the SOUL-mandated handoff signal: a [INTENT_READY] line followed by
|
||||
# a ```json ... ``` block. The model is told to emit this only when it judges
|
||||
# the user's task is fully specified.
|
||||
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
||||
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
||||
_INTENT_READY_PATTERN = re.compile(
|
||||
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
@@ -52,12 +92,17 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
|
||||
@router.post("/init")
|
||||
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
|
||||
"""Create a fresh thread for an intent-clarification session.
|
||||
"""创建任务理解会话用的空 thread。
|
||||
|
||||
Returns the stable built-in ``agent_id`` so the frontend doesn't need to
|
||||
know it. Unlike multi-agent ``/init``, no coordinator is built — the
|
||||
intent agent is long-lived and already registered via the legacy
|
||||
agent-sync path on disk.
|
||||
请求体:``{ "model": "deepseek-chat" }``(可选,当前 init 未把 model 写入 thread,
|
||||
实际模型在 ``/stream`` 的每次 run 里指定)。
|
||||
|
||||
响应::
|
||||
|
||||
{ "status": "success", "agent_id": "roundtable-intent", "thread_id": "<uuid>" }
|
||||
|
||||
与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位
|
||||
thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。
|
||||
"""
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
@@ -83,9 +128,12 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
through transparently, then appends one status frame:
|
||||
|
||||
{
|
||||
"status": "asking" | "done" | "error",
|
||||
"status": "asking" | "clarification" | "done" | "error",
|
||||
"content": "<final AI text>",
|
||||
"summary": {...} | null // only present when status == "done"
|
||||
"summary": {...} | null, // only when status == "done"
|
||||
// when status == "clarification":
|
||||
"question", "clarification_type", "clarification_context",
|
||||
"options", "allow_custom", "allow_multiple"
|
||||
}
|
||||
"""
|
||||
thread_id = str(payload.get("thread_id") or "").strip()
|
||||
@@ -115,6 +163,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# everything else so the model can't drift into solutioning
|
||||
# (e.g. saving a design doc with write_file) — the model
|
||||
# tends to ignore SOUL prose, hard exclusion is reliable.
|
||||
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
@@ -135,6 +184,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
|
||||
captured: list[str] = []
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
@@ -143,44 +193,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
# Find the latest AIMessage with tool_calls (typically
|
||||
# ask_clarification) — falls back to the latest plain
|
||||
# AIMessage when the model produced no tool call.
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
# Clarification branch: the agent ended its run by calling
|
||||
# ask_clarification. ClarificationMiddleware intercepts with
|
||||
# Command(goto=END) and stashes the structured question in the
|
||||
# tool_call args, so the AIMessage `content` is only the
|
||||
# preamble ("下面我用几个问题确认:") and stops there. We splice
|
||||
# the question back into the user-facing text so the frontend
|
||||
# actually shows what the agent wants to ask.
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
# Build a single content string the frontend can render
|
||||
# as-is: preamble (if any) + the actual question.
|
||||
parts: list[str] = []
|
||||
if content.strip():
|
||||
parts.append(content.strip())
|
||||
if follow_up and follow_up != question:
|
||||
parts.append(follow_up)
|
||||
if question:
|
||||
parts.append(question)
|
||||
content = "\n\n".join(parts) if parts else content
|
||||
|
||||
# Scan ALL AI messages for the [INTENT_READY] marker, not just
|
||||
# dispatch_msg.content. The model often emits the summary in an
|
||||
# earlier AI message and then keeps yapping (or doing extra
|
||||
# tool calls) — the marker is the source of truth, wherever it
|
||||
# appears in the run.
|
||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
|
||||
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
|
||||
summary: dict[str, Any] | None = None
|
||||
ready_text: str = ""
|
||||
for msg in reversed(messages):
|
||||
@@ -198,17 +215,52 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
summary = None
|
||||
ready_text = msg_text
|
||||
break
|
||||
# When the marker was found in an earlier message, surface that
|
||||
# message's text (which contains the user-facing 📋 汇总 + JSON)
|
||||
# instead of the final filler AI text like "好的,收到".
|
||||
if summary and ready_text:
|
||||
content = ready_text
|
||||
final_frame = {
|
||||
"status": "done",
|
||||
"content": ready_text,
|
||||
"summary": summary,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
status_value = "done" if summary else "asking"
|
||||
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
|
||||
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
|
||||
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
display_content = follow_up or content or question
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# --- 终态分支 3:普通 AI 回复,继续多轮对话 ---
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": status_value,
|
||||
"status": "asking",
|
||||
"content": content,
|
||||
"summary": summary,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@@ -1,18 +1,47 @@
|
||||
"""Multi-agent orchestration endpoints.
|
||||
"""圆桌规划 · Step 2「多智能体研讨」Gateway 路由。
|
||||
|
||||
Wraps the legacy ``newpython/OUT.py`` demo into the Gateway. Provides:
|
||||
前端页面:``RoundtablePlanningPage`` Step 2
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/multi-agent.ts``
|
||||
|
||||
* ``POST /api/multi-agent/init`` — create a coordinator agent and per-sub-agent
|
||||
threads. Returns the ``{agent_name: thread_id}`` map the client passes back
|
||||
on subsequent calls.
|
||||
* ``POST /api/multi-agent/run/stream`` — drive a *leader* or *special* agent
|
||||
via SSE. The leader dispatches work to sub-agents through the
|
||||
``agent_orchestration`` skill; sub-agents execute and broadcast their result.
|
||||
本模块把原 ``newpython/OUT.py`` 演示编排进 FastAPI Gateway,通过 **HTTP loopback**
|
||||
(``127.0.0.1`` + 调用方 ``Authorization``)复用既有 ``/api/threads``、
|
||||
``/api/agents``、``/api/threads/{id}/runs/stream``,不再硬编码 token。
|
||||
|
||||
Internal calls loop back to the same Gateway over HTTP, reusing the caller's
|
||||
``Authorization`` header — so the demo's hard-coded ``get_token("ji")`` is gone.
|
||||
The model used by both the coordinator agent and every streamed run is taken
|
||||
from the request's ``model`` field.
|
||||
对外接口
|
||||
--------
|
||||
1. ``POST /api/multi-agent/init``(SSE)
|
||||
- 为每个研讨席位(``agent_names``)并行创建 thread,按完成顺序推送
|
||||
``seat_ready``;
|
||||
- 动态创建**当次会话**的总控协调智能体(``main_agent_name``,如
|
||||
``roundtable-coordinator-xxx``),写入席位列表 SOUL + ``agent_orchestration`` skill;
|
||||
- 结束帧 ``init_done`` 携带 ``thread_ids: { agent_name: thread_id, ... }``,
|
||||
含协调智能体自身 thread。
|
||||
|
||||
2. ``POST /api/multi-agent/run/stream``(SSE)
|
||||
- ``agent_type=leader``:总控一轮——可能派活、可能澄清、可能共识(空 dispatch);
|
||||
- ``agent_type=special``:单席位子智能体执行一轮交付。
|
||||
|
||||
跨席位「广播」语义
|
||||
------------------
|
||||
通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加
|
||||
**只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到
|
||||
总控派活/用户干预/子 agent 完成摘要。前端编排循环仍靠显式多次 ``/run/stream``
|
||||
驱动各 agent 真正推理。
|
||||
|
||||
Leader 终态帧(前端 ``isFinalStatusFrame``)
|
||||
-------------------------------------------
|
||||
- ``status: [[agent_name, task], ...]`` — 派活列表,前端并行 special run;
|
||||
- ``status: []`` — 无 tool_calls,视为共识,可进 Step 3;
|
||||
- ``status: "clarification"`` — 总控 ``ask_clarification``,前端暂停编排展示澄清条;
|
||||
- ``status: "子智能体 X 完成..."`` — special 完成(字符串状态);
|
||||
- ``status: "error"`` — 失败。
|
||||
|
||||
共享导出
|
||||
--------
|
||||
``intent.py`` / ``recommend.py`` 从此模块 import 底层流式与消息解析函数,
|
||||
勿在 intent/recommend 中重复实现 loopback 逻辑。
|
||||
|
||||
相关文档:``frontend-web/docs/multi-agent-backend-dev.md``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +54,7 @@ import time
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -33,11 +63,12 @@ router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loopback helpers
|
||||
# Loopback 辅助函数(Gateway → Gateway,同进程)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _loopback_base(request: Request) -> str:
|
||||
"""当前请求的 scheme+host,用于 httpx 打回本机 Gateway。"""
|
||||
return f"{request.url.scheme}://{request.url.netloc}"
|
||||
|
||||
|
||||
@@ -49,6 +80,7 @@ def _auth_headers(request: Request) -> dict[str, str]:
|
||||
|
||||
|
||||
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
|
||||
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
|
||||
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["thread_id"]
|
||||
@@ -92,6 +124,11 @@ async def _append_thread_message(
|
||||
thread_id: str,
|
||||
content: str,
|
||||
) -> None:
|
||||
"""向指定 thread 的 checkpoint 追加一条 human 消息(广播上下文,不触发 LLM run)。
|
||||
|
||||
失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
|
||||
前端对该席位 thread 发起的 ``/run/stream``。
|
||||
"""
|
||||
state_url = f"{base}/api/threads/{thread_id}/state"
|
||||
get_resp = await client.get(state_url, headers=headers)
|
||||
if get_resp.status_code != 200:
|
||||
@@ -117,6 +154,7 @@ async def _broadcast(
|
||||
agent_threads: dict[str, str],
|
||||
skip: list[str],
|
||||
) -> None:
|
||||
"""并行向 ``agent_threads`` 中除 ``skip`` 外的所有 thread 追加同一条 human 消息。"""
|
||||
targets = [tid for name, tid in agent_threads.items() if name not in skip]
|
||||
if not targets:
|
||||
return
|
||||
@@ -213,6 +251,13 @@ def _run_payload(
|
||||
excluded_tools: list[str],
|
||||
skill_stop_names: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||
|
||||
- ``context.agent_name``:本次 run 使用的 agent(内置 id 或 init 创建的 coordinator id)。
|
||||
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
|
||||
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
|
||||
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
|
||||
"""
|
||||
return {
|
||||
"input": {
|
||||
"messages": [
|
||||
@@ -245,26 +290,31 @@ def _run_payload(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init endpoint
|
||||
# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_multi_agent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""Streaming /init.
|
||||
"""流式初始化圆桌 Step 2 所需的 thread 映射与协调智能体。
|
||||
|
||||
Emits text/event-stream frames as each seat/coordinator becomes ready, so
|
||||
the frontend can visualize the parallel bring-up rather than blocking on
|
||||
one bulk JSON. Frames:
|
||||
请求体::
|
||||
|
||||
data: {"event":"seat_ready","agent_name":"...","thread_id":"...","display_name":"...","description":"..."}
|
||||
data: {"event":"coordinator_ready","main_agent_name":"...","thread_id":"..."}
|
||||
data: {"event":"init_done","main_agent_name":"...","thread_ids":{...}}
|
||||
data: {"event":"error","detail":"..."} # on failure
|
||||
{
|
||||
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
|
||||
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
|
||||
"model": "deepseek-chat" // 可选,写入 coordinator agent
|
||||
}
|
||||
|
||||
Validation errors (missing agent_names, bad id pattern) still raise
|
||||
HTTPException synchronously so the connection fails fast with a real 4xx
|
||||
instead of a 200 SSE that just carries an error frame.
|
||||
SSE 事件(每条 ``data: {...}\\n\\n``)::
|
||||
|
||||
seat_ready — 某一席位 thread 已创建(``asyncio.as_completed`` 顺序,先完先发)
|
||||
coordinator_ready — 总控 agent + thread 已就绪
|
||||
init_done — ``thread_ids`` 全量映射,前端存 ``threadIds`` 并开始编排
|
||||
error — 失败详情(同步校验失败则直接 HTTP 4xx,不进 SSE)
|
||||
|
||||
协调智能体 SOUL 在运行时根据各席位 ``GET /api/agents/{id}`` 的 name/description 拼接,
|
||||
强调 ``agent_name`` 必须用英文 id(``roundtable-intelligence``),禁止带中文括号后缀。
|
||||
"""
|
||||
agent_names_in = payload.get("agent_names") or []
|
||||
agent_names: list[str] = [str(name).lower() for name in agent_names_in]
|
||||
@@ -329,6 +379,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
for sid, _, display_name, description in prepared
|
||||
]
|
||||
|
||||
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
||||
@@ -387,7 +438,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stream endpoint
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -398,7 +449,15 @@ async def _stream_upstream(
|
||||
thread_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> AsyncIterator[tuple[str | None, list[str] | None]]:
|
||||
"""Yield (raw_line, None) for each SSE line, then ``(None, captured_data_lines)`` once."""
|
||||
"""消费 ``/api/threads/{thread_id}/runs/stream``,逐行 yield 原始 SSE。
|
||||
|
||||
产出元组:
|
||||
- ``(line, None)`` — 可原样转发给浏览器(含 ``event:`` / ``data:`` 行);
|
||||
- ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表,
|
||||
供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。
|
||||
|
||||
日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。
|
||||
"""
|
||||
|
||||
url = f"{base}/api/threads/{thread_id}/runs/stream"
|
||||
captured: list[str] = []
|
||||
@@ -520,12 +579,19 @@ async def _leader_run(
|
||||
skill_stop_names: list[str],
|
||||
model_name: str,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
# Fine-grained timing for the leader path. The user reported the
|
||||
# coordinator "stalls before output" — we log each major await so we can
|
||||
# see whether the wait is in pre-broadcast scheduling, upstream stream
|
||||
# setup, first-token latency, or post-stream parsing.
|
||||
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
||||
|
||||
时序要点:
|
||||
1. **预广播**(与 LLM 并行):把用户/综合消息写入其它席位 thread,skip 总控自身;
|
||||
2. 跑总控 thread 的 stream,yield 每一行原始 SSE;
|
||||
3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表;
|
||||
4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
|
||||
|
||||
``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
|
||||
pre_broadcast_task = asyncio.create_task(
|
||||
_broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
|
||||
)
|
||||
@@ -594,12 +660,8 @@ async def _leader_run(
|
||||
leader_text = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
# Clarification branch: the coordinator called ask_clarification. The
|
||||
# ClarificationMiddleware intercepts with Command(goto=END) and stashes
|
||||
# the structured question in the tool_call args; sometimes a richer
|
||||
# follow-up AIMessage is also produced after the ToolMessage. Emit a
|
||||
# dedicated frame so the frontend can render it as a question instead of
|
||||
# treating an empty dispatch as "consensus reached".
|
||||
# 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
|
||||
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
@@ -616,15 +678,17 @@ async def _leader_run(
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
return
|
||||
|
||||
if not tool_calls:
|
||||
# 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
|
||||
yield {"status": [], "content": leader_text, "agent_name": agent_name}
|
||||
return
|
||||
|
||||
# Run all dispatch broadcasts concurrently — they target disjoint thread
|
||||
# 派活广播:告知各席位「总控给 X 派了什么活」,与即将发起的 special run 配合。
|
||||
# sets and we don't care about ordering, so serializing them just
|
||||
# multiplied latency before the frontend could start a sub-agent run.
|
||||
dispatched: list[list[str]] = []
|
||||
@@ -671,7 +735,13 @@ async def _special_run(
|
||||
skill_stop_names: list[str],
|
||||
model_name: str,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
# Same instrumentation as _leader_run so all 6 seats are diagnosable.
|
||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||
|
||||
- ``excluded_tools`` 禁用派活/澄清等,避免席位抢总控活;
|
||||
- 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文;
|
||||
- **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要,
|
||||
同时尽量让前端先收到结果关闭 loading。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
thread_id = agent_threads[agent_name]
|
||||
body = _run_payload(
|
||||
@@ -757,8 +827,29 @@ async def _special_run(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/multi-agent/run/stream — leader / special 单轮 SSE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/run/stream")
|
||||
async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""驱动总控或子智能体的一轮 LangGraph run。
|
||||
|
||||
请求体::
|
||||
|
||||
{
|
||||
"agent_type": "leader" | "special",
|
||||
"d_agent_thread_id": { "<agent_id>": "<thread_id>", ... }, // init_done.thread_ids
|
||||
"agent_name": "<本次运行的 agent_id>",
|
||||
"new_message": "<用户消息或派活 task 文本>",
|
||||
"model": "deepseek-chat",
|
||||
"skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration
|
||||
}
|
||||
|
||||
响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。
|
||||
客户端应使用 AbortSignal 支持「挂起协商」时取消 in-flight 请求。
|
||||
"""
|
||||
agent_type: str = str(payload.get("agent_type") or "leader").lower()
|
||||
agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
|
||||
agent_name: str = str(payload.get("agent_name") or "").lower()
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""圆桌规划 · Step 1→2 之间「智能体推荐」Gateway 路由。
|
||||
|
||||
前端页面:推荐弹窗 ``RecommendAgentsDialog``
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/recommend.ts``
|
||||
|
||||
职责
|
||||
----
|
||||
根据 Step 1 的 ``IntentSummary`` 与前端过滤后的候选池,驱动内置
|
||||
``roundtable-recommender``(SOUL 见 ``.deer-flow/agents/roundtable-recommender/``),
|
||||
输出 2–6 个 ``{agent_id, reason}`` 推荐席位。
|
||||
|
||||
设计为**无状态单次调用**:
|
||||
- **不暴露** ``/init``:每次请求内部 ``_create_thread``,推荐 agent 不需要历史轮次。
|
||||
- 候选池过滤由前端完成(剔除 ``roundtable-intent``、``roundtable-recommender``、
|
||||
``roundtable-coordinator-*``),本路由只做校验与 clamp。
|
||||
|
||||
SSE 协议
|
||||
--------
|
||||
1. 透传上游 LangGraph 流(前端用 ``parseRecommendStreamContent`` 把正文拆成
|
||||
``[RECOMMEND_READY]`` 前的 ``rationale`` 与 JSON 里的 ``picks``,实现卡片增量渲染)。
|
||||
2. 末尾状态帧::
|
||||
|
||||
{ "status": "done"|"asking"|"error", "content": "<全文>", "picks": [...]|null }
|
||||
|
||||
- ``done``:``picks`` 经 ``_validate_picks`` 后 ≥2 条且 id 均在候选池内;
|
||||
- ``asking``:未匹配 ``[RECOMMEND_READY]`` 或校验后不足 2 条(前端走 FALLBACK 默认 6 席);
|
||||
- ``error``:HTTP/未捕获异常。
|
||||
|
||||
与 SOUL 的契约
|
||||
--------------
|
||||
模型须在自然语言思路后输出::
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{"picks": [{"agent_id": "...", "reason": "..."}, ...]}
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_parse_last_messages,
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/recommend", tags=["recommend"])
|
||||
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
|
||||
# 从模型最终 AIMessage 全文提取 picks;非贪婪 ``.*?`` 只匹配第一个 json 块。
|
||||
_RECOMMEND_READY_PATTERN = re.compile(
|
||||
r"\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# 与 SOUL / 前端 ``RecommendAgentsDialog`` 一致:少于 2 无法成桌,多于 6 由网关截断。
|
||||
# 前端确认按钮另限制 2≤N≤8(用户可手动多选),后端此处只负责模型输出校验。
|
||||
_MIN_PICKS = 2
|
||||
_MAX_PICKS = 6
|
||||
|
||||
|
||||
def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> str:
|
||||
"""Render the intent + candidate pool into the single user message the
|
||||
recommender agent reads. Keep the format close to what the SOUL example
|
||||
shows so the model has a clean pattern to mirror."""
|
||||
objective = str(intent.get("objective") or "").strip() or "(未提供)"
|
||||
constraints = intent.get("constraints") or []
|
||||
assumptions = intent.get("assumptions") or []
|
||||
|
||||
constraint_lines = "; ".join(str(c).strip() for c in constraints if str(c).strip()) or "(无)"
|
||||
assumption_lines = "; ".join(str(a).strip() for a in assumptions if str(a).strip()) or "(无)"
|
||||
|
||||
candidate_lines: list[str] = []
|
||||
for c in candidates:
|
||||
cid = str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
cname = str(c.get("name") or "").strip()
|
||||
cdesc = str(c.get("description") or "").strip()
|
||||
if not cid:
|
||||
continue
|
||||
# Format: "- <id> (<name>): <description>" — matches the SOUL example.
|
||||
suffix = f" ({cname})" if cname else ""
|
||||
desc_part = f": {cdesc}" if cdesc else ""
|
||||
candidate_lines.append(f"- {cid}{suffix}{desc_part}")
|
||||
|
||||
candidate_block = "\n".join(candidate_lines) if candidate_lines else "(候选池为空)"
|
||||
|
||||
return (
|
||||
f"任务目标:{objective}\n"
|
||||
f"约束:{constraint_lines}\n"
|
||||
f"关键假设:{assumption_lines}\n\n"
|
||||
f"候选智能体池:\n{candidate_block}\n\n"
|
||||
f"请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。"
|
||||
)
|
||||
|
||||
|
||||
def _validate_picks(
|
||||
raw_picks: Any,
|
||||
candidate_ids: set[str],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Filter the model's picks against the candidate pool and clamp to 2-6.
|
||||
|
||||
Drops picks whose ``agent_id`` isn't in the candidate set (guards against
|
||||
model hallucination), de-duplicates, and trims to ``_MAX_PICKS`` if the
|
||||
model over-picked. Returns ``[]`` if validation leaves us with fewer than
|
||||
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
|
||||
defaults".
|
||||
"""
|
||||
if not isinstance(raw_picks, list):
|
||||
return []
|
||||
|
||||
picked: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_picks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = str(item.get("agent_id") or "").strip()
|
||||
reason = str(item.get("reason") or "").strip()
|
||||
if not aid or aid not in candidate_ids or aid in seen:
|
||||
continue
|
||||
picked.append({"agent_id": aid, "reason": reason})
|
||||
seen.add(aid)
|
||||
if len(picked) >= _MAX_PICKS:
|
||||
break
|
||||
|
||||
if len(picked) < _MIN_PICKS:
|
||||
return []
|
||||
return picked
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_recommend(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""Drive one recommendation turn via SSE.
|
||||
|
||||
Body::
|
||||
|
||||
{
|
||||
"intent": {"objective": "...", "constraints": [...], "assumptions": [...]},
|
||||
"candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...],
|
||||
"model": "deepseek-chat" # optional
|
||||
}
|
||||
|
||||
Stream: transparent upstream LangGraph frames, followed by one status
|
||||
frame::
|
||||
|
||||
{
|
||||
"status": "done" | "asking" | "error",
|
||||
"content": "<final AI text>",
|
||||
"picks": [{"agent_id": "...", "reason": "..."}] | null
|
||||
}
|
||||
|
||||
``done`` means picks were parsed and validated; ``asking`` means the
|
||||
marker was missing or validation left fewer than 2 valid picks (caller
|
||||
should apply its fallback list); ``error`` is upstream failure.
|
||||
"""
|
||||
intent = payload.get("intent") or {}
|
||||
candidates = payload.get("candidates") or []
|
||||
model_name = str(payload.get("model") or "deepseek-chat")
|
||||
|
||||
if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="intent.objective is required")
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
raise HTTPException(status_code=400, detail="candidates must be a non-empty array")
|
||||
|
||||
candidate_ids = {
|
||||
str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
for c in candidates
|
||||
if isinstance(c, dict)
|
||||
}
|
||||
candidate_ids.discard("")
|
||||
if not candidate_ids:
|
||||
raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id")
|
||||
|
||||
prompt = _build_prompt(intent, candidates)
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=RECOMMENDER_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=prompt,
|
||||
# 推荐只做判断:禁止澄清、派活、文件等,避免跑题或二次追问。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"ask_clarification",
|
||||
],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
yield line if line.endswith("\n") else line + "\n"
|
||||
elif done is not None:
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
last_ai_content = ""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
text = _flatten_content(msg.get("content", "")).strip()
|
||||
if text:
|
||||
last_ai_content = text
|
||||
break
|
||||
|
||||
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
|
||||
picks: list[dict[str, str]] = []
|
||||
match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("recommend picks JSON parse failed: %s", exc)
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
picks = _validate_picks(parsed.get("picks"), candidate_ids)
|
||||
|
||||
# asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
|
||||
status_value = "done" if picks else "asking"
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": status_value,
|
||||
"content": last_ai_content,
|
||||
"picks": picks if picks else None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("recommend stream unexpected failure")
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
+67
-64
@@ -6,6 +6,8 @@ from collections.abc import Callable
|
||||
from hashlib import sha256
|
||||
from typing import override
|
||||
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
@@ -67,7 +69,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
question = args.get("question", "")
|
||||
clarification_type = args.get("clarification_type", "missing_info")
|
||||
context = args.get("context")
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
|
||||
# Type-specific icons
|
||||
type_icons = {
|
||||
@@ -95,62 +97,63 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
# Add options in a cleaner format
|
||||
if options and len(options) > 0:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
}
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
"allow_multiple": resolve_allow_multiple(args),
|
||||
}
|
||||
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||
"""Handle clarification request and return command to interrupt execution.
|
||||
@@ -176,13 +179,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
|
||||
# Return a Command that:
|
||||
# 1. Adds the formatted tool message
|
||||
|
||||
+7
-3
@@ -15,6 +15,7 @@ def ask_clarification_tool(
|
||||
],
|
||||
context: str | None = None,
|
||||
options: list[str] | None = None,
|
||||
allow_multiple: bool | None = None,
|
||||
) -> str:
|
||||
"""Ask the user for clarification when you need more information to proceed.
|
||||
|
||||
@@ -50,8 +51,9 @@ def ask_clarification_tool(
|
||||
Best practices:
|
||||
- One question per ask_clarification call — keep each `question` focused on a single decision.
|
||||
- Be specific and clear in your question.
|
||||
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
|
||||
this lets the user multi-select in the UI instead of typing free text.
|
||||
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices.
|
||||
- By default the UI is **single-select** (click an option to send). Pass `allow_multiple=True`
|
||||
only when the user may legitimately pick several options together (e.g. multiple constraints).
|
||||
- Don't make assumptions when clarification is needed.
|
||||
- For risky operations, ALWAYS ask for confirmation.
|
||||
- After calling this tool (or several in one turn), execution will be interrupted automatically.
|
||||
@@ -60,7 +62,9 @@ def ask_clarification_tool(
|
||||
question: The clarification question to ask the user. Focused on a single decision; be specific and clear.
|
||||
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
|
||||
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
|
||||
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from; the UI supports multi-select.
|
||||
options: Optional list of choices. Present clear options for the user to choose from.
|
||||
allow_multiple: When True, the UI allows multi-select plus a submit button. When False or
|
||||
omitted, the UI is single-select and sends immediately on click (default).
|
||||
"""
|
||||
# This is a placeholder implementation
|
||||
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""ask_clarification 工具参数的共享解析(Harness + Gateway)。
|
||||
|
||||
圆桌 Step 1(``intent.py``)与 Step 2 总控(``multi_agent.py``)在 SSE 终态帧里
|
||||
都会带上 ``allow_multiple``,供前端 ``resolveAllowMultiple`` 决定选项卡片是
|
||||
「点击即发送(单选)」还是「多选 + 发送按钮」。
|
||||
|
||||
约定(与前端 ``roundtable-planning/lib/clarification.ts`` 一致):
|
||||
- 模型**未传** ``allow_multiple`` 或传 ``null`` → **False**(单选);
|
||||
- 仅当模型显式传 ``true`` 时才多选;
|
||||
- 不再根据 ``clarification_type`` 推断多选(旧逻辑已移除)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def coerce_bool(value: object | None, *, default: bool) -> bool:
|
||||
"""把 LLM tool args 里可能出现的 bool/数字/字符串统一成 bool。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "yes", "y", "on"):
|
||||
return True
|
||||
if lowered in ("false", "0", "no", "n", "off"):
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def resolve_allow_multiple(args: dict) -> bool:
|
||||
"""是否允许多选。仅 ``args["allow_multiple"]`` 显式为真时返回 True。"""
|
||||
if "allow_multiple" in args and args["allow_multiple"] is not None:
|
||||
return coerce_bool(args["allow_multiple"], default=False)
|
||||
return False
|
||||
Reference in new issue
Block a user