/** * Step 3「结果绘制」模式。 * * 布局对齐第 2 步: * - 左栏(~300px,第 2 步白卡风格):把原本的最终方案内容「收起」成三张源卡 * (总控结论 / 各席位交付 / 各席位生成文件),点击才弹窗展开。 * - 右栏:复用 `Step2SandboxLayout`(与主聊天同一套沙箱)。左 60% 显示方案总结 * 智能体的流式绘制进度;右 40% 沙箱在它写 HTML 时显示代码、写完自动切 HTML 预览。 * * 进入即自动开跑(`useStep3Report.start`),右上角「重新绘制」可重跑。 */ import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ArrowLeft, CheckCircle, Download, Eye, FileText, LayoutDashboard, ListOrdered, Loader2, PanelLeftClose, PanelLeftOpen, Pause, RefreshCw, SendHorizontal, Sparkles, X, } from "lucide-react"; import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { useArtifacts } from "@/components/workspace/artifacts"; import { useLocalSettings } from "@/core/settings"; import { cn } from "@/lib/utils"; import { buildArtifactDownloadUrl, listSessionArtifacts, type SessionArtifactFile, type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton"; import { desensitize } from "@/lib/desensitize"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import { isDashboardHtml } from "@/roundtable-planning/lib/report-template"; import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; import type { ReportIntent, UseStep3ReportReturn, } from "@/roundtable-planning/hooks/useStep3Report"; import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission"; interface SeatDelivery { sender: string; avatarType: string; content: string; } export interface ModelOption { name: string; display_name?: string; } interface Step3ReportModeProps { threadIds: ThreadIdMap | null; coordinatorName: string | null; consensusText: string; hasConsensus: boolean; seatDeliveries: SeatDelivery[]; seatNameMap: Record; intentReady: ReportIntent | null; selectedModel: string; availableModels: ReadonlyArray; modelsLoading: boolean; setSelectedModel: (model: string) => void; /** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */ savedReport?: DraftStep3Snapshot | null; /** 页面层常驻的经典绘制流(2026-06-13 提升:切走不断流,进入续看)。 */ report: UseStep3ReportReturn; /** 页面层常驻的大屏多页流(模式二)。 */ dashboard: UseStep3DashboardReturn; onBack: () => void; taskArtifactSubmission?: TaskArtifactSubmissionController; } type SourceModal = "consensus" | "seats" | "files" | null; export function Step3ReportMode({ threadIds, coordinatorName, consensusText, hasConsensus, seatDeliveries, seatNameMap, intentReady, selectedModel, availableModels, modelsLoading, setSelectedModel, savedReport, report, dashboard, onBack, taskArtifactSubmission, }: Step3ReportModeProps) { const reportSeatDeliveries = useMemo( () => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })), [seatDeliveries], ); const consensusDocTitle = useMemo(() => { const objective = intentReady?.objective?.trim(); if (!objective) return "圆桌总控最终结论"; const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective; return `圆桌总控结论-${short}`; }, [intentReady?.objective]); // 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。 const { phase, error, reportThreadId, dialogues, seatStubMessages, activeArtifactThreadId, start, hydrateSaved, redraw, pause, interject, themeMismatch, openArtifactFromStep, openArtifactPreview, } = report; const [modal, setModal] = useState(null); // 左栏「研讨原料」收起态:打开沙箱时默认收起成图标条(对齐第 2 步侧栏收起), // 关闭沙箱时自动展开;中途用户可手动切换(effect 只在沙箱开关变化的边沿触发)。 const { open: artifactsOpen } = useArtifacts(); const [leftCollapsed, setLeftCollapsed] = useState(false); const prevArtifactsOpenRef = useRef(artifactsOpen); useEffect(() => { if (artifactsOpen !== prevArtifactsOpenRef.current) { prevArtifactsOpenRef.current = artifactsOpen; setLeftCollapsed(artifactsOpen); } }, [artifactsOpen]); // ── 绘制模式(2026-06-13):classic = 经典单页看板(模型自由写整页 HTML); // dashboard = 大屏多页(模型只出 report-json,模板渲染)。两条流现在**并行常驻** // (页面进入第三步即三流齐跑),进入本页默认落经典视图;仅旧版草稿「只有大屏 HTML // 存在 html 槽位(带 marker)」时直接落大屏。 const [dashboardMode, setDashboardMode] = useState(() => isDashboardHtml(savedReport?.html)); // 预览开关:经典绘制视图在前台(非大屏模式)时本流才允许自动打开沙箱(并行流防抢); // 进入时若已有产物/进行中会立即接管沙箱续看。 useEffect(() => { if (dashboardMode) return; report.setPreviewEnabled(true); return () => report.setPreviewEnabled(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dashboardMode]); // 续看语义(2026-06-13):hook 常驻页面层,进入第三步时页面已统一编排(总结 + // 经典绘制并行启动)。这里只兜底"从未启动过"(idle)的场景,**卸载不再 abort**—— // 切回总结报告 / 回第二步,绘制流照跑,回头继续看。 useEffect(() => { if (dashboardMode || phase !== "idle") return; if (savedReport?.html && !isDashboardHtml(savedReport.html)) { hydrateSaved(savedReport); } else { start(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 与第 2 步一致:编排中或有任意气泡仍在流式 → 沙箱开启 artifacts 轮询 + HTML 显示代码态。 const isStreaming = phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true); const activeSeatMessages = activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined; const statusLabel = phase === "creating" ? "正在初始化绘制线程…" : phase === "streaming" ? "方案总结智能体绘制中…" : phase === "done" ? "绘制完成" : phase === "error" ? "绘制失败" : phase === "paused" ? "已暂停" : "待绘制"; // ── 模式二:大屏多页(模板渲染)。经典模式的所有 hooks 已在上方调用完毕, // 这里早退渲染不违反 hooks 规则。 ── if (dashboardMode) { return ( { // 切回经典:**不打断**大屏流(切走不断流);经典从未启动则补一次。 setDashboardMode(false); if (phase === "idle") { if (savedReport?.html && !isDashboardHtml(savedReport.html)) { hydrateSaved(savedReport); } else { start(); } } }} onBack={onBack} /> ); } return (
{/* 顶栏 */}

结果绘制 · 方案可视化

{(phase === "creating" || phase === "streaming") && ( )} {phase === "done" && } {statusLabel}
{(phase === "creating" || phase === "streaming") && ( )}
{/* 主体:左源卡 + 右沙箱 */}
{/* 左栏:研讨原料源卡。打开沙箱时收起成 52px 图标条(对齐第 2 步侧栏收起样式)。 */} {/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
redraw()} onPause={pause} onRevisionSubmit={interject} onArtifactStepClick={openArtifactFromStep} onArtifactPreview={openArtifactPreview} selectedModel={selectedModel} availableModels={availableModels} modelsLoading={modelsLoading} setSelectedModel={setSelectedModel} taskArtifactSubmission={taskArtifactSubmission} />
{/* 弹窗 */} {modal === "consensus" && ( setModal(null)} headerAction={ } > {consensusText ? ( {desensitize(consensusText)} ) : (

暂无总控结论。

)}
)} {modal === "seats" && ( setModal(null)}> )} {modal === "files" && ( setModal(null)} /> )}
); } // ── 左栏源卡 ───────────────────────────────────────────────────────────── function SourceCard({ icon, title, hint, badge, disabled, onClick, }: { icon: React.ReactNode; title: string; hint: string; badge?: string; disabled?: boolean; onClick: () => void; }) { return ( ); } // 收起态的源卡:仅图标,居中,点击仍打开对应弹窗(对齐第 2 步 CollapsedSidebarSection 样式)。 function CollapsedSourceCard({ icon, title, disabled, onClick, }: { icon: React.ReactNode; title: string; disabled?: boolean; onClick: () => void; }) { return ( ); } // ── 右栏:智能体进度面板(沙箱的 60% 区域)──────────────────────────────── function AgentProgressPanel({ phase, dialogues, error, themeMismatch, onRetry, onPause, onRevisionSubmit, onArtifactStepClick, onArtifactPreview, selectedModel, availableModels, modelsLoading, setSelectedModel, taskArtifactSubmission, }: { phase: UseStep3ReportReturn["phase"]; dialogues: UseStep3ReportReturn["dialogues"]; error: string | null; themeMismatch: boolean; onRetry: () => void; /** 输入框「暂停」键 —— 绘制中显示,中断在途请求并把当前气泡收尾为「已暂停」。 */ onPause: () => void; onRevisionSubmit: (revisionInstruction: string) => void; onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"]; onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"]; selectedModel: string; availableModels: ReadonlyArray; modelsLoading: boolean; setSelectedModel: (model: string) => void; taskArtifactSubmission?: TaskArtifactSubmissionController; }) { const busy = phase === "creating" || phase === "streaming"; // 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送—— // 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。 const inputLocked = phase === "creating"; const scrollRef = useRef(null); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 const [localSettings] = useLocalSettings(); const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const dialogueMetrics = useDialogueSpeedMetrics(dialogues); const [revisionText, setRevisionText] = useState(""); const handleRevisionSubmit = (e: FormEvent) => { e.preventDefault(); const text = revisionText.trim(); if (!text || inputLocked) return; setRevisionText(""); onRevisionSubmit(text); }; return (
图
方案总结智能体
据各席位成果绘制可视化 HTML 看板
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
{phase === "creating" && dialogues.length === 0 && (
正在初始化绘制线程…
)} {dialogues.map((msg) => ( onArtifactStepClick(msg, step)} onFileCardClick={(path) => { if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); }} taskArtifactSubmission={taskArtifactSubmission} /> ))} {busy && (
右侧沙箱将实时显示绘制代码,完成后自动切换 HTML 预览
)} {phase === "error" && (
绘制失败
{error}
)} {phase === "paused" && (
已暂停。可在下方输入修改要求继续,或点右上角「重新绘制」。
)} {phase === "done" && (
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
)} {themeMismatch && (
界面主题已切换,这份报告还是旧主题的配色。
)}
{!atBottom && }
setRevisionText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); e.currentTarget.form?.requestSubmit(); } }} disabled={inputLocked} rows={1} placeholder={ busy ? "随时输入修改要求——发送会打断当前绘制并按新要求继续" : "输入修改要求,例如:增加风险矩阵,执行路线改成时间轴" } className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" /> {busy && ( )}
); } // ── 弹窗外壳 ───────────────────────────────────────────────────────────── function ContentModal({ title, children, onClose, headerAction, }: { title: string; children: React.ReactNode; onClose: () => void; headerAction?: React.ReactNode; }) { return (
e.stopPropagation()} >

{title}

{headerAction}
{children}
); } // ── 各席位交付(弹窗内的折叠列表)──────────────────────────────────────── function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) { const [openIdx, setOpenIdx] = useState(0); if (seatDeliveries.length === 0) { return

本次研讨尚未产生子智能体交付。

; } return (
{seatDeliveries.map((seat, idx) => { const open = openIdx === idx; return (
{open && (
{desensitize(seat.content || "(空)")}
)}
); })}
); } // ── 各席位生成文件(弹窗,按需拉取 outputs 列表)────────────────────────── interface SeatArtifacts { agentName: string; displayName: string; threadId: string; files: SessionArtifactFile[]; loading: boolean; error: string | null; } function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean { const mime = file.mimeType ?? ""; if (mime.startsWith("text/")) return true; if (["application/json"].includes(mime)) return true; return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name); } function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function SeatFilesModal({ threadIds, coordinatorName, seatNameMap, taskArtifactSubmission, onClose, }: { threadIds: ThreadIdMap | null; coordinatorName: string | null; seatNameMap: Record; taskArtifactSubmission?: TaskArtifactSubmissionController; onClose: () => void; }) { const seatEntries = useMemo(() => { if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>; return Object.entries(threadIds) .filter(([name]) => !coordinatorName || name !== coordinatorName) .map(([agentName, threadId]) => ({ agentName, threadId, displayName: seatNameMap[agentName.toLowerCase()] ?? agentName, })); }, [threadIds, coordinatorName, seatNameMap]); const [bySeat, setBySeat] = useState([]); const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>( null, ); useEffect(() => { if (seatEntries.length === 0) { setBySeat([]); return; } setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null }))); const abort = new AbortController(); let cancelled = false; (async () => { const results = await Promise.all( seatEntries.map(async (seat) => { try { const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal }); return { seat, files: res.files, error: null as string | null }; } catch (err) { if ((err as Error).name === "AbortError") { return { seat, files: [] as SessionArtifactFile[], error: null }; } return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message }; } }), ); if (cancelled) return; setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error }))); })(); return () => { cancelled = true; abort.abort(); }; }, [seatEntries]); const handlePreview = (threadId: string, file: SessionArtifactFile) => { if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) { setPreviewing({ threadId, file }); return; } if (typeof window !== "undefined") { window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer"); } }; const handleDownload = (threadId: string, file: SessionArtifactFile) => { if (typeof window !== "undefined") { window.open( buildArtifactDownloadUrl(threadId, file.path, { download: true }), "_blank", "noopener,noreferrer", ); } }; return ( <> {bySeat.length === 0 ? (

尚未初始化席位线程,无法列出文件。

) : (
{bySeat.map((seat) => (
{seat.displayName} {seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
{seat.loading ? (
正在查询 outputs 目录…
) : seat.error ? (
读取失败:{seat.error}
) : seat.files.length === 0 ? (
该席位未产生输出文件
) : (
    {seat.files.map((f) => (
  • {f.name}
    {formatBytes(f.sizeBytes)}
  • ))}
)}
))}
)}
{previewing && ( setPreviewing(null)} /> )} ); }