圆桌 Step1:支持多澄清实时展示与合并提交

流式解析 ask_clarification 逐题渲染澄清卡;草稿恢复 selectedAgents 修复参与角色;Step3 隐藏左侧栏让报告区全宽。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-05-31 14:23:43 +08:00
1 parent 838cae0d2d
commit 23d6f9efc2
9 files changed
+376 -136

No files matched your search

+17 -5
View File
@@ -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<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`
@@ -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`
@@ -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[];
@@ -160,6 +160,14 @@ export interface StreamIntentRequest {
toolName?: string,
args?: Record<string, unknown>,
) => 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<string, unknown>;
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<string, unknown>).clarification;
if (!clar || typeof clar !== "object") return null;
const c = clar as Record<string, unknown>;
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<StreamInte
content: "",
summary: null,
clarification: null,
clarifications: [],
errorDetail: null,
};
// Clarifications collected live from `type:"tool"` frames, deduped by
// tool_call_id. The terminal frame only carries the first question, so this
// is the source of truth for multi-question turns.
const seenClarificationIds = new Set<string>();
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<StreamInte
if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
}
if (parsed.status === "clarification") {
if (parsed.status === "clarification" && result.clarifications.length === 0) {
// Fallback path: no live `type:"tool"` clarification frames were seen
// (older backend, or single-question turn) — synthesize one from the
// terminal frame so the UI still gets a card. When tool frames *were*
// seen, `clarifications` already holds the full (possibly multi-question)
// set and we keep it.
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
result.clarification = {
const fallback: ClarificationPayload = {
question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType,
context:
@@ -346,11 +430,22 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
clarificationType,
),
};
result.clarification = fallback;
result.clarifications = [fallback];
}
continue;
}
if (lastEvent === "messages") {
// `ask_clarification` tool result frame → collect + surface in real-time.
// These carry the fully-formed clarification (one per question); the
// agent may emit several in a single turn.
const toolClar = extractClarificationToolMessage(parsed);
if (toolClar) {
collectClarification(toolClar.payload, toolClar.toolCallId);
continue;
}
// Phase inference from LangGraph messages-tuple frames. Tool calls
// (typically ask_clarification) flip phase to tool_calling; AI text
// chunks flip to streaming. Everything else leaves phase alone.
@@ -227,6 +227,13 @@ export interface StreamMultiAgentRequest {
}
export interface ClarificationPayload {
/**
* Stable id for this clarification — the originating `ask_clarification`
* tool_call_id. Lets the UI key per-question selections when the agent asks
* several clarifications in one turn (Step 1 multi-question support).
* Omitted on the Step 2 leader path (single clarification per turn).
*/
id?: string;
/** Raw question text from the model's ask_clarification args. */
question: string;
/** Optional category hint from the model (e.g. "missing_info"). */
@@ -12,6 +12,20 @@ export type IntentClarificationSelection = {
custom: string;
};
/**
* Composite selection-map key for one clarification question. When the intent
* agent asks several questions in one turn, each needs its own selection state,
* so we key by `${msgId}::${clarificationId}` (falling back to the arrival
* index when the payload has no id). The map itself lives in `useStep1Intent`.
*/
export function clarificationKey(
msgId: number,
clarId: string | undefined,
index: number,
): string {
return `${msgId}::${clarId ?? `idx-${index}`}`;
}
type ClarificationOption = {
id: string;
label: string;
@@ -72,6 +86,13 @@ interface IntentClarificationCardProps {
submitting?: boolean;
/** Multi-select: submit current selection. Single-select: also used for custom text. */
onSubmit: (overrideSelection?: IntentClarificationSelection) => 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({
</div>
{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 (
<button
key={option.id}
@@ -152,10 +185,10 @@ export function IntentClarificationCard({
onClick={() => handleOptionClick(option.id)}
className={cn(
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
isSelected && allowMultiple
showSelected
? "border-primary bg-primary text-white shadow-sm"
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
!allowMultiple && !deferSubmit && !disabled && !submitting && "active:scale-[0.99]",
(disabled || submitting) && "cursor-not-allowed opacity-60",
)}
>
@@ -165,14 +198,14 @@ export function IntentClarificationCard({
<span
className={cn(
"text-[10px] leading-snug",
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
showSelected ? "text-white/80" : "text-slate-500",
)}
>
{option.description}
</span>
) : null}
</span>
{isSelected && allowMultiple ? (
{showSelected ? (
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
) : null}
</button>
@@ -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<number, IntentClarificationSelection>;
intentClarificationSelections: Map<string, IntentClarificationSelection>;
// ─── Setters / Handlers ──────────────────────────────────────
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
React.SetStateAction<Map<string, IntentClarificationSelection>>
>;
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<string, IntentClarificationSelection>,
) => void;
}
@@ -90,7 +92,7 @@ export function Step1Panel(props: Step1PanelProps) {
onEnterRoundtable,
onManualSave,
onTriggerToast,
submitIntentClarification,
submitIntentClarifications,
} = props;
const step1ScrollRef = useRef<HTMLDivElement>(null);
@@ -138,7 +140,7 @@ export function Step1Panel(props: Step1PanelProps) {
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarification={submitIntentClarification}
submitIntentClarifications={submitIntentClarifications}
/>
))}
</div>
@@ -266,15 +268,15 @@ interface Step1MessageProps {
msg: InteractiveMessage;
intentReady: object | null;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<number, IntentClarificationSelection>;
intentClarificationSelections: Map<string, IntentClarificationSelection>;
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
React.SetStateAction<Map<string, IntentClarificationSelection>>
>;
isIntentSending: boolean;
submitIntentClarification: (
submitIntentClarifications: (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
clarifications: ClarificationPayload[],
overrideByKey?: Record<string, IntentClarificationSelection>,
) => 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 && <StreamingCursor />}
{isPendingClarification && clarification && (
<IntentClarificationCard
payload={clarification}
fallbackContent={parts.before || undefined}
selection={clarificationSelection}
onSelectionChange={(next) => {
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 && (
<div className="space-y-2">
{clarifications.map((clar, index) => {
const key = clarificationKey(msg.id, clar.id, index);
const selection =
intentClarificationSelections.get(key) ?? {
selectedIds: [],
custom: "",
};
return (
<IntentClarificationCard
key={key}
payload={clar}
fallbackContent={clar.question || parts.before || undefined}
selection={selection}
// 单问题:沿用原交互(单选点击即发送 / 多选 / 自定义走卡片自己的按钮)。
// 多问题:才 defer —— 逐题只记录选择,由下方一个合并按钮统一发送。
deferSubmit={multiQuestion}
onSelectionChange={(next) => {
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 && (
<div className="flex items-center justify-between gap-2 pt-0.5">
<span className="text-[10px] text-slate-400">
共 {clarifications.length} 个问题,可逐个作答后一并发送
</span>
<Button
type="button"
size="sm"
disabled={isIntentSending}
onClick={() => submitIntentClarifications(msg.id, clarifications)}
className="h-8 rounded-xl px-4 text-xs font-bold"
>
{isIntentSending ? "发送中…" : "发送全部回答"}
</Button>
</div>
)}
</div>
)}
</div>
) : (
@@ -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<number, IntentClarificationSelection>;
/** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */
intentClarificationSelections: Map<string, IntentClarificationSelection>;
interactiveMessages: InteractiveMessage[];
timelineEntries: TimelineEntry[];
// ─── Mutators ────────────────────────────────────────────────────────
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<number, IntentClarificationSelection>>
React.SetStateAction<Map<string, IntentClarificationSelection>>
>;
// ─── Handlers ────────────────────────────────────────────────────────
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
runStep1Init: () => void;
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
sendIntentMessage: (userText: string) => Promise<void>;
/** 提交澄清卡片的选项 / 自定义回答。 */
submitIntentClarification: (
/**
* 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
*
* `overrideByKey`:单题「点击即发送」用 —— 单选卡点击选项时选择还没写进 state
* map(setState 异步),把该题的即时选择按 `clarificationKey` 传进来直接用。
*/
submitIntentClarifications: (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
clarifications: ClarificationPayload[],
overrideByKey?: Record<string, IntentClarificationSelection>,
) => void;
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
stopIntentStream: () => void;
@@ -143,7 +162,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
useState<number | null>(null);
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
Map<number, IntentClarificationSelection>
Map<string, IntentClarificationSelection>
>(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<string, IntentClarificationSelection>,
) => {
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,
@@ -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);
@@ -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<typeof useStep1Intent>["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 (
<section
id="left-sidebar"
@@ -879,55 +879,7 @@ function LeftSidebar({
</button>
)}
{currentStep === 3 ? (
<>
{/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用,
删掉以降低维护负担,需要时去 git history 翻 fdeffde 之前的 commit。 */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">参与与结论贡献</h2>
</div>
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{[
{
id: "human",
name: "用户 (指挥官)",
role: "人类介入协调",
icon: <Man1Avatar size="h-7 w-7" />,
},
...selectedAgents.map((agent, i) => ({
id: agent.agent_id,
name: getDisplayNameFor(agent),
role: BUILTIN_AGENT_META[agent.agent_id]
? `${BUILTIN_AGENT_META[agent.agent_id].displayName} 席位`
: "圆桌席位",
icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"),
})),
].map((contrib, idx) => (
<div
key={idx}
className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100 transition hover:border-emerald-100 hover:bg-emerald-50/10"
>
<div className="flex items-center gap-2.5">
{contrib.icon}
<div className="flex flex-col">
<span className="text-xs font-bold text-slate-800 tracking-tight leading-tight">
{contrib.name}
</span>
<span className="text-[9px] text-primary font-semibold">{contrib.role}</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0 text-emerald-600 bg-emerald-50 font-bold text-[9.5px] px-1.5 py-0.5 rounded-full">
<Check className="h-3 w-3 text-emerald-500 font-black" />
已贡献
</div>
</div>
))}
</div>
</div>
</>
) : currentStep === 2 ? (
{currentStep === 2 ? (
sidebarExpanded ? (
<>
<IntentSummaryCard