接口联调

This commit is contained in:
董宏凯 committed 2026-05-23 23:05:01 +08:00
1 parent 43df9175df
commit 8673eb5eca
148 files changed
+2050 -37963

No files matched your search

+492
View File
@@ -0,0 +1,492 @@
# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现
> Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。
>
> 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。
---
## 1. 目标与边界
### ✅ 要做的事
- 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位
- 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream`
- 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper)
- 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration`
### ❌ 不做的事
- 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样)
- 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可)
- 不做时间线(timelineEntries)的后端持久化(前端临时状态够用)
- 不做"评分/置信度"等假指标
- 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL`
---
## 2. 数据流概览
```
[Step 1 入口]
↓
前端:POST /api/intent/init
← {agent_id: "roundtable-intent", thread_id: "..."}
用户输入第一句话:"帮我规划一个生产系统安全改造方案"
↓
前端:POST /api/intent/stream (SSE)
← (流式 AI 文本) "好的,预算大致是多少?"
← {status: "asking", content: "..."} ← 智能体反问中,需要用户继续
用户回:"500 万左右"
↓
前端再次 POST /api/intent/stream
← (流式) "明白,还有什么时间约束吗?"
← {status: "asking", ...}
...对话进行几轮 ...
用户:可以了
↓
前端再 POST /api/intent/stream
← (流式) "好,我理解的任务是: <汇总>"
← {status: "done", content: "<最终汇总文本>", summary: {...}} ← 信息充分,可进入 step 2
[Step 1 完成 → Step 2 init]
前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent()
```
---
## 3. 接口设计
跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。
### 3.1 `POST /api/intent/init`
**请求体**
```json
{
"model": "deepseek-chat" // 可选,默认全局 DEFAULT_MODEL
}
```
**响应 200**
```json
{
"status": "success",
"agent_id": "roundtable-intent",
"thread_id": "<langgraph_thread_id>"
}
```
**行为**:
1. 鉴权(透传 `Authorization`)
2. 调上游 `POST /api/threads` 建 1 个 thread
3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**)
**与 step 2 `/init` 的区别**:
- 没有"协调智能体"概念,只有 1 个 agent
- 没有 6 个 sub 席位,只有 1 个 thread
- 不需要传 `agent_names` 数组
- `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的)
### 3.2 `POST /api/intent/stream`
**请求体**
```json
{
"thread_id": "<from /init>",
"message": "用户这一轮要说的话",
"model": "deepseek-chat" // 可选
}
```
**响应**:`text/event-stream`,SSE 流式。
**SSE 帧组成**:
1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量
2. 流末追加一个**状态帧**(跟 step 2 同样模式):
```json
{
"status": "asking" | "done" | "error",
"content": "<本轮 AI 文本>",
"summary": {
"objective": "...", // 仅 status=done 时有: 任务目标
"constraints": ["...", ...], // 仅 status=done 时有: 约束清单
"assumptions": ["...", ...] // 仅 status=done 时有: 关键假设
}
}
```
**`status` 取值**:
- `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框
- `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent
- `"error"` —— 上游异常,`content` 里是错误信息
**判定 done/asking 的简单策略**(后端实现):
- 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧
- 否则一律是 `asking`
> 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取:
>
> ```
> [INTENT_READY]
> ```json
> {"objective": "...", "constraints": ["..."], "assumptions": ["..."]}
> ```
> ```
>
> 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。
---
## 4. 内置智能体配置
**位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/`
### 4.1 `config.yaml`
```yaml
id: roundtable-intent
name: 任务理解智能体
description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。
```
### 4.2 `SOUL.md`
```markdown
# 任务理解智能体
## 角色定位
你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。
## 核心职责
- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
- 不要追问已经清楚的事
- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
- 信息足够后,立即停止追问,产出结构化汇总
## 信息充分性判定
当满足以下条件时,认为信息已充分:
1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
2. 至少 1 条硬约束(预算/时间/合规/范围之一)
3. 没有明显的歧义点
## 信息汇总产出
信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):
```
[INTENT_READY]
```json
{
"objective": "用一句话写出用户最终要做的事",
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
"assumptions": ["假设 1", "假设 2", ...]
}
```
```
输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。
## 输出风格
- 一次回复不超过 3 句话
- 提问要具体,不要"还有什么补充吗"这种空问
- 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总)
## 边界
- 不调用 agent_orchestration(这是 step 2 的协调智能体的事)
- 不写文件、不做计算、不联网
- 用户消息歧义大时,优先调 ask_clarification 工具引导
```
### 4.3 注册方式
跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。
---
## 5. 后端实现要点
### 5.1 新建 router 文件
**位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py`
```python
"""任务理解智能体接口 - Step 1 的极简包装。
跟 multi_agent.py 同构但简化:
- 没有 broadcast(单 thread,无需广播)
- 没有派活逻辑
- 没有 leader/special 分支,只有一种调用方式
- 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking
"""
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
import httpx, json, re, asyncio, logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"])
# 复用 multi_agent.py 里已有的工具函数
from app.gateway.routers.multi_agent import (
_loopback_base, _auth_headers,
_create_thread, _stream_upstream, _parse_last_messages,
_flatten_content, _run_payload,
)
INTENT_AGENT_ID = "roundtable-intent"
_INTENT_READY_PATTERN = re.compile(
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
re.DOTALL,
)
@router.post("/init")
async def init_intent(request: Request, payload: dict = Body(...)) -> dict:
base = _loopback_base(request)
headers = _auth_headers(request)
async with httpx.AsyncClient(timeout=None) as client:
thread_id = await _create_thread(client, base, headers)
return {
"status": "success",
"agent_id": INTENT_AGENT_ID,
"thread_id": thread_id,
}
@router.post("/stream")
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
thread_id = str(payload.get("thread_id") or "").strip()
message = str(payload.get("message") or "").strip()
model_name = str(payload.get("model") or "deepseek-chat")
if not thread_id or not message:
raise HTTPException(status_code=400, detail="thread_id and message required")
base = _loopback_base(request)
headers = _auth_headers(request)
async def gen():
async with httpx.AsyncClient(timeout=None) as client:
try:
body = _run_payload(
agent_name=INTENT_AGENT_ID,
model_name=model_name,
thread_id=thread_id,
new_message=message,
excluded_tools=["web_search", "present_files",
"view_image", "agent_orchestration"],
skill_stop_names=[],
)
captured = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield (line if line.endswith("\n") else line + "\n")
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
last_ai = {}
for m in reversed(messages):
if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_ai = m
break
content = _flatten_content(last_ai.get("content", ""))
# 检测 [INTENT_READY] 标记
match = _INTENT_READY_PATTERN.search(content)
if match:
try:
summary = json.loads(match.group(1))
except json.JSONDecodeError:
summary = None
frame = {"status": "done", "content": content, "summary": summary}
else:
frame = {"status": "asking", "content": content}
yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
except HTTPException as exc:
yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.exception("intent stream failure")
yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
```
### 5.2 在 `app/gateway/app.py` 注册
```python
from app.gateway.routers import intent
app.include_router(intent.router)
```
---
## 6. 前端实现要点
### 6.1 新增 API helper
**位置**:`frontend-web/src/roundtable-planning/api/intent.ts`
```ts
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface IntentInitResponse {
status: "success";
agent_id: string;
thread_id: string;
}
export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`);
return res.json();
}
export interface IntentSummary {
objective: string;
constraints: string[];
assumptions?: string[];
}
export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
signal?: AbortSignal;
onTextDelta?: (text: string, id: string) => void;
}
export interface StreamIntentResult {
status: "asking" | "done" | "error";
content: string;
summary: IntentSummary | null;
}
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
// 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
// {status, content, summary?} 格式。
// ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
}
```
### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染
替换 `handleSendMessage`:
```ts
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
useEffect(() => {
if (currentStep === 1 && !intentThreadId) {
initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id));
}
}, [currentStep]);
const handleSendMessage = async () => {
if (!inputText.trim() || !intentThreadId) return;
const userText = inputText.trim();
// (push user msg to interactiveMessages 略)
setInputText("");
// 占位 agent 气泡
const agentMsgId = pushAgentPlaceholder("任务理解智能体");
const res = await streamIntent({
threadId: intentThreadId,
message: userText,
model: DEFAULT_MODEL,
onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta),
});
finalizeAgentMsg(agentMsgId, res.content);
if (res.status === "done" && res.summary) {
setIntentReady(res.summary);
triggerToast("✅ 任务理解完成,可以进入圆桌研讨");
}
};
```
### 6.3 "进入 step 2"按钮的联动
- 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`)
- 改造:只有 `intentReady !== null` 时该按钮才 enabled
- 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑)
```ts
const buildInitialIntent = useCallback(() => {
if (intentReady) {
return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
}
// fallback: 旧逻辑保留兜底
const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
const userIntent = lastUserMessage?.content ?? "请围绕给定任务展开圆桌研讨";
const constraints = `预算硬约束:${budgetLimit} 万元`;
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
}, [interactiveMessages, budgetLimit, intentReady]);
```
---
## 7. 实施清单
| 优先级 | 项 | 文件 |
|---|---|---|
| P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` |
| P0 | 新 router | `backend/app/gateway/routers/intent.py` |
| P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) |
| P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
| P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) |
| P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 |
| P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) |
| P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 |
---
## 8. 与 step 2 的衔接
```
step 1 输出: intentReady.objective + intentReady.constraints
↓
step 2 入口: buildInitialIntent() 拼成 leader 的 new_message
↓
step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了),
除非模型遇到完全意料之外的歧义
↓
研讨更聚焦,减少 step2 阶段的 clarification 轮次
```
副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。
---
## 9. 路径速查
| 资源 | 位置 |
|---|---|
| Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) |
| 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
| 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
| 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) |
---
## 10. 验收清单
- [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id
- [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout)
- [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary
- [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态
- [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话)
- [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)
+264
View File
@@ -0,0 +1,264 @@
# 多智能体圆桌 · Step 3 · 接口与对接说明
> Step 3(`/page/roundtable/planning` 的"最终方案展示"页)对应 `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx`。当前页面是产品早期的"展厅效果",绝大多数内容是硬编码 mock。本文档梳理**真正需要的数据**、**用哪些接口**、**前后端怎么对接**。
---
## 1. 改造方向(在做什么,不做什么)
### ✅ 保留并对接真实数据
| 区块 | 现状 | 改造后 |
|---|---|---|
| 标题栏 + 生成时间 | 硬编码"2026-05-21 11:01:12" | 用本地 `Date.now()` 或 init/最后一次 leader 完成时刻 |
| "已达成共识"徽章 | 静态显示 | step2 自然收敛(`leaderRes.dispatched.length === 0`)后才显示 |
| **总控最终结论** | 完全是 mock 文本 | 取最后一次 `leaderRes.content`(已经在 step2 拿到了) |
| **各席位最终交付(折叠卡片)** | 当前 step3 没渲染 | 取每个 sub 线程的最后一条 AIMessage 文本 |
| **生成的报告文件清单** | 5 张假"档案" | 真实列出 `outputs/` 目录里的所有产出(markdown / pdf / csv 等) |
| **文件下载** | 假 toast | 用现有 `GET /api/threads/{tid}/artifacts/{path}` |
| "重新起草新规划" | 调用 `setCurrentStep(1)` 重置 | **保留**,含义就是回到 step1 重新开始 |
### ❌ 不做(直接删 / 不接口)
| 区块 | 原因 |
|---|---|
| 4 张 metric 卡片(设计全要素通过率 99.4% / Tier III+ / 终审共识度 42 轮研商 / 90.3分)| 全是按 `budgetLimit` 编出来的"展厅指标",没有真实数据源 |
| 三阶段推进路线图(0-24h / 24-60h / 60-72h 三张卡)| 假设业务必然是 72 小时交付,写死场景,不通用 |
| "采纳理由与最终裁定判词"段 | 整段都是模板话术,没接口能算出"90.3 分总评" |
| "12 轮极限对抗研商" 数据 | 当前没记录"轮次"语义,临时就用 leader 实际轮数代替即可,不值得为它出接口 |
| "签发起草成果并投产运行" 按钮 | 无对应业务动作(不是要部署、不是要审批),删掉 |
| "执行摘要" 段写死的 3 个 bullet | 模板话术,跟实际任务无关 |
最终 step3 页面只保留四块:**头部状态 + 总控结论 + 席位交付列表 + 文件下载清单**,再加底部"重新起草"。
---
## 2. 数据来源全景
```
┌───────────────────────────────────────────────────────────┐
│ step2 全程结束时,前端手里已经有的本地状态 │
├───────────────────────────────────────────────────────────┤
│ coordinatorName: "roundtable-coordinator-xxx" │
│ threadIds: { [agent_name]: thread_id, ... } │ ← /init 返回
│ step2RoundtableDialogues: [...所有气泡, 含每个 sub 最终 │
│ 交付的 content...] │
│ leaderRes.content (最后一次 leader 的 final text) │
└───────────────────────────────────────────────────────────┘
后端权威数据(需要时从后端拉):
- 每个 sub 线程的完整 state → GET /api/threads/{tid}/state
- 单个 outputs 文件内容/下载 → GET /api/threads/{tid}/artifacts/{path}
- outputs 目录文件清单 ❗暂无 → 新增接口(见 §3)
```
**关键判断**:席位最终交付文本 + 总控结论文本 **已经在 step2 内存里**,无需重新调接口。文件清单是**目前唯一缺失**的数据源。
---
## 3. 接口需求
### 3.1 新增(**必需**):列出某 thread 的产出文件
```
GET /api/multi-agent/sessions/artifacts?thread_id=<sub_thread_id>
```
或者更通用、放进 artifacts 路由下:
```
GET /api/threads/{thread_id}/artifacts
```
**为什么需要**:现有 artifacts endpoint 只能"按已知路径下载文件",没有"列出目录"功能。Step 3 需要知道 sub-agent 写到 `/mnt/user-data/outputs/` 下哪些文件,才能在前端列出可下载清单。
**响应**:
```json
{
"thread_id": "<sub_thread_id>",
"files": [
{
"path": "mnt/user-data/outputs/方案设计报告_生产系统安全改造技术方案.md",
"name": "方案设计报告_生产系统安全改造技术方案.md",
"size_bytes": 12480,
"mime_type": "text/markdown",
"modified_at": "2026-05-23T14:32:30.123Z"
},
...
]
}
```
**实现建议**:
- 列 `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` 下的文件
- 鉴权复用 `@require_permission("threads", "read", owner_check=True)`
- 路径要做 traversal 保护(虚拟路径仅限 `mnt/user-data/outputs/`)
### 3.2 新增(**可选,体感优化**):一次性聚合 step3 数据
```
POST /api/multi-agent/sessions/summary
Body: {
"main_agent_name": "roundtable-coordinator-xxx",
"thread_ids": { "<agent_name>": "<thread_id>", ... }
}
```
**为什么可选**:不加这个接口的话,前端需要为每个席位发一次 `GET /state` + 一次 `GET /artifacts`(共 7-13 次 round-trip)。加了这个接口,**一次返回所有数据**。
**响应**:
```json
{
"main_agent_name": "roundtable-coordinator-xxx",
"coordinator": {
"thread_id": "...",
"final_text": "...", // 最后一条 AIMessage content
"dispatch_rounds": 3 // leader 派活的轮数(可选展示)
},
"seats": [
{
"agent_name": "roundtable-intelligence",
"thread_id": "...",
"display_name": "情报收集",
"final_text": "...", // 最后一条 AIMessage content
"files": [ // 该席位写到 outputs/ 的文件
{ "path": "...", "name": "...", "size_bytes": ..., "mime_type": "..." }
]
},
...
]
}
```
**取舍**:
- 优点:1 次请求 vs 13 次;体验更顺
- 缺点:等于把 3.1 + N 次 state 读放到一个 endpoint,实现稍复杂
- 推荐:**先做 3.1,3.2 暂缓**;前端用现有 endpoint 串起来够用
### 3.3 复用(**无需改动**)
| Endpoint | 用途 |
|---|---|
| `GET /api/threads/{tid}/state` | 取席位 / 总控线程的完整状态,提取最后一条 AIMessage content |
| `GET /api/threads/{tid}/artifacts/{path}` | 点击文件名预览或下载(已有 `?download=true` 支持) |
> Note: 这两个 endpoint 已经在 `app.gateway.routers.threads` / `app.gateway.routers.artifacts` 里实现,前端 `fetch-client` 也有现成调用方式。
---
## 4. 前后端对接流程
### 4.1 进入 step3 时
```ts
// 触发条件:runOrchestration 自然结束(leaderRes.dispatched.length === 0
// 且无 clarification),用户点"达成共识生成全案成果"按钮 / 自动跳转。
// 前端手里已有:threadIds, coordinatorName, 最后的 leaderRes
// 1. 总控结论文本 — 已在内存,直接用
const consensusText = lastLeaderRes.content;
// 2. 各席位交付 — 已在 step2RoundtableDialogues 里,过滤"子智能体交付"的卡片
const seatDeliveries = step2RoundtableDialogues
.filter(d => d.confidence === "子智能体交付")
.map(d => ({ sender: d.sender, content: d.content }));
// 3. 文件清单 — 调新接口
const filesPerSeat = await Promise.all(
Object.entries(threadIds)
.filter(([name]) => name !== coordinatorName)
.map(async ([name, tid]) => ({
agentName: name,
files: (await fetch(`/api/threads/${tid}/artifacts`).then(r => r.json())).files,
})),
);
```
### 4.2 用户点击文件时
```ts
// 在线预览(mime 是 markdown/text/pdf 自动 inline)
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}`);
// 强制下载
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}?download=true`);
```
### 4.3 "重新起草新规划" 按钮
```ts
// 跟现在一样,纯前端:清掉 dialogues + 跳 step1
setCurrentStep(1);
setInteractiveMessages(...); // 已有逻辑
setStep2RoundtableDialogues([]);
// 不需要调任何接口
```
---
## 5. 改造前端的具体文件清单
| 文件 | 改动 |
|---|---|
| `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` | **大改**。删除四张 metric 卡 / 三阶段路线图 / 论证要件 / 裁定判词;新增结论卡 + 席位折叠列表 + 文件清单 |
| `frontend-web/src/roundtable-planning/api/multi-agent.ts` | 新增 `listSessionArtifacts(threadId)` helper(封装 §3.1 接口) |
| `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 把 `threadIds` / `coordinatorName` / `lastLeaderContent` 传进 `HighFidelityReport` 的 props(目前没传) |
---
## 6. 改造后端的具体文件清单
| 文件 | 改动 |
|---|---|
| `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | 新增 `GET /api/threads/{tid}/artifacts`(不带 path,列目录) |
| `offline-backend-20260512/backend/CLAUDE.md` | 在 artifacts 路由表里增加新条目说明 |
| `offline-backend-20260512/backend/tests/test_artifacts_list.py` | 列目录新接口的单测(覆盖:空目录返回 [];traversal 路径返回 403;不存在 thread 返回 404)|
---
## 7. 实施优先级
| 优先级 | 项 | 备注 |
|---|---|---|
| P0 | 新增 `GET /api/threads/{tid}/artifacts` 列文件接口 | 必需,没它前端无法列文件 |
| P0 | step3 大改:删 mock 区块、加结论/席位/文件三块 | 主要价值改造 |
| P1 | 前端 helper `listSessionArtifacts` | 封装 fetch + 类型 |
| P2 | 单测 `test_artifacts_list.py` | 上线后跟进 |
| P3 | `POST /api/multi-agent/sessions/summary` 聚合接口 | 13 次→1 次,体感优化,**非必需** |
---
## 8. 显式不做的事 ❌
1. **不做"PDF 导出"**:后端没有现成 PDF 渲染管线,要做就是单独的项目。当前页面那个 `导出方案 PDF` 按钮直接删掉,或者保留按钮但点击只触发 `window.print()`(让浏览器自己生成 PDF)。
2. **不做"安全分享"**:项目里已有 `/api/thread-shares`(per-thread 分享)和 `/api/public/shares`(公开页面),但那是针对单个 thread 的,不是多 thread 会话的。要做"多 thread 整合分享"是新功能,远超 step3 的范围。
3. **不做"指标雷达图"**(什么 90.3 分总评):没有真实数据源,强行加只会变成下一个 mock。
4. **不做"签发投产"**:纯展示按钮,不接业务系统,删。
5. **不做"轮次/对抗次数"显示**:除非前端记一下 `dispatch_count`,否则后端没现成统计。如果真要展示,用现成的"派活了几次"代替"对抗 N 轮"。
---
## 9. 验收清单
step3 改造完成后应该能:
- [ ] 看到当前 session 的总控最终结论,markdown 渲染
- [ ] 看到 6 个席位各自的最终交付内容(默认折叠,点击展开)
- [ ] 看到所有 sub-agent 写到 outputs 的文件列表,含文件名、大小、类型 icon
- [ ] 点击文件名能在线预览(markdown / pdf / 图片)
- [ ] 点击下载按钮能下载(带正确文件名)
- [ ] 点"重新起草新规划"能回到 step1 且 step2 内存清空
- [ ] 页面不再包含任何来自 `budgetLimit` 的"假指标"
---
## 10. 路径速查
| 资源 | 位置 |
|---|---|
| Step 3 组件 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
| 父页面 (state 持有者) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 后端 artifacts 路由 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 后端 multi-agent 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| 物理 outputs 目录 | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |