feat(会商第三步): 恢复普通会商完整成果并隔离8BF报告模式

- 为普通多智能体会商恢复报告完成后的结构化流程图、最终方案展示、经典结果绘制和大屏多页,并保持三个生成流独立并行。

 - 8BF 入口显式采用 report-only 模式:只生成和编辑 Markdown 总结报告,不再启动或露出流程图、结果绘制及大屏功能。

 - 结构化流程图增加抽取、校验、暂停、失败重试和歧义选择交互;报告完成后状态文案准确切换,自动滚动定位到抽取与校验进度。

 - 为总结报告、经典 HTML 绘制和大屏虚拟 write_file 补齐完成结果及历史恢复处理,避免并行分支或旧草稿让完成产物误显示为生成中。

 - 最终方案展示中的已落盘文件直接按完成态预览,并恢复 MD、HTML 等文件类型徽标,不再被提交按钮覆盖。
This commit is contained in:
董宏凯 committed 2026-08-30 21:24:47 +08:00
1 parent 2254841822
commit fbf9031e25
8 files changed
+469 -76

No files matched your search

@@ -273,6 +273,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
hideHeaderSaveDraft hideHeaderSaveDraft
hideHeaderSettings hideHeaderSettings
newTaskLabel="新建会话" newTaskLabel="新建会话"
step3Mode="report-only"
hideDefaultStep1RightSidebar hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`} step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`} step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
@@ -61,6 +61,8 @@ interface SeatDelivery {
interface HighFidelityReportProps { interface HighFidelityReportProps {
useStep1Chrome?: boolean; useStep1Chrome?: boolean;
headerTitle?: string; headerTitle?: string;
/** `report-only` is used by 8BF; normal roundtables retain every Step 3 view. */
step3Mode?: "full" | "report-only";
threadIds: ThreadIdMap | null; threadIds: ThreadIdMap | null;
coordinatorName: string | null; coordinatorName: string | null;
/** The most recent leader-agent text — rendered as the final consensus. */ /** The most recent leader-agent text — rendered as the final consensus. */
@@ -121,9 +123,26 @@ function formatGeneratedAt(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} }
/** 最终成果区打开的是已落盘文件,不走实时流;独立给工具栏一个稳定的文件类型徽标。 */
function artifactTypeLabel(fileName: string): string {
const normalized = fileName.trim().toLowerCase();
if (/\.(md|markdown)$/.test(normalized)) return "MD";
if (/\.html?$/.test(normalized)) return "HTML";
if (/\.(mmd|mermaid)$/.test(normalized)) return "MMD";
if (/\.json$/.test(normalized)) return "JSON";
if (/\.csv$/.test(normalized)) return "CSV";
if (/\.ya?ml$/.test(normalized)) return "YAML";
if (/\.tsx?$/.test(normalized)) return "TS";
if (/\.js$/.test(normalized)) return "JS";
if (/\.py$/.test(normalized)) return "PY";
if (/\.txt$/.test(normalized)) return "TXT";
return "FILE";
}
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
useStep1Chrome = false, useStep1Chrome = false,
headerTitle, headerTitle,
step3Mode = "full",
threadIds, threadIds,
coordinatorName, coordinatorName,
consensusText, consensusText,
@@ -156,7 +175,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
return `圆桌总控结论-${short}`; return `圆桌总控结论-${short}`;
}, [intentReady?.objective]); }, [intentReady?.objective]);
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。 const hasFullStep3Outputs = step3Mode === "full";
// 第 3 步主页为总结报告。普通会商可继续打开最终方案、经典可视化和大屏多页;
// 8BF 明确停在报告工作台,不启动或露出这些后续交付。
const [summaryMode, setSummaryMode] = useState(true); const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false); const [drawMode, setDrawMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起 // 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
@@ -398,6 +419,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
const fileSandboxVirtualUrl = fileSandboxTarget const fileSandboxVirtualUrl = fileSandboxTarget
? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0` ? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0`
: ""; : "";
const fileSandboxTypeLabel = fileSandboxTarget
? artifactTypeLabel(fileSandboxTarget.file.name)
: "FILE";
const fileSandboxThreadStub = useMemo(() => { const fileSandboxThreadStub = useMemo(() => {
const messages = const messages =
fileSandboxTarget && fileSandboxContent != null fileSandboxTarget && fileSandboxContent != null
@@ -414,6 +438,14 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
}, },
], ],
}, },
// 最终产物仅借用 write-file 虚拟 URL 来复用预览器;内容已经通过下载接口完整读回,
// 不是流式写入。因此要同时提供 tool result,避免 ArtifactFileDetail 误显示「正在生成」。
{
id: "hfr-file-tool-result",
type: "tool",
content: "",
tool_call_id: "tc-hfr-0",
},
] ]
: []; : [];
return { return {
@@ -434,7 +466,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[], [],
); );
if (summaryMode) { if (summaryMode || !hasFullStep3Outputs) {
return ( return (
<Step3SummaryMode <Step3SummaryMode
useStep1Chrome={useStep1Chrome} useStep1Chrome={useStep1Chrome}
@@ -448,16 +480,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
intentReady={intentReady} intentReady={intentReady}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt} coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary} savedSummary={savedSummary}
summary={summaryFlow} summary={summaryFlow}
enableFlow={hasFullStep3Outputs}
onViewStatic={hasFullStep3Outputs ? () => setSummaryMode(false) : undefined}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
/> />
); );
} }
if (drawMode) { if (drawMode && hasFullStep3Outputs) {
return ( return (
<Step3ReportMode <Step3ReportMode
threadIds={threadIds} threadIds={threadIds}
@@ -1043,21 +1078,26 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
allowVirtualMarkdownEdit allowVirtualMarkdownEdit
headerLeadingActionBare headerLeadingActionBare
headerLeadingAction={( headerLeadingAction={(
<TaskArtifactSubmitButton <div className="flex items-center gap-1.5">
compact <span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
controller={taskArtifactSubmission} {fileSandboxTypeLabel}
target={taskArtifactSubmission?.targetFor({ </span>
threadId: fileSandboxTarget.threadId, <TaskArtifactSubmitButton
path: fileSandboxTarget.file.path, compact
name: fileSandboxTarget.file.name, controller={taskArtifactSubmission}
mimeType: fileSandboxTarget.file.mimeType, target={taskArtifactSubmission?.targetFor({
}) ?? { threadId: fileSandboxTarget.threadId,
threadId: fileSandboxTarget.threadId, path: fileSandboxTarget.file.path,
path: fileSandboxTarget.file.path, name: fileSandboxTarget.file.name,
name: fileSandboxTarget.file.name, mimeType: fileSandboxTarget.file.mimeType,
mimeType: fileSandboxTarget.file.mimeType, }) ?? {
}} threadId: fileSandboxTarget.threadId,
/> path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}}
/>
</div>
)} )}
/> />
</ThreadContext.Provider> </ThreadContext.Provider>
@@ -26,6 +26,8 @@ export interface Step3PanelProps {
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */ /** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
useStep1Chrome?: boolean; useStep1Chrome?: boolean;
headerTitle?: string; headerTitle?: string;
/** 普通会商提供完整交付;8BF 仅保留 Markdown 总结报告。 */
step3Mode?: "full" | "report-only";
threadIds: ThreadIdMap | null; threadIds: ThreadIdMap | null;
coordinatorName: string | null; coordinatorName: string | null;
consensusText: string; consensusText: string;
@@ -65,6 +67,7 @@ export function Step3Panel(props: Step3PanelProps) {
const { const {
useStep1Chrome = false, useStep1Chrome = false,
headerTitle, headerTitle,
step3Mode = "full",
threadIds, threadIds,
coordinatorName, coordinatorName,
consensusText, consensusText,
@@ -115,6 +118,7 @@ export function Step3Panel(props: Step3PanelProps) {
<HighFidelityReport <HighFidelityReport
useStep1Chrome={useStep1Chrome} useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle} headerTitle={headerTitle}
step3Mode={step3Mode}
threadIds={threadIds} threadIds={threadIds}
coordinatorName={coordinatorName} coordinatorName={coordinatorName}
consensusText={consensusText} consensusText={consensusText}
@@ -14,6 +14,7 @@ import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { import {
ArrowRight,
CheckCircle, CheckCircle,
Download, Download,
Eye, Eye,
@@ -26,6 +27,7 @@ import {
RefreshCw, RefreshCw,
SendHorizontal, SendHorizontal,
Sparkles, Sparkles,
Workflow,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -42,6 +44,12 @@ import {
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
detectFlowIntent,
extractFlowGraph,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -74,6 +82,7 @@ interface Step3SummaryModeProps {
intentReady: ReportIntent | null; intentReady: ReportIntent | null;
selectedModel: string; selectedModel: string;
availableModels: ReadonlyArray<ModelOption>; availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */ /** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
@@ -81,6 +90,10 @@ interface Step3SummaryModeProps {
savedSummary?: DraftStep3SummarySnapshot | null; savedSummary?: DraftStep3SummarySnapshot | null;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */ /** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn; summary: UseStep3SummaryReturn;
/** 普通会商保留流程图交付;8BF 为 false。 */
enableFlow?: boolean;
/** 普通会商进入完整的最终方案展示;8BF 不提供该入口。 */
onViewStatic?: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController; taskArtifactSubmission?: TaskArtifactSubmissionController;
} }
@@ -98,10 +111,13 @@ export function Step3SummaryMode({
intentReady, intentReady,
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading,
setSelectedModel, setSelectedModel,
coordinatorPrompt, coordinatorPrompt,
savedSummary, savedSummary,
summary, summary,
enableFlow = false,
onViewStatic,
taskArtifactSubmission, taskArtifactSubmission,
}: Step3SummaryModeProps) { }: Step3SummaryModeProps) {
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容 void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
@@ -135,6 +151,36 @@ export function Step3SummaryMode({
const { open: artifactsOpen } = useArtifacts(); const { open: artifactsOpen } = useArtifacts();
// 普通会商的结构化流程图:优先使用后端抽取+校验的结果,兼容旧草稿里 flow-json。
const flowGraph = useMemo(() => {
if (!enableFlow) return null;
if (summary.flowPhase === "done" && summary.flowGraph) {
return { graph: summary.flowGraph, streaming: false };
}
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
return null;
}
for (let index = dialogues.length - 1; index >= 0; index -= 1) {
const dialogue = dialogues[index];
if (dialogue.avatarType === "human") continue;
const graph = extractFlowGraph(dialogue.content ?? "");
if (graph && (dialogue.content ?? "").includes(FLOW_FENCE_OPEN)) {
return { graph, streaming: dialogue.streaming === true };
}
}
return null;
}, [dialogues, enableFlow, summary.flowGraph, summary.flowPhase]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!enableFlow || !flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// 自动生成的图优先打开;若用户已在报告沙箱内,保留当前阅读位置并露出切换入口。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [artifactsOpen, enableFlow, flowGraph, summary.autoFlow]);
// 沙箱里已写出的 Markdown 总结报告。 // 沙箱里已写出的 Markdown 总结报告。
const mdArtifact = useMemo(() => { const mdArtifact = useMemo(() => {
for (const [threadId, msgs] of Object.entries(seatStubMessages)) { for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
@@ -156,16 +202,22 @@ export function Step3SummaryMode({
return null; return null;
}, [seatStubMessages]); }, [seatStubMessages]);
const handleAsk = (message: string, files?: File[]) => ask(message, files); const handleAsk = (message: string, files?: File[]) => {
if (enableFlow && detectFlowIntent(message)) {
flowAutoOpenedRef.current = true;
setFlowView(true);
}
ask(message, files);
};
// 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染, // 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
// 保持与第二步一致的流式过程可见性。 // 保持与第二步一致的流式过程可见性。
const displayDialogues = dialogues; const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。 // 右侧沙箱或流程图露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true), // 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。 // 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
const rightPanelVisible = artifactsOpen; const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
const prevRightPanelVisibleRef = useRef(false); const prevRightPanelVisibleRef = useRef(false);
useEffect(() => { useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) { if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
@@ -195,16 +247,23 @@ export function Step3SummaryMode({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const isFlowRunning =
enableFlow &&
(summary.flowPhase === "extracting" || summary.flowPhase === "validating");
const isStreaming = const isStreaming =
phase === "creating" || phase === "creating" ||
phase === "streaming" || phase === "streaming" ||
isFlowRunning ||
dialogues.some((d) => d.streaming === true); dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId] ? seatStubMessages[activeArtifactThreadId]
: undefined; : undefined;
const statusLabel = const statusLabel = isFlowRunning
phase === "creating" ? summary.flowPhase === "extracting"
? "结构化流程图抽取中…"
: "流程图校验 / 修复中…"
: phase === "creating"
? "正在初始化总结线程…" ? "正在初始化总结线程…"
: phase === "streaming" : phase === "streaming"
? "方案总结报告智能体撰写中…" ? "方案总结报告智能体撰写中…"
@@ -225,7 +284,7 @@ export function Step3SummaryMode({
<Sparkles className="w-5 h-5 text-primary" /> <Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3> <h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full"> <span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && ( {isStreaming && (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
)} )}
{phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />} {phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />}
@@ -237,12 +296,22 @@ export function Step3SummaryMode({
<button <button
type="button" type="button"
onClick={() => redraw()} onClick={() => redraw()}
disabled={phase === "creating" || phase === "streaming"} disabled={busy}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition" className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
> >
<RefreshCw className="w-3.5 h-3.5 text-slate-500" /> <RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成 重新生成
</button> </button>
{onViewStatic && (
<button
type="button"
onClick={onViewStatic}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
最终方案展示
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -347,6 +416,24 @@ export function Step3SummaryMode({
isStreaming={isStreaming} isStreaming={isStreaming}
seatMessages={activeSeatMessages} seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
sidePanel={
enableFlow && flowGraph && flowView ? (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
onSwitchToReport={
mdArtifact
? () => {
setFlowView(false);
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
}
: undefined
}
onChange={summary.setFlowGraph}
/>
) : undefined
}
> >
<AgentProgressPanel <AgentProgressPanel
useStep1Chrome={useStep1Chrome} useStep1Chrome={useStep1Chrome}
@@ -356,6 +443,11 @@ export function Step3SummaryMode({
error={error} error={error}
busy={busy} busy={busy}
mdAvailable={!!mdArtifact} mdAvailable={!!mdArtifact}
enableFlow={enableFlow}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
pendingFlowClarification={summary.pendingFlowClarification}
onResolveFlowClarification={summary.resolveFlowClarification}
onRetry={redraw} onRetry={redraw}
onAsk={handleAsk} onAsk={handleAsk}
onPause={pause} onPause={pause}
@@ -363,11 +455,31 @@ export function Step3SummaryMode({
onArtifactPreview={openArtifactPreview} onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
/> />
</Step2SandboxLayout> </Step2SandboxLayout>
{enableFlow && flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
</section> </section>
</div> </div>
@@ -481,6 +593,11 @@ function AgentProgressPanel({
error, error,
busy, busy,
mdAvailable, mdAvailable,
enableFlow,
flowPhase,
onRegenerateFlow,
pendingFlowClarification,
onResolveFlowClarification,
onRetry, onRetry,
onAsk, onAsk,
onPause, onPause,
@@ -488,6 +605,7 @@ function AgentProgressPanel({
onArtifactPreview, onArtifactPreview,
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading,
setSelectedModel, setSelectedModel,
taskArtifactSubmission, taskArtifactSubmission,
}: { }: {
@@ -499,6 +617,12 @@ function AgentProgressPanel({
busy: boolean; busy: boolean;
/** 沙箱里是否已有 Markdown 总结报告。 */ /** 沙箱里是否已有 Markdown 总结报告。 */
mdAvailable: boolean; mdAvailable: boolean;
/** 普通会商才会生成、展示和编辑流程图。 */
enableFlow: boolean;
flowPhase: UseStep3SummaryReturn["flowPhase"];
onRegenerateFlow: () => void;
pendingFlowClarification: string | null;
onResolveFlowClarification: UseStep3SummaryReturn["resolveFlowClarification"];
onRetry: () => void; onRetry: () => void;
onAsk: (message: string, files?: File[]) => void; onAsk: (message: string, files?: File[]) => void;
onPause: () => void; onPause: () => void;
@@ -506,21 +630,46 @@ function AgentProgressPanel({
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"]; onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string; selectedModel: string;
availableModels: ReadonlyArray<ModelOption>; availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController; taskArtifactSubmission?: TaskArtifactSubmissionController;
}) { }) {
// 进度面板独立根据 props 判断流程图阶段,不能引用外层 Step3SummaryMode 的局部变量。
const isFlowRunning =
enableFlow && (flowPhase === "extracting" || flowPhase === "validating");
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, flowPhase],
{ contentRef },
);
// 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底, // 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底,
// 让用户看到新阶段。仅在气泡**条数**增加时触发, // 让用户看到新阶段。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。 // 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length); const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => { useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) { if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom(); forceScrollToBottom();
} }
prevDialogueCountRef.current = dialogues.length; prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]); }, [dialogues.length, forceScrollToBottom]);
// 结构化抽取可能复用前一张「调度中」气泡,导致消息条数不变;只依赖上面的
// dialogues.length 时就不会触发定位。阶段切换后等 DOM 提交一帧再强制下滑,
// 保证用户能立刻看到抽取/校验进度;后续增量仍由 useAutoScroll 按用户是否上翻决定。
useEffect(() => {
if (!enableFlow || (flowPhase !== "extracting" && flowPhase !== "validating")) return;
let followUpFrameId: number | null = null;
const frameId = window.requestAnimationFrame(() => {
forceScrollToBottom();
// 校验卡及其中的流式内容可能在第一帧后才完成布局,再确认一次最终高度。
followUpFrameId = window.requestAnimationFrame(forceScrollToBottom);
});
return () => {
window.cancelAnimationFrame(frameId);
if (followUpFrameId !== null) window.cancelAnimationFrame(followUpFrameId);
};
}, [enableFlow, flowPhase, forceScrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -598,10 +747,12 @@ function AgentProgressPanel({
id="step3-summary-model-select" id="step3-summary-model-select"
value={selectedModel} value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || availableModels.length === 0} disabled={busy || modelsLoading || availableModels.length === 0}
title={ title={
availableModels.length === 0 modelsLoading
? "未在 config.yaml 中配置任何模型" ? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy : busy
? "撰写中不可切换;完成后或重新生成时生效" ? "撰写中不可切换;完成后或重新生成时生效"
: "切换撰写使用的推理模型(下次生成生效)" : "切换撰写使用的推理模型(下次生成生效)"
@@ -609,7 +760,7 @@ function AgentProgressPanel({
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border" className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
> >
{availableModels.length === 0 && ( {availableModels.length === 0 && (
<option value="">未配置模型</option> <option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)} )}
{availableModels.map((m) => ( {availableModels.map((m) => (
<option key={m.name} value={m.name}> <option key={m.name} value={m.name}>
@@ -639,10 +790,11 @@ function AgentProgressPanel({
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( className={cn(
"size-full overflow-y-auto overflow-x-hidden p-4 space-y-4", "size-full overflow-y-auto overflow-x-hidden",
useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white", useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white",
)} )}
> >
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -663,10 +815,68 @@ function AgentProgressPanel({
/> />
))} ))}
{busy && ( {busy && !isFlowRunning && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500"> <div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" /> <span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"} {mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "正在撰写 Markdown 总结报告"}
</div>
)}
{enableFlow && (flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting"
? "报告已完成,正在抽取结构化流程图"
: "报告已完成,正在校验 / 修复流程图"}
<button
type="button"
onClick={onPause}
title="暂停结构化流程图抽取,已完成的报告会保留"
className="-mr-1 rounded-full border border-blue-200 bg-white px-1.5 py-0.5 text-[10px] font-bold text-blue-700 transition hover:bg-blue-100"
>
<Pause className="mr-0.5 inline-block h-3 w-3" />
暂停
</button>
</div>
)}
{enableFlow && flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">可切换模型后重新生成流程图。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{enableFlow && pendingFlowClarification && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="mb-2 font-bold">请确认下一步要做什么:</div>
<div className="flex flex-wrap gap-1.5">
{(
[
["regenerate_report", "重新生成报告"],
["modify_flow", "修改流程图"],
["regenerate_flow", "重新生成流程图"],
["qa", "只是提问"],
] as const
).map(([intent, label]) => (
<button
key={intent}
type="button"
onClick={() => onResolveFlowClarification(intent)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{label}
</button>
))}
</div>
</div> </div>
)} )}
@@ -696,10 +906,13 @@ function AgentProgressPanel({
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600"> <div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
{mdAvailable {mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。" ? enableFlow
? "已生成方案总结报告与流程图,右侧可预览 / 下载 .md 或切换流程图。"
: "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
: "已生成方案总结,可在下方继续修改报告或提问。"} : "已生成方案总结,可在下方继续修改报告或提问。"}
</div> </div>
)} )}
</div>
</div> </div>
{!atBottom && ( {!atBottom && (
<ScrollToBottomButton <ScrollToBottomButton
@@ -783,7 +996,9 @@ function AgentProgressPanel({
placeholder={ placeholder={
busy busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…" ? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)或提问" : enableFlow
? "继续对话:改报告、修改流程图或提问"
: "继续对话:改报告(如「执行路线写细些」)或提问"
} }
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" 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"
/> />
@@ -20,9 +20,10 @@ import {
type StreamPhase, type StreamPhase,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { import {
Step2Dialogue, completeAllStubWriteFiles,
StubAiMessage, type Step2Dialogue,
type StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration"; } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { import {
buildDashboardPrompt, buildDashboardPrompt,
@@ -234,6 +235,16 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
return { messageId, toolCallId }; return { messageId, toolCallId };
}, []); }, []);
/** 前端已完成模板渲染后,为虚拟 HTML 文件补齐 tool result,解除沙箱「正在生成」状态。 */
const completeDashStubWriteFiles = useCallback((threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeAllStubWriteFiles(current);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
}, []);
// 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。 // 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。
const previewEnabledRef = useRef(false); const previewEnabledRef = useRef(false);
const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>( const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
@@ -274,8 +285,9 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const current = htmlRef.current; const current = htmlRef.current;
if (!current) return; if (!current) return;
const { messageId, toolCallId } = upsertDashStub(tid, current); const { messageId, toolCallId } = upsertDashStub(tid, current);
completeDashStubWriteFiles(tid);
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制 openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]); }, [activeArtifactThreadId, upsertDashStub, completeDashStubWriteFiles, openDashboardArtifact]);
const abort = useCallback(() => { const abort = useCallback(() => {
aborterRef.current?.abort(); aborterRef.current?.abort();
@@ -345,7 +357,10 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const data = extractReportData(res.content); const data = extractReportData(res.content);
if (!data) { if (!data) {
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。 // 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current); if (htmlRef.current) {
upsertDashStub(threadId, htmlRef.current);
completeDashStubWriteFiles(threadId);
}
finalizeDialogue( finalizeDialogue(
bubbleId, bubbleId,
(res.content || "").trim() || (res.content || "").trim() ||
@@ -362,6 +377,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染 setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动 dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
const { messageId, toolCallId } = upsertDashStub(threadId, rendered); const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
completeDashStubWriteFiles(threadId);
openDashboardArtifact(threadId, messageId, toolCallId); openDashboardArtifact(threadId, messageId, toolCallId);
finalizeDialogue(bubbleId, res.content, "大屏绘制完成"); finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
@@ -378,6 +394,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setDialogueConfidence, setDialogueConfidence,
finalizeDialogue, finalizeDialogue,
upsertDashStub, upsertDashStub,
completeDashStubWriteFiles,
openDashboardArtifact, openDashboardArtifact,
], ],
); );
@@ -483,6 +500,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setDashThreadId(synthThreadId); setDashThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId); setActiveArtifactThreadId(synthThreadId);
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml); const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
completeDashStubWriteFiles(synthThreadId);
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。 // 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId }; lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。 // 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
@@ -495,7 +513,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
: null; : null;
if (savedStubs && Object.keys(savedStubs).length) { if (savedStubs && Object.keys(savedStubs).length) {
// 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。 // 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。
const merged = { ...stubRef.current, ...savedStubs }; const merged: Record<string, StubAiMessage[]> = Object.fromEntries(
Object.entries({ ...stubRef.current, ...savedStubs }).map(([threadId, messages]) => [
threadId,
completeAllStubWriteFiles(messages as StubAiMessage[]),
]),
);
stubRef.current = merged; stubRef.current = merged;
setSeatStubMessages(merged); setSeatStubMessages(merged);
} }
@@ -522,7 +545,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setPhase("done"); setPhase("done");
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。 // 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
}, },
[abort, upsertDashStub], [abort, upsertDashStub, completeDashStubWriteFiles],
); );
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */ /** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
@@ -556,6 +579,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setData(parsed); setData(parsed);
dataRef.current = parsed; // 续轮修改的基线 dataRef.current = parsed; // 续轮修改的基线
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered); const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
completeDashStubWriteFiles(synthThreadId);
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId }; lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
} else { } else {
htmlRef.current = null; htmlRef.current = null;
@@ -567,7 +591,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setError(null); setError(null);
setPhase(parsed ? "done" : "idle"); setPhase(parsed ? "done" : "idle");
}, },
[abort, themeMode, upsertDashStub], [abort, themeMode, upsertDashStub, completeDashStubWriteFiles],
); );
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。 // 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
@@ -478,6 +478,36 @@ export function useStep3Report(
[], [],
); );
/**
* 自定义 SSE 的 stub 不会天然带回 LangGraph 的 ToolMessage。经典绘制正常收尾时,
* 为每个 write_file 补一条等价的完成消息,让通用沙箱能结束「正在生成」状态并自动预览。
*/
const completeStubWriteFiles = useCallback((threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const aiMessage = current.find((message) => message.type === "ai");
if (!aiMessage) return;
const completedIds = new Set(
current
.filter((message) => message.type === "tool" && message.tool_call_id)
.map((message) => message.tool_call_id!),
);
const toolResults: StubAiMessage[] = aiMessage.tool_calls
.filter((toolCall) => toolCall.name === "write_file" && !completedIds.has(toolCall.id))
.map((toolCall) => ({
id: `roundtable-report-tool-result-${threadId}-${toolCall.id}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCall.id,
}));
if (toolResults.length === 0) return;
const nextMap = { ...stubRef.current, [threadId]: [...current, ...toolResults] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
}, []);
const buildWriteFileVirtualUrl = useCallback( const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) => (path: string, messageId: string, toolCallId: string) =>
new URL( new URL(
@@ -825,6 +855,9 @@ export function useStep3Report(
? await reconcileStubFromRealFile(threadId, aborter.signal) ? await reconcileStubFromRealFile(threadId, aborter.signal)
: ""; : "";
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
// 该轮已正常收尾:解除当前 HTML 虚拟预览的写入态。不能等其它并行的总结/大屏流,
// 它们各自有独立线程和 stub,互不影响。
completeStubWriteFiles(threadId);
finalizeDialogue(bubbleId, closing, "绘制完成"); finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done"); setPhase("done");
setReportTheme(theme); setReportTheme(theme);
@@ -844,6 +877,7 @@ export function useStep3Report(
selectedModel, selectedModel,
openRealArtifactFallback, openRealArtifactFallback,
reconcileStubFromRealFile, reconcileStubFromRealFile,
completeStubWriteFiles,
finalizeDialogue, finalizeDialogue,
], ],
); );
@@ -1043,6 +1077,13 @@ export function useStep3Report(
}, },
], ],
}, },
{
id: `roundtable-report-tool-result-${synthThreadId}-${toolCallId}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCallId,
},
], ],
}; };
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。 // 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
@@ -34,9 +34,11 @@ import type {
ReportIntent, ReportIntent,
ReportSeatDelivery, ReportSeatDelivery,
} from "@/roundtable-planning/hooks/useStep3Report"; } from "@/roundtable-planning/hooks/useStep3Report";
import type { import {
Step2Dialogue, completeAllStubWriteFiles,
StubAiMessage, completeStubWriteFile,
type Step2Dialogue,
type StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration"; } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import { import {
@@ -127,7 +129,7 @@ export interface UseStep3SummaryReturn {
dialogues: Step2Dialogue[]; dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>; seatStubMessages: Record<string, StubAiMessage[]>;
activeArtifactThreadId: string | null; activeArtifactThreadId: string | null;
/** 是否正在生成中(撰写/初始化/上传)—— 驱动暂停按钮与发送态。 */ /** 是否正在生成中(报告撰写或流程图抽取/校验)—— 驱动暂停按钮与发送态。 */
busy: boolean; busy: boolean;
/** 启动总结(幂等:进行中重复调用会被忽略)。 */ /** 启动总结(幂等:进行中重复调用会被忽略)。 */
start: () => void; start: () => void;
@@ -821,6 +823,34 @@ export function useStep3Summary(
[scheduleStubMessagesFlush], [scheduleStubMessagesFlush],
); );
/** 将 summary special 流的真实 tool 返回同步到虚拟 Markdown 文件。 */
const completeSummaryStubWriteFile = useCallback(
(threadId: string, toolCallId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeStubWriteFile(current, toolCallId);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
},
[scheduleStubMessagesFlush],
);
/** summary 一轮已完成时补齐尚未透传的 write_file tool 返回。 */
const completeSummaryStubWriteFiles = useCallback(
(threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeAllStubWriteFiles(current);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
},
[scheduleStubMessagesFlush],
);
const buildWriteFileVirtualUrl = useCallback( const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) => (path: string, messageId: string, toolCallId: string) =>
new URL( new URL(
@@ -955,6 +985,11 @@ export function useStep3Summary(
(frame: unknown, threadId: string) => { (frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return; if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined; const chunk = frame[0] as Record<string, unknown> | undefined;
if (String(chunk?.type ?? "").toLowerCase() === "tool") {
const toolCallId = typeof chunk?.tool_call_id === "string" ? chunk.tool_call_id : "";
if (toolCallId) completeSummaryStubWriteFile(threadId, toolCallId);
return;
}
const toolCalls = chunk?.tool_calls; const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return; if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) { for (const tc of toolCalls) {
@@ -970,7 +1005,7 @@ export function useStep3Summary(
upsertStub(threadId, name, a as Record<string, unknown>); upsertStub(threadId, name, a as Record<string, unknown>);
} }
}, },
[upsertStub], [completeSummaryStubWriteFile, upsertStub],
); );
const openArtifactPreview = useCallback( const openArtifactPreview = useCallback(
@@ -1162,6 +1197,9 @@ export function useStep3Summary(
flowActiveRef.current = false; flowActiveRef.current = false;
} }
// 报告线程本轮已收尾。即使后端没有单独透传 tool 帧,也只结束本线程的预览写入态;
// 后续自动流程图/大屏等并行分支完全不参与这个判断。
completeSummaryStubWriteFiles(threadId);
finalizeDialogue(bubbleId, res.content, "总结完成"); finalizeDialogue(bubbleId, res.content, "总结完成");
setPhase("done"); setPhase("done");
@@ -1187,6 +1225,7 @@ export function useStep3Summary(
handleArtifactToolPhase, handleArtifactToolPhase,
handleUpstreamFrame, handleUpstreamFrame,
reconcileStubFromRealFile, reconcileStubFromRealFile,
completeSummaryStubWriteFiles,
finalizeDialogue, finalizeDialogue,
seedStubWithCurrentMd, seedStubWithCurrentMd,
], ],
@@ -1939,6 +1978,13 @@ export function useStep3Summary(
}, },
], ],
}, },
{
id: `roundtable-stub-tool-result-${messageId}-${toolCallId}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCallId,
},
], ],
} }
: {}; : {};
@@ -1953,7 +1999,15 @@ export function useStep3Summary(
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。 // 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub = const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub; savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
replaceStubMessages(effectiveStub); // 恢复的是静态完成记录;兼容旧草稿没有保存 tool result 的场景。
replaceStubMessages(
Object.fromEntries(
Object.entries(effectiveStub).map(([threadId, messages]) => [
threadId,
completeAllStubWriteFiles(messages),
]),
),
);
currentMdRef.current = snap.md; currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId; liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。 // 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -2015,7 +2069,11 @@ export function useStep3Summary(
[abort, buildWriteFileVirtualUrl, replaceStubMessages], [abort, buildWriteFileVirtualUrl, replaceStubMessages],
); );
const busy = phase === "creating" || phase === "streaming"; const busy =
phase === "creating" ||
phase === "streaming" ||
flowPhase === "extracting" ||
flowPhase === "validating";
return { return {
phase, phase,
@@ -350,6 +350,11 @@ export default function RoundtablePlanningPage({
hideHeaderSettings = false, hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */ /** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig, taskArtifactSubmissionConfig,
/**
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
*/
step3Mode = "full",
}: { }: {
compactChrome?: boolean; compactChrome?: boolean;
forceDarkAppearance?: boolean; forceDarkAppearance?: boolean;
@@ -396,6 +401,7 @@ export default function RoundtablePlanningPage({
newTaskLabel?: string; newTaskLabel?: string;
hideHeaderSettings?: boolean; hideHeaderSettings?: boolean;
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig; taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
step3Mode?: "full" | "report-only";
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。 // 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
@@ -403,6 +409,7 @@ export default function RoundtablePlanningPage({
// 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式, // 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式,
// 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。 // 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。
const isDark = forceDarkAppearance || theme === "dark"; const isDark = forceDarkAppearance || theme === "dark";
const hasFullStep3Outputs = step3Mode === "full";
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null); const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
@@ -1634,9 +1641,9 @@ export default function RoundtablePlanningPage({
.filter((g) => g.length > 0), .filter((g) => g.length > 0),
[orchestrationPlan], [orchestrationPlan],
); );
// 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物, // 普通会商:Markdown 报告完成后自动续跑结构化流程图;8BF:只完成报告即结束。
// 避免报告写完后额外触发一次结构化抽取请求。 // 这个开关由入口显式传入,而不是从业务链/任务状态推断,防止两类会商串行为。
const autoSummaryFlow = false; const autoSummaryFlow = hasFullStep3Outputs;
const summaryFlow = useStep3Summary({ const summaryFlow = useStep3Summary({
intentReady: step1.intentReady, intentReady: step1.intentReady,
@@ -1646,7 +1653,7 @@ export default function RoundtablePlanningPage({
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null, coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels, chainLevels,
autoFlow: autoSummaryFlow, autoFlow: autoSummaryFlow,
flowEnabled: false, flowEnabled: hasFullStep3Outputs,
savedSummary: step3Report?.summaryReport ?? null, savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved, onSummarySaved: handleSummarySaved,
ingestAvailable, ingestAvailable,
@@ -1753,28 +1760,30 @@ export default function RoundtablePlanningPage({
} else { } else {
summaryFlow.start(); summaryFlow.start();
} }
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker), if (hasFullStep3Outputs) {
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。 // ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
if (rep?.html && !isDashboardHtml(rep.html)) { // 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
reportFlow.hydrateSaved(rep); if (rep?.html && !isDashboardHtml(rep.html)) {
} else { reportFlow.hydrateSaved(rep);
staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开 } else {
} staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。 }
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。 // ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
const dashSaved = { // 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
dialogues: rep?.dashboardDialogues, const dashSaved = {
seatStubMessages: rep?.dashboardSeatStubMessages, dialogues: rep?.dashboardDialogues,
}; seatStubMessages: rep?.dashboardSeatStubMessages,
if (rep?.dashboardJson) { };
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved); if (rep?.dashboardJson) {
} else if (rep?.html && isDashboardHtml(rep.html)) { dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
dashboardFlow.hydrateSaved(rep.html, dashSaved); } else if (rep?.html && isDashboardHtml(rep.html)) {
} else { dashboardFlow.hydrateSaved(rep.html, dashSaved);
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2); } else {
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2);
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]); }, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers, hasFullStep3Outputs]);
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**, // 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。 // 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
@@ -2608,6 +2617,7 @@ export default function RoundtablePlanningPage({
<Step3Panel <Step3Panel
useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false} useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false}
headerTitle={step3HeaderTitle} headerTitle={step3HeaderTitle}
step3Mode={step3Mode}
threadIds={step2.threadIds} threadIds={step2.threadIds}
coordinatorName={step2.coordinatorName} coordinatorName={step2.coordinatorName}
consensusText={step2.lastLeaderContent} consensusText={step2.lastLeaderContent}