Merge remote-tracking branch 'origin/master' into ji

This commit is contained in:
吉鑫升 committed 2026-05-26 14:25:02 +08:00
commit b671b49725
17 files changed
+2784 -130

No files matched your search

+432 -11
View File
@@ -10,14 +10,16 @@
## 0. 速读 ## 0. 速读
- 三个圆桌 Router(`intent / recommend / multi_agent`)**全部走 HTTP loopback**:在同进程内通过 `httpx.AsyncClient` 调本机 `127.0.0.1` 的 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream`。 - 三个圆桌 Router(`intent / recommend / multi_agent`)**通过 httpx ASGITransport 进程内直调本机 ASGI app**,复用既有 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream` 路由——**不走 TCP / 不经过网络栈**。所有 client 都由 `multi_agent._make_loopback_client(request)` 工厂统一构造。
- **所有 loopback `httpx.AsyncClient` 必须 `trust_env=False`**。开发机若设了 `HTTP_PROXY` / `HTTPS_PROXY`,httpx 默认会把 loopback 也走代理 → 不可达 → 单次请求多出 40 秒超时(典型症状日志:`headers_in=42.160s`)。 - 历史包袱:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:<port>`,遇到开发机 `HTTP_PROXY` / `HTTPS_PROXY` 时仍可能把回环走代理(症状 `headers_in=42.160s`)。**改用 ASGITransport 后此问题不再存在**,相关 `trust_env=False` workaround 全部移除。
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。 - **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。
- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**——任何环境都必须把 `roundtable-intent` + `roundtable-recommender` 两个**功能型 agent** 持久化进发布物,否则 `/api/intent/stream` 报 500。**互联网 demo 还需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。
- **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。 - **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。 - **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。 - **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
- **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。 - **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。
- **后端 picks clamp 2–6,前端用户可手动加到 8**。 - **后端 picks clamp 2–6,前端用户可手动加到 8**。
- **不写死任何具体模型名**:`intent / recommend / multi_agent` 三个 router 的 `model_name = str(payload.get("model") or "")`,空串会被下游 `lead_agent._resolve_model_name()` 自动回退到 `config.yaml` 的 `models[0]`(与 `/api/ai-writing` 行为一致)。内网部署只需配 `config.yaml`,前后端均无需改硬编码。
--- ---
@@ -57,7 +59,7 @@ Step 3 最终方案展示
| 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 | | 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 |
| 3 | 3 | `artifacts.py`(通用) | 无新 agent | | 3 | 3 | `artifacts.py`(通用) | 无新 agent |
所有接口需 `Authorization: Bearer <JWT>`。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:`headers_in=40s+`,前端表现为「Step 1 init 卡顿 / Step 2 init 永远不发 seat_ready」)。 所有接口需 `Authorization: Bearer <JWT>`。Loopback 调用一律由 `_make_loopback_client(request)` 构造(`httpx.ASGITransport(app=request.app)`,base_url 占位 `http://loopback`),**不经 TCP**,因此不再受本机 `HTTP_PROXY` 干扰。
--- ---
@@ -497,7 +499,7 @@ async def _broadcast(client, base, headers, message, agent_threads, skip):
(None, captured) — 流结束,captured 是所有 data: 行的字符串列表 (None, captured) — 流结束,captured 是所有 data: 行的字符串列表
``` ```
日志 `[upstream-timing] <agent> headers_in=<>` / `first_line=<>`:排查首 token 慢、代理导致 loopback 超时(见 `trust_env=False`)。 日志 `[upstream-timing] <agent> headers_in=<>` / `first_line=<>`:排查上游首字节延迟;改 ASGITransport 后通常 `headers_in` < 10ms,长尾大概率出在路由 handler 内部(不再是网络/代理问题)。
`_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。 `_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。
@@ -591,14 +593,21 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力
## 9. 关键设计决策 ## 9. 关键设计决策
### 9.1 HTTP loopback + `trust_env=False` ### 9.1 ASGITransport 进程内 loopback(替代旧的 HTTP loopback)
三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。 三个 router 通过 `httpx.ASGITransport(app=request.app)` 直调本机 ASGI app,**不走 TCP / 不开 socket / 不经网络栈**,但仍然完整复用 FastAPI 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应。代价仅是路由层的少量 dispatch 开销(< 1ms),相对 LLM run 本身的数百 ms~数十秒可忽略。
**每个 `httpx.AsyncClient(timeout=None, trust_env=False)` 都是必须的**: 统一入口:`multi_agent._make_loopback_client(request)`,返回:
- `timeout=None` — SSE 长连接需要 ```python
- `trust_env=False` — 否则 httpx 会读取 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量,把 loopback `127.0.0.1` 也走代理。开发机常配的「校园网代理 / 公司代理」会把回环地址丢弃,导致单次请求 40+ 秒超时(日志典型症状:`headers_in=42.160s`) httpx.AsyncClient(
transport=httpx.ASGITransport(app=request.app),
base_url="http://loopback", # 占位 host,ASGITransport 不在意
timeout=None, # SSE 长连接仍需 None
)
```
**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:<port>` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。
### 9.2 协调智能体每次新建 ### 9.2 协调智能体每次新建
@@ -664,12 +673,249 @@ grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_l
`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型(见前端文档 §5.5 Persona 抽屉)。 `model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型(见前端文档 §5.5 Persona 抽屉)。
**绝不在路由里硬编码具体模型名**。三个 router 一律 `str(payload.get("model") or "")`:
- 前端不传 / 传空串 → 下游 `lead_agent._resolve_model_name("")` 命中 falsy 分支,返回 `app_config.models[0].name`(`config.yaml` 配置顺序里的第一项)
- 前端传了一个不在 `config.yaml` 里的 model 名 → 同样回退到首项,并 `logger.warning("Model 'x' not found ... fallback to ...")`
- 前端传了合法 model 名 → 直接使用
这是 `/api/ai-writing` 与圆桌三路由的统一行为,让本地 / 内网部署只需要改 `config.yaml`,不需要触前端常量或路由代码。详见 `deerflow/agents/lead_agent/agent.py:_resolve_model_name`。
### 11.3 新增席位 ### 11.3 新增席位
1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md` 1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md`
2. 前端候选池 / 推荐逻辑纳入该 id 2. 前端候选池 / 推荐逻辑纳入该 id
3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改** 3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改**
### 11.7 内置 agent 部署持久化(必读,关系到内网部署能否启动)
#### 11.7.1 问题背景
圆桌功能涉及的 agent 都靠**磁盘文件**激活(`.deer-flow/agents/<id>/{config.yaml,SOUL.md}`),但仓库根 `.gitignore` 第 64 / 74 行有:
```
offline-backend-20260512/backend/.deer-flow/*
```
整个 `.deer-flow/` 被 git 排除。新机器 `git clone` 后 `.deer-flow/agents/` 不存在 → 圆桌相关 agent 缺失 → 进入 `/api/intent/init` 后 `_create_thread` 本身能成功,但下一步 `/api/intent/stream` 真正调用 LangGraph 时会因为 agent 解析失败抛 500(典型表现:前端 Step 1 init 一次成功,下一句话发出去就 500)。
#### 11.7.1.1 哪些 agent **必须**打包,哪些**不需要**
圆桌涉及的 agent 分**两类**,部署侧需求完全不同:
| Agent | 角色 | 是否必须打包发布 |
|-------|------|------------------|
| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ **必须**(任何环境) |
| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ **必须**(任何环境) |
| `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 |
| `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 |
| `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 |
| `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 |
| `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 |
| `roundtable-summary` | 示例研讨席位(总结陈述) | ⚠️ 同上 |
| `roundtable-coordinator-*` | 协调智能体壳,每次会话动态新建 | ❌ **不打包**(运行时生成) |
**关键区分**:
- `roundtable-intent` / `roundtable-recommender` 是**圆桌功能本身的一部分**——没有它们,整个 Step 1 + 推荐流程跑不起来,跟客户业务无关
- 6 个 `roundtable-<领域>` 席位只是**互联网 demo 用的示例研讨者**,让访客打开页面就能看到完整三步流程。内网部署面对的是客户自己的业务场景,会把客户自有的领域 agent(行业专家、合规审查员、业务规则引擎……)注册成候选 agent;这时 6 个示例席位反而成噪声
**对应的部署组合**:
| 部署场景 | 打包内容 |
|----------|----------|
| **互联网 demo** | `roundtable-intent` + `roundtable-recommender` + **6 个示例席位** |
| **内网客户部署** | `roundtable-intent` + `roundtable-recommender`(仅此 2 个,**不带** 6 个示例席位) |
| **本地开发** | 全要(方便快速跑通) |
**内网部署的额外注意**(去掉 6 个示例席位时):
1. **客户自有的候选 agent 必须先存在**:内网部署落地时,客户的业务 agent 应该已经注册进 `.deer-flow/agents/`(手工建目录或通过 `POST /api/agents` 创建)。否则候选池为空,推荐拿不到任何 picks。
2. **前端 `FALLBACK_AGENT_IDS` 需要适配**:前端 `RecommendAgentsDialog.tsx` 里硬编码了 6 个示例席位 id 作为 fallback;若推荐失败回退路径会拿这 6 个去 `candidates` 里交集,内网部署该交集恒为空,**fallback 会失败**。建议要么:
- **方案 1**(最稳):内网客户的候选池足够丰富 + recommender SOUL 调好,让推荐成功率 ~100%,不依赖 fallback
- **方案 2**:把 `FALLBACK_AGENT_IDS` 改成从后端 `/api/agents` 取前 6 个 `published=true` 的候选 agent(动态 fallback)。改动点见前端文档 §6.2 `RecommendAgentsDialog`
3. **推荐 SOUL(`roundtable-recommender/SOUL.md`)通常无需改**:SOUL 本身没写死候选 id,只描述「从给的候选池里挑」;候选池由前端传入,模型读到客户自有 agent 后会照样工作。
下文给出三种修复方案,**推荐方案 A**(最简单,无需写代码)。
#### 11.7.2 方案 A:`.gitignore` 白名单(推荐)
让 git 只跟踪 **`roundtable-intent` + `roundtable-recommender` 两个功能型 agent** 的 `config.yaml` 与 `SOUL.md`,其它一律忽略。6 个示例席位**不进白名单**——互联网 demo 部署机如果需要它们,按 §11.7.2.A 在该分支单独追加白名单;内网部署机直接享受「干净的 `.deer-flow/`」。
**步骤 1:编辑仓库根 `.gitignore`**
在最后追加白名单段(位置很重要,**必须在 `offline-backend-20260512/backend/.deer-flow/*` 之后**,否则被前面的通配符吃掉):
```gitignore
# === 圆桌功能型 agent 跟随仓库发布(任何部署都需要) ===
# 父目录先 unignore,git 才能往下递归
!offline-backend-20260512/backend/.deer-flow/
!offline-backend-20260512/backend/.deer-flow/agents/
# 仅放行 2 个功能型 agent;6 个示例席位由互联网分支自行放行
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/SOUL.md
```
⚠️ **不要**直接写 `!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/**`——`git` 不会进入还在 ignore 的父目录,必须逐层 un-ignore,再精确放行文件。
**步骤 2:强制 add 并提交**
```bash
# 验证白名单生效
git check-ignore -v offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml
# 期望输出无任何匹配,或最后一条匹配是上面的 ! 行
# 加入索引(只加 2 个功能型 agent)
git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent
git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender
git status # 应只看到 2 × 2 = 4 个新文件,绝不出现 users/、threads/、data/、6 个示例席位
git commit -m "chore: 把圆桌功能型 agent(intent + recommender)纳入版本管理"
```
**步骤 3:部署机验证**
```bash
# clone 之后立刻
ls offline-backend-20260512/backend/.deer-flow/agents/
# 内网部署:应看到 roundtable-intent/ + roundtable-recommender/(+ 客户自有 agent)
# 互联网 demo:还应看到 6 个 roundtable-<领域>/ 示例席位
```
启动后端,前端打开圆桌规划页,Step 1 第一句话发出去能正常拿到 streaming 响应而不是 500,就说明 OK。
##### 11.7.2.A 互联网 demo 分支:追加 6 个示例席位
互联网 demo 维护一条独立分支(或独立 `.gitignore` 段,用 build flag 切),在白名单后追加:
```gitignore
# === 仅互联网 demo 需要的 6 个示例研讨席位 ===
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/SOUL.md
```
`git add` 对应 6 个目录、提交,互联网分支即拿到全部 8 个内置 agent。两条分支共享同一份 `roundtable-intent / roundtable-recommender`,SOUL 调整对两边都生效,避免双维护。
**优点**:
- 零代码改动;新机器 `git clone` 即可,部署文档不用单独说「记得拷一下 `.deer-flow/agents/`」
- 修改 SOUL 直接走 git 走 review 流程
- 多人协作时 SOUL 改动可见
**注意事项**:
- `git status` 检查时确保**只**有 16 个内置文件进了索引,**绝对不能**包括 `users/`、`threads/`、`data/*.db`、`roundtable-coordinator-*/` 这些运行时数据
- `roundtable-coordinator-mp*/` 这类历史会话残留目录会被 ignore 规则自动忽略(白名单没放行它们),无需手动清理
- 部署机如果之前手动建过 `.deer-flow/agents/roundtable-intent/`,`git pull` 会把仓库版本覆盖上去——这是期望行为
#### 11.7.3 方案 B:源码内置 + 启动时种子化
如果不想让 `.deer-flow/` 这种「运行时目录」出现在 git 里,可以把 SOUL/config 放进 `packages/harness/` 里作为只读资源,启动时检查 `.deer-flow/agents/<id>/` 不存在就从源拷过去。
**步骤大纲**:
1. 在 `packages/harness/deerflow/agents/builtin_personas/` 下建 8 个目录(`roundtable-intent/` 等),把 SOUL.md + config.yaml 搬过去
2. 在 `app/gateway/app.py` 的 `lifespan` 启动阶段新增 `_seed_builtin_roundtable_agents()`:
```python
def _seed_builtin_roundtable_agents() -> None:
from importlib.resources import files
src_root = files("deerflow.agents.builtin_personas")
dst_root = get_paths().agents_dir() # .deer-flow/agents/
for agent_id in BUILTIN_ROUNDTABLE_AGENT_IDS: # 8 个常量
dst = dst_root / agent_id
if dst.exists():
continue # 已有(可能是用户改过的),不覆盖
dst.mkdir(parents=True)
for fname in ("config.yaml", "SOUL.md"):
(dst / fname).write_bytes((src_root / agent_id / fname).read_bytes())
```
3. 紧接着调一次 `_sync_legacy_agents(store)`,把这些刚落地的目录 upsert 进 `agents` 表
4. 把 `BUILTIN_ROUNDTABLE_AGENT_IDS` 常量也导出给 `recommend.py` / `multi_agent.py` 共享
**优点**:
- `.deer-flow/` 仍然作为「运行时数据目录」语义干净
- 运维可以手动改 `.deer-flow/agents/roundtable-intent/SOUL.md` 临时调试,重启不被覆盖(`if dst.exists(): continue`)
- 升级时如果想强制刷新,加个 `--force-reseed-builtins` 启动参数即可
**缺点**:
- 多一段 bootstrap 代码 + 一份 `pyproject.toml` 的 `package-data` 声明(确保 SOUL.md 跟着 wheel 一起发布)
- 「现场改 SOUL 不被覆盖」既是优点也是坑——A 改了 SOUL 但忘了同步源码,下次新机器部署又回到旧版本
#### 11.7.4 方案 C:纯运维流程(最弱,不推荐)
不动代码、不动 .gitignore,发布说明里写一行:
> 部署前请将 `<开发机>:offline-backend-20260512/backend/.deer-flow/agents/` 整个目录 `scp` 到部署机同位置。
只在「不允许改仓库结构」或「初期试跑」时用。长期维护几乎一定会有人忘记拷而部署失败。
#### 11.7.5 方案选择决策
| 场景 | 推荐方案 |
|------|----------|
| 单仓库多人协作、SOUL 经常调整 | **A**(.gitignore 白名单) |
| SOUL 极少改,希望严格区分代码与运行时数据 | **B**(源码 + lifespan 种子化) |
| 一次性 POC / 即将切换到 B | C |
无论选哪种,部署机第一次启动后都建议跑这条 smoke test 验证:
```bash
curl -X POST http://127.0.0.1:8001/api/intent/init \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{}'
# 期望: 200 + {"status":"success","agent_id":"roundtable-intent","thread_id":"..."}
curl -X POST http://127.0.0.1:8001/api/intent/stream \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"thread_id":"<上一步返回的>","message":"测试一下"}'
# 期望: 200 + SSE 长连接,最后一帧 status 为 asking 或 clarification
# 若 500 → 看后端日志,大概率是 SOUL.md 找不到或 LLM 外网不通(看上一条排查项)
```
#### 11.7.6 推荐智能体(`roundtable-recommender`)与研讨席位的关系(注:完整定制流程见 §11.8)
推荐路由 `recommend.py` 用到的 `roundtable-recommender` 与「研讨席位」是**两件事**:
- **`roundtable-recommender`**:负责从候选池里挑 picks 的「推荐者」本身,是个 LLM agent,**功能型**,所有部署必须有
- **研讨席位**(无论是 6 个示例还是客户自有 agent):是「被挑选的候选」,前端 `filterRecommendCandidates` 过滤后送进 `/api/recommend/stream` 的 `candidates` 数组;recommender 在 SOUL 里看到候选池后才能输出 `[RECOMMEND_READY]` JSON
「推荐改成动态」改的是**从池子里挑哪几个**,不是「池子可以没有」。**池子的来源在两类部署里不同**:
| 部署 | 候选池来源 |
|------|------------|
| 互联网 demo | 仓库自带的 6 个 `roundtable-<领域>` 示例席位(+ 用户自建的) |
| 内网客户 | 客户自有的业务 agent(通过 `POST /api/agents` 或 `.deer-flow/agents/<id>/` 注册)|
`roundtable-recommender/SOUL.md` 本身没写死任何候选 id(只描述「从给的候选池里挑」),所以内网部署即使候选池从「6 个示例」换成「客户自有 agent」,**recommender 的 SOUL 无需改动**,候选池由前端传入。
**前端 fallback 行为差异**:
互联网 demo 因为有这 6 个内置示例席位垫底,推荐失败时 `FALLBACK_AGENT_IDS ∩ candidates` 还能拿到回退选项;**内网部署该交集恒为空**,回退路径失效——所以内网部署应通过「候选池足够丰富 + recommender SOUL 调好」让推荐成功率接近 100%,或改造前端 `FALLBACK_AGENT_IDS` 为动态从 `/api/agents` 取前 N 个(见前端文档 §6.2)。
### 11.4 新增 Step 1 工具 ### 11.4 新增 Step 1 工具
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。 若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。
@@ -682,6 +928,181 @@ grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_l
`_append_thread_message` 通过 `GET state → mutate → POST state` 实现,是为了与 LangGraph checkpoint 一致;如果将来 Gateway 提供 `POST /api/threads/{id}/messages/append` 这类原子接口,可换上,降低并发竞争(虽然圆桌当前不会并发广播同一 thread)。 `_append_thread_message` 通过 `GET state → mutate → POST state` 实现,是为了与 LangGraph checkpoint 一致;如果将来 Gateway 提供 `POST /api/threads/{id}/messages/append` 这类原子接口,可换上,降低并发竞争(虽然圆桌当前不会并发广播同一 thread)。
### 11.8 添加 / 替换任务推荐智能体(`roundtable-recommender`)
推荐智能体 id 在后端是**硬编码**常量(`recommend.py:63` → `RECOMMENDER_AGENT_ID = "roundtable-recommender"`),路由不接收 `agent_id` 参数。因此「添加推荐智能体」**不是新增一个 id**,而是落地这个固定 id 对应的文件(首次部署)或**就地改 SOUL**(定制行为)。
#### 11.8.1 何时需要动它
| 场景 | 是否需要动 SOUL |
|------|-----------------|
| 全新部署,目录还不存在 | ✅ 必须先把目录搭起来(§11.8.2) |
| 候选池从 6 个互联网示例换成客户自有 agent | ❌ **不用改**——SOUL 没写死候选 id,候选池由前端传入 |
| 想改变推荐风格(更激进 / 更保守 / 偏好某领域) | ✅ 改 SOUL(§11.8.4) |
| 想让模型偏好你新加的领域 agent | ⚠️ **优先**改新 agent 的 `description`(候选池 prompt 用的就是它),其次才改 recommender SOUL |
| 想改输出 JSON 格式 | ❌ **不要**——后端正则 `_RECOMMEND_READY_PATTERN` 写死了 `[RECOMMEND_READY]` + ```json ``` 块;改 SOUL 输出会让后端解析失败走 fallback |
#### 11.8.2 标准目录结构
```
.deer-flow/agents/roundtable-recommender/
├── config.yaml # id / name / description(只是 DB 元数据,不进 prompt)
└── SOUL.md # 行为契约,详见 11.8.4
```
**`config.yaml` 模板**(**id 必须严格等于 `roundtable-recommender`**,否则 `recommend.py` 找不到):
```yaml
description: 智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单
id: roundtable-recommender
name: 智能体推荐
```
字段说明:
- `id`:**不可改**,与 `RECOMMENDER_AGENT_ID` 常量绑死
- `name`:DB 显示名,候选池过滤时不参与(`filterRecommendCandidates` 按 id 排除)
- `description`:DB 元数据,不进 prompt;改了不会影响推荐效果
**SOUL.md** 内容见 §11.8.4 模板。新增之后按 §11.7.2 把这两个文件加入 git 白名单 + commit 即可。
#### 11.8.3 后端给推荐智能体喂的 prompt 长什么样
`recommend.py:_build_prompt()` 把前端传的 intent + candidates 拼成下面这段,作为**唯一一条 user message** 投递:
```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元; 工期 ≤ 6 个月; 必须满足等保 2.0
关键假设:现有系统具备基础数字化能力; 允许预定窗口停机
候选智能体池:
- roundtable-intelligence (情报收集): 检索同类历史案例、行业最佳实践与外部情报
- roundtable-solution-design (方案设计): 产出主备方案与关键路径
- my-cost-calc (成本测算): 基于预算和资源单价计算总成本
- ...
请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。
```
**关键事实**:
- **候选 agent 的 `name` 与 `description` 在 prompt 里就是它的「介绍」**——这是模型判断「这个 agent 适不适合本任务」的全部信息。客户自有 agent 想被推荐准确,`description` 必须写**任务导向**的能力描述,不要写 "由 XX 团队维护" 这种元信息
- **候选池由前端过滤后传入**,后端不读 DB;前端 `filterRecommendCandidates` 已剔除 `roundtable-intent / roundtable-recommender / roundtable-coordinator-*`
- SOUL 看不到「intent agent 上一轮说了什么 / 用户原始 message」,只看到结构化的 `objective / constraints / assumptions`——这是设计选择,让推荐稳定
#### 11.8.4 SOUL 设计要点(模板 + 注解)
SOUL **必须**满足三件事,其它都可定制:
1. **输出格式严格**:自然语言思路 → `[RECOMMEND_READY]` 单独成行 → ```json {"picks": [{"agent_id": "...", "reason": "..."}, ...]} ```
2. **`agent_id` 严格来自候选池**:模型造词出来会被 `_validate_picks` 过滤掉
3. **2 ≤ picks ≤ 6**:少于 2 → 整轮判 `asking` 走 fallback;多于 6 → 后端截断
下面是一份可直接用的最小 SOUL,重点行带注释(**真正落盘时去掉 `<!-- -->`**):
```markdown
# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成
「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,
挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。
## 核心职责
- 仔细阅读用户的任务目标与约束
- 浏览候选池中每个智能体的 `name` 与 `description`
- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个)
- 为每个推荐的智能体写一句**具体、可验证**的推荐理由
- 不推荐重复职能 / 不推荐与任务明显无关的智能体(宁缺毋滥)
## 数量约束
- **下限**:至少 2 个 / **上限**:最多 6 个
- 候选池不足 2 个时,有几个推几个
## 输出协议(后端用正则识别,严格执行)
1. 先用自然语言简短说明推荐思路(1-3 句话)
2. 输出标记 `[RECOMMEND_READY]` 单独成行
3. 紧接一个 ```json 代码块:
```json
{
"picks": [
{"agent_id": "<候选池中的 id>", "reason": "<一句推荐理由>"},
...
]
}
```
格式注意:
- `[RECOMMEND_READY]` **单独成行**,前后无其他字符
- `agent_id` **严格**来自候选池,不要造词
- `reason` **具体**,不要"很合适"这种空话
## 边界
- 不调用任何工具(已被后端 excluded_tools 屏蔽)
- 不评价候选 agent 优劣,只判断"是否适合本次任务"
- 不发起对话、不反问用户
## 示例
<!-- 示例对模型的引导能力远超规则文字,
建议示例的候选池里**包含一两个你部署里的客户自有 agent**,
让模型知道这种"非 roundtable-* 前缀"的 id 也是合法的。 -->
```
##### 定制方向
- **偏好新加的 agent**:在「示例」里把它列进候选池并出现在推荐结果中,模型会学到这种 id 也可推荐。比单纯在「核心职责」里加文字约束有效
- **行业偏向**:在「角色定位」加一句「优先考虑能直接对接 <行业> 合规要求的智能体」
- **激进 vs 保守**:调「数量约束」——例如「能挑出强相关的 3 个就不要凑 6 个」会让 picks 缩到 3-4 个
- **不要做的事**:
- ❌ 改 `[RECOMMEND_READY]` 为别的标记 → 解析失败
- ❌ 把 JSON 结构改成 `{"recommendations": [...]}` 之类 → 解析失败
- ❌ 让模型只输出 JSON 不带前面的自然语言 → 前端 `parseRecommendStreamContent` 把整段当 `rationale`,弹窗顶部为空白
#### 11.8.5 验证流程
部署完成后跑 smoke test 确认推荐路径通:
```bash
# 1. 确认 agent 文件落地
ls .deer-flow/agents/roundtable-recommender/
# 期望: SOUL.md config.yaml
# 2. 确认 DB 已 upsert(任意 /api/agents 请求会触发 _sync_legacy_agents)
curl -H "Authorization: Bearer <TOKEN>" http://127.0.0.1:8001/api/agents | \
python -c "import sys,json; ids=[a['id'] for a in json.load(sys.stdin).get('agents',[])]; print('roundtable-recommender' in ids)"
# 期望: True
# 3. 触发一次推荐(用最小 intent + 候选池)
curl -X POST http://127.0.0.1:8001/api/recommend/stream \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"intent": {
"objective": "测试推荐链路",
"constraints": ["响应时间 < 30s"],
"assumptions": []
},
"candidates": [
{"agent_id": "agent-a", "name": "A", "description": "做 X"},
{"agent_id": "agent-b", "name": "B", "description": "做 Y"},
{"agent_id": "agent-c", "name": "C", "description": "做 Z"}
]
}'
# 期望(SSE 流):最后一帧 data: {"status":"done","content":"...","picks":[...]}
# 若 picks 是空 → SOUL 没产生合法 [RECOMMEND_READY] / agent_id 不在候选池
# 若整轮 status=error → 看后端 _diagnose_exception 输出的 hint
```
#### 11.8.6 故障排查清单
| 现象 | 排查方向 |
|------|----------|
| 推荐永远走 fallback(status=asking) | 后端日志 grep `recommend stream`,看 `last_ai_content` 是不是不含 `[RECOMMEND_READY]`——SOUL 没遵守协议 |
| `picks` 数量被截断到 6 | 后端 `_MAX_PICKS=6` 故意限制,前端用户可再手加到 8 |
| 模型推荐了候选池里没有的 id | `_validate_picks` 会丢掉,但若有效项不足 2 个会整轮走 fallback;改 SOUL 强调「严格来自候选池」 |
| 推荐结果不偏向客户自有 agent | 先检查该 agent 的 `description` 是不是写得「任务导向」;再考虑在 SOUL 示例里加它 |
| 推荐稳定性差(同 intent 重跑结果飘) | 推荐 model 用低 temperature(在 `roundtable-recommender/config.yaml` 加 `model: <某个低 temp 模型>` 或直接前端选 deterministic 模型) |
| 前端弹窗顶部 rationale 区空白 | SOUL 让模型只输出 JSON 没有自然语言;恢复成「先思路 + 后 JSON」 |
--- ---
## 12. 故障排查 ## 12. 故障排查
@@ -691,7 +1112,7 @@ grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_l
| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) | | Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) |
| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) | | 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) |
| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 | | Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 |
| Step 2 init 完全卡住 ~40 秒 | `httpx` 走了 `HTTP_PROXY` | 确认 `trust_env=False` 仍在;grep `headers_in=` 看时长 | | Step 2 init 完全卡住(历史症状,ASGITransport 改造后已不应再出现) | 旧版 `httpx` 走 `HTTP_PROXY` 拦回环 → 40s 超时 | 自改 ASGITransport 起此现象物理上不可能复现;若仍卡,看后端日志栈、检查路由 handler 是否阻塞 |
| leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` | | leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` |
| dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 | | dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 |
| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 | | 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 |
@@ -708,7 +1129,7 @@ grep "dispatch failed" .runtime/logs/*.log
grep "upstream-timing" .runtime/logs/*.log grep "upstream-timing" .runtime/logs/*.log
grep "leader-timing" .runtime/logs/*.log grep "leader-timing" .runtime/logs/*.log
grep "special-timing" .runtime/logs/*.log grep "special-timing" .runtime/logs/*.log
grep "headers_in=" .runtime/logs/*.log # 走代理症状 grep "headers_in=" .runtime/logs/*.log # 上游首字节延迟;ASGITransport 改造后通常 < 10ms
``` ```
--- ---
+21 -13
View File
@@ -10,7 +10,7 @@
- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。 - **整页只有一个文件**:`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`)。 - **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **唯一默认模型**:`DEFAULT_MODEL = "deepseek-chat"`(页面常量,写在 `RoundtablePlanningPage.tsx`)。`useModels()` 只用于 Persona 抽屉的下拉选项。 - **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。 - **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。 - **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。 - **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
@@ -124,17 +124,17 @@ frontend-web/src/roundtable-planning/
[进入 Step 3] [进入 Step 3]
HighFidelityReport HighFidelityReport
consensusText ← lastLeaderContent consensusText ← lastLeaderContent
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
``` ```
--- ---
## 4. API 层 ## 4. API 层
### 4.1 `api/intent.ts` — Step 1 ### 4.1 `api/intent.ts` — Step 1
| 函数 | 说明 | | 函数 | 说明 |
|------|------| |------|------|
@@ -364,13 +364,22 @@ isOrchestrating = false
- 抽屉控件: - 抽屉控件:
- **System Prompt**:仅前端记录(后端覆写未开放) - **System Prompt**:仅前端记录(后端覆写未开放)
- **Temp**:仅前端记录 - **Temp**:仅前端记录
- **推理模型**:选项来自 `useModels()`,默认 `DEFAULT_MODEL` - **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel`
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`: - 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
- 把新的 `(seatId, model)` 写入 `agentConfigs` - 把新的 `(seatId, model)` 写入 `agentConfigs`
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡 - 追加一条「⚡ [模型偏好已保存] ...」对话气泡
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数 - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
### 5.6 草稿持久化(`utils/drafts.ts`) ### 5.6 全局模型选择器(右侧 sidebar 顶部)
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
### 5.7 草稿持久化(`utils/drafts.ts`)
- Key:`roundtable-planning:drafts:v1`(`localStorage`) - Key:`roundtable-planning:drafts:v1`(`localStorage`)
- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep` - 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
@@ -544,7 +553,6 @@ interface StreamMultiAgentResult {
| 常量 | 值 | 位置 | | 常量 | 值 | 位置 |
|------|----|------| |------|----|------|
| `DEFAULT_MODEL` | `"deepseek-chat"` | RoundtablePlanningPage |
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage | | `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) | | `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog | | `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -5,7 +5,7 @@ import { env } from "@/env";
* 素材的 recUuid 直接拼接在其后即为完整原文链接。 * 素材的 recUuid 直接拼接在其后即为完整原文链接。
*/ */
export const INTRANET_SOURCE_URL_BASE = export const INTRANET_SOURCE_URL_BASE =
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid="; "https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid-detail?recUuid=";
/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */ /** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */
export function buildIntranetSourceUrl(recUuid?: string | null): string { export function buildIntranetSourceUrl(recUuid?: string | null): string {
+4 -1
View File
@@ -26,7 +26,10 @@ export const DESENSITIZE_MAP: Record<string, string> = {
模拟: "MN", 模拟: "MN",
推演: "TY", 推演: "TY",
认知: "RZ", 认知: "RZ",
触及: "CJ" 触及: "CJ",
军: "J",
美军: "MJ",
台军: "TJ",
}; };
/** /**
@@ -19,7 +19,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps { interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void; onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean; disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */ /** workspace 入口:隐藏严格模式、目标读者 */
hideAdvancedFields?: boolean; hideAdvancedFields?: boolean;
} }
@@ -33,7 +33,7 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50'; 'focus-visible:ring-ring disabled:opacity-50';
const DEFAULT_KEYWORD_COUNT = 6; const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者'; const DEFAULT_AUDIENCE = '普通读者';
const MAX_INTENT_LENGTH = 500; const MAX_INTENT_LENGTH = 500;
@@ -50,7 +50,6 @@ export function AIWritingForm({
const [articleType, setArticleType] = useState('analysis'); const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800'); const [wordCountInput, setWordCountInput] = useState('800');
const [keywordCountInput, setKeywordCountInput] = useState('1');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]); const [models, setModels] = useState<ModelOption[]>([]);
@@ -110,23 +109,10 @@ export function AIWritingForm({
} }
} }
// 检索关键词数量限定 1~10,非法值回退默认 6
function handleKeywordCountBlur() {
const num = parseInt(keywordCountInput, 10);
if (isNaN(num)) {
setKeywordCountInput('1');
} else {
setKeywordCountInput(String(Math.max(1, Math.min(10, num))));
}
}
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!intent.trim()) return; if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
const keywordCount = hideAdvancedFields
? DEFAULT_KEYWORD_COUNT
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({ onSubmit({
userIntent: intent.trim(), userIntent: intent.trim(),
articleType, articleType,
@@ -134,7 +120,7 @@ export function AIWritingForm({
? DEFAULT_AUDIENCE ? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE, : audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount, wordCountTarget: wordCount,
keywordCount, keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined, modelName: modelName || undefined,
}); });
@@ -235,25 +221,6 @@ export function AIWritingForm({
{!hideAdvancedFields && ( {!hideAdvancedFields && (
<> <>
<div className="space-y-2">
<label className="text-sm font-semibold text-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground/80">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3"> <div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
<input <input
type="checkbox" type="checkbox"
@@ -158,7 +158,7 @@ export interface AIWritingRequest {
targetAudience: string; targetAudience: string;
wordCountTarget: number; wordCountTarget: number;
modelName?: string; modelName?: string;
/** 素材检索关键词数量(1~10,默认 6) */ /** 素材检索关键词数量(1~10,默认 1) */
keywordCount: number; keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */ /** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose'; writingMode: 'strict' | 'loose';
@@ -21,9 +21,80 @@ export interface IntentInitResponse {
thread_id: string; thread_id: string;
} }
/**
* Structured diagnostic payload surfaced by the backend whenever an intent
* route raises (init) or yields a final `status: "error"` frame (stream).
* Same shape across `init_intent` / `stream_intent`; renderer keys off `code`
* for quick visual triage, but also shows `hint` + `trace_tail` as a panel
* so deployment-time issues (config.yaml unreachable LLM, missing builtin
* agent dirs, loopback going through proxy) are debuggable from the UI alone
* without tail-ing backend logs.
*/
export interface IntentErrorDetail {
/** Stable short category — see backend `_diagnose_exception`. */
code: string;
/** One-line human-readable description (already includes exception type). */
message: string;
exception_type: string;
exception_repr: string;
/** Root of the `__cause__` chain when distinct from the outer exception. */
root_type?: string | null;
root_repr?: string | null;
/** Actionable hint in Chinese, keyed by `code`. May be null for unrecognized errors. */
hint?: string | null;
/** Last ~12 lines of the traceback for in-UI inspection. */
trace_tail?: string[];
/** Caller-supplied label (e.g. "intent_init"). */
context?: string | null;
}
/**
* Thrown by `initIntent` when the HTTP call fails. Carries the parsed
* `IntentErrorDetail` (if the backend emitted one) so the page can render
* a rich diagnostic panel instead of a one-line message.
*/
export class IntentInitError extends Error {
/** HTTP status (typically 500 for diagnosed failures, 401/403 for auth). */
readonly status: number;
/** Structured detail when the backend emitted `_diagnose_exception` output. */
readonly detail: IntentErrorDetail | null;
/** Raw body text when JSON parse failed (kept for completeness). */
readonly rawBody: string;
constructor(status: number, detail: IntentErrorDetail | null, rawBody: string) {
super(detail?.message || `init intent failed (${status}): ${rawBody}`);
this.name = "IntentInitError";
this.status = status;
this.detail = detail;
this.rawBody = rawBody;
}
}
function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
if (!value || typeof value !== "object") return null;
const obj = value as Record<string, unknown>;
if (typeof obj.code !== "string" || typeof obj.message !== "string") return null;
return {
code: obj.code,
message: obj.message,
exception_type: typeof obj.exception_type === "string" ? obj.exception_type : "",
exception_repr: typeof obj.exception_repr === "string" ? obj.exception_repr : "",
root_type: typeof obj.root_type === "string" ? obj.root_type : null,
root_repr: typeof obj.root_repr === "string" ? obj.root_repr : null,
hint: typeof obj.hint === "string" ? obj.hint : null,
trace_tail: Array.isArray(obj.trace_tail) ? obj.trace_tail.filter((l): l is string => typeof l === "string") : [],
context: typeof obj.context === "string" ? obj.context : null,
};
}
/** /**
* Create a fresh thread for an intent-clarification session. * Create a fresh thread for an intent-clarification session.
* The backend returns the built-in agent id so the frontend doesn't hardcode it. * The backend returns the built-in agent id so the frontend doesn't hardcode it.
*
* On failure throws `IntentInitError` whose `.detail` carries the backend's
* `_diagnose_exception` payload — the page should surface `detail.hint` /
* `detail.trace_tail` to help diagnose intranet deployment issues without
* shell access to the server.
*/ */
export async function initIntent(model?: string): Promise<IntentInitResponse> { export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
@@ -32,8 +103,18 @@ export async function initIntent(model?: string): Promise<IntentInitResponse> {
body: JSON.stringify({ model }), body: JSON.stringify({ model }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const raw = await res.text().catch(() => "");
throw new Error(`init intent failed (${res.status}): ${text}`); let detail: IntentErrorDetail | null = null;
try {
const parsed = JSON.parse(raw);
// FastAPI HTTPException(detail=dict) → { "detail": {...} }
if (parsed && typeof parsed === "object" && parsed !== null) {
detail = parseIntentErrorDetail((parsed as { detail?: unknown }).detail) || parseIntentErrorDetail(parsed);
}
} catch {
// body wasn't JSON — fall through with raw text only.
}
throw new IntentInitError(res.status, detail, raw);
} }
return (await res.json()) as IntentInitResponse; return (await res.json()) as IntentInitResponse;
} }
@@ -89,6 +170,12 @@ export interface StreamIntentResult {
* cards + custom input instead of treating the turn as plain text only. * cards + custom input instead of treating the turn as plain text only.
*/ */
clarification: ClarificationPayload | null; clarification: ClarificationPayload | null;
/**
* Populated only when status === "error" and the backend emitted a
* structured diagnostic (new in `_diagnose_exception`). UI uses this to show
* the same rich panel as init failures.
*/
errorDetail: IntentErrorDetail | null;
} }
/** /**
@@ -106,6 +193,7 @@ function isFinalIntentFrame(value: unknown): value is {
options?: unknown[]; options?: unknown[];
allow_custom?: boolean; allow_custom?: boolean;
allow_multiple?: boolean; allow_multiple?: boolean;
error_detail?: unknown;
} { } {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false; if (!("status" in value) || !("content" in value)) return false;
@@ -192,6 +280,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
content: "", content: "",
summary: null, summary: null,
clarification: null, clarification: null,
errorDetail: null,
}; };
while (true) { while (true) {
@@ -231,6 +320,9 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
if (parsed.summary && typeof parsed.summary === "object") { if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary; result.summary = parsed.summary as IntentSummary;
} }
if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
}
if (parsed.status === "clarification") { if (parsed.status === "clarification") {
const clarificationType = const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : ""; typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
@@ -26,7 +26,13 @@
* 7) **Persona 抽屉**改的 model 不会立即重启编排,下一轮该席位被派活时由 `runOrchestration` * 7) **Persona 抽屉**改的 model 不会立即重启编排,下一轮该席位被派活时由 `runOrchestration`
* 读 `agentConfigs[meta.speakerId]?.model` 传入 special。prompt / temp 当前**仅前端记录**。 * 读 `agentConfigs[meta.speakerId]?.model` 传入 special。prompt / temp 当前**仅前端记录**。
* *
* 8) **常量**:`DEFAULT_MODEL`、`MAX_CYCLES=8`、`COMMANDER_SEAT_ID=1`、`INTENT_READY_RE`。 * 8) **模型选择**:不再硬编码 "deepseek-chat"。全局选中 model 由 `selectedModel`
* state 维护(首次 `useModels()` 完成后初始化为 `availableModels[0].name`),
* 顶栏「模型」下拉可切换;Persona 抽屉的 per-seat 覆盖更高优先级。所有 API 调用
* 传 `selectedModel || undefined`,后端缺省由 `lead_agent._resolve_model_name()`
* 回退到 `config.yaml` 的 `models[0]`。内网部署只需配 `config.yaml` 即可。
*
* 9) **常量**:`MAX_CYCLES=8`、`COMMANDER_SEAT_ID=1`、`INTENT_READY_RE`。
* *
* 调试开关(默认开启): * 调试开关(默认开启):
* window.__MULTI_AGENT_INIT_TIMING__ // /api/multi-agent/init 各阶段耗时 * window.__MULTI_AGENT_INIT_TIMING__ // /api/multi-agent/init 各阶段耗时
@@ -51,7 +57,9 @@ import {
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { import {
initIntent, initIntent,
IntentInitError,
streamIntent, streamIntent,
type IntentErrorDetail,
type IntentStreamPhase, type IntentStreamPhase,
type IntentSummary, type IntentSummary,
} from "@/roundtable-planning/api/intent"; } from "@/roundtable-planning/api/intent";
@@ -189,7 +197,13 @@ function getAvatar(type: string, size: string = "h-8 w-8") {
} }
// --- Multi-agent backend wiring --- // --- Multi-agent backend wiring ---
const DEFAULT_MODEL = "deepseek-chat"; //
// 模型选择策略(与 /page/canvas/ai-writing 对齐,不再写死 "deepseek-chat"):
// - 全局选中模型由 selectedModel state 维护(首次 useModels 完成后初始化为 models[0].name)
// - 所有 API 调用传 model 时统一用 selectedModel || undefined;后端缺省由
// lead_agent._resolve_model_name() 回退到 config.yaml 的 models[0]
// - Persona 抽屉的 per-seat 覆盖优先(agentConfigs[seat]?.model > selectedModel)
// 内网部署只需在 config.yaml 配自家模型,前后端都不需要再硬编码具体模型名。
type RoleEntry = { type RoleEntry = {
id: number; id: number;
@@ -373,16 +387,24 @@ export default function RoundtablePlanningPage() {
// for each of them concurrently instead of only the most recently set one. // for each of them concurrently instead of only the most recently set one.
const [activeSpeakerSet, setActiveSpeakerSet] = useState<Set<number>>(new Set()); const [activeSpeakerSet, setActiveSpeakerSet] = useState<Set<number>>(new Set());
// Custom presets for agent model, weight, temperature and prompt editors // Custom presets for agent model, weight, temperature and prompt editors.
// model 字段留空,运行时由 runOrchestration 解析为
// agentConfigs[seat]?.model || selectedModel || undefined
// 即「席位覆盖优先,否则跟随全局选中模型,否则让后端回退到 config.yaml models[0]」。
const [agentConfigs, setAgentConfigs] = useState<{ [key: number]: { model: string; weight: number; prompt: string; temp: number } }>({ const [agentConfigs, setAgentConfigs] = useState<{ [key: number]: { model: string; weight: number; prompt: string; temp: number } }>({
2: { model: DEFAULT_MODEL, weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" }, 2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
3: { model: DEFAULT_MODEL, weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" }, 3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
4: { model: DEFAULT_MODEL, weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" }, 4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
5: { model: DEFAULT_MODEL, weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" }, 5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
6: { model: DEFAULT_MODEL, weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" }, 6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
7: { model: DEFAULT_MODEL, weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" }, 7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
}); });
// 全局选中模型(对应顶栏「模型」下拉)。
// 首次 useModels 加载完成后由下面的 useEffect 初始化为 availableModels[0].name,
// 用户可以通过下拉框切换;Persona 抽屉的 per-seat 覆盖更高优先级。
const [selectedModel, setSelectedModel] = useState<string>("");
// Message lists state representation to mimic image items & enable actual conversations // Message lists state representation to mimic image items & enable actual conversations
const [interactiveMessages, setInteractiveMessages] = useState([ const [interactiveMessages, setInteractiveMessages] = useState([
{ {
@@ -484,6 +506,8 @@ export default function RoundtablePlanningPage() {
const [intentThreadId, setIntentThreadId] = useState<string | null>(null); const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null); const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null); const [intentInitError, setIntentInitError] = useState<string | null>(null);
// 后端 _diagnose_exception 输出的结构化诊断,有就额外渲染 hint + trace_tail 面板。
const [intentInitErrorDetail, setIntentInitErrorDetail] = useState<IntentErrorDetail | null>(null);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false); const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null); const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null);
const [intentClarificationSelections, setIntentClarificationSelections] = useState< const [intentClarificationSelections, setIntentClarificationSelections] = useState<
@@ -561,6 +585,17 @@ export default function RoundtablePlanningPage() {
} }
}, []); }, []);
// 首次 /api/models 返回后,把 selectedModel 初始化为列表第一个 —— 与
// AIWritingForm 的行为对齐。若 selectedModel 已经被用户在下拉框里选过,不再覆盖。
// 若当前选中的 model 已不在 availableModels 里(后端配置变了),也回退到首项。
useEffect(() => {
if (availableModels.length === 0) return;
const stillExists = availableModels.some((m) => m.name === selectedModel);
if (!selectedModel || !stillExists) {
setSelectedModel(availableModels[0].name);
}
}, [availableModels, selectedModel]);
// Toast auto-dismiss logic // Toast auto-dismiss logic
useEffect(() => { useEffect(() => {
if (showToast) { if (showToast) {
@@ -837,7 +872,7 @@ export default function RoundtablePlanningPage() {
dAgentThreadId: ids, dAgentThreadId: ids,
agentName: coord, agentName: coord,
newMessage: nextLeaderTask, newMessage: nextLeaderTask,
model: DEFAULT_MODEL, model: selectedModel || undefined,
signal: leaderAborter.signal, signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName) => { onPhaseChange: (phase, toolName) => {
@@ -918,7 +953,8 @@ export default function RoundtablePlanningPage() {
dAgentThreadId: ids, dAgentThreadId: ids,
agentName: subName, agentName: subName,
newMessage: task, newMessage: task,
model: perSeatModel || DEFAULT_MODEL, // 优先级: 席位 Persona 覆盖 → 全局选中模型 → 让后端回退到 config.yaml models[0]
model: perSeatModel || selectedModel || undefined,
signal: subAborter.signal, signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text), onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName) => { onPhaseChange: (phase, toolName) => {
@@ -1026,7 +1062,7 @@ export default function RoundtablePlanningPage() {
const initRes = await initMultiAgent({ const initRes = await initMultiAgent({
agentNames: roundtableAgentIds, agentNames: roundtableAgentIds,
mainAgentName: coord, mainAgentName: coord,
model: DEFAULT_MODEL, model: selectedModel || undefined,
// As each sub-agent's thread comes online, flip every role that // As each sub-agent's thread comes online, flip every role that
// shares the agentId to "在线". Some seats reuse the same agent // shares the agentId to "在线". Some seats reuse the same agent
// (e.g. seat 8 reuses roundtable-summary) so we match on agentId // (e.g. seat 8 reuses roundtable-summary) so we match on agentId
@@ -1148,7 +1184,9 @@ export default function RoundtablePlanningPage() {
const role = roles.find(r => r.id === id); const role = roles.find(r => r.id === id);
const roleName = role?.name || "专家智能体"; const roleName = role?.name || "专家智能体";
const cfg = agentConfigs[id]; const cfg = agentConfigs[id];
const targetModel = cfg?.model || DEFAULT_MODEL; // Persona 抽屉里的「保存」反馈文字: 优先显示用户在抽屉里选的 model,
// 没选时退回全局 selectedModel,最后才显示「默认」二字。
const targetModel = cfg?.model || selectedModel || "默认模型";
const avatarColor = const avatarColor =
id === 2 ? "blue" id === 2 ? "blue"
@@ -1191,6 +1229,7 @@ export default function RoundtablePlanningPage() {
const myGen = ++step1InitGenRef.current; const myGen = ++step1InitGenRef.current;
setIntentReady(null); setIntentReady(null);
setIntentInitError(null); setIntentInitError(null);
setIntentInitErrorDetail(null);
setIntentThreadId(null); setIntentThreadId(null);
setPendingIntentClarificationMsgId(null); setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map()); setIntentClarificationSelections(new Map());
@@ -1206,7 +1245,9 @@ export default function RoundtablePlanningPage() {
]); ]);
(async () => { (async () => {
try { try {
const res = await initIntent(DEFAULT_MODEL); // initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
const res = await initIntent(selectedModel || undefined);
if (step1InitGenRef.current !== myGen) return; if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id); setIntentThreadId(res.thread_id);
setTimelineEntries([ setTimelineEntries([
@@ -1220,7 +1261,14 @@ export default function RoundtablePlanningPage() {
} catch (err) { } catch (err) {
if (step1InitGenRef.current !== myGen) return; if (step1InitGenRef.current !== myGen) return;
console.error("init intent failed:", err); console.error("init intent failed:", err);
setIntentInitError((err as Error).message); if (err instanceof IntentInitError) {
// 优先使用后端的可读 message;若 detail 在,渲染诊断面板。
setIntentInitError(err.detail?.message || err.message);
setIntentInitErrorDetail(err.detail);
} else {
setIntentInitError((err as Error).message);
setIntentInitErrorDetail(null);
}
} }
})(); })();
}, []); }, []);
@@ -1384,6 +1432,7 @@ export default function RoundtablePlanningPage() {
setIntentThreadId(draft.step1.intentThreadId); setIntentThreadId(draft.step1.intentThreadId);
setIntentReady(draft.step1.intentReady); setIntentReady(draft.step1.intentReady);
setIntentInitError(null); setIntentInitError(null);
setIntentInitErrorDetail(null);
setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages); setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages);
setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries); setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries);
step1InitRef.current = true; step1InitRef.current = true;
@@ -1540,7 +1589,7 @@ export default function RoundtablePlanningPage() {
const res = await streamIntent({ const res = await streamIntent({
threadId: intentThreadId, threadId: intentThreadId,
message: trimmed, message: trimmed,
model: DEFAULT_MODEL, model: selectedModel || undefined,
signal: aborter.signal, signal: aborter.signal,
onTextDelta: (delta) => { onTextDelta: (delta) => {
setInteractiveMessages(prev => setInteractiveMessages(prev =>
@@ -1593,6 +1642,15 @@ export default function RoundtablePlanningPage() {
return next; return next;
}); });
} else if (res.status === "error") { } else if (res.status === "error") {
// 同时把诊断信息写到顶部面板:toast 一闪而过,面板能稳定展示 hint + trace_tail
// 方便用户截图发给后端排查。
if (res.errorDetail) {
setIntentInitError(res.errorDetail.message || res.content);
setIntentInitErrorDetail(res.errorDetail);
} else {
setIntentInitError(res.content);
setIntentInitErrorDetail(null);
}
triggerToast(`❌ 智能体调用失败: ${res.content}`); triggerToast(`❌ 智能体调用失败: ${res.content}`);
} }
} catch (err) { } catch (err) {
@@ -1791,7 +1849,7 @@ export default function RoundtablePlanningPage() {
<RecommendAgentsDialog <RecommendAgentsDialog
open={isRecommendDialogOpen} open={isRecommendDialogOpen}
intent={intentReady} intent={intentReady}
model={DEFAULT_MODEL} model={selectedModel || undefined}
onCancel={() => setIsRecommendDialogOpen(false)} onCancel={() => setIsRecommendDialogOpen(false)}
onConfirm={handleConfirmAgents} onConfirm={handleConfirmAgents}
/> />
@@ -2441,9 +2499,40 @@ export default function RoundtablePlanningPage() {
</button> </button>
</div> </div>
{intentInitError && ( {intentInitError && (
<p className="mt-2 text-[11px] text-red-500 font-medium"> <div className="mt-2 rounded-lg border border-red-200 bg-red-50/70 p-3 text-[11px] leading-relaxed">
智能体初始化失败: {intentInitError} <div className="flex items-center gap-2 mb-1.5">
</p> <span className="font-bold text-red-700">智能体初始化失败</span>
{intentInitErrorDetail?.code && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded font-mono text-[10px]">
{intentInitErrorDetail.code}
</span>
)}
{intentInitErrorDetail?.exception_type && (
<span className="text-[10px] text-red-600 font-mono">
{intentInitErrorDetail.exception_type}
{intentInitErrorDetail.root_type && intentInitErrorDetail.root_type !== intentInitErrorDetail.exception_type
? ` → ${intentInitErrorDetail.root_type}`
: ""}
</span>
)}
</div>
<p className="text-red-700 font-medium break-all">{intentInitError}</p>
{intentInitErrorDetail?.hint && (
<p className="mt-1.5 text-red-800 bg-amber-50 border-l-2 border-amber-400 px-2 py-1 rounded-sm">
<span className="font-bold">💡 建议:</span> {intentInitErrorDetail.hint}
</p>
)}
{intentInitErrorDetail?.trace_tail && intentInitErrorDetail.trace_tail.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-red-600 hover:text-red-800 select-none text-[10px] font-bold">
展开 traceback 末段(共 {intentInitErrorDetail.trace_tail.length} 行)
</summary>
<pre className="mt-1 max-h-48 overflow-auto bg-slate-900 text-slate-100 text-[10px] leading-snug p-2 rounded font-mono whitespace-pre">
{intentInitErrorDetail.trace_tail.join("\n")}
</pre>
</details>
)}
</div>
)} )}
</div> </div>
)} )}
@@ -3091,7 +3180,8 @@ export default function RoundtablePlanningPage() {
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">推理本源模型</label> <label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">推理本源模型</label>
<select <select
value={agentConfigs[selectedAgentId]?.model || DEFAULT_MODEL} // 抽屉里显示的「当前模型」:Persona 覆盖优先,否则跟随全局 selectedModel
value={agentConfigs[selectedAgentId]?.model || selectedModel || ""}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; const val = e.target.value;
setAgentConfigs(prev => ({ setAgentConfigs(prev => ({
@@ -3106,7 +3196,7 @@ export default function RoundtablePlanningPage() {
disabled={modelsLoading} disabled={modelsLoading}
> >
{availableModels.length === 0 && ( {availableModels.length === 0 && (
<option value={DEFAULT_MODEL}>{DEFAULT_MODEL}</option> <option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)} )}
{availableModels.map((m) => ( {availableModels.map((m) => (
<option key={m.name} value={m.name}> <option key={m.name} value={m.name}>
@@ -3580,6 +3670,44 @@ export default function RoundtablePlanningPage() {
</> </>
) : ( ) : (
<> <>
{/* Panel 0: 推理模型选择器
─────────────────────────────────────────────────────
全局选中模型(selectedModel)影响 Step 1 / 推荐 / Step 2 init /
总控 leader / 子席位 special 所有 API 调用。Persona 抽屉里
per-seat 覆盖优先级更高。
选项来自 /api/models(由 config.yaml 配置),内网部署只需配自家模型。 */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
</div>
<select
id="global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
}
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
{/* Panel 1: 当前意图摘要 */} {/* Panel 1: 当前意图摘要 */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]"> <div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5"> <div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
@@ -0,0 +1,411 @@
"""圆桌功能型 agent 的「调用前自检 + 缺失自动补建」工具。
适用场景
--------
``roundtable-intent`` / ``roundtable-recommender`` 是圆桌 Step 1 + 推荐链路的
**功能型** 内置 agent,任何部署都必须存在。但仓库根 ``.gitignore`` 把整个
``.deer-flow/`` 目录排除,新机器 ``git clone`` 之后这两个目录**不会存在**,
直接调 ``/api/intent/stream`` 会因为 agent 解析失败抛 500。
本模块在三个路由入口(``intent.init``、``intent.stream``、``recommend.stream``)
被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板写回去。
``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert 进 DB,
所以这里只关心磁盘文件,不直接动 DB。
为什么嵌入字符串而不是用 importlib.resources 资源文件
------------------------------------------------------
SOUL 体量不大(两个加起来 ~200 行),嵌入到 Python 文件里是**单一可信源**——改
SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
``package-data`` 导致部署机拿不到。
"""
from __future__ import annotations
import logging
from typing import Iterable
import yaml
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
INTENT_AGENT_NAME = "任务理解"
INTENT_AGENT_DESCRIPTION = (
"任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的"
"「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入"
)
# 与 .deer-flow/agents/roundtable-intent/SOUL.md 保持完全一致;
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
INTENT_AGENT_SOUL = """# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。
---
## 1. 你的目标
通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用:
| 字段 | 含义 |
|------|------|
| `objective` | 一句话任务目标 |
| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) |
| `assumptions` | 你替用户做的合理假设(可空数组) |
**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。
---
## 2. 工作流(必须遵守)
```
用户描述任务
↓
信息够不够?
├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片)
↓
用户回复(可能是「我选择:…」或自由文本)
↓
重复 2–5 轮
↓
信息充分 → 自然语言汇总 + [INTENT_READY] + JSON
↓
停止追问(本轮及之后都不要再 ask_clarification)
```
**关键规则**:
- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。
- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。
- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。
---
## 3. 唯一工具:`ask_clarification`
当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。
### 3.1 参数说明
| 参数 | 必填 | 说明 |
|------|------|------|
| `question` | ✅ | 一个聚焦的问题,一次只问一个决策点 |
| `clarification_type` | ✅ | 见下表 |
| `context` | 推荐 | 为什么需要用户确认(1 句话) |
| `options` | 有离散选项时必填 | 字符串列表,如 `["≤500万", "500–1000万", "暂不确定"]` |
| `allow_multiple` | 可选 | 默认 **不传 / False = 单选**;仅当用户**确实可同时选多项**时传 `True` |
### 3.2 `clarification_type` 怎么选
| 类型 | 何时使用 | 选项 UI |
|------|----------|---------|
| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` |
| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 |
| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) |
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
| `suggestion` | 你有建议,需用户批准 | **单选** |
### 3.3 选项卡片(前端行为)
- 你提供 `options` 后,前端会渲染**可点击卡片**。
- **默认单选**:用户点一项即发送,无需再点「发送」。
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
### 3.4 调用示例
**缺信息 + 单选预算档:**
```
ask_clarification(
question="这次任务的预算大致在哪个区间?",
clarification_type="missing_info",
context="预算会影响后续圆桌的可行方案范围",
options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"]
)
```
**选部署方式(单选):**
```
ask_clarification(
question="优先采用哪种部署方式?",
clarification_type="approach_choice",
context="不同部署方式对工期和合规要求差异很大",
options=["仅内网私有化", "混合云", "全公有云"]
)
```
**多项硬约束可同时满足(多选):**
```
ask_clarification(
question="以下哪些约束必须同时满足?(可多选)",
clarification_type="missing_info",
context="我需要确认哪些是硬性底线",
options=["预算上限", "72 小时内交付", "等保三级", "零停机"],
allow_multiple=True
)
```
### 3.5 同轮多个独立问题
若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。
若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。
---
## 4. 信息充分性判定
满足 **全部** 条件即可结束澄清:
1. 能写出一句明确的 `objective`(用户希望实现 X)
2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一)
3. 没有会妨碍 Step 2 研讨的明显歧义
若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。
---
## 5. 结束协议:`[INTENT_READY]`
信息充分时,回复结构必须是:
1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review)
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
格式要求(**严格**):
```
[INTENT_READY]
```json
{
"objective": "用一句话写出用户最终要做的事",
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
"assumptions": ["假设 1", "假设 2"]
}
```
```
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
- 标记后**紧跟** ```json 代码块
- 字段名必须是 `objective` / `constraints` / `assumptions`
- `constraints` 至少 1 条;`assumptions` 可为 `[]`
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
---
## 6. 输出风格
- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短)
- 提问要**具体**(❓),汇总要**结构化**(✅ 📋)
- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总
---
## 7. 禁止事项
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
- ❌ 输出具体实施方案、架构图、代码
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
---
## 8. 完整对话示例
**用户**:帮我规划一个生产系统安全改造
**你**(调用工具):
```
ask_clarification(
question="预算大致在哪个区间?工期有没有硬性上限?",
clarification_type="missing_info",
context="这两项是后续圆桌排期的基础",
options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"]
)
```
**用户**:我选择:预算 300–800万 / 6–12个月
**你**(若还需确认合规):
```
ask_clarification(
question="是否需要满足特定合规标准?",
clarification_type="missing_info",
options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"]
)
```
**用户**:我选择:等保 2.0
**你**(汇总,不再调用工具):
✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。
📋
[INTENT_READY]
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
"""
RECOMMENDER_AGENT_ID = "roundtable-recommender"
RECOMMENDER_AGENT_NAME = "智能体推荐"
RECOMMENDER_AGENT_DESCRIPTION = (
"智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 "
"2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单"
)
RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。
## 核心职责
- 仔细阅读用户的任务目标与约束
- 浏览候选池中每个智能体的 `name` 与 `description`
- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个)
- 为每个推荐的智能体写一句**具体、可验证**的推荐理由——必须能回答"它能为这次任务做什么"
- 不推荐重复职能的智能体(如果两个智能体能力高度重叠,只选一个)
- 不推荐与任务明显无关的智能体(宁缺毋滥)
## 数量约束
- **下限**:至少推荐 2 个(单个智能体谈不上"圆桌")
- **上限**:最多推荐 6 个(超过 6 个会让协调智能体派活混乱、上下文膨胀)
- 候选池不足 2 个时,有几个推几个
## 输出协议
**你必须严格按下述格式输出**(后端用正则识别):
1. 先用自然语言简短说明你的推荐思路(1-3 句话,让用户能 review)
2. 然后输出标记 `[RECOMMEND_READY]` 单独成行
3. 紧接一个 ```json 代码块,内容为:
```json
{
"picks": [
{"agent_id": "<候选池中的智能体 id>", "reason": "<一句推荐理由>"},
...
]
}
```
格式注意:
- `[RECOMMEND_READY]` 标记必须**单独成行**,前后无其他字符
- 标记紧接着一个 ```json 代码块
- `picks` 数组长度严格在 [2, 6] 之间(候选池不足时按实际数量,但不要少于 2 除非候选池本身就 < 2)
- `agent_id` 必须**严格**来自候选池(不要造词)
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
## 输出风格
- 思路部分简短:用一句话概括你为什么挑这些(例如"按情报→设计→风险→执行的研讨节奏挑选了 4 位")
- 推荐理由要**具体到任务**,例如"负责检索同类合规改造案例,直接对应本次等保 2.0 的对标需求"
- 适度用 emoji:📋 表示开始汇总
## 边界
- 不调用任何工具(不联网、不执行命令、不读写文件)
- 不评价候选智能体的优劣(只判断"是否适合本次任务")
- 不发起对话、不反问用户——你只产出推荐结果
- 候选池里没有的智能体绝不出现在 `picks` 中
## 示例
**用户输入**(由后端拼装):
```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
候选智能体池:
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
```
**你的输出**:
按"情报→设计→风险→执行"的研讨节奏,挑选 5 位最贴合本次等保 2.0 改造任务的智能体,并加入成本测算覆盖预算硬约束。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
]
}
```
"""
_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str], ...] = (
(INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL),
(RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_NAME, RECOMMENDER_AGENT_DESCRIPTION, RECOMMENDER_AGENT_SOUL),
)
def _ensure_one(agent_id: str, name: str, description: str, soul: str) -> bool:
"""Create the on-disk files for one functional agent if missing.
Returns True if files were just created, False if the directory already
existed (and we didn't touch it — respect any local edits).
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
agent_dir.mkdir(parents=True, exist_ok=True)
# newline="\n" 强制 LF;否则 Windows 下 open() 把 \n 翻成 \r\n,跨 OS 部署时与
# Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。
config_yaml = yaml.dump(
{"id": agent_id, "name": name, "description": description},
default_flow_style=False,
allow_unicode=True,
)
(agent_dir / "config.yaml").write_bytes(config_yaml.encode("utf-8"))
(agent_dir / "SOUL.md").write_bytes(soul.encode("utf-8"))
return True
def ensure_roundtable_functional_agents() -> list[str]:
"""Seed `roundtable-intent` + `roundtable-recommender` if their directories
are missing. Idempotent — fast no-op when both exist.
Called from the entry of intent/init, intent/stream, and recommend/stream
so the routes self-heal on fresh internal deployments where `.deer-flow/`
is gitignored and the agent directories were never shipped.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
must not block the request; the downstream route will surface its own
actionable error if the agent really cannot be resolved.
"""
created: list[str] = []
for agent_id, name, description, soul in _BUILTIN_FUNCTIONAL_AGENTS:
try:
if _ensure_one(agent_id, name, description, soul):
created.append(agent_id)
except Exception:
logger.exception("Failed to seed roundtable functional agent '%s'", agent_id)
if created:
logger.info("Seeded missing roundtable functional agents: %s", ", ".join(created))
return created
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(a[0] for a in _BUILTIN_FUNCTIONAL_AGENTS)
@@ -63,13 +63,16 @@ from fastapi.responses import StreamingResponse
# Reuse the building blocks already in multi_agent.py — keeps both routers # Reuse the building blocks already in multi_agent.py — keeps both routers
# identical in connection handling, payload shape, and error mapping. # identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import ( from app.gateway.routers.multi_agent import (
_auth_headers, _auth_headers,
_create_thread, _create_thread,
_diagnose_exception,
_find_dispatch_message, _find_dispatch_message,
_find_trailing_ai_text, _find_trailing_ai_text,
_flatten_content, _flatten_content,
_loopback_base, _loopback_base,
_make_loopback_client,
_parse_last_messages, _parse_last_messages,
_run_payload, _run_payload,
_stream_upstream, _stream_upstream,
@@ -94,8 +97,9 @@ _INTENT_READY_PATTERN = re.compile(
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]: async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
"""创建任务理解会话用的空 thread。 """创建任务理解会话用的空 thread。
请求体:``{ "model": "deepseek-chat" }``(可选,当前 init 未把 model 写入 thread, 请求体:``{ "model": "<可选>" }``(可选,当前 init 未把 model 写入 thread,
实际模型在 ``/stream`` 的每次 run 里指定)。 实际模型在 ``/stream`` 的每次 run 里指定;缺省由 lead_agent 回退到
``config.yaml`` 的 ``models[0]``,前端通常传当前下拉框选中的 model 名)。
响应:: 响应::
@@ -104,15 +108,29 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位 与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位
thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。 thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。
""" """
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在,
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
ensure_roundtable_functional_agents()
base = _loopback_base(request) base = _loopback_base(request)
headers = _auth_headers(request) headers = _auth_headers(request)
# trust_env=False: this client only talks to the same-process gateway at # TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
# 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# on the dev machine) makes httpx route loopback calls through an upstream # 看不到逐 token 增量。
# proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per try:
# request before falling back. Symptom in logs: `headers_in=42.160s`. async with _make_loopback_client(request) as client:
async with httpx.AsyncClient(timeout=None, trust_env=False) as client: thread_id = await _create_thread(client, base, headers)
thread_id = await _create_thread(client, base, headers) except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise
except Exception as exc:
# 把根因暴露给前端而不是吞成裸 500——内网部署常见的「config.yaml 模型外网不通」
# / 「内置 agent 目录缺失」/ 「loopback 走代理」都能在前端面板直接看到。
# FastAPI 把 detail=dict 序列化为 {"detail": {...}};前端 initIntent
# 在 !res.ok 分支里读 detail.hint / trace_tail 渲染诊断面板。
diagnostic = _diagnose_exception(exc, context="intent_init")
logger.error("intent init failed: %s", diagnostic, exc_info=exc)
raise HTTPException(status_code=500, detail=diagnostic) from exc
return { return {
"status": "success", "status": "success",
"agent_id": INTENT_AGENT_ID, "agent_id": INTENT_AGENT_ID,
@@ -138,21 +156,25 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
""" """
thread_id = str(payload.get("thread_id") or "").strip() thread_id = str(payload.get("thread_id") or "").strip()
message = str(payload.get("message") or "").strip() message = str(payload.get("message") or "").strip()
model_name = str(payload.get("model") or "deepseek-chat") # 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
# 自己的本地模型即可工作。
model_name = str(payload.get("model") or "")
if not thread_id: if not thread_id:
raise HTTPException(status_code=400, detail="thread_id is required") raise HTTPException(status_code=400, detail="thread_id is required")
if not message: if not message:
raise HTTPException(status_code=400, detail="message is required") raise HTTPException(status_code=400, detail="message is required")
# 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。
ensure_roundtable_functional_agents()
base = _loopback_base(request) base = _loopback_base(request)
headers = _auth_headers(request) headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# trust_env=False: same-process loopback to 127.0.0.1 — honoring # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**,
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
# causes httpx to route loopback calls through an unreachable upstream async with _make_loopback_client(request) as client:
# proxy and wait ~40 seconds for timeout before falling back.
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
try: try:
body = _run_payload( body = _run_payload(
agent_name=INTENT_AGENT_ID, agent_name=INTENT_AGENT_ID,
@@ -266,11 +288,40 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
except HTTPException as exc: except HTTPException as exc:
logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail) logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None} # HTTPException 的 detail 可能本身就是 dict(上游已结构化),也可能是 str。
# 统一包装成与 _diagnose_exception 同构的字段,便于前端按一种模型渲染。
detail_payload: Any = exc.detail
error_detail: dict[str, Any]
if isinstance(detail_payload, dict) and "code" in detail_payload:
error_detail = detail_payload
else:
error_detail = {
"code": "http_exception",
"message": f"[{exc.status_code}] {detail_payload}",
"exception_type": "HTTPException",
"exception_repr": repr(exc),
"root_type": None,
"root_repr": None,
"hint": None,
"trace_tail": [],
"context": "intent_stream",
}
err = {
"status": "error",
"content": error_detail["message"],
"summary": None,
"error_detail": error_detail,
}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
except Exception as exc: except Exception as exc:
logger.exception("intent stream unexpected failure") diagnostic = _diagnose_exception(exc, context="intent_stream")
err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None} logger.error("intent stream failed: %s", diagnostic, exc_info=exc)
err = {
"status": "error",
"content": diagnostic["message"],
"summary": None,
"error_detail": diagnostic,
}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
return StreamingResponse( return StreamingResponse(
@@ -49,11 +49,14 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import re import re
import time import time
import traceback
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import httpx import httpx
from deerflow.config import get_app_config
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -63,13 +66,216 @@ router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Loopback 辅助函数(Gateway → Gateway,同进程) # 共享:异常诊断器(intent/recommend/multi-agent 共用)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, Any]:
"""把后端异常转成结构化诊断 dict,直接给前端展示用。
设计目标:让运维 / 前端开发不用翻 backend 日志就能看到根因和处置建议。
返回字段:
- code: 短稳定分类(loopback_connect_failed / upstream_connect_failed / ...)
- message: 一行人类可读
- exception_type: 表层异常类名
- exception_repr: repr(exc)
- root_type / root_repr: 沿 __cause__ 链找到的最深异常(若与表层不同)
- hint: 针对常见失败模式的可执行建议(中文)
- trace_tail: traceback 最后 ~12 行(避免响应体过大)
- context: 调用方标签(如 "intent_init")
"""
exc_type = type(exc).__name__
exc_repr = repr(exc)
# 沿 __cause__ 链找最深异常:httpx 经常把 ConnectError 包成 RemoteProtocolError 等。
root: BaseException = exc
seen: set[int] = {id(root)}
while getattr(root, "__cause__", None) is not None and id(root.__cause__) not in seen:
root = root.__cause__ # type: ignore[assignment]
seen.add(id(root))
code = "unexpected"
hint = "查看 trace_tail 最后一帧定位代码位置;若信息不足请到 backend 日志找完整 traceback。"
if isinstance(root, httpx.ConnectError):
target = str(root).strip().lower()
# loopback 走真 TCP 到 127.0.0.1:<gateway port>;失败 = Gateway 进程没起 / 端口被占。
# outbound 失败(其它 host)= LLM 提供商不可达。
if "127.0.0.1" in target or "localhost" in target or "loopback" in target:
code = "loopback_connect_failed"
hint = (
"本地 Gateway loopback 连接失败:确认后端进程正在监听配置的端口。"
"端口配置优先级(高→低):env DEER_FLOW_GATEWAY_PORT > config.yaml 的 "
"gateway.port > 默认 8001。若部署机改了端口,在 config.yaml 加 "
"`gateway:\\n port: <端口>` 即可。"
)
else:
code = "upstream_connect_failed"
hint = (
"外网/上游服务连接失败:大概率是 LLM 提供商不可达"
"(如 api.deepseek.com 在内网被防火墙拦截)。"
"把 config.yaml 里所有 base_url 改成内网可达的 LLM endpoint,"
"或加白名单/正向代理(HTTPS_PROXY)。"
)
elif isinstance(root, httpx.TimeoutException):
code = "upstream_timeout"
hint = (
"上游请求超时:网络抖动 / 模型推理过慢。"
"先 curl <base_url>/v1/models 确认可达,再看模型是否过载或 max_tokens 过大。"
)
elif isinstance(root, httpx.HTTPStatusError):
code = "upstream_http_error"
status = getattr(getattr(root, "response", None), "status_code", "?")
hint = (
f"上游接口返回 HTTP {status}:可能是 API key 失效、模型名不对、"
"或目标服务自身报错。查看 backend 日志里 response.text 拿详细错误体。"
)
elif isinstance(root, FileNotFoundError):
code = "file_not_found"
hint = (
f"找不到路径:{root}。常见原因:内置 agent 目录(.deer-flow/agents/<id>/)缺失,"
"或 SOUL.md 没拷过来;详见后端开发文档 §11.7。"
)
elif isinstance(root, KeyError):
code = "key_error"
hint = (
f"缺少 key:{root}。若 key 是 agent_name/agent_id 类:大概率内置 agent 目录缺失;"
"若是配置 key:检查 config.yaml schema。"
)
elif isinstance(root, ValueError) and "model" in str(root).lower():
code = "no_models_configured"
hint = "config.yaml 没有任何 models[],或所有条目都解析失败。检查 yaml 语法与 $ENV 引用。"
trace_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
trace_tail = "".join(trace_lines).splitlines()[-12:]
return {
"code": code,
"message": f"{exc_type}: {exc}" if str(exc) else exc_type,
"exception_type": exc_type,
"exception_repr": exc_repr,
"root_type": type(root).__name__ if root is not exc else None,
"root_repr": repr(root) if root is not exc else None,
"hint": hint,
"trace_tail": trace_tail,
"context": context or None,
}
# ---------------------------------------------------------------------------
# Loopback 辅助函数(Gateway → Gateway,同进程)
#
# 圆桌三路由(intent / recommend / multi_agent)需要从一个 FastAPI handler 里
# 调用本机 Gateway 的另一个 endpoint(`/api/threads`、`/api/agents`、
# `/api/threads/{id}/runs/stream`)。这里用真 TCP loopback 到本机的 127.0.0.1
# + Gateway 监听端口。
#
# ⚠️ 历史踩坑(三个) ⚠️
# 1. 直接走系统代理:开发机若设了 HTTP_PROXY / HTTPS_PROXY,httpx 默认会把
# 127.0.0.1 也走代理 → 上游代理不可达,40s 超时(典型日志 headers_in=42.160s)。
# → 修法:client 用 `trust_env=False`,强制忽略代理 env vars。
#
# 2. 用 request.url.netloc 拼 base URL:Nginx 反代部署时,客户端打到 nginx 的
# public host(如 example.com),nginx 把请求转给 gateway 时 Host header
# 通常是 `gateway:8001` 或 public host;后者 httpx 会真的去 DNS 解析,
# 在 gateway 容器内可能不可达 → 连接失败。
# → 修法:**写死 127.0.0.1**,完全无视 request.url.netloc。
#
# 3. 一度尝试用 httpx.ASGITransport(app=request.app) 进程内直调,看似一劳永逸,
# 但 httpx 0.28 的 ASGITransport 对 streaming response **整段 buffer**,
# 一直收齐所有 body chunks 才一次性把响应给消费方 → SSE 完全失效,前端表现
# 为"调度阶段很长 + 流式结果一次性出现"。已弃用。
# → 详见:git log 6e3a07b(那次改 ASGI 引入)及随后回退。
#
# 端口策略(优先级,从高到低):
# 1. 环境变量 DEER_FLOW_GATEWAY_PORT
# ── 部署侧紧急覆盖通道,适合 docker-compose / k8s 注入或临时调试
# 2. config.yaml 的 `gateway.port`
# ── 部署侧**推荐**的配置方式,改完不需要重启二进制(get_app_config 有 mtime
# 热加载,但仍建议跑一次 reload 以确保所有 worker 拿到新值)
# 3. request.url.port
# ── 浏览器直连 gateway:8001 时拿得到;走 Nginx 80/443 反代时会落到下一档
# 4. 默认 8001(与 `make dev` / `make gateway` / `scripts/start-all.sh` 写死的一致)
#
# 端口在哪里改(给运维 / 部署侧的提示):
# - yaml 配置:打开 config.yaml,加 / 改:
# gateway:
# port: 9999
# - 环境变量:
# export DEER_FLOW_GATEWAY_PORT=9999
#
# 修改这两个函数时务必跑一次 SSE 流式 smoke 验证(见 multi-agent-backend-dev.md
# §13 测试建议),确认 chunks 是逐个到达而不是一次喷出。
# ---------------------------------------------------------------------------
# 写死的最终 fallback。**不要**为了改端口而修改这个常量——优先用 config.yaml /
# env 这两层(它们在部署侧不需要改源码即可生效)。这个值只在 yaml 缺失 + env
# 没设 + 请求里也拿不到合法 port 时才会用到。
_DEFAULT_GATEWAY_PORT = 8001
def _resolve_gateway_port(request: Request) -> int:
"""按四档 fallback 解析 Gateway 监听端口。详见模块顶部「端口策略」。"""
# 第 1 档:环境变量。允许非法值(garbage),只警告并继续下一档,不抛异常。
env_port = os.environ.get("DEER_FLOW_GATEWAY_PORT", "").strip()
if env_port:
try:
port = int(env_port)
if 1 <= port <= 65535:
return port
raise ValueError("out of range")
except ValueError:
logger.warning(
"DEER_FLOW_GATEWAY_PORT=%r 不是合法端口,fallback 到 config.yaml / request / %d",
env_port,
_DEFAULT_GATEWAY_PORT,
)
# 第 2 档:config.yaml 的 gateway.port。GatewayConfig.port 的 pydantic 校验
# 已经保证它在 1-65535 范围内,无需再次校验。读 config 失败时降级,不阻断业务。
try:
configured = get_app_config().gateway.port
if configured:
return configured
except Exception as exc:
# 极端场景:config.yaml 不存在或解析失败。这种情况下 gateway 本身也起不来,
# loopback 端口"猜错"也已经无所谓。仅记 debug 日志方便排查。
logger.debug("read gateway.port from app config failed: %s", exc)
# 第 3 档:从当前请求拿 port。浏览器直连 gateway 时是真实监听端口;
# 走 Nginx 反代时通常是 80/443(public port),那就跳过,落到最终默认。
inbound_port = request.url.port
if inbound_port is not None and inbound_port not in (80, 443):
return inbound_port
# 第 4 档:写死默认。
return _DEFAULT_GATEWAY_PORT
def _loopback_base(request: Request) -> str: def _loopback_base(request: Request) -> str:
"""当前请求的 scheme+host,用于 httpx 打回本机 Gateway。""" """返回 `http://127.0.0.1:<port>`,供 loopback 调用拼 URL。
return f"{request.url.scheme}://{request.url.netloc}"
强制 127.0.0.1 是为了避免 Nginx 反代场景下读到经过 Host 改写的 netloc。
端口按 `_resolve_gateway_port` 的四档 fallback 解析。
"""
return f"http://127.0.0.1:{_resolve_gateway_port(request)}"
def _make_loopback_client(request: Request) -> httpx.AsyncClient:
"""构造同进程 loopback 用的 httpx.AsyncClient(真 TCP)。
所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用必须走这里,
保持配置一致。
- `trust_env=False`:**关键**,忽略 HTTP_PROXY / HTTPS_PROXY,防代理拦截 127.0.0.1
- `timeout=None`:SSE 长连接需要,LangGraph runs/stream 可能跑数十秒
- `request` 参数当前未被使用,保留是为了:(a) 未来若需要从 request 取部署
上下文(如多租户端口隔离)可以无侵入扩展;(b) 与调用方现有签名兼容。
"""
return httpx.AsyncClient(
timeout=None,
trust_env=False,
)
def _auth_headers(request: Request) -> dict[str, str]: def _auth_headers(request: Request) -> dict[str, str]:
@@ -303,7 +509,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
{ {
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id "agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成 "main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
"model": "deepseek-chat" // 可选,写入 coordinator agent "model": "<可选>" // 可选,写入 coordinator agent;不传由 lead_agent 回退到 config.yaml models[0]
} }
SSE 事件(每条 ``data: {...}\\n\\n``):: SSE 事件(每条 ``data: {...}\\n\\n``)::
@@ -335,11 +541,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# trust_env=False: same-process loopback to 127.0.0.1 — honoring # TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
# causes httpx to route loopback calls through an unreachable upstream async with _make_loopback_client(request) as client:
# proxy and wait ~40 seconds for timeout before falling back.
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
try: try:
# Per-seat prepare task — same body as before, but we no longer # Per-seat prepare task — same body as before, but we no longer
# await them with asyncio.gather. Instead, asyncio.as_completed # await them with asyncio.gather. Instead, asyncio.as_completed
@@ -456,7 +660,9 @@ async def _stream_upstream(
- ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表, - ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表,
供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。 供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。
日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。 日志 ``[upstream-timing]``:排查首 token 慢;headers_in 长尾若 >> 网络往返(loopback
本机往返通常 <10ms),怀疑 HTTP_PROXY 拦了回环——检查 client 是否走 _make_loopback_client
(trust_env=False)。
""" """
url = f"{base}/api/threads/{thread_id}/runs/stream" url = f"{base}/api/threads/{thread_id}/runs/stream"
@@ -843,8 +1049,8 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
"d_agent_thread_id": { "<agent_id>": "<thread_id>", ... }, // init_done.thread_ids "d_agent_thread_id": { "<agent_id>": "<thread_id>", ... }, // init_done.thread_ids
"agent_name": "<本次运行的 agent_id>", "agent_name": "<本次运行的 agent_id>",
"new_message": "<用户消息或派活 task 文本>", "new_message": "<用户消息或派活 task 文本>",
"model": "deepseek-chat", "model": "<可选>", // 不传由 lead_agent 回退到 config.yaml models[0]
"skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration "skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration
} }
响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。 响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。
@@ -855,7 +1061,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
agent_name: str = str(payload.get("agent_name") or "").lower() agent_name: str = str(payload.get("agent_name") or "").lower()
new_message: str = str(payload.get("new_message") or "") new_message: str = str(payload.get("new_message") or "")
skill_stop_names: list[str] = payload.get("skill_stop_names") or [] skill_stop_names: list[str] = payload.get("skill_stop_names") or []
model_name: str = str(payload.get("model") or "deepseek-chat") # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。
model_name: str = str(payload.get("model") or "")
if not agent_name or agent_name not in agent_threads: if not agent_name or agent_name not in agent_threads:
raise HTTPException( raise HTTPException(
@@ -870,11 +1078,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
headers = _auth_headers(request) headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# trust_env=False: same-process loopback to 127.0.0.1 — honoring # TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
# causes httpx to route loopback calls through an unreachable upstream async with _make_loopback_client(request) as client:
# proxy and wait ~40 seconds for timeout before falling back.
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
try: try:
if agent_type == "leader": if agent_type == "leader":
gen = _leader_run( gen = _leader_run(
@@ -47,11 +47,13 @@ import httpx
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import ( from app.gateway.routers.multi_agent import (
_auth_headers, _auth_headers,
_create_thread, _create_thread,
_flatten_content, _flatten_content,
_loopback_base, _loopback_base,
_make_loopback_client,
_parse_last_messages, _parse_last_messages,
_run_payload, _run_payload,
_stream_upstream, _stream_upstream,
@@ -151,7 +153,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
{ {
"intent": {"objective": "...", "constraints": [...], "assumptions": [...]}, "intent": {"objective": "...", "constraints": [...], "assumptions": [...]},
"candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...], "candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...],
"model": "deepseek-chat" # optional "model": "<可选>" # optional;不传由 lead_agent 回退到 config.yaml models[0]
} }
Stream: transparent upstream LangGraph frames, followed by one status Stream: transparent upstream LangGraph frames, followed by one status
@@ -169,7 +171,10 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
""" """
intent = payload.get("intent") or {} intent = payload.get("intent") or {}
candidates = payload.get("candidates") or [] candidates = payload.get("candidates") or []
model_name = str(payload.get("model") or "deepseek-chat") # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0]。内网部署可能没有 deepseek-chat,前端传当前选中的
# model 名即可,缺失也不会硬错。
model_name = str(payload.get("model") or "")
if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip(): if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip():
raise HTTPException(status_code=400, detail="intent.objective is required") raise HTTPException(status_code=400, detail="intent.objective is required")
@@ -185,16 +190,19 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
if not candidate_ids: if not candidate_ids:
raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id") raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id")
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,roundtable-recommender 目录可能不存在,
# 缺失则用嵌入模板补建,避免下游 lead_agent 解析失败抛 500。
ensure_roundtable_functional_agents()
prompt = _build_prompt(intent, candidates) prompt = _build_prompt(intent, candidates)
base = _loopback_base(request) base = _loopback_base(request)
headers = _auth_headers(request) headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# trust_env=False: same-process loopback to 127.0.0.1 — honoring # TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# causes httpx to route loopback calls through an unreachable upstream # 看不到逐 token 增量。
# proxy and wait ~40 seconds for timeout before falling back. async with _make_loopback_client(request) as client:
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
try: try:
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。 # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
thread_id = await _create_thread(client, base, headers) thread_id = await _create_thread(client, base, headers)
@@ -2,6 +2,13 @@
config_version: 8 config_version: 8
log_level: info log_level: info
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
gateway:
port: 8001
models: models:
- name: deepseek-chat - name: deepseek-chat
display_name: DeepSeek Chat display_name: DeepSeek Chat
@@ -1,5 +1,6 @@
from .app_config import get_app_config from .app_config import get_app_config
from .extensions_config import ExtensionsConfig, get_extensions_config from .extensions_config import ExtensionsConfig, get_extensions_config
from .gateway_config import GatewayConfig
from .memory_config import MemoryConfig, get_memory_config from .memory_config import MemoryConfig, get_memory_config
from .paths import Paths, get_paths from .paths import Paths, get_paths
from .skill_evolution_config import SkillEvolutionConfig from .skill_evolution_config import SkillEvolutionConfig
@@ -20,6 +21,7 @@ __all__ = [
"SkillsConfig", "SkillsConfig",
"ExtensionsConfig", "ExtensionsConfig",
"get_extensions_config", "get_extensions_config",
"GatewayConfig",
"MemoryConfig", "MemoryConfig",
"get_memory_config", "get_memory_config",
"get_tracing_config", "get_tracing_config",
@@ -13,6 +13,7 @@ from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_c
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig from deerflow.config.model_config import ModelConfig
@@ -122,6 +123,7 @@ class AppConfig(BaseModel):
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration") skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration") database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)")
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
@@ -0,0 +1,46 @@
"""Gateway 进程的本机绑定配置。
目前唯一字段是 ``port``——Gateway FastAPI 的监听端口。圆桌三路由
(intent / recommend / multi_agent) 内部用 loopback 调本机的其它 endpoint
(``/api/threads``、``/api/agents`` 等),需要知道这个端口才能拼出 base URL。
为什么放在配置里而不是写死
--------------------------
- 部署方常常会改 Gateway 监听端口(端口被占用、合规要求、容器化端口映射),
写死代码意味着每次改端口都得改源码 + 重新构建/分发,**改 config 不需要重启二进制**
- ``DEER_FLOW_GATEWAY_PORT`` 环境变量仍然是更高优先级的紧急覆盖通道,适合
临时调试或者 docker-compose / k8s 注入
修改方式(部署侧)
------------------
在 ``config.yaml`` 顶层加:
.. code-block:: yaml
gateway:
port: 9999 # 默认 8001;部署机改了 gateway 端口时改这里
只想临时改一次(不动 yaml)→ 设环境变量:
.. code-block:: bash
export DEER_FLOW_GATEWAY_PORT=9999
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class GatewayConfig(BaseModel):
"""Gateway 本机 loopback 端口配置。"""
port: int = Field(
default=8001,
ge=1,
le=65535,
description=(
"Gateway FastAPI 监听端口。圆桌内部 loopback 调用会拼到 "
"``http://127.0.0.1:<port>``。默认 8001(与 Makefile/scripts/start-all.sh 对齐)。"
),
)