782 lines
55 KiB
Markdown
782 lines
55 KiB
Markdown
# 多智能体圆桌研讨 · 前端开发文档
|
||
|
||
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
|
||
|
||
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
|
||
|
||
---
|
||
|
||
## 0. 速读:你需要先知道的事
|
||
|
||
- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。
|
||
- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。
|
||
- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。
|
||
- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。
|
||
- **`<think>` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `<think>...</think>`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。
|
||
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
|
||
- **草稿/历史持久化在后端(2026-06)**:草稿不再存浏览器 `localStorage`,改走后端 MySQL(`/api/roundtable-drafts`,按登录用户隔离、跨设备保留)。`api/drafts.ts` 封装 CRUD,`useDraftPersistence` 异步对接、对外接口不变。**推荐弹窗带「会话级推荐历史」**:每个草稿一串历史,重开弹窗先显示上次推荐结果,可「重新分析」重跑(详见 §5.7 / §6.2)。后端实现见 backend doc 的「圆桌草稿持久化」一节。
|
||
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
|
||
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
|
||
- **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。
|
||
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
|
||
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
|
||
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
|
||
|
||
---
|
||
|
||
## 1. 功能定位
|
||
|
||
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
|
||
|
||
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
|
||
|
||
**核心能力**:
|
||
|
||
| 步骤 | 用户目标 | 真实后端对接 |
|
||
|------|----------|--------------|
|
||
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
|
||
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
|
||
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
|
||
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
|
||
|
||
**步骤门禁**:
|
||
|
||
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
|
||
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
|
||
|
||
---
|
||
|
||
## 2. 目录结构
|
||
|
||
```
|
||
frontend-web/src/roundtable-planning/
|
||
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
|
||
├── pages/
|
||
│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
|
||
├── api/
|
||
│ ├── intent.ts # Step 1:initIntent、streamIntent
|
||
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent;**+ 推荐历史 listRecommendHistory / saveRecommendHistory**
|
||
│ ├── drafts.ts # **草稿后端 CRUD**:listDrafts / getDraft / createDraft / updateDraft / deleteDraft / deriveDraftName(替代旧 localStorage utils/drafts.ts)
|
||
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
|
||
├── components/
|
||
│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
|
||
│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置
|
||
│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup)
|
||
│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props)
|
||
│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入)
|
||
│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport
|
||
│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model)
|
||
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
|
||
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
|
||
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
|
||
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
|
||
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
|
||
├── hooks/
|
||
│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
|
||
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
|
||
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
|
||
│ └── useDraftPersistence.ts # 草稿(后端版):异步走 api/drafts;自动保存(前进切换)/手动保存/加载/重命名/删除/ensureDraft
|
||
├── lib/
|
||
│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
|
||
│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `<think>...</think>` 思考过程
|
||
│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等)
|
||
│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染
|
||
├── utils/ # (旧 drafts.ts 已删除 —— 草稿改走后端 api/drafts.ts)
|
||
└── styles/
|
||
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
|
||
```
|
||
|
||
**模块依赖方向(自上而下)**:
|
||
|
||
```
|
||
pages/RoundtablePlanningPage
|
||
├─ uses → hooks/useStep1Intent
|
||
├─ uses → hooks/useStep2Orchestration
|
||
├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate)
|
||
├─ uses → components/Step{1,2,3}Panel (纯 props 驱动)
|
||
├─ uses → components/RecommendAgentsDialog
|
||
└─ uses → components/Avatars
|
||
|
||
hooks/useStep1Intent → api/intent
|
||
hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型
|
||
hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型
|
||
```
|
||
|
||
主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。
|
||
|
||
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
|
||
|
||
```tsx
|
||
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
|
||
```
|
||
|
||
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
|
||
|
||
**环境变量**(由 `scripts/start-frontend.sh` 注入):
|
||
|
||
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
|
||
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
|
||
|
||
---
|
||
|
||
## 3. 三步数据流(前端视角)
|
||
|
||
```
|
||
[进入 Step 1]
|
||
useEffect → runStep1Init()
|
||
initIntent(model) → { agent_id, thread_id }
|
||
setIntentThreadId、setTimelineEntries
|
||
interactiveMessages seed 欢迎语(本地 seed,非 API)
|
||
|
||
[用户发送 / 选项卡片提交]
|
||
sendIntentMessage(text)
|
||
追加 user 气泡 + agent 占位(streaming + phase)
|
||
streamIntent({ onTextDelta, onPhaseChange, signal })
|
||
终态:
|
||
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
|
||
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
|
||
asking → 继续对话
|
||
error → toast + 红色 (错误) 文案
|
||
|
||
[点击「进入多智能体研讨」且 intentReady]
|
||
setIsRecommendDialogOpen(true)
|
||
RecommendAgentsDialog
|
||
listAgents() → filterRecommendCandidates()
|
||
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
|
||
用户勾选 2–8 个 → onConfirm(agentIds)
|
||
handleConfirmAgents
|
||
setSelectedAgents(agentIds.map(...))
|
||
step2InitRef.current = false // 复位 Step 2 init 闸门
|
||
setCurrentStep(2)
|
||
|
||
[进入 Step 2]
|
||
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
|
||
onSeatReady:右侧席位面板逐个「在线」
|
||
onCoordinatorReady / init_done → threadIds + coordinatorName
|
||
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
|
||
while !cancelled && cycles<8:
|
||
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
|
||
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
|
||
dispatched.length === 0 → hasConsensus = true,可进 Step 3
|
||
|
||
[进入 Step 3]
|
||
HighFidelityReport
|
||
consensusText ← lastLeaderContent
|
||
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
|
||
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
|
||
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. API 层
|
||
|
||
### 4.1 `api/intent.ts` — Step 1
|
||
|
||
| 函数 | 说明 |
|
||
|------|------|
|
||
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
|
||
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
|
||
|
||
**`StreamIntentRequest` 重要字段**:
|
||
|
||
- `threadId`:必填,跨多轮对话保持
|
||
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
|
||
- `onTextDelta(text, messageId)`:每个 AI 文本增量
|
||
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
|
||
- **`onClarification(clarification, index)`(2026-06)**:每解析到一条 `ask_clarification` 工具结果帧就回调一次,让前端**实时**逐个渲染澄清卡。任务理解智能体**一轮可能问多个问题**,这是「实时接收显示协助过程」的关键入口。
|
||
|
||
**`StreamIntentResult`**:
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
|
||
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
|
||
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
|
||
| `clarification` | `ClarificationPayload \| null` | **第一个**澄清(向后兼容字段) |
|
||
| `clarifications` | `ClarificationPayload[]` | **本轮全部澄清问题**(2026-06)。来源是流式 `type:"tool"` 帧而非终态帧——**终态帧只带第 1 个**,多问题必须靠它。每条带 `id`(= `tool_call_id`)供前端按题维护选择。 |
|
||
|
||
**SSE 解析要点**:
|
||
|
||
- `event: metadata` → phase `connected`
|
||
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
|
||
- `event: messages` + `type:"tool"` 且 `name==="ask_clarification"` → `extractClarificationToolMessage` 解出完整澄清(options 已是 `{id,label}`),按 `tool_call_id` 去重后 `onClarification` 实时回调并收进 `result.clarifications`
|
||
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
|
||
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`);`status==="clarification"` 且**未**收到任何流式 tool 澄清帧时,才用终态帧字段兜底合成单条澄清
|
||
|
||
**Abort 行为**:
|
||
|
||
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
|
||
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
|
||
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
|
||
|
||
### 4.2 `api/recommend.ts` — 推荐弹窗
|
||
|
||
| 函数 | 说明 |
|
||
|------|------|
|
||
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
|
||
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
|
||
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
|
||
|
||
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
|
||
|
||
- `roundtable-intent`
|
||
- `roundtable-recommender`
|
||
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
|
||
|
||
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
|
||
|
||
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
|
||
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
|
||
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
|
||
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
|
||
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
|
||
|
||
**终态语义**:
|
||
|
||
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
|
||
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
|
||
|
||
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
|
||
|
||
| 函数 | 说明 |
|
||
|------|------|
|
||
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
|
||
| `streamMultiAgent(req)` | leader / special 一轮 |
|
||
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
|
||
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
|
||
|
||
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
|
||
|
||
```ts
|
||
{ event: "seat_ready", agent_name, thread_id, display_name, description }
|
||
{ event: "coordinator_ready", main_agent_name, thread_id }
|
||
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
|
||
{ event: "error", detail }
|
||
```
|
||
|
||
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
|
||
|
||
| `status` | 含义 | 前端处理 |
|
||
|----------|------|----------|
|
||
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
|
||
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
|
||
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
|
||
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
|
||
| `"error"` | 失败 | toast + 红色气泡 |
|
||
|
||
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
|
||
|
||
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
|
||
|
||
**调试开关**:
|
||
|
||
```js
|
||
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
|
||
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
|
||
```
|
||
|
||
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
|
||
|
||
---
|
||
|
||
## 5. 主页面状态(`RoundtablePlanningPage`)
|
||
|
||
> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。
|
||
|
||
| 维度 | 所有者 | 暴露给主页面的方式 |
|
||
|------|--------|-------------------|
|
||
| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState |
|
||
| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 |
|
||
| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 |
|
||
| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 |
|
||
| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref |
|
||
|
||
### 5.1 步骤与门禁
|
||
|
||
- `currentStep`: `1 | 2 | 3`
|
||
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
|
||
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
|
||
|
||
### 5.2 Step 1 关键 state
|
||
|
||
| 状态 | 用途 |
|
||
|------|------|
|
||
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
|
||
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
|
||
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
|
||
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
|
||
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
|
||
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
|
||
| `intentAborterRef` | 当前 stream 的 AbortController |
|
||
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
|
||
| `step1InitRef` | 闸门:true 即认为已 init 过 |
|
||
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
|
||
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
|
||
|
||
**`step1InitGenRef` 的存在原因**:
|
||
|
||
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
|
||
|
||
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
|
||
|
||
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
|
||
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
|
||
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
|
||
|
||
### 5.3 推荐与 Step 2 席位
|
||
|
||
| 状态 | 用途 |
|
||
|------|------|
|
||
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
|
||
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
|
||
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
|
||
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
|
||
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
|
||
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
|
||
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
|
||
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
|
||
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
|
||
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
|
||
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
|
||
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
|
||
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
|
||
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
|
||
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
|
||
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
|
||
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
|
||
|
||
**协调智能体命名(2026-05 改造)**:现在是**固定单例 id** `roundtable-coordinator`(见 `lib/roundtable-constants.ts::COORDINATOR_AGENT_ID`)。`newCoordinatorName()` 函数为兼容性保留,内部直接返回这个常量字符串。后端 `ensure_roundtable_functional_agents()` 缺失自动落地,不再每次新建。`filterRecommendCandidates` 在 `EXCLUDED_AGENT_IDS` 集合里同时排除:
|
||
|
||
- `roundtable-coordinator`(新版单例 id,永远过滤);
|
||
- `roundtable-coordinator-*` 前缀(历史残留,管理员清理之前先在 UI 隐藏)。
|
||
|
||
> 历史草稿兼容性:用户加载 2026-05 前保存的草稿时,存储的 `coordinatorName` 仍是 `roundtable-coordinator-<ts36>` 形式。只要那个旧 agent 目录还在磁盘上(即运维没手动删过),加载后继续在 Step 2 干活就能正常工作;如果旧 agent 被清理掉了,需要让用户「重新起草」从头跑一遍 Step 1 → Step 2。
|
||
|
||
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
|
||
|
||
伪代码:
|
||
|
||
```
|
||
isOrchestrating = true
|
||
orchestrationCancelRef = false
|
||
nextLeaderTask = seedMessage
|
||
cycles = 0
|
||
MAX_CYCLES = 8
|
||
|
||
while !cancelled && cycles < MAX_CYCLES:
|
||
cycles += 1
|
||
highlight(speakerId=2) // 总控
|
||
leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
|
||
res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
|
||
|
||
if res.clarification:
|
||
finalize(leaderBubble, content, "等待用户澄清")
|
||
setPendingClarification(res.clarification)
|
||
break // 等用户回复后由 submitClarification 重启循环
|
||
|
||
finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
|
||
if res.content: setLastLeaderContent(res.content)
|
||
|
||
if res.dispatched.length === 0:
|
||
setHasConsensus(true); setConsensusPercentage(100); break
|
||
|
||
for [subName, task] of res.dispatched: // **顺序**,不是并行
|
||
meta = agentNameToRole[subName.toLowerCase()]
|
||
markSpeakerStreaming(meta.speakerId)
|
||
perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
|
||
subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
|
||
specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
|
||
finalize(subBubble, specRes.content, "子智能体交付")
|
||
markSpeakerIdle(meta.speakerId)
|
||
if cancelled: break
|
||
|
||
setConsensusPercentage(prev => min(95, prev + 8))
|
||
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
|
||
|
||
if cycles >= MAX_CYCLES:
|
||
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
|
||
|
||
clearAllStreamingSpeakers()
|
||
isOrchestrating = false
|
||
```
|
||
|
||
要点:
|
||
|
||
- **`buildInitialIntent()`**:
|
||
- 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
|
||
- 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
|
||
- 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
|
||
- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
|
||
- **挂起 / 恢复**:
|
||
- 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
|
||
- 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
|
||
- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
|
||
|
||
### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
|
||
|
||
- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
|
||
- 抽屉控件:
|
||
- **System Prompt**:仅前端记录(后端覆写未开放)
|
||
- **Temp**:仅前端记录
|
||
- **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel`
|
||
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
|
||
- 把新的 `(seatId, model)` 写入 `agentConfigs`
|
||
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
|
||
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
|
||
|
||
### 5.6 全局模型选择器(右侧 sidebar 顶部)
|
||
|
||
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
|
||
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
|
||
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
|
||
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
|
||
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
|
||
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
|
||
|
||
### 5.7 草稿持久化(`api/drafts.ts` + 后端 MySQL,2026-06 改造)
|
||
|
||
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
|
||
|
||
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
|
||
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
|
||
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
|
||
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
|
||
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
|
||
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
|
||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
|
||
- **手动保存 / `ensureDraft`**:`handleManualSave` 走同一条保存队列;`ensureDraft(furthestStep?)` 幂等保证后端有一条对应草稿并返回 id(**推荐弹窗打开前调用**,让推荐历史有 `draft_id` 归属)。
|
||
- **加载草稿 `handleLoadDraft`**:
|
||
- 走 `getDraft(id)` **实时拉服务端最新全量**(不再读内存里可能过期的列表快照);404 → toast「草稿已不存在」并刷新列表。
|
||
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`(顺序不能反)。
|
||
- `setStep2InitGate(draft.furthestStep >= 2)`:曾走到 Step 2 才跳过自动 init。
|
||
- 角色面板基线:被恢复时全部「在线」,否则 init 流程逐个「在线」。
|
||
|
||
**本次随手修复的 4 个旧 localStorage 版 bug**(对接后端时一并解决):
|
||
|
||
1. **自动保存覆盖手动重命名** —— 现在所有 `updateDraft` 一律**不带 title**;标题只由 create(派生)与显式「重命名」改写。
|
||
2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条**串行队列**(`saveQueueRef`)+ 同步镜像 `currentDraftIdRef`,create 完成即写 id,后续保存读到 id 走 update 而非再 create。
|
||
3. **加载读到内存旧列表快照** —— 改为 `getDraft(id)` 实时拉。
|
||
4. **localStorage 配额静默写失败 / 数据丢失** —— 迁后端后不复存在。
|
||
|
||
---
|
||
|
||
## 6. 组件说明
|
||
|
||
### 6.1 `IntentClarificationCard`
|
||
|
||
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`、**`deferSubmit?`(2026-06)**
|
||
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
|
||
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
|
||
- 仅选项 → `我选择:A、B`
|
||
- 仅自定义 → 直接发自定义文本
|
||
- 同时存在 → `我选择:A、B;补充:…`
|
||
- **单问题 vs 多问题的判断(2026-06)**:`Step1Message` 按 `clarifications.length` 分流,**默认单选**(后端 `allow_multiple` 缺省 false,`resolveAllowMultiple` 兜底):
|
||
- **单问题(最常见)**:`deferSubmit={false}`,沿用原交互 —— 单选**点击选项即发送**;多选 / 允许自定义则走卡片自带的「发送回答」按钮。**不**显示合并按钮。
|
||
- **多问题**:`deferSubmit={true}` —— 连单选点击也只**记录**选择(不立即发送),卡片自身隐藏按钮;父组件在卡片下方渲染**一个**「发送全部回答」按钮,统一提交。
|
||
- 该判断纯在前端(不动工具/后端):依据 `clarifications.length` + `allow_multiple`,后端 `ask_clarification` 本就默认单选。
|
||
- **合并提交逻辑**:`submitIntentClarifications(msgId, clarifications, overrideByKey?)` 逐题取选择、格式化、拼成一条消息(多题带 `1. <问题>\n答:<答案>` 编号;单题直接发答案;至少答一题才发)。`overrideByKey` 供「单题单选点击即发送」用——此时选择还没写进 state map(setState 异步),把即时选择按 `clarificationKey` 传进去直接用。
|
||
- **逐题选择隔离**:选择 Map 改为 `Map<string, IntentClarificationSelection>`,key 由 `clarificationKey(msgId, clar.id, index)` 生成(`clar.id` = `tool_call_id`,缺省退化为 `idx-N`)。
|
||
- 展示条件:`isAgent && clarifications.length>0 && !intentReady && !parts.summary`;**流式期也展示**(卡片 `disabled`,只读,体现「协助过程」实时出现),仅当终态且 `pendingIntentClarificationMsgId === msg.id && !streaming` 时才可交互。出现 `[INTENT_READY]` / summary 后隐藏。
|
||
- **步骤链强化**:`onClarification` 同时往 `msg.steps` 追加一条 `追问 N:<问题>`,让 `MessageStepsCard` 把每个 `ask_clarification` 调用显示成独立步骤。
|
||
|
||
### 6.2 `RecommendAgentsDialog`
|
||
|
||
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
|
||
|
||
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
|
||
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
|
||
- 阶段 UI(`InternalState.phase`):
|
||
- `idle` → `loading_agents` → **`loading_history`** → **`history`**(展示上次推荐,可直接进入研讨或「重新分析」)
|
||
- 或 `loading_agents` → `recommending`(顶部「模型生成内容」流式;推荐卡 streaming=true)→ `ready` / `fallback` / `error`
|
||
- **推荐历史(会话级)**:
|
||
- 打开时若该 `draftId` 有历史,进入 `history` 态:蓝色提示条标注上次推荐时间,picks 默认勾选、rationale 回显,**不**自动重跑模型。
|
||
- 「**重新分析**」按钮(`history` 态显示该文案,其它态为「重新推荐」)→ `start(true)` 强制跑新推荐,跳过历史。
|
||
- 每次**新推荐**完成(`ready`/`fallback`)后 `saveRecommendHistory(draftId, {objective,status,model,rationale,picks,candidates})` best-effort 追加一条(失败仅告警不阻断)。
|
||
- `history`/`fallback`/`ready` 三态都允许 `onConfirm`(用户可直接采纳历史 picks)。
|
||
- **布局分两段**:
|
||
- **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `<think>` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。
|
||
- **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。
|
||
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
|
||
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
|
||
- 反竞态:内部 `generationRef`,每次 `start()` 自增,所有 setState(含历史读取/流式回调)都校验 `myGen === generationRef.current`,避免「重新分析」时旧 stream / 旧历史写脏 state
|
||
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
|
||
|
||
### 6.3 `HighFidelityReport`(Step 3)
|
||
|
||
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
|
||
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
|
||
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
|
||
- **文件预览策略**(`shouldPreviewInModal`):
|
||
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
|
||
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
|
||
- 命中规则:
|
||
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
|
||
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
|
||
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
|
||
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
|
||
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
|
||
- **Step 3 不显示左侧栏(2026-06)**:`LeftSidebar` 在 `currentStep === 3` 时 `return null`(原「参与与结论贡献」列已移除),右侧栏在 Step !== 1 时本就 `return null`,因此中间 `middle-viewport`(`flex-1`)会自适应撑满整行,报告区获得最大宽度。
|
||
|
||
### 6.4 `ArtifactPreviewModal`
|
||
|
||
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
|
||
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
|
||
- 其它文本类 → `<pre>` 直出
|
||
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
|
||
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
|
||
|
||
### 6.5 共享 UI 模式与辅助
|
||
|
||
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
|
||
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
|
||
- `connecting` → 「调度中」
|
||
- `connected` → 「已连接,等待响应」
|
||
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
|
||
- `tool_result` → 「处理工具结果」
|
||
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
|
||
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
|
||
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
|
||
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
|
||
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
|
||
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
|
||
|
||
---
|
||
|
||
## 7. 澄清交互(与主聊天对齐)
|
||
|
||
| 能力 | 实现位置 |
|
||
|------|----------|
|
||
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
|
||
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
|
||
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
|
||
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
|
||
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
|
||
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
|
||
|
||
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
|
||
|
||
**关键差异**:
|
||
|
||
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
|
||
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
|
||
|
||
---
|
||
|
||
## 8. 与主聊天的差异
|
||
|
||
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|
||
|------|-----------------------------------|----------|
|
||
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
|
||
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
|
||
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
|
||
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
|
||
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
|
||
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
|
||
|
||
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
|
||
|
||
---
|
||
|
||
## 9. 关键交互清单
|
||
|
||
| 交互 | 行为 |
|
||
|------|------|
|
||
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
|
||
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
|
||
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
|
||
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
|
||
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
|
||
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
|
||
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
|
||
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
|
||
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
|
||
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
|
||
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
|
||
|
||
---
|
||
|
||
## 10. 类型与常量速查
|
||
|
||
```ts
|
||
// Step 1 输出 → Step 2 输入
|
||
interface IntentSummary {
|
||
objective: string;
|
||
constraints: string[];
|
||
assumptions?: string[];
|
||
}
|
||
|
||
// Step 2 线程映射(init_done.thread_ids)
|
||
type ThreadIdMap = Record<string, string>;
|
||
|
||
// 派活
|
||
type LeaderDispatch = [string, string]; // [agent_name, task]
|
||
|
||
// 当前选中的研讨席位
|
||
type SelectedAgent = { agent_id: string; name: string };
|
||
|
||
// Step 1 单条澄清问题
|
||
interface ClarificationPayload {
|
||
question: string;
|
||
clarificationType: string;
|
||
context: string;
|
||
options: unknown[];
|
||
allowCustom: boolean;
|
||
allowMultiple?: boolean;
|
||
}
|
||
|
||
// Step 2 stream 终态
|
||
interface StreamMultiAgentResult {
|
||
dispatched: LeaderDispatch[];
|
||
message: string | null;
|
||
content: string;
|
||
agentName: string | null;
|
||
clarification: ClarificationPayload | null;
|
||
}
|
||
```
|
||
|
||
**核心常量**:
|
||
|
||
| 常量 | 值 | 位置 |
|
||
|------|----|------|
|
||
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
|
||
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
|
||
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
|
||
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
|
||
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
|
||
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
|
||
| ~~`STORAGE_KEY`~~ | 已废弃 —— 草稿改存后端 `/api/roundtable-drafts`,不再用 localStorage | (旧 utils/drafts.ts,已删除) |
|
||
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
|
||
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
|
||
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
|
||
|
||
---
|
||
|
||
## 11. 故障排查
|
||
|
||
| 现象 | 可能原因 | 排查 |
|
||
|------|----------|------|
|
||
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
|
||
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
|
||
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
|
||
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
|
||
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
|
||
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
|
||
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
|
||
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
|
||
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
|
||
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
|
||
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
|
||
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
|
||
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
|
||
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
|
||
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
|
||
|
||
**本地调试开关**:
|
||
|
||
```js
|
||
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
|
||
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
|
||
```
|
||
|
||
---
|
||
|
||
## 12. 扩展指南
|
||
|
||
### 12.1 新增默认研讨席位
|
||
|
||
1. 后端 `.deer-flow/agents/roundtable-xxx/`
|
||
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
|
||
3. 无需改 API 层(`agent_names` 由推荐结果传入)
|
||
|
||
### 12.2 调整 Step 1 澄清轮次上限
|
||
|
||
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
|
||
|
||
### 12.3 调整 Step 2 编排上限
|
||
|
||
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
|
||
|
||
### 12.4 把 special 改回并行
|
||
|
||
不推荐;若一定要做:
|
||
|
||
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
|
||
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
|
||
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
|
||
|
||
### 12.5 接入真实 Persona 覆盖
|
||
|
||
当前 prompt / temp 只在前端记录;要落地需:
|
||
|
||
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
|
||
- 前端:`agentConfigs[meta.speakerId]` 一并传入
|
||
|
||
### 12.6 拆分页面(已完成 · 2026-05)
|
||
|
||
主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
|
||
|
||
- `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步
|
||
- `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清
|
||
- `hooks/useDraftPersistence.ts` —— 草稿自动保存 / 手动保存 / 加载 / 重命名 / 删除
|
||
- `components/Step{1,2,3}Panel.tsx` —— 纯 UI,只接收 props,不持有业务状态
|
||
- `components/PersonaDrawer.tsx` —— Persona 抽屉
|
||
- `components/Avatars.tsx` —— 所有 SVG 头像 + getAvatar
|
||
- `lib/roundtable-constants.ts` —— 常量、类型、纯工具函数(buildRoleTemplates 等)
|
||
- `lib/step-display.tsx` —— ChainOfThought 折叠卡的图标 + 文案 + 子节点渲染
|
||
|
||
**主页面只保留**:顶栏(StepNavigation / HistoryDropdown)、左右两侧栏(LeftSidebar / RightSidebar)、步骤路由、推荐弹窗,以及编排 hook 与跨步 handler(`handleConfirmAgents` / `handleStartNewTask`)。
|
||
|
||
继续重构请遵守:
|
||
|
||
1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。
|
||
2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。
|
||
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `api/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改;后端以 JSON 整存,不强约束嵌套字段。
|
||
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持 0 个新增错误(预存量错误来自其它无关模块如 `strategy-components` / `next/link` shim,与本特性无关)。
|
||
|
||
### 12.7 文档协同
|
||
|
||
`@/roundtable-planning/` 内一切公共 API 都应当:
|
||
|
||
1. 在 `api/*.ts` 文件级注释里说明 endpoint
|
||
2. 在本页第 4 节 / 第 10 节同步对照
|
||
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
|
||
|
||
---
|
||
|
||
## 13. 路径速查
|
||
|
||
| 资源 | 路径 |
|
||
|------|------|
|
||
| 主页面(组合 + 顶栏 + 侧栏) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||
| Step 1 hook | `frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts` |
|
||
| Step 2 hook(含编排循环) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` |
|
||
| 草稿 hook | `frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts` |
|
||
| Step 1 面板 | `frontend-web/src/roundtable-planning/components/Step1Panel.tsx` |
|
||
| Step 2 面板 | `frontend-web/src/roundtable-planning/components/Step2Panel.tsx` |
|
||
| Step 3 面板 | `frontend-web/src/roundtable-planning/components/Step3Panel.tsx` |
|
||
| 消息气泡共享件 | `frontend-web/src/roundtable-planning/components/MessageBubble.tsx` |
|
||
| 步骤折叠卡(工具/思考) | `frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx` |
|
||
| `<think>` 提取工具 | `frontend-web/src/roundtable-planning/lib/reasoning.ts` |
|
||
| Persona 抽屉 | `frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx` |
|
||
| 头像集合 | `frontend-web/src/roundtable-planning/components/Avatars.tsx` |
|
||
| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` |
|
||
| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
|
||
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||
| 推荐 API(含推荐历史) | `frontend-web/src/roundtable-planning/api/recommend.ts` |
|
||
| 草稿 API(后端 CRUD) | `frontend-web/src/roundtable-planning/api/drafts.ts` |
|
||
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
|
||
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
|
||
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
|
||
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
|
||
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
|
||
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
|
||
| 后端草稿/历史路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py` |
|
||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
|
||
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
|
||
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
|
||
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
|