接口联调
This commit is contained in:
1 parent
43df9175df
commit
8673eb5eca
148 files changed
+2050
-37963
No files matched your search
@@ -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 兜底"或者明确错误提示)
|
||||
@@ -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/` |
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
function filenameFromContentDisposition(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||
if (star) {
|
||||
const raw = star[1].replace(/["']/g, "").trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||
return plain ? plain[1].trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeFallbackFilename(title: string | undefined): string {
|
||||
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
|
||||
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
|
||||
}
|
||||
|
||||
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "导出 Word 失败" }));
|
||||
const detail = error.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
throw new Error("导出 Word 失败");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename =
|
||||
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||
fallbackFilename;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
|
||||
export async function downloadAIWritingDocx(options: {
|
||||
title?: string;
|
||||
draftMarkdown: string;
|
||||
}): Promise<void> {
|
||||
const draftMarkdown = options.draftMarkdown.trim();
|
||||
if (!draftMarkdown) {
|
||||
throw new Error("文档内容为空");
|
||||
}
|
||||
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: options.title?.trim() || undefined,
|
||||
draft_markdown: draftMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||
import { exportMarkdownToDocx } from "../../utils/export-docx";
|
||||
import { downloadAIWritingDocx } from "../../api/writing-export";
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -82,9 +82,12 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
|
||||
if (!canExport) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportMarkdownToDocx(markdown ?? "", title);
|
||||
await downloadAIWritingDocx({
|
||||
title,
|
||||
draftMarkdown: markdown ?? "",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[export-docx] failed", err);
|
||||
console.error("[writing-export] failed", err);
|
||||
toast.error("导出 Word 失败,请稍后重试");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
|
||||
@@ -3,10 +3,27 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
export interface SeatReadyEvent {
|
||||
agentName: string;
|
||||
threadId: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CoordinatorReadyEvent {
|
||||
mainAgentName: string;
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export interface InitMultiAgentRequest {
|
||||
agentNames: string[];
|
||||
mainAgentName: string;
|
||||
model?: string;
|
||||
/** Fires the moment each sub-agent's thread is ready (real completion order). */
|
||||
onSeatReady?: (event: SeatReadyEvent) => void;
|
||||
/** Fires when the coordinator's thread + agent have been created. */
|
||||
onCoordinatorReady?: (event: CoordinatorReadyEvent) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface InitMultiAgentResponse {
|
||||
@@ -15,6 +32,12 @@ export interface InitMultiAgentResponse {
|
||||
thread_ids: ThreadIdMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the /api/multi-agent/init SSE stream. Fires per-seat / coordinator
|
||||
* callbacks as events arrive so the UI can light up roles individually, and
|
||||
* resolves with the final {main_agent_name, thread_ids} once init_done lands.
|
||||
* If the server emits an "error" frame mid-stream, the promise rejects.
|
||||
*/
|
||||
export async function initMultiAgent(
|
||||
payload: InitMultiAgentRequest,
|
||||
): Promise<InitMultiAgentResponse> {
|
||||
@@ -26,18 +49,114 @@ export async function initMultiAgent(
|
||||
main_agent_name: payload.mainAgentName,
|
||||
model: payload.model,
|
||||
}),
|
||||
signal: payload.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as InitMultiAgentResponse;
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let mainAgentName = "";
|
||||
let threadIds: ThreadIdMap | null = null;
|
||||
let aborted = false;
|
||||
let errorDetail: string | null = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") continue;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const event = typeof obj.event === "string" ? obj.event : "";
|
||||
switch (event) {
|
||||
case "seat_ready": {
|
||||
payload.onSeatReady?.({
|
||||
agentName: String(obj.agent_name ?? ""),
|
||||
threadId: String(obj.thread_id ?? ""),
|
||||
displayName: String(obj.display_name ?? ""),
|
||||
description: String(obj.description ?? ""),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "coordinator_ready": {
|
||||
payload.onCoordinatorReady?.({
|
||||
mainAgentName: String(obj.main_agent_name ?? ""),
|
||||
threadId: String(obj.thread_id ?? ""),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "init_done": {
|
||||
mainAgentName = String(obj.main_agent_name ?? "");
|
||||
threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
errorDetail = String(obj.detail ?? "unknown init error");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unknown event — ignore for forward-compat.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
|
||||
if (!aborted) throw err;
|
||||
}
|
||||
|
||||
if (errorDetail) {
|
||||
throw new Error(`init multi-agent failed: ${errorDetail}`);
|
||||
}
|
||||
if (aborted) {
|
||||
throw new DOMException("init aborted", "AbortError");
|
||||
}
|
||||
if (!threadIds || !mainAgentName) {
|
||||
throw new Error("init multi-agent stream ended without init_done");
|
||||
}
|
||||
return {
|
||||
status: "success",
|
||||
main_agent_name: mainAgentName,
|
||||
thread_ids: threadIds,
|
||||
};
|
||||
}
|
||||
|
||||
export type StreamAgentType = "leader" | "special";
|
||||
|
||||
export type LeaderDispatch = [string, string];
|
||||
|
||||
/**
|
||||
* High-level phases the SSE stream goes through. Used by the UI to show
|
||||
* "what the backend is doing" while the user waits — most of the time before
|
||||
* a first token is upstream/LLM latency, which feels like a stall otherwise.
|
||||
*
|
||||
* - `connecting` — fetch sent, no frames yet
|
||||
* - `connected` — got the `event: metadata` frame (run_id assigned)
|
||||
* - `streaming` — first AI text chunk arrived; tokens flowing
|
||||
* - `tool_calling` — model emitted a tool_call chunk (e.g. agent_orchestration,
|
||||
* present_files); usually means the run is about to interrupt
|
||||
* - `tool_result` — a tool result message came back; brief, may go back to streaming
|
||||
*/
|
||||
export type StreamPhase = "connecting" | "connected" | "streaming" | "tool_calling" | "tool_result";
|
||||
|
||||
export interface StreamMultiAgentRequest {
|
||||
agentType: StreamAgentType;
|
||||
dAgentThreadId: ThreadIdMap;
|
||||
@@ -54,6 +173,25 @@ export interface StreamMultiAgentRequest {
|
||||
* `messageId` is stable per AI message so callers can group deltas.
|
||||
*/
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
/**
|
||||
* Fires whenever the inferred SSE phase changes. The UI can use this to
|
||||
* show progressive status messages during the inevitable first-token wait.
|
||||
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
||||
*/
|
||||
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
|
||||
}
|
||||
|
||||
export interface ClarificationPayload {
|
||||
/** Raw question text from the model's ask_clarification args. */
|
||||
question: string;
|
||||
/** Optional category hint from the model (e.g. "missing_info"). */
|
||||
clarificationType: string;
|
||||
/** Free-text reason the model needs clarification. */
|
||||
context: string;
|
||||
/** Suggested answer chips (may be empty). */
|
||||
options: unknown[];
|
||||
/** Whether the user is allowed to type a custom answer. */
|
||||
allowCustom: boolean;
|
||||
}
|
||||
|
||||
export interface StreamMultiAgentResult {
|
||||
@@ -65,6 +203,14 @@ export interface StreamMultiAgentResult {
|
||||
content: string;
|
||||
/** Echoed agent_name of the agent that produced this run. */
|
||||
agentName: string | null;
|
||||
/**
|
||||
* Populated when the leader called ask_clarification — the run ended without
|
||||
* dispatching, and the model is asking the user a question instead. When
|
||||
* present, callers should render `content` (or `clarification.question` as
|
||||
* a fallback) as a question and collect a user reply rather than treating
|
||||
* an empty `dispatched` as consensus.
|
||||
*/
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +222,11 @@ function isFinalStatusFrame(value: unknown): value is {
|
||||
status: LeaderDispatch[] | string;
|
||||
content?: string;
|
||||
agent_name?: string;
|
||||
question?: string;
|
||||
clarification_type?: string;
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
@@ -125,6 +276,44 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
|
||||
return { text, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download for a text blob. No-op outside the browser.
|
||||
* Used by streamMultiAgent's dump-to-file debug mode.
|
||||
*/
|
||||
function downloadTextBlob(filename: string, text: string): void {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||
try {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Defer revoke so the browser has a chance to start the download.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (err) {
|
||||
// Downloads must never break the actual stream — just log and continue.
|
||||
console.warn("[stream] downloadTextBlob failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether streamMultiAgent should auto-download the raw SSE transcript to a
|
||||
* .txt when each call ends. Default ON for now because we're debugging the
|
||||
* "sub-agent stream looks empty" issue and the browser console truncates
|
||||
* large message logs. Toggle from devtools:
|
||||
* window.__MULTI_AGENT_DUMP__ = false
|
||||
*/
|
||||
function isDumpEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const flag = (window as unknown as { __MULTI_AGENT_DUMP__?: boolean }).__MULTI_AGENT_DUMP__;
|
||||
// Default: enabled. Only disabled if explicitly set to false.
|
||||
return flag !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
|
||||
* stream completes. The final frame (`{status: ...}`) is interpreted; every
|
||||
@@ -168,66 +357,184 @@ export async function streamMultiAgent(
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
|
||||
|
||||
const result: StreamMultiAgentResult = { dispatched: [], message: null, content: "", agentName: null };
|
||||
// Raw SSE transcript buffer. Every raw line read from the stream (event:,
|
||||
// data:, blank framing lines, anything) is appended verbatim so the dumped
|
||||
// .txt is byte-for-byte what the frontend received off the socket. This is
|
||||
// independent of parsing: even malformed/non-JSON frames end up in the file.
|
||||
const dumpEnabled = isDumpEnabled();
|
||||
const transcript: string[] = dumpEnabled
|
||||
? [
|
||||
`# multi-agent stream transcript`,
|
||||
`# agent_type: ${req.agentType}`,
|
||||
`# agent_name: ${req.agentName}`,
|
||||
`# started_at: ${new Date().toISOString()}`,
|
||||
`# new_message: ${req.newMessage.replace(/\n/g, " ⏎ ")}`,
|
||||
`# model: ${req.model ?? "(default)"}`,
|
||||
`# ---- raw SSE follows ----`,
|
||||
"",
|
||||
]
|
||||
: [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const result: StreamMultiAgentResult = {
|
||||
dispatched: [],
|
||||
message: null,
|
||||
content: "",
|
||||
agentName: null,
|
||||
clarification: null,
|
||||
};
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
|
||||
// value actually changes (the reader loop sees many frames per phase).
|
||||
let currentPhase: StreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: StreamPhase, name?: string) => {
|
||||
if (next === currentPhase && !name) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next, name);
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("event:")) {
|
||||
lastEvent = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
// Wrap the read loop so we always flush the transcript to disk, even on
|
||||
// abort / network failure. Otherwise an interrupted run would lose the very
|
||||
// evidence we want to inspect.
|
||||
const finishDump = (reason: string) => {
|
||||
if (!dumpEnabled) return;
|
||||
transcript.push("");
|
||||
transcript.push(`# ---- stream ended (${reason}) ----`);
|
||||
transcript.push(`# ended_at: ${new Date().toISOString()}`);
|
||||
transcript.push(`# frames_parsed: ${frameSeq}`);
|
||||
transcript.push(`# final_content_length: ${result.content.length}`);
|
||||
transcript.push(`# dispatched_count: ${result.dispatched.length}`);
|
||||
const tsSafe = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const nameSafe = req.agentName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const filename = `multi-agent_${req.agentType}_${nameSafe}_${tsSafe}.txt`;
|
||||
downloadTextBlob(filename, transcript.join("\n"));
|
||||
};
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
frameSeq += 1;
|
||||
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
if (isFinalStatusFrame(parsed)) {
|
||||
if (Array.isArray(parsed.status)) {
|
||||
result.dispatched = parsed.status;
|
||||
} else {
|
||||
result.message = parsed.status;
|
||||
for (const rawLine of lines) {
|
||||
// Record EVERY raw line — including blank framing lines and event:
|
||||
// headers — so the dumped transcript matches what the socket sent.
|
||||
if (dumpEnabled) transcript.push(rawLine);
|
||||
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("event:")) {
|
||||
lastEvent = line.slice(6).trim();
|
||||
// First metadata frame = run accepted by upstream. Move phase out
|
||||
// of "connecting" so the UI can switch from "调度中" to "已连接".
|
||||
if (lastEvent === "metadata" && currentPhase === "connecting") {
|
||||
setPhase("connected");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof parsed.content === "string") {
|
||||
result.content = parsed.content;
|
||||
}
|
||||
if (typeof parsed.agent_name === "string") {
|
||||
result.agentName = parsed.agent_name;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
|
||||
continue;
|
||||
}
|
||||
|
||||
req.onUpstreamFrame?.(parsed);
|
||||
frameSeq += 1;
|
||||
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
|
||||
|
||||
// Phase inference from LangGraph messages-tuple frames. We only flip
|
||||
// to tool_calling/tool_result on chunks that clearly indicate them;
|
||||
// everything else with non-empty AI text means we're streaming.
|
||||
if (lastEvent === "messages" && Array.isArray(parsed) && parsed.length > 0) {
|
||||
const chunk = parsed[0];
|
||||
if (chunk && typeof chunk === "object") {
|
||||
const chunkObj = chunk as Record<string, unknown>;
|
||||
const chunkType = String(chunkObj.type ?? "").toLowerCase();
|
||||
const toolCalls = chunkObj.tool_calls;
|
||||
const toolCallChunks = chunkObj.tool_call_chunks;
|
||||
const content = chunkObj.content;
|
||||
const hasToolCall =
|
||||
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
|
||||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
|
||||
if (chunkType === "tool") {
|
||||
setPhase("tool_result");
|
||||
} else if (hasToolCall) {
|
||||
// Pick the most informative name we can find in either field.
|
||||
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
|
||||
? (toolCalls[0] as Record<string, unknown>)
|
||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||
? (toolCallChunks[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||
setPhase("tool_calling", toolName);
|
||||
} else if (
|
||||
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||||
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||
) {
|
||||
setPhase("streaming");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinalStatusFrame(parsed)) {
|
||||
if (Array.isArray(parsed.status)) {
|
||||
result.dispatched = parsed.status;
|
||||
} else {
|
||||
result.message = parsed.status;
|
||||
// "clarification" status carries the leader's ask_clarification
|
||||
// payload. Extract the structured fields so the page can render
|
||||
// a question (or chip choices) instead of mistaking the run for
|
||||
// a consensus.
|
||||
if (parsed.status === "clarification") {
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType:
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (typeof parsed.content === "string") {
|
||||
result.content = parsed.content;
|
||||
}
|
||||
if (typeof parsed.agent_name === "string") {
|
||||
result.agentName = parsed.agent_name;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
}
|
||||
|
||||
req.onUpstreamFrame?.(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
|
||||
if (debugEnabled) {
|
||||
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
|
||||
}
|
||||
finishDump("normal");
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Flush whatever we got so the user can still inspect the transcript on
|
||||
// abort / network drop. Then rethrow so callers' own handling runs.
|
||||
const aborted = (err as Error)?.name === "AbortError" || req.signal?.aborted === true;
|
||||
finishDump(aborted ? "aborted" : `error: ${(err as Error)?.message ?? String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -6,7 +8,9 @@ import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
initMultiAgent,
|
||||
streamMultiAgent,
|
||||
type ClarificationPayload,
|
||||
type LeaderDispatch,
|
||||
type StreamPhase,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
@@ -39,7 +43,8 @@ import {
|
||||
Cpu,
|
||||
CornerDownRight,
|
||||
Star,
|
||||
ShieldCheck
|
||||
ShieldCheck,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
// --- Custom Crisp SVGs to Match High Fidelity Avatars ---
|
||||
@@ -306,6 +311,11 @@ export default function RoundtablePlanningPage() {
|
||||
const [isInitializing, setIsInitializing] = useState<boolean>(false);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const [isOrchestrating, setIsOrchestrating] = useState<boolean>(false);
|
||||
// When the coordinator calls ask_clarification, the leader run ends with
|
||||
// this payload populated; the page renders a question card and only
|
||||
// resumes orchestration once the user replies.
|
||||
const [pendingClarification, setPendingClarification] = useState<ClarificationPayload | null>(null);
|
||||
const [clarificationDraft, setClarificationDraft] = useState<string>("");
|
||||
const orchestrationCancelRef = useRef<boolean>(false);
|
||||
const step2InitRef = useRef<boolean>(false);
|
||||
// Tracks every AbortController active in this Step 2 visit so the "停止" /
|
||||
@@ -368,6 +378,38 @@ export default function RoundtablePlanningPage() {
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Translate an inferred SSE phase into a human-readable confidence label
|
||||
// for the dialogue bubble. We keep the bubble's existing in-progress
|
||||
// confidence ("调度中" / "正在输出…") when phase doesn't add information,
|
||||
// and switch to a specific label when something specific is happening.
|
||||
const phaseLabel = useCallback((phase: StreamPhase, agentType: "leader" | "special", toolName?: string): string => {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度中";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "tool_calling":
|
||||
if (toolName === "agent_orchestration") return "正在派活";
|
||||
if (toolName === "ask_clarification") return "正在提问";
|
||||
if (toolName === "present_files") return "正在交付文件";
|
||||
return toolName ? `调用工具:${toolName}` : "调用工具";
|
||||
case "tool_result":
|
||||
return "处理工具结果";
|
||||
case "streaming":
|
||||
return agentType === "leader" ? "正在派活说明" : "正在输出";
|
||||
default:
|
||||
return agentType === "leader" ? "调度中" : "正在输出…";
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update only the confidence field of a streaming card by id. Used by the
|
||||
// phase callback so we can change the label without touching content.
|
||||
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
|
||||
setStep2RoundtableDialogues(prev =>
|
||||
prev.map(d => (d.id === id ? { ...d, confidence } : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Streaming variant: append a placeholder card and return its id so subsequent
|
||||
// text deltas can be appended in-place via appendToDialogue.
|
||||
const appendStreamingDialogue = useCallback((card: {
|
||||
@@ -501,8 +543,10 @@ export default function RoundtablePlanningPage() {
|
||||
safetyCounter += 1;
|
||||
highlightSpeaker(2);
|
||||
|
||||
// Leader bubble — created BEFORE the call so users see "正在输出…" the
|
||||
// moment the request is in flight, then text fills in as deltas arrive.
|
||||
// Leader bubble — created BEFORE the call so users see "调度中" the
|
||||
// moment the request is in flight, then phase changes flip the
|
||||
// confidence label so the user sees what the backend is actually
|
||||
// doing during the ~1-2s first-token wait.
|
||||
const leaderBubbleId = appendStreamingDialogue({
|
||||
sender: "总控协调",
|
||||
avatarType: "blue",
|
||||
@@ -523,6 +567,9 @@ export default function RoundtablePlanningPage() {
|
||||
model: DEFAULT_MODEL,
|
||||
signal: leaderAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName) => {
|
||||
setDialogueConfidence(leaderBubbleId, phaseLabel(phase, "leader", toolName));
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
activeAbortersRef.current.delete(leaderAborter);
|
||||
@@ -536,6 +583,18 @@ export default function RoundtablePlanningPage() {
|
||||
|
||||
if (orchestrationCancelRef.current) break;
|
||||
|
||||
// Clarification branch — leader asked the user a question. Stop the
|
||||
// auto loop, surface the question card, and wait for the user to
|
||||
// reply before another leader cycle.
|
||||
if (leaderRes.clarification) {
|
||||
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
|
||||
highlightSpeaker(null);
|
||||
setPendingClarification(leaderRes.clarification);
|
||||
setClarificationDraft("");
|
||||
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
|
||||
break;
|
||||
}
|
||||
|
||||
finalizeDialogue(
|
||||
leaderBubbleId,
|
||||
leaderRes.content,
|
||||
@@ -549,52 +608,59 @@ export default function RoundtablePlanningPage() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Dispatch all sub-agents in parallel; per-seat model preference
|
||||
// overrides the default if the user picked one in the drawer. Each
|
||||
// sub-agent gets its own streaming bubble that fills independently.
|
||||
await Promise.allSettled(
|
||||
leaderRes.dispatched.map(async ([subName, task]) => {
|
||||
if (orchestrationCancelRef.current) return;
|
||||
const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()];
|
||||
// Add this speaker to the concurrent active set so its avatar
|
||||
// lights up alongside any other sub-agents streaming in parallel.
|
||||
if (meta) markSpeakerStreaming(meta.speakerId);
|
||||
const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
|
||||
// Dispatch sub-agents sequentially (one at a time). Running them in
|
||||
// parallel meant N concurrent SSE streams each triggering a full-list
|
||||
// setState on every token delta, which made the browser stutter badly
|
||||
// when the leader 派活给多个席位。改为顺序后,同一时刻只有一个 SSE 在跑。
|
||||
for (const [subName, task] of leaderRes.dispatched) {
|
||||
if (orchestrationCancelRef.current) break;
|
||||
const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()];
|
||||
if (meta) markSpeakerStreaming(meta.speakerId);
|
||||
const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
|
||||
|
||||
const subBubbleId = appendStreamingDialogue({
|
||||
sender: meta?.displayName ?? subName,
|
||||
avatarType: meta?.avatarType ?? "blue",
|
||||
initialContent: "",
|
||||
confidence: "正在输出…",
|
||||
const subBubbleId = appendStreamingDialogue({
|
||||
sender: meta?.displayName ?? subName,
|
||||
avatarType: meta?.avatarType ?? "blue",
|
||||
initialContent: "",
|
||||
confidence: "调度中",
|
||||
});
|
||||
|
||||
const subAborter = new AbortController();
|
||||
activeAbortersRef.current.add(subAborter);
|
||||
let aborted = false;
|
||||
try {
|
||||
const specRes = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
dAgentThreadId: ids,
|
||||
agentName: subName,
|
||||
newMessage: task,
|
||||
model: perSeatModel || DEFAULT_MODEL,
|
||||
signal: subAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
onPhaseChange: (phase, toolName) => {
|
||||
setDialogueConfidence(subBubbleId, phaseLabel(phase, "special", toolName));
|
||||
},
|
||||
});
|
||||
|
||||
const subAborter = new AbortController();
|
||||
activeAbortersRef.current.add(subAborter);
|
||||
try {
|
||||
const specRes = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
dAgentThreadId: ids,
|
||||
agentName: subName,
|
||||
newMessage: task,
|
||||
model: perSeatModel || DEFAULT_MODEL,
|
||||
signal: subAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
});
|
||||
if (orchestrationCancelRef.current) return;
|
||||
if (orchestrationCancelRef.current) {
|
||||
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
|
||||
aborted = true;
|
||||
} else {
|
||||
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
|
||||
} catch (err) {
|
||||
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
|
||||
} else {
|
||||
console.error(`[special:${subName}] error:`, err);
|
||||
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
}
|
||||
} finally {
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (meta) markSpeakerIdle(meta.speakerId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
|
||||
aborted = true;
|
||||
} else {
|
||||
console.error(`[special:${subName}] error:`, err);
|
||||
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
}
|
||||
} finally {
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (meta) markSpeakerIdle(meta.speakerId);
|
||||
}
|
||||
if (aborted) break;
|
||||
}
|
||||
|
||||
if (orchestrationCancelRef.current) break;
|
||||
|
||||
@@ -635,6 +701,12 @@ export default function RoundtablePlanningPage() {
|
||||
abortAllInFlight();
|
||||
step2InitRef.current = false;
|
||||
setIsOrchestrating(false);
|
||||
setPendingClarification(null);
|
||||
setClarificationDraft("");
|
||||
// Re-entering step 2 should start with the discussion nominally
|
||||
// "running" so the suspend button comes back; the prior visit might
|
||||
// have ended in a paused state.
|
||||
setIsRoundTableRunning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -654,6 +726,23 @@ export default function RoundtablePlanningPage() {
|
||||
agentNames: ROUNDTABLE_AGENT_IDS,
|
||||
mainAgentName: coord,
|
||||
model: DEFAULT_MODEL,
|
||||
// As each sub-agent's thread comes online, flip every role that
|
||||
// shares the agentId to "在线". Some seats reuse the same agent
|
||||
// (e.g. seat 8 reuses roundtable-summary) so we match on agentId
|
||||
// rather than a single id.
|
||||
onSeatReady: ({ agentName }) => {
|
||||
if (cancelled) return;
|
||||
const id = agentName.toLowerCase();
|
||||
setRoles(prev =>
|
||||
prev.map(r => (r.agentId === id ? { ...r, status: "在线" } : r)),
|
||||
);
|
||||
},
|
||||
// Coordinator readiness is a no-op for the role list (the
|
||||
// coordinator isn't rendered as a seat), but we keep the hook in
|
||||
// case we want to drop a "系统" toast later.
|
||||
onCoordinatorReady: () => {
|
||||
if (cancelled) return;
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
setThreadIds(initRes.thread_ids);
|
||||
@@ -851,11 +940,15 @@ export default function RoundtablePlanningPage() {
|
||||
};
|
||||
|
||||
// Step 2 role statuses transition when entering roundtable!
|
||||
// On Step 2 entry we set every agent seat to "正在上线" (spinner state);
|
||||
// the /init SSE stream then flips each one to "在线" as its thread/agent
|
||||
// actually finishes on the backend. Seat 1 (human commander) stays online
|
||||
// throughout — it's not part of the bring-up.
|
||||
useEffect(() => {
|
||||
if (currentStep === 1) {
|
||||
setRoles(makeRoles("等待激活", { 2: "在线" }));
|
||||
} else if (currentStep === 2) {
|
||||
setRoles(makeRoles("在线", { 8: "等待陈述" }));
|
||||
setRoles(makeRoles("正在上线", { 1: "在线" }));
|
||||
} else {
|
||||
setRoles(makeRoles("已完成"));
|
||||
}
|
||||
@@ -887,6 +980,36 @@ export default function RoundtablePlanningPage() {
|
||||
void runOrchestration(`用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`, threadIds, coordinatorName);
|
||||
};
|
||||
|
||||
// Submit the user's answer to the coordinator's clarification question and
|
||||
// resume orchestration. The reply is fed back as the next leader prompt on
|
||||
// the same coordinator thread (no re-init needed).
|
||||
const submitClarification = (rawAnswer: string) => {
|
||||
const answer = rawAnswer.trim();
|
||||
if (!answer) return;
|
||||
if (!threadIds || !coordinatorName) {
|
||||
triggerToast("⚠️ 圆桌尚未就绪,无法回复澄清");
|
||||
return;
|
||||
}
|
||||
if (isOrchestrating) {
|
||||
triggerToast("⏳ 当前轮次仍在进行中,请稍候");
|
||||
return;
|
||||
}
|
||||
const question = pendingClarification?.question || "(澄清问题)";
|
||||
appendDialogue({
|
||||
sender: "用户 (澄清回复)",
|
||||
avatarType: "human",
|
||||
content: `🗣 回复总控提问:${answer}`,
|
||||
confidence: "澄清回复",
|
||||
});
|
||||
setPendingClarification(null);
|
||||
setClarificationDraft("");
|
||||
void runOrchestration(
|
||||
`用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1182,13 +1305,18 @@ export default function RoundtablePlanningPage() {
|
||||
{roles.map((role) => {
|
||||
const isActive = role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
|
||||
const isOngoing = role.status === "研讨中";
|
||||
|
||||
const isBootingUp = role.status === "正在上线";
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
id={`role-item-${role.id}`}
|
||||
key={role.id}
|
||||
key={role.id}
|
||||
className={`flex items-center justify-between p-2 rounded-xl transition ${
|
||||
isActive ? "bg-slate-50 border border-slate-100" : "opacity-60"
|
||||
isActive
|
||||
? "bg-slate-50 border border-slate-100"
|
||||
: isBootingUp
|
||||
? "bg-amber-50/60 border border-amber-100"
|
||||
: "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -1229,18 +1357,22 @@ export default function RoundtablePlanningPage() {
|
||||
</span>
|
||||
) : role.status === "已完成" ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-500 font-bold" />
|
||||
) : isBootingUp ? (
|
||||
<Loader2 className="h-3 w-3 text-amber-500 animate-spin" />
|
||||
) : (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
|
||||
)}
|
||||
|
||||
<span className={`text-[10px] font-semibold ${
|
||||
isOngoing
|
||||
? "text-[var(--brand-900)]"
|
||||
isOngoing
|
||||
? "text-[var(--brand-900)]"
|
||||
: role.status === "在线"
|
||||
? "text-emerald-600 font-bold"
|
||||
: role.status === "已完成"
|
||||
? "text-emerald-600"
|
||||
: "text-slate-400"
|
||||
: isBootingUp
|
||||
? "text-amber-600"
|
||||
: "text-slate-400"
|
||||
}`}>
|
||||
{role.status}
|
||||
</span>
|
||||
@@ -1608,23 +1740,33 @@ export default function RoundtablePlanningPage() {
|
||||
>极速</button>
|
||||
</div>
|
||||
|
||||
{isRoundTableRunning ? (
|
||||
<button
|
||||
{/* Suspend / resume button visibility:
|
||||
- Show "挂起协商" only while a run is actually in flight
|
||||
(isOrchestrating + still nominally running). When the
|
||||
discussion finishes naturally there's nothing to
|
||||
suspend, so the button hides; it'll come back the
|
||||
next time orchestration starts (user "干预研审" or
|
||||
clarification reply).
|
||||
- Show "恢复会商" any time the user has explicitly paused
|
||||
(!isRoundTableRunning) regardless of orchestration
|
||||
state, so they can always recover from a manual pause. */}
|
||||
{isRoundTableRunning && isOrchestrating ? (
|
||||
<button
|
||||
onClick={() => { setIsRoundTableRunning(false); triggerToast("⏸️ 会商模拟暂挂!智能体挂起..."); }}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
挂起协商
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
) : !isRoundTableRunning ? (
|
||||
<button
|
||||
onClick={() => { setIsRoundTableRunning(true); triggerToast("▶️ 恢复大模型并行博弈与推理决策"); }}
|
||||
className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
||||
恢复会商
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1995,17 +2137,165 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono font-medium">{msg.time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
|
||||
{/* Pre-streaming hint — when the bubble is created but
|
||||
no AI token has arrived yet, show a small spinner
|
||||
+ the current phase label so the user can see the
|
||||
backend is doing something (调度中 → 已连接 →
|
||||
正在派活/调用工具 → 流式输出). */}
|
||||
{msg.streaming && !msg.content && (
|
||||
<div className="flex items-center gap-2 py-1 text-[11px] text-slate-500 italic">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||
<span>{msg.confidence}</span>
|
||||
</div>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Compact spacing for chat bubbles — react-markdown's
|
||||
// default block margins are too tall for this UI.
|
||||
p: ({ children }) => <p className="my-1 whitespace-pre-wrap">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc pl-5 my-1 space-y-0.5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal pl-5 my-1 space-y-0.5">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-snug">{children}</li>,
|
||||
h1: ({ children }) => <h1 className="text-sm font-bold mt-2 mb-1">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-bold mt-2 mb-1">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-xs font-bold mt-1.5 mb-0.5">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="text-xs font-bold mt-1 mb-0.5">{children}</h4>,
|
||||
strong: ({ children }) => <strong className="font-bold text-slate-900">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = /language-/.test(className ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline hover:opacity-80"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<table className="my-1 border-collapse text-[11px]">{children}</table>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-slate-200 px-1.5 py-0.5">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="my-2 border-slate-200" />,
|
||||
}}
|
||||
>
|
||||
{msg.content ?? ""}
|
||||
</ReactMarkdown>
|
||||
{msg.streaming && (
|
||||
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clarification card — shown only when the coordinator asked
|
||||
the user a question via ask_clarification. The auto-loop is
|
||||
paused; replying resumes orchestration on the same threads. */}
|
||||
{pendingClarification && (
|
||||
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-extrabold text-amber-900">
|
||||
总控协调需要澄清
|
||||
</span>
|
||||
{pendingClarification.clarificationType && (
|
||||
<span className="text-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
|
||||
{pendingClarification.clarificationType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pendingClarification.context && (
|
||||
<p className="text-[11px] text-amber-800 italic">
|
||||
{pendingClarification.context}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
|
||||
{pendingClarification.question}
|
||||
</p>
|
||||
{Array.isArray(pendingClarification.options) &&
|
||||
pendingClarification.options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{pendingClarification.options.map((opt, idx) => {
|
||||
const label =
|
||||
typeof opt === "string"
|
||||
? opt
|
||||
: opt && typeof opt === "object" && "label" in (opt as object)
|
||||
? String((opt as { label: unknown }).label ?? "")
|
||||
: String(opt ?? "");
|
||||
if (!label) return null;
|
||||
return (
|
||||
<button
|
||||
key={`${idx}-${label}`}
|
||||
type="button"
|
||||
onClick={() => submitClarification(label)}
|
||||
className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pendingClarification.allowCustom !== false && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={clarificationDraft}
|
||||
onChange={(e) => setClarificationDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitClarification(clarificationDraft);
|
||||
}}
|
||||
placeholder="输入你的回答…"
|
||||
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitClarification(clarificationDraft)}
|
||||
disabled={!clarificationDraft.trim()}
|
||||
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Console control / Custom injection bar */}
|
||||
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
|
||||
<input
|
||||
|
||||
Reference in new issue
Block a user