/** * Step 2 主面板 —— 多智能体圆桌研讨。 * * 包含 4 块: * 1) 顶部 Headline + 推理模型下拉 + 挂起/恢复按钮 * 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`) * 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown) * 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口 * * 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染) * 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 1 保持一致。 * * 圆桌可视化 + 博弈边界调控台目前**未启用**(原代码用 `false &&` 隐藏), * 这里保留同样的占位逻辑,后续要恢复时改 `SHOW_VISUALIZER` 即可。 * * 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。 */ import { useRef } from "react"; import { Cpu, FileIcon, HelpCircle, Loader2, Pause, Play, Sparkles, } from "lucide-react"; import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { getAvatar } from "@/roundtable-planning/components/Avatars"; import { MarkdownContent, MessageBubble, MessageLoadingPlaceholder, ReasoningBlock, StreamingCursor, } from "@/roundtable-planning/components/MessageBubble"; import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import type { AgentConfig, Step2Dialogue, } from "@/roundtable-planning/hooks/useStep2Orchestration"; import type { RoleEntry, } from "@/roundtable-planning/lib/roundtable-constants"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; interface ModelOption { name: string; display_name?: string; } export interface Step2PanelProps { // ─── State ──────────────────────────────────────────────────── step2RoundtableDialogues: Step2Dialogue[]; isInitializing: boolean; isRoundTableRunning: boolean; isOrchestrating: boolean; pendingClarification: ClarificationPayload | null; clarificationDraft: string; customAgentInput: string; hasConsensus: boolean; agentConfigs: Record; selectedAgentId: number | null; roles: RoleEntry[]; selectedModel: string; availableModels: ModelOption[]; modelsLoading: boolean; setSelectedModel: React.Dispatch>; activeArtifactThreadId: string | null; seatStubMessages: Record; openArtifactFromStep: ( dialogue: Step2Dialogue, step: NonNullable[number], ) => void; /** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */ openArtifactPreview: (threadId: string, path: string) => void; // ─── Setters ────────────────────────────────────────────────── setIsRoundTableRunning: React.Dispatch>; setClarificationDraft: React.Dispatch>; setCustomAgentInput: React.Dispatch>; setAgentConfigs: React.Dispatch< React.SetStateAction> >; setSelectedAgentId: React.Dispatch>; // ─── Handlers ───────────────────────────────────────────────── onSubmitClarification: (answer: string) => void; onInjectToRoundtable: () => void; onSavePersona: (id: number) => void; onProceedToStep3: () => void; onTriggerToast: (msg: string) => void; } export function Step2Panel(props: Step2PanelProps) { const { step2RoundtableDialogues, isInitializing, isRoundTableRunning, isOrchestrating, pendingClarification, clarificationDraft, customAgentInput, hasConsensus, agentConfigs, selectedAgentId, roles, selectedModel, availableModels, modelsLoading, setSelectedModel, activeArtifactThreadId, seatStubMessages, openArtifactFromStep, openArtifactPreview, setIsRoundTableRunning, setClarificationDraft, setCustomAgentInput, setAgentConfigs, setSelectedAgentId, onSubmitClarification, onInjectToRoundtable, onSavePersona, onProceedToStep3, onTriggerToast, } = props; const step2ScrollRef = useRef(null); const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); const isSandboxStreaming = isOrchestrating || step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); const chatBody = ( <> {/* Headline */}

多智能体圆桌会商中心

分布式领域专家实时动态博弈与纳什协商收敛中

{isRoundTableRunning ? ( ) : ( )}
{/* Persona drawer — 点圆桌头像后展开。当前圆桌可视化用 `false &&` 隐藏, 所以入口只剩 LeftSidebar 或将来重新启用的可视化按钮。 */} {selectedAgentId && ( setSelectedAgentId(null)} onSave={onSavePersona} /> )} {/* Conversation log */}
{isInitializing && step2RoundtableDialogues.length === 0 && (
正在初始化圆桌...
正在为各席位建立独立线程,请稍候
)} {step2RoundtableDialogues.map((msg) => ( openArtifactFromStep(msg, step)} onFileCardClick={(path) => { if (msg.agentThreadId) { openArtifactPreview(msg.agentThreadId, path); } }} /> ))}
{!step2AtBottom && }
{/* Clarification card */} {pendingClarification && (
总控协调需要澄清 {pendingClarification.clarificationType && ( {pendingClarification.clarificationType} )}
{pendingClarification.context && (

{pendingClarification.context}

)}

{pendingClarification.question}

{Array.isArray(pendingClarification.options) && pendingClarification.options.length > 0 && (
{pendingClarification.options.map((opt, idx) => { const label = typeof opt === "string" ? opt : opt && typeof opt === "object" && "label" in (opt as object) ? String((opt as { label: unknown }).label ?? "") : String(opt ?? ""); if (!label) return null; return ( ); })}
)} {pendingClarification.allowCustom !== false && (
setClarificationDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") onSubmitClarification(clarificationDraft); }} placeholder="输入你的回答…" className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium" />
)}
)} {/* Console + intervention bar */}
setCustomAgentInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") onInjectToRoundtable(); }} placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..." className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium" />
); const activeSeatMessages = activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined; return (
{chatBody}
); } /** * 单条 Step 2 对话气泡。包含: * - ChainOfThought 折叠卡(若 msg.steps 非空) * - 流式无内容的 loading 占位 * - markdown 正文 + 流式 cursor * * 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。 */ interface Step2MessageProps { msg: Step2Dialogue; onArtifactStepClick?: ( step: NonNullable[number], ) => void; /** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */ onFileCardClick?: (path: string) => void; } function Step2Message({ msg, onArtifactStepClick, onFileCardClick, }: Step2MessageProps) { const isInjected = msg.sender === "用户 (插话干预)"; const isStreaming = msg.streaming === true; // 抽出 `...` 作 reasoning;剩下的是要渲染的正文。 // 用户「插话干预」/「澄清回复」这类系统注入的气泡内容里基本不会有 think 标签, // 但 splitInlineReasoning 对空 reasoning 文本返回 null,放心套用就好。 const rawContent = msg.content ?? ""; const { content, reasoning } = splitInlineReasoning(rawContent); const hasContent = content.length > 0; const presentedFiles = collectPresentedFiles(msg.steps); return ( {Array.isArray(msg.steps) && msg.steps.length > 0 && ( )} {reasoning && ( {reasoning} )}
{isStreaming && !hasContent && } {content} {isStreaming && }
{presentedFiles.length > 0 && ( )}
); } /** * 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。 * 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。 */ function collectPresentedFiles( steps: Step2Dialogue["steps"], ): string[] { if (!Array.isArray(steps) || steps.length === 0) return []; const out: string[] = []; const seen = new Set(); for (const step of steps) { if (step.key !== "tool_calling:present_files") continue; const fps = step.args?.filepaths; if (!Array.isArray(fps)) continue; for (const fp of fps) { if (typeof fp !== "string" || !fp || seen.has(fp)) continue; seen.add(fp); out.push(fp); } } return out; } function PresentFilesRow({ files, onClick, disabled, }: { files: string[]; onClick?: (path: string) => void; disabled: boolean; }) { return (
{files.map((path) => ( onClick?.(path)} /> ))}
); } const FILE_EXT_LABELS: Record = { md: "Markdown", json: "JSON", csv: "CSV", txt: "TXT", py: "Python", js: "JS", ts: "TS", tsx: "TSX", jsx: "JSX", html: "HTML", css: "CSS", xml: "XML", yaml: "YAML", yml: "YAML", pdf: "PDF", png: "PNG", jpg: "JPG", jpeg: "JPEG", svg: "SVG", zip: "ZIP", }; function PresentFileCard({ path, disabled, onClick, }: { path: string; disabled: boolean; onClick: () => void; }) { const filename = path.split("/").pop() ?? path; const ext = filename.split(".").pop()?.toLowerCase() ?? ""; const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE"); return ( ); }