feat(会商第三步): 恢复普通会商完整成果并隔离8BF报告模式
- 为普通多智能体会商恢复报告完成后的结构化流程图、最终方案展示、经典结果绘制和大屏多页,并保持三个生成流独立并行。 - 8BF 入口显式采用 report-only 模式:只生成和编辑 Markdown 总结报告,不再启动或露出流程图、结果绘制及大屏功能。 - 结构化流程图增加抽取、校验、暂停、失败重试和歧义选择交互;报告完成后状态文案准确切换,自动滚动定位到抽取与校验进度。 - 为总结报告、经典 HTML 绘制和大屏虚拟 write_file 补齐完成结果及历史恢复处理,避免并行分支或旧草稿让完成产物误显示为生成中。 - 最终方案展示中的已落盘文件直接按完成态预览,并恢复 MD、HTML 等文件类型徽标,不再被提交按钮覆盖。
This commit is contained in:
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
|
||||
hideHeaderSettings
|
||||
newTaskLabel="新建会话"
|
||||
step3Mode="report-only"
|
||||
hideDefaultStep1RightSidebar
|
||||
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
|
||||
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
|
||||
|
||||
@@ -61,6 +61,8 @@ interface SeatDelivery {
|
||||
interface HighFidelityReportProps {
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
/** `report-only` is used by 8BF; normal roundtables retain every Step 3 view. */
|
||||
step3Mode?: "full" | "report-only";
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
/** 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())}`;
|
||||
}
|
||||
|
||||
/** 最终成果区打开的是已落盘文件,不走实时流;独立给工具栏一个稳定的文件类型徽标。 */
|
||||
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> = ({
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
step3Mode = "full",
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
@@ -156,7 +175,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。
|
||||
const hasFullStep3Outputs = step3Mode === "full";
|
||||
// 第 3 步主页为总结报告。普通会商可继续打开最终方案、经典可视化和大屏多页;
|
||||
// 8BF 明确停在报告工作台,不启动或露出这些后续交付。
|
||||
const [summaryMode, setSummaryMode] = useState(true);
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
|
||||
@@ -398,6 +419,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
const fileSandboxVirtualUrl = fileSandboxTarget
|
||||
? `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 messages =
|
||||
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 {
|
||||
@@ -434,7 +466,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
if (summaryMode) {
|
||||
if (summaryMode || !hasFullStep3Outputs) {
|
||||
return (
|
||||
<Step3SummaryMode
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
@@ -448,16 +480,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
intentReady={intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
summary={summaryFlow}
|
||||
enableFlow={hasFullStep3Outputs}
|
||||
onViewStatic={hasFullStep3Outputs ? () => setSummaryMode(false) : undefined}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (drawMode) {
|
||||
if (drawMode && hasFullStep3Outputs) {
|
||||
return (
|
||||
<Step3ReportMode
|
||||
threadIds={threadIds}
|
||||
@@ -1043,21 +1078,26 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
allowVirtualMarkdownEdit
|
||||
headerLeadingActionBare
|
||||
headerLeadingAction={(
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}) ?? {
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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">
|
||||
{fileSandboxTypeLabel}
|
||||
</span>
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}) ?? {
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Step3PanelProps {
|
||||
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
/** 普通会商提供完整交付;8BF 仅保留 Markdown 总结报告。 */
|
||||
step3Mode?: "full" | "report-only";
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
@@ -65,6 +67,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
const {
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
step3Mode = "full",
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
@@ -115,6 +118,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
<HighFidelityReport
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
headerTitle={headerTitle}
|
||||
step3Mode={step3Mode}
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createPortal } from "react-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -42,6 +44,12 @@ import {
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
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 { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
@@ -74,6 +82,7 @@ interface Step3SummaryModeProps {
|
||||
intentReady: ReportIntent | null;
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
@@ -81,6 +90,10 @@ interface Step3SummaryModeProps {
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
|
||||
summary: UseStep3SummaryReturn;
|
||||
/** 普通会商保留流程图交付;8BF 为 false。 */
|
||||
enableFlow?: boolean;
|
||||
/** 普通会商进入完整的最终方案展示;8BF 不提供该入口。 */
|
||||
onViewStatic?: () => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
@@ -98,10 +111,13 @@ export function Step3SummaryMode({
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
summary,
|
||||
enableFlow = false,
|
||||
onViewStatic,
|
||||
taskArtifactSubmission,
|
||||
}: Step3SummaryModeProps) {
|
||||
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
|
||||
@@ -135,6 +151,36 @@ export function Step3SummaryMode({
|
||||
|
||||
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 总结报告。
|
||||
const mdArtifact = useMemo(() => {
|
||||
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
|
||||
@@ -156,16 +202,22 @@ export function Step3SummaryMode({
|
||||
return null;
|
||||
}, [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 原样渲染,
|
||||
// 保持与第二步一致的流式过程可见性。
|
||||
const displayDialogues = dialogues;
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||
// 右侧沙箱或流程图露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
|
||||
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
|
||||
const rightPanelVisible = artifactsOpen;
|
||||
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
|
||||
const prevRightPanelVisibleRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
|
||||
@@ -195,16 +247,23 @@ export function Step3SummaryMode({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isFlowRunning =
|
||||
enableFlow &&
|
||||
(summary.flowPhase === "extracting" || summary.flowPhase === "validating");
|
||||
const isStreaming =
|
||||
phase === "creating" ||
|
||||
phase === "streaming" ||
|
||||
isFlowRunning ||
|
||||
dialogues.some((d) => d.streaming === true);
|
||||
const activeSeatMessages = activeArtifactThreadId
|
||||
? seatStubMessages[activeArtifactThreadId]
|
||||
: undefined;
|
||||
|
||||
const statusLabel =
|
||||
phase === "creating"
|
||||
const statusLabel = isFlowRunning
|
||||
? summary.flowPhase === "extracting"
|
||||
? "结构化流程图抽取中…"
|
||||
: "流程图校验 / 修复中…"
|
||||
: phase === "creating"
|
||||
? "正在初始化总结线程…"
|
||||
: phase === "streaming"
|
||||
? "方案总结报告智能体撰写中…"
|
||||
@@ -225,7 +284,7 @@ export function Step3SummaryMode({
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<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">
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
{isStreaming && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />}
|
||||
@@ -237,12 +296,22 @@ export function Step3SummaryMode({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新生成
|
||||
</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>
|
||||
)}
|
||||
@@ -347,6 +416,24 @@ export function Step3SummaryMode({
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
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
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
@@ -356,6 +443,11 @@ export function Step3SummaryMode({
|
||||
error={error}
|
||||
busy={busy}
|
||||
mdAvailable={!!mdArtifact}
|
||||
enableFlow={enableFlow}
|
||||
flowPhase={summary.flowPhase}
|
||||
onRegenerateFlow={summary.regenerateFlow}
|
||||
pendingFlowClarification={summary.pendingFlowClarification}
|
||||
onResolveFlowClarification={summary.resolveFlowClarification}
|
||||
onRetry={redraw}
|
||||
onAsk={handleAsk}
|
||||
onPause={pause}
|
||||
@@ -363,11 +455,31 @@ export function Step3SummaryMode({
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -481,6 +593,11 @@ function AgentProgressPanel({
|
||||
error,
|
||||
busy,
|
||||
mdAvailable,
|
||||
enableFlow,
|
||||
flowPhase,
|
||||
onRegenerateFlow,
|
||||
pendingFlowClarification,
|
||||
onResolveFlowClarification,
|
||||
onRetry,
|
||||
onAsk,
|
||||
onPause,
|
||||
@@ -488,6 +605,7 @@ function AgentProgressPanel({
|
||||
onArtifactPreview,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
@@ -499,6 +617,12 @@ function AgentProgressPanel({
|
||||
busy: boolean;
|
||||
/** 沙箱里是否已有 Markdown 总结报告。 */
|
||||
mdAvailable: boolean;
|
||||
/** 普通会商才会生成、展示和编辑流程图。 */
|
||||
enableFlow: boolean;
|
||||
flowPhase: UseStep3SummaryReturn["flowPhase"];
|
||||
onRegenerateFlow: () => void;
|
||||
pendingFlowClarification: string | null;
|
||||
onResolveFlowClarification: UseStep3SummaryReturn["resolveFlowClarification"];
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string, files?: File[]) => void;
|
||||
onPause: () => void;
|
||||
@@ -506,21 +630,46 @@ function AgentProgressPanel({
|
||||
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
// 进度面板独立根据 props 判断流程图阶段,不能引用外层 Step3SummaryMode 的局部变量。
|
||||
const isFlowRunning =
|
||||
enableFlow && (flowPhase === "extracting" || flowPhase === "validating");
|
||||
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 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
|
||||
const prevDialogueCountRef = useRef(dialogues.length);
|
||||
useEffect(() => {
|
||||
if (dialogues.length > prevDialogueCountRef.current) {
|
||||
scrollToBottom();
|
||||
forceScrollToBottom();
|
||||
}
|
||||
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。
|
||||
const [localSettings] = useLocalSettings();
|
||||
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||
@@ -598,10 +747,12 @@ function AgentProgressPanel({
|
||||
id="step3-summary-model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={busy || availableModels.length === 0}
|
||||
disabled={busy || modelsLoading || availableModels.length === 0}
|
||||
title={
|
||||
availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
modelsLoading
|
||||
? "正在加载模型列表…"
|
||||
: availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: 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"
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">未配置模型</option>
|
||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
@@ -639,10 +790,11 @@ function AgentProgressPanel({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
|
||||
{phase === "creating" && dialogues.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -696,10 +906,13 @@ function AgentProgressPanel({
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{mdAvailable
|
||||
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
|
||||
? enableFlow
|
||||
? "已生成方案总结报告与流程图,右侧可预览 / 下载 .md 或切换流程图。"
|
||||
: "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
|
||||
: "已生成方案总结,可在下方继续修改报告或提问。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<ScrollToBottomButton
|
||||
@@ -783,7 +996,9 @@ function AgentProgressPanel({
|
||||
placeholder={
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
type StreamPhase,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
import {
|
||||
completeAllStubWriteFiles,
|
||||
type Step2Dialogue,
|
||||
type StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
buildDashboardPrompt,
|
||||
@@ -234,6 +235,16 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
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 lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
|
||||
@@ -274,8 +285,9 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
const current = htmlRef.current;
|
||||
if (!current) return;
|
||||
const { messageId, toolCallId } = upsertDashStub(tid, current);
|
||||
completeDashStubWriteFiles(tid);
|
||||
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
|
||||
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]);
|
||||
}, [activeArtifactThreadId, upsertDashStub, completeDashStubWriteFiles, openDashboardArtifact]);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
aborterRef.current?.abort();
|
||||
@@ -345,7 +357,10 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
const data = extractReportData(res.content);
|
||||
if (!data) {
|
||||
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
|
||||
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current);
|
||||
if (htmlRef.current) {
|
||||
upsertDashStub(threadId, htmlRef.current);
|
||||
completeDashStubWriteFiles(threadId);
|
||||
}
|
||||
finalizeDialogue(
|
||||
bubbleId,
|
||||
(res.content || "").trim() ||
|
||||
@@ -362,6 +377,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
|
||||
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
|
||||
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
|
||||
completeDashStubWriteFiles(threadId);
|
||||
openDashboardArtifact(threadId, messageId, toolCallId);
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
|
||||
@@ -378,6 +394,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setDialogueConfidence,
|
||||
finalizeDialogue,
|
||||
upsertDashStub,
|
||||
completeDashStubWriteFiles,
|
||||
openDashboardArtifact,
|
||||
],
|
||||
);
|
||||
@@ -483,6 +500,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setDashThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
|
||||
completeDashStubWriteFiles(synthThreadId);
|
||||
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
|
||||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||||
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
|
||||
@@ -495,7 +513,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
: null;
|
||||
if (savedStubs && Object.keys(savedStubs).length) {
|
||||
// 合并历史 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;
|
||||
setSeatStubMessages(merged);
|
||||
}
|
||||
@@ -522,7 +545,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setPhase("done");
|
||||
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
|
||||
},
|
||||
[abort, upsertDashStub],
|
||||
[abort, upsertDashStub, completeDashStubWriteFiles],
|
||||
);
|
||||
|
||||
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
|
||||
@@ -556,6 +579,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setData(parsed);
|
||||
dataRef.current = parsed; // 续轮修改的基线
|
||||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
|
||||
completeDashStubWriteFiles(synthThreadId);
|
||||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||||
} else {
|
||||
htmlRef.current = null;
|
||||
@@ -567,7 +591,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setError(null);
|
||||
setPhase(parsed ? "done" : "idle");
|
||||
},
|
||||
[abort, themeMode, upsertDashStub],
|
||||
[abort, themeMode, upsertDashStub, completeDashStubWriteFiles],
|
||||
);
|
||||
|
||||
// 恢复路径辅助: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(
|
||||
(path: string, messageId: string, toolCallId: string) =>
|
||||
new URL(
|
||||
@@ -825,6 +855,9 @@ export function useStep3Report(
|
||||
? await reconcileStubFromRealFile(threadId, aborter.signal)
|
||||
: "";
|
||||
if (aborter.signal.aborted) return;
|
||||
// 该轮已正常收尾:解除当前 HTML 虚拟预览的写入态。不能等其它并行的总结/大屏流,
|
||||
// 它们各自有独立线程和 stub,互不影响。
|
||||
completeStubWriteFiles(threadId);
|
||||
finalizeDialogue(bubbleId, closing, "绘制完成");
|
||||
setPhase("done");
|
||||
setReportTheme(theme);
|
||||
@@ -844,6 +877,7 @@ export function useStep3Report(
|
||||
selectedModel,
|
||||
openRealArtifactFallback,
|
||||
reconcileStubFromRealFile,
|
||||
completeStubWriteFiles,
|
||||
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 —— 新版草稿存了就整段回显,像第二步一样。
|
||||
|
||||
@@ -34,9 +34,11 @@ import type {
|
||||
ReportIntent,
|
||||
ReportSeatDelivery,
|
||||
} from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
import {
|
||||
completeAllStubWriteFiles,
|
||||
completeStubWriteFile,
|
||||
type Step2Dialogue,
|
||||
type StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
|
||||
import {
|
||||
@@ -127,7 +129,7 @@ export interface UseStep3SummaryReturn {
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
activeArtifactThreadId: string | null;
|
||||
/** 是否正在生成中(撰写/初始化/上传)—— 驱动暂停按钮与发送态。 */
|
||||
/** 是否正在生成中(报告撰写或流程图抽取/校验)—— 驱动暂停按钮与发送态。 */
|
||||
busy: boolean;
|
||||
/** 启动总结(幂等:进行中重复调用会被忽略)。 */
|
||||
start: () => void;
|
||||
@@ -821,6 +823,34 @@ export function useStep3Summary(
|
||||
[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(
|
||||
(path: string, messageId: string, toolCallId: string) =>
|
||||
new URL(
|
||||
@@ -955,6 +985,11 @@ export function useStep3Summary(
|
||||
(frame: unknown, threadId: string) => {
|
||||
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||
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;
|
||||
if (!Array.isArray(toolCalls)) return;
|
||||
for (const tc of toolCalls) {
|
||||
@@ -970,7 +1005,7 @@ export function useStep3Summary(
|
||||
upsertStub(threadId, name, a as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
[upsertStub],
|
||||
[completeSummaryStubWriteFile, upsertStub],
|
||||
);
|
||||
|
||||
const openArtifactPreview = useCallback(
|
||||
@@ -1162,6 +1197,9 @@ export function useStep3Summary(
|
||||
flowActiveRef.current = false;
|
||||
}
|
||||
|
||||
// 报告线程本轮已收尾。即使后端没有单独透传 tool 帧,也只结束本线程的预览写入态;
|
||||
// 后续自动流程图/大屏等并行分支完全不参与这个判断。
|
||||
completeSummaryStubWriteFiles(threadId);
|
||||
finalizeDialogue(bubbleId, res.content, "总结完成");
|
||||
setPhase("done");
|
||||
|
||||
@@ -1187,6 +1225,7 @@ export function useStep3Summary(
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
reconcileStubFromRealFile,
|
||||
completeSummaryStubWriteFiles,
|
||||
finalizeDialogue,
|
||||
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 保住各气泡步骤卡的真线程预览。
|
||||
const effectiveStub =
|
||||
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;
|
||||
liveThreadRef.current = realThreadId;
|
||||
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
|
||||
@@ -2015,7 +2069,11 @@ export function useStep3Summary(
|
||||
[abort, buildWriteFileVirtualUrl, replaceStubMessages],
|
||||
);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
const busy =
|
||||
phase === "creating" ||
|
||||
phase === "streaming" ||
|
||||
flowPhase === "extracting" ||
|
||||
flowPhase === "validating";
|
||||
|
||||
return {
|
||||
phase,
|
||||
|
||||
@@ -350,6 +350,11 @@ export default function RoundtablePlanningPage({
|
||||
hideHeaderSettings = false,
|
||||
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
|
||||
taskArtifactSubmissionConfig,
|
||||
/**
|
||||
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
|
||||
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
|
||||
*/
|
||||
step3Mode = "full",
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
forceDarkAppearance?: boolean;
|
||||
@@ -396,6 +401,7 @@ export default function RoundtablePlanningPage({
|
||||
newTaskLabel?: string;
|
||||
hideHeaderSettings?: boolean;
|
||||
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
|
||||
step3Mode?: "full" | "report-only";
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
@@ -403,6 +409,7 @@ export default function RoundtablePlanningPage({
|
||||
// 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式,
|
||||
// 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。
|
||||
const isDark = forceDarkAppearance || theme === "dark";
|
||||
const hasFullStep3Outputs = step3Mode === "full";
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
@@ -1634,9 +1641,9 @@ export default function RoundtablePlanningPage({
|
||||
.filter((g) => g.length > 0),
|
||||
[orchestrationPlan],
|
||||
);
|
||||
// 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物,
|
||||
// 避免报告写完后额外触发一次结构化抽取请求。
|
||||
const autoSummaryFlow = false;
|
||||
// 普通会商:Markdown 报告完成后自动续跑结构化流程图;8BF:只完成报告即结束。
|
||||
// 这个开关由入口显式传入,而不是从业务链/任务状态推断,防止两类会商串行为。
|
||||
const autoSummaryFlow = hasFullStep3Outputs;
|
||||
|
||||
const summaryFlow = useStep3Summary({
|
||||
intentReady: step1.intentReady,
|
||||
@@ -1646,7 +1653,7 @@ export default function RoundtablePlanningPage({
|
||||
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
|
||||
chainLevels,
|
||||
autoFlow: autoSummaryFlow,
|
||||
flowEnabled: false,
|
||||
flowEnabled: hasFullStep3Outputs,
|
||||
savedSummary: step3Report?.summaryReport ?? null,
|
||||
onSummarySaved: handleSummarySaved,
|
||||
ingestAvailable,
|
||||
@@ -1753,28 +1760,30 @@ export default function RoundtablePlanningPage({
|
||||
} else {
|
||||
summaryFlow.start();
|
||||
}
|
||||
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
|
||||
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
|
||||
if (rep?.html && !isDashboardHtml(rep.html)) {
|
||||
reportFlow.hydrateSaved(rep);
|
||||
} else {
|
||||
staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开
|
||||
}
|
||||
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
|
||||
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
|
||||
const dashSaved = {
|
||||
dialogues: rep?.dashboardDialogues,
|
||||
seatStubMessages: rep?.dashboardSeatStubMessages,
|
||||
};
|
||||
if (rep?.dashboardJson) {
|
||||
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
|
||||
} else if (rep?.html && isDashboardHtml(rep.html)) {
|
||||
dashboardFlow.hydrateSaved(rep.html, dashSaved);
|
||||
} else {
|
||||
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2);
|
||||
if (hasFullStep3Outputs) {
|
||||
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
|
||||
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
|
||||
if (rep?.html && !isDashboardHtml(rep.html)) {
|
||||
reportFlow.hydrateSaved(rep);
|
||||
} else {
|
||||
staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开
|
||||
}
|
||||
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
|
||||
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
|
||||
const dashSaved = {
|
||||
dialogues: rep?.dashboardDialogues,
|
||||
seatStubMessages: rep?.dashboardSeatStubMessages,
|
||||
};
|
||||
if (rep?.dashboardJson) {
|
||||
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
|
||||
} else if (rep?.html && isDashboardHtml(rep.html)) {
|
||||
dashboardFlow.hydrateSaved(rep.html, dashSaved);
|
||||
} else {
|
||||
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]);
|
||||
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers, hasFullStep3Outputs]);
|
||||
|
||||
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
|
||||
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
|
||||
@@ -2608,6 +2617,7 @@ export default function RoundtablePlanningPage({
|
||||
<Step3Panel
|
||||
useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false}
|
||||
headerTitle={step3HeaderTitle}
|
||||
step3Mode={step3Mode}
|
||||
threadIds={step2.threadIds}
|
||||
coordinatorName={step2.coordinatorName}
|
||||
consensusText={step2.lastLeaderContent}
|
||||
|
||||
Reference in new issue
Block a user