From 23d6f9efc2505aac76691d0b7e7830884e6aab5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 31 May 2026 14:23:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=86=E6=A1=8C=20Step1=EF=BC=9A=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E6=BE=84=E6=B8=85=E5=AE=9E=E6=97=B6=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E4=B8=8E=E5=90=88=E5=B9=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流式解析 ask_clarification 逐题渲染澄清卡;草稿恢复 selectedAgents 修复参与角色;Step3 隐藏左侧栏让报告区全宽。 Co-authored-by: Cursor --- frontend-web/docs/multi-agent-frontend-dev.md | 22 ++- .../src/roundtable-planning/api/drafts.ts | 6 + .../src/roundtable-planning/api/intent.ts | 99 ++++++++++++- .../roundtable-planning/api/multi-agent.ts | 7 + .../components/IntentClarificationCard.tsx | 43 +++++- .../components/Step1Panel.tsx | 130 ++++++++++++------ .../hooks/useStep1Intent.ts | 130 ++++++++++++++---- .../hooks/useStep2Orchestration.ts | 13 ++ .../pages/RoundtablePlanningPage.tsx | 62 +-------- 9 files changed, 376 insertions(+), 136 deletions(-) diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index ea5235f7..a8d8e82c 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -184,6 +184,7 @@ hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型 - `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消) - `onTextDelta(text, messageId)`:每个 AI 文本增量 - `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling` +- **`onClarification(clarification, index)`(2026-06)**:每解析到一条 `ask_clarification` 工具结果帧就回调一次,让前端**实时**逐个渲染澄清卡。任务理解智能体**一轮可能问多个问题**,这是「实时接收显示协助过程」的关键入口。 **`StreamIntentResult`**: @@ -192,14 +193,16 @@ hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型 | `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 | | `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) | | `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 | -| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 | +| `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`) +- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`);`status==="clarification"` 且**未**收到任何流式 tool 澄清帧时,才用终态帧字段兜底合成单条澄清 **Abort 行为**: @@ -436,7 +439,8 @@ isOrchestrating = false > **重大变更**:草稿从浏览器 `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.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。 +- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。 + - **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。 - `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。 - **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。 - **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。 @@ -460,13 +464,20 @@ isOrchestrating = false ### 6.1 `IntentClarificationCard` -- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent` +- 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;补充:…` -- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片) +- **单问题 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`,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` @@ -502,6 +513,7 @@ isOrchestrating = false - 各席位最终交付(中段折叠区)数据源:父组件传入的 `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` diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index 008305a5..b3278770 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -30,6 +30,12 @@ export interface DraftStep1Snapshot { } export interface DraftStep2Snapshot { + /** + * The seats actually selected for this session. Restored on load so the + * Step 2/3 「参与角色」panel shows the chosen agents instead of the default 6. + * Optional — drafts saved before 2026-06 won't carry it. + */ + selectedAgents?: Array<{ agent_id: string; name: string }>; threadIds: ThreadIdMap | null; coordinatorName: string | null; step2RoundtableDialogues: unknown[]; diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index d45eae3f..f88e2327 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -160,6 +160,14 @@ export interface StreamIntentRequest { toolName?: string, args?: Record, ) => void; + /** + * Fires for every `ask_clarification` tool message parsed out of the live + * stream (the intent agent may emit several in a single turn). Lets the UI + * surface each assistance question in real-time instead of waiting for the + * terminal frame — which only carries the *first* question. `index` is the + * 0-based arrival order within this turn. + */ + onClarification?: (clarification: ClarificationPayload, index: number) => void; } export interface StreamIntentResult { @@ -172,8 +180,16 @@ export interface StreamIntentResult { /** * Populated when the intent agent called ask_clarification — render option * cards + custom input instead of treating the turn as plain text only. + * This is the *first* clarification (kept for backward compat); prefer + * `clarifications` which carries the full set. */ clarification: ClarificationPayload | null; + /** + * All `ask_clarification` questions the agent asked this turn, in arrival + * order. Collected from the live `type:"tool"` frames (the terminal frame + * only carries the first). Empty unless status === "clarification". + */ + clarifications: ClarificationPayload[]; /** * Populated only when status === "error" and the backend emitted a * structured diagnostic (new in `_diagnose_exception`). UI uses this to show @@ -241,6 +257,54 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul return { text, id }; } +/** + * Parse a live `type:"tool"` `ask_clarification` frame into a ClarificationPayload. + * + * The intent agent emits one such frame per question (ClarificationMiddleware + * formats them), each carrying a fully-formed clarification in + * `additional_kwargs.clarification` — options already normalized to + * `{id, label}` objects, snake_case keys. Returns null for any other frame + * (AI text chunks, tool_call_chunks, value snapshots). + */ +function extractClarificationToolMessage( + parsed: unknown, +): { payload: ClarificationPayload; toolCallId: string } | null { + if (!Array.isArray(parsed) || parsed.length < 1) return null; + const msg = parsed[0]; + if (!msg || typeof msg !== "object") return null; + const obj = msg as Record; + if (String(obj.type ?? "").toLowerCase() !== "tool") return null; + if (obj.name !== "ask_clarification") return null; + const kwargs = obj.additional_kwargs; + if (!kwargs || typeof kwargs !== "object") return null; + const clar = (kwargs as Record).clarification; + if (!clar || typeof clar !== "object") return null; + const c = clar as Record; + const toolCallId = + typeof obj.tool_call_id === "string" && obj.tool_call_id + ? obj.tool_call_id + : typeof obj.id === "string" + ? obj.id + : ""; + const clarificationType = + typeof c.clarification_type === "string" ? c.clarification_type : ""; + return { + toolCallId, + payload: { + id: toolCallId || undefined, + question: typeof c.question === "string" ? c.question : "", + clarificationType, + context: typeof c.context === "string" ? c.context : "", + options: Array.isArray(c.options) ? c.options : [], + allowCustom: c.allow_custom !== false, + allowMultiple: resolveAllowMultiple( + typeof c.allow_multiple === "boolean" ? c.allow_multiple : undefined, + clarificationType, + ), + }, + }; +} + /** * Open an SSE connection to /api/intent/stream and resolve when the upstream * stream completes. Each call is one user turn; reuse the same threadId @@ -288,9 +352,24 @@ export async function streamIntent(req: StreamIntentRequest): Promise(); + const collectClarification = (payload: ClarificationPayload, toolCallId: string) => { + const dedupeKey = toolCallId || `idx-${result.clarifications.length}`; + if (seenClarificationIds.has(dedupeKey)) return; + seenClarificationIds.add(dedupeKey); + const index = result.clarifications.length; + result.clarifications.push(payload); + if (!result.clarification) result.clarification = payload; + req.onClarification?.(payload, index); + }; + while (true) { const { done, value } = await reader.read(); if (done) break; @@ -331,10 +410,15 @@ export async function streamIntent(req: StreamIntentRequest): Promise void; + /** + * Defer submission: even single-select option clicks only *record* the + * selection (via onSelectionChange) instead of sending immediately, and the + * card hides its own submit button. Used when several questions are answered + * together and the parent owns one combined submit button. + */ + deferSubmit?: boolean; } export function IntentClarificationCard({ @@ -82,6 +103,7 @@ export function IntentClarificationCard({ disabled = false, submitting = false, onSubmit, + deferSubmit = false, }: IntentClarificationCardProps) { const options = useMemo( () => normalizeOptions(Array.isArray(payload.options) ? payload.options : []), @@ -97,11 +119,18 @@ export function IntentClarificationCard({ return selection.selectedIds.length > 0 || selection.custom.trim().length > 0; }, [selection.custom, selection.selectedIds]); - const showSubmitButton = allowMultiple || payload.allowCustom !== false; + // In defer mode the parent owns the (combined) submit button. + const showSubmitButton = !deferSubmit && (allowMultiple || payload.allowCustom !== false); function handleOptionClick(optionId: string) { if (disabled || submitting) return; if (!allowMultiple) { + if (deferSubmit) { + // Single-select but combined submit: just record the choice (replace), + // keep any custom text the user already typed. + onSelectionChange({ ...selection, selectedIds: [optionId] }); + return; + } onSubmit({ selectedIds: [optionId], custom: "" }); return; } @@ -144,6 +173,10 @@ export function IntentClarificationCard({ {options.map((option) => { const isSelected = selection.selectedIds.includes(option.id); + // Highlight selected options whenever the choice is "sticky" — i.e. + // multi-select, or single-select in defer mode (where a click records + // rather than immediately sends). + const showSelected = isSelected && (allowMultiple || deferSubmit); return ( diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx index 6184e20d..381e2c35 100644 --- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx @@ -25,8 +25,10 @@ import { X, } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars"; import { + clarificationKey, IntentClarificationCard, type IntentClarificationSelection, } from "@/roundtable-planning/components/IntentClarificationCard"; @@ -55,10 +57,10 @@ export interface Step1PanelProps { intentReady: object | null; isIntentSending: boolean; pendingIntentClarificationMsgId: number | null; - intentClarificationSelections: Map; + intentClarificationSelections: Map; // ─── Setters / Handlers ────────────────────────────────────── setIntentClarificationSelections: React.Dispatch< - React.SetStateAction> + React.SetStateAction> >; onSendMessage: (text: string) => void; onStopIntent: () => void; @@ -67,10 +69,10 @@ export interface Step1PanelProps { onEnterRoundtable: () => void; onManualSave: () => void; onTriggerToast: (msg: string) => void; - submitIntentClarification: ( + submitIntentClarifications: ( msgId: number, - payload: ClarificationPayload, - overrideSelection?: IntentClarificationSelection, + clarifications: ClarificationPayload[], + overrideByKey?: Record, ) => void; } @@ -90,7 +92,7 @@ export function Step1Panel(props: Step1PanelProps) { onEnterRoundtable, onManualSave, onTriggerToast, - submitIntentClarification, + submitIntentClarifications, } = props; const step1ScrollRef = useRef(null); @@ -138,7 +140,7 @@ export function Step1Panel(props: Step1PanelProps) { intentClarificationSelections={intentClarificationSelections} setIntentClarificationSelections={setIntentClarificationSelections} isIntentSending={isIntentSending} - submitIntentClarification={submitIntentClarification} + submitIntentClarifications={submitIntentClarifications} /> ))} @@ -266,15 +268,15 @@ interface Step1MessageProps { msg: InteractiveMessage; intentReady: object | null; pendingIntentClarificationMsgId: number | null; - intentClarificationSelections: Map; + intentClarificationSelections: Map; setIntentClarificationSelections: React.Dispatch< - React.SetStateAction> + React.SetStateAction> >; isIntentSending: boolean; - submitIntentClarification: ( + submitIntentClarifications: ( msgId: number, - payload: ClarificationPayload, - overrideSelection?: IntentClarificationSelection, + clarifications: ClarificationPayload[], + overrideByKey?: Record, ) => void; } @@ -285,7 +287,7 @@ function Step1Message({ intentClarificationSelections, setIntentClarificationSelections, isIntentSending, - submitIntentClarification, + submitIntentClarifications, }: Step1MessageProps) { const isStreaming = msg.streaming === true; const isAgent = msg.sender !== "user"; @@ -297,24 +299,23 @@ function Step1Message({ ? splitInlineReasoning(rawContent) : { content: rawContent, reasoning: null }; const hasContent = thoughtFreeContent.length > 0; - const clarification = msg.clarification; + // 一轮可能有多条澄清问题(任务理解智能体一次问多个)。流式期就逐个灌进来, + // 实时展示「协助过程」;终态再以权威集合覆盖。 + const clarifications = msg.clarifications ?? []; const parts = isAgent ? splitIntentReadyBlock(thoughtFreeContent) : { before: thoughtFreeContent, summary: null as IntentSummary | null, after: "" }; - const isPendingClarification = - isAgent && - clarification && - !intentReady && - !parts.summary && - pendingIntentClarificationMsgId === msg.id; - const clarificationSelection = - intentClarificationSelections.get(msg.id) ?? { - selectedIds: [], - custom: "", - }; + // 是否展示澄清卡(还没出 summary / 还没整体就绪时)。流式中也展示(只读), + // 终态且该消息为当前待澄清条目时才可交互。 + const showClarifications = + isAgent && clarifications.length > 0 && !intentReady && !parts.summary; + // 一轮多个问题才进入「逐题作答 + 合并发送」模式;单问题沿用「点击即发送」。 + const multiQuestion = clarifications.length > 1; + const isPending = pendingIntentClarificationMsgId === msg.id; + const clarificationsInteractive = isPending && !isStreaming; const introText = - clarification && - parts.before.trim() === (clarification.question || "").trim() + clarifications.length === 1 && + parts.before.trim() === (clarifications[0].question || "").trim() ? "" : parts.before; @@ -361,24 +362,63 @@ function Step1Message({ 把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。 */} {isStreaming && } - {isPendingClarification && clarification && ( - { - setIntentClarificationSelections((prev) => { - const map = new Map(prev); - map.set(msg.id, next); - return map; - }); - }} - disabled={!isPendingClarification} - submitting={isIntentSending && Boolean(isPendingClarification)} - onSubmit={(override) => - submitIntentClarification(msg.id, clarification, override) - } - /> + {showClarifications && ( +
+ {clarifications.map((clar, index) => { + const key = clarificationKey(msg.id, clar.id, index); + const selection = + intentClarificationSelections.get(key) ?? { + selectedIds: [], + custom: "", + }; + return ( + { + setIntentClarificationSelections((prev) => { + const map = new Map(prev); + map.set(key, next); + return map; + }); + }} + disabled={!clarificationsInteractive} + submitting={isIntentSending && isPending} + onSubmit={(override) => + submitIntentClarifications( + msg.id, + clarifications, + // 单题单选「点击即发送」时选择还没进 state map,把即时值 + // 按 key 传进去直接用。 + override ? { [key]: override } : undefined, + ) + } + /> + ); + })} + {/* 合并提交按钮仅多问题时出现;单问题点选项即发送,无需此按钮。 */} + {multiQuestion && clarificationsInteractive && ( +
+ + 共 {clarifications.length} 个问题,可逐个作答后一并发送 + + +
+ )} +
)} ) : ( diff --git a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts index fc637a3b..89ec494e 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts @@ -22,7 +22,11 @@ import { type IntentStreamPhase, type IntentSummary, } from "@/roundtable-planning/api/intent"; -import { formatIntentClarificationAnswer, type IntentClarificationSelection } from "@/roundtable-planning/components/IntentClarificationCard"; +import { + clarificationKey, + formatIntentClarificationAnswer, + type IntentClarificationSelection, +} from "@/roundtable-planning/components/IntentClarificationCard"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; import { nowTime, @@ -38,7 +42,15 @@ export type InteractiveMessage = { time: string; streaming?: boolean; phase?: string; + /** First clarification (back-compat); prefer `clarifications`. */ clarification?: ClarificationPayload; + /** + * All `ask_clarification` questions the agent asked this turn (the agent may + * ask several at once). Populated live from the stream so each question card + * appears in real-time. Each carries a stable `id` (tool_call_id) for keying + * per-question selections. + */ + clarifications?: ClarificationPayload[]; /** * ChainOfThought 折叠卡的步骤累积。每次 `onPhaseChange` 都会把当前 phase + toolName * 拼成 `phase:toolName` 作为 key,与 Step 2 的 `Step2Dialogue.steps` 同形。 @@ -86,23 +98,30 @@ export interface UseStep1IntentReturn { intentInitErrorDetail: IntentErrorDetail | null; isIntentSending: boolean; pendingIntentClarificationMsgId: number | null; - intentClarificationSelections: Map; + /** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */ + intentClarificationSelections: Map; interactiveMessages: InteractiveMessage[]; timelineEntries: TimelineEntry[]; // ─── Mutators ──────────────────────────────────────────────────────── setIntentClarificationSelections: React.Dispatch< - React.SetStateAction> + React.SetStateAction> >; // ─── Handlers ──────────────────────────────────────────────────────── /** 清空当前 Step 1 状态并重新拉取 intent thread。 */ runStep1Init: () => void; /** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */ sendIntentMessage: (userText: string) => Promise; - /** 提交澄清卡片的选项 / 自定义回答。 */ - submitIntentClarification: ( + /** + * 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息 + * 发回。至少答一题才会发送。单题时退化为发送该题答案。 + * + * `overrideByKey`:单题「点击即发送」用 —— 单选卡点击选项时选择还没写进 state + * map(setState 异步),把该题的即时选择按 `clarificationKey` 传进来直接用。 + */ + submitIntentClarifications: ( msgId: number, - payload: ClarificationPayload, - overrideSelection?: IntentClarificationSelection, + clarifications: ClarificationPayload[], + overrideByKey?: Record, ) => void; /** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */ stopIntentStream: () => void; @@ -143,7 +162,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState(null); const [intentClarificationSelections, setIntentClarificationSelections] = useState< - Map + Map >(new Map()); // 原始页面带了一段写死的 demo 对话,只是首次进入 Step 1 还没初始化前的占位; @@ -355,10 +374,53 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe }), ); }, + onClarification: (clarification, index) => { + // 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现, + // 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条 + // 「追问 N:<问题>」,与「强化步骤链」诉求对齐。 + const clarId = clarification.id ?? `idx-${index}`; + const stepKey = `clarification:${clarId}`; + const qLabel = clarification.question + ? `追问 ${index + 1}:${ + clarification.question.length > 24 + ? clarification.question.slice(0, 24) + "…" + : clarification.question + }` + : `追问 ${index + 1}`; + setInteractiveMessages((prev) => + prev.map((m) => { + if (m.id !== agentMsgId) return m; + const list = m.clarifications ?? []; + // 按 id 去重 / 覆盖,流式重发同一条时不重复 push。 + const existingIdx = list.findIndex( + (c) => (c.id ?? "") === (clarification.id ?? ""), + ); + const nextList = + existingIdx >= 0 && clarification.id + ? list.map((c, i) => (i === existingIdx ? clarification : c)) + : [...list, clarification]; + const steps = m.steps ?? []; + const hasStep = steps.some((s) => s.key === stepKey); + const nextSteps = hasStep + ? steps + : [ + ...steps, + { + id: `${agentMsgId}-clar-${clarId}`, + key: stepKey, + label: qLabel, + ts: nowTime(), + }, + ]; + return { ...m, clarifications: nextList, steps: nextSteps }; + }), + ); + }, }); const parsedFromContent = splitIntentReadyBlock(res.content || "").summary; const resolvedSummary = res.summary ?? parsedFromContent; + const finalClarifications = resolvedSummary ? [] : res.clarifications; setInteractiveMessages((prev) => prev.map((m) => m.id === agentMsgId @@ -366,9 +428,13 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe ...m, content: res.content || m.content || "(无文本输出)", streaming: false, + // 终态以 res.clarifications 为权威集合(覆盖流式期增量构建的), + // 既保证多问题完整,也在解析出 summary 时清空澄清卡。 clarification: resolvedSummary ? undefined : res.clarification ?? undefined, + clarifications: + finalClarifications.length > 0 ? finalClarifications : undefined, } : m, ), @@ -390,11 +456,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe }, ]); triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨"); - } else if (res.status === "clarification" && res.clarification) { + } else if (res.status === "clarification" && res.clarifications.length > 0) { setPendingIntentClarificationMsgId(agentMsgId); setIntentClarificationSelections((prev) => { const next = new Map(prev); - next.set(agentMsgId, { selectedIds: [], custom: "" }); + res.clarifications.forEach((c, i) => { + const key = clarificationKey(agentMsgId, c.id, i); + if (!next.has(key)) next.set(key, { selectedIds: [], custom: "" }); + }); return next; }); } else if (res.status === "error") { @@ -448,24 +517,37 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe [intentThreadId, triggerToast], ); - const submitIntentClarification = useCallback( + const submitIntentClarifications = useCallback( ( msgId: number, - payload: ClarificationPayload, - overrideSelection?: IntentClarificationSelection, + clarifications: ClarificationPayload[], + overrideByKey?: Record, ) => { - const selection = - overrideSelection ?? - intentClarificationSelections.get(msgId) ?? { - selectedIds: [], - custom: "", - }; - const answer = formatIntentClarificationAnswer(payload, selection); - if (!answer) { - triggerToast("请先选择选项或输入回答"); + // 逐题取选择并格式化;只把答了的题拼进去。 + const answered = clarifications + .map((payload, index) => { + const key = clarificationKey(msgId, payload.id, index); + const selection = + overrideByKey?.[key] ?? + intentClarificationSelections.get(key) ?? { selectedIds: [], custom: "" }; + const answer = formatIntentClarificationAnswer(payload, selection); + return answer ? { question: payload.question, answer } : null; + }) + .filter((x): x is { question: string; answer: string } => x !== null); + + if (answered.length === 0) { + triggerToast("请至少回答一个问题"); return; } - void sendIntentMessage(answer); + + // 单题:直接发答案(与旧行为一致)。多题:带上问题做编号 Q/A,合并成一条消息。 + const combined = + answered.length === 1 + ? answered[0].answer + : answered + .map((qa, i) => `${i + 1}. ${qa.question}\n答:${qa.answer}`) + .join("\n\n"); + void sendIntentMessage(combined); }, [intentClarificationSelections, sendIntentMessage, triggerToast], ); @@ -499,7 +581,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe setIntentClarificationSelections, runStep1Init, sendIntentMessage, - submitIntentClarification, + submitIntentClarifications, stopIntentStream, hydrateFromDraft, appendLocalMessage, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 6c9ac6fa..c025126d 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -84,6 +84,13 @@ export type AgentConfig = { /** 草稿持久化的 Step 2 快照。 */ export type Step2Snapshot = { + /** + * 本次研讨实际选中的席位 —— 加载草稿后据此重建左侧「参与角色」面板。 + * 不存的话(2026-06 之前的旧草稿)hydrate 会回退到 DEFAULT_SELECTED_AGENTS, + * 导致 Step 2/3 左侧显示默认 6 个而非当时真正选的那几个。反序列化时 + * 老草稿按 undefined 兜底(保留当前 selectedAgents,不强行覆盖)。 + */ + selectedAgents?: SelectedAgent[]; threadIds: ThreadIdMap | null; coordinatorName: string | null; step2RoundtableDialogues: Step2Dialogue[]; @@ -1420,6 +1427,12 @@ export function useStep2Orchestration( setPendingClarification(null); setClarificationDraft(""); + // 先恢复 selectedAgents —— roleTemplates / agentNameToRole 都由它派生, + // 主页面的「参与角色」面板据此重建,否则会停留在默认 6 个席位。 + // 老草稿没有这字段时保持当前值不动(避免清空成空数组)。 + if (snapshot.selectedAgents && snapshot.selectedAgents.length > 0) { + setSelectedAgents(snapshot.selectedAgents); + } setThreadIds(snapshot.threadIds); setCoordinatorName(snapshot.coordinatorName); setStep2RoundtableDialogues(snapshot.step2RoundtableDialogues); diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 1aa88409..91832475 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -63,8 +63,6 @@ import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orche import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence"; import { BUILTIN_AGENT_META, - getAvatarTypeFor, - getDisplayNameFor, makeRolesFromTemplates, nowTime, type RoleEntry, @@ -187,6 +185,7 @@ export default function RoundtablePlanningPage() { timelineEntries: step1.timelineEntries, }), getStep2Snapshot: () => ({ + selectedAgents: step2.selectedAgents, threadIds: step2.threadIds, coordinatorName: step2.coordinatorName, step2RoundtableDialogues: step2.step2RoundtableDialogues, @@ -438,7 +437,6 @@ export default function RoundtablePlanningPage() { currentStep={currentStep} roles={roles} agentNameToRole={step2.agentNameToRole} - selectedAgents={step2.selectedAgents} budgetLimit={step2.budgetLimit} intentReady={step1.intentReady} sidebarExpanded={step2SidebarExpanded} @@ -474,7 +472,7 @@ export default function RoundtablePlanningPage() { }} onManualSave={drafts.handleManualSave} onTriggerToast={triggerToast} - submitIntentClarification={step1.submitIntentClarification} + submitIntentClarifications={step1.submitIntentClarifications} /> )} @@ -814,7 +812,6 @@ interface LeftSidebarProps { string, { speakerId: number; avatarType: string; displayName: string } >; - selectedAgents: SelectedAgent[]; budgetLimit: number; intentReady: ReturnType["intentReady"]; sidebarExpanded: boolean; @@ -851,7 +848,6 @@ function LeftSidebar({ currentStep, roles, agentNameToRole, - selectedAgents, budgetLimit: _budgetLimit, intentReady, sidebarExpanded, @@ -859,6 +855,10 @@ function LeftSidebar({ }: LeftSidebarProps) { const isStep2 = currentStep === 2; + // Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应 + // 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。 + if (currentStep === 3) return null; + return (