feat(圆桌): Step3 三产物并行 + 大屏多页模式 + 后台挂起查看恢复流程

后台挂起 Step3:
- engine 总结报告/结果绘制/大屏数据 asyncio.gather 三流并行,故障隔离
- request.state 回归修复(进程内虚拟 request 补 state),恢复后台报告
- ROUNDTABLE_JOB_GATEWAY 默认 inprocess;产物自愈重试(截断/缺失纠偏)
- 大屏后端只产 report-json(dashboardJson),前端按当前主题渲染

结果绘制大屏多页模式(模式二):
- report-template 模板渲染(report-json→大屏HTML),零截断、主题跟随
- 与模式一同款交互(流式+沙箱+可修改);沙箱新窗口打开按钮

最终方案展示:左侧席位栏+总控头像、交付/文件合并、收展动画、内联沙箱

后台挂起查看=恢复实时流程:
- handleLoadDraft 返回 Promise,await hydrate 后订阅 streamJob SSE 跟随
- saveDraftNow 强制存(发起前+查看前各一次)防丢

第二步优化:总控判断串/并行(并行单列可写)、席位上下文+交付模板、
peerDeliveries、失败重试、共识标记、流程图小窗(缩放/拖动/并行批)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
This commit is contained in:
董宏凯 committed 2026-06-13 10:36:46 +08:00
1 parent a7596001c5
commit d5f319d9cb
51 files changed
+5016 -1186

No files matched your search

@@ -0,0 +1,312 @@
/**
* Step3「结果绘制 · 大屏多页模式(模式二)」—— 与模式一同款交互(2026-06-13 重构)。
*
* 布局/交互对齐模式一(Step3ReportMode):左侧流式对话气泡 + 右侧沙箱(复用
* `Step2SandboxLayout`,大屏 HTML 以 write_file 虚拟 stub 进沙箱 → 代码/预览切换、
* 下载、新窗口打开全部可用)+ 底部修改要求输入框(同线程续轮修订全量 report-json)。
*
* 与模式一的差别只在产物管线:模型输出 1-2KB ```report-json,前端模板渲染
* (lib/report-template)—— 快一个数量级、零截断;链路(initReportThread +
* streamMultiAgent)与模式一完全同源。
*/
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import {
ArrowLeft,
CheckCircle,
Eye,
LayoutDashboard,
Loader2,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { isDashboardHtml, stripReportFence } from "@/roundtable-planning/lib/report-template";
interface Step3DashboardModeProps {
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
savedReport?: DraftStep3Snapshot | null;
/** 页面层常驻的大屏生成流(2026-06-13 提升:切走不断流,进入续看)。 */
dashboard: UseStep3DashboardReturn;
/** 切回经典「单页看板」绘制模式。 */
onSwitchClassic: () => void;
/** 返回最终方案展示页。 */
onBack: () => void;
}
export function Step3DashboardMode({
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
dashboard,
onSwitchClassic,
onBack,
}: Step3DashboardModeProps) {
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
dashThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
busy,
html,
start,
redraw,
ask,
abort,
hydrateSaved,
openDashboardPreview,
} = dashboard;
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
// 产物/进行中会立即接管沙箱续看。
useEffect(() => {
dashboard.setPreviewEnabled(true);
return () => dashboard.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 续看语义:只兜底"从未启动过"(idle);**卸载不再 abort**(切走不断流)。
// 优先 dashboardJson(结构化数据,按当前主题渲染);旧版 marker html 兜底。
useEffect(() => {
if (phase !== "idle") return;
if (savedReport?.dashboardJson) {
dashboard.hydrateFromJson(savedReport.dashboardJson);
} else if (savedReport?.html && isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport.html);
} else {
start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [revision, setRevision] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
useAutoScroll(scrollRef, [dialogues]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const text = revision.trim();
if (!text) return;
setRevision("");
ask(text);
};
// 气泡显示:report-json 生 JSON 替换成一句人话(对齐 flow-json 的处理)。
const displayDialogues = useMemo(
() =>
dialogues.map((d) => {
const content = d.content ?? "";
if (!content.includes("```report-json")) return d;
return { ...d, content: stripReportFence(content, d.streaming === true) };
}),
[dialogues],
);
const isStreaming = busy || dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
const statusLabel =
phase === "creating"
? "正在初始化绘制线程…"
: phase === "streaming"
? "大屏数据提炼中…"
: phase === "done"
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏(对齐模式一样式) */}
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<LayoutDashboard 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") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title="切换大屏绘制使用的推理模型"
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary disabled:opacity-50"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
{busy && (
<button
type="button"
onClick={abort}
title="暂停生成"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
暂停
</button>
)}
{html && (
<button
type="button"
onClick={openDashboardPreview}
title="在右侧沙箱打开大屏预览"
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"
>
<Eye className="w-3.5 h-3.5 text-slate-500" />
打开大屏预览
</button>
)}
<button
type="button"
onClick={() => redraw()}
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>
<button
type="button"
onClick={onSwitchClassic}
title="切回经典单页看板绘制(模型自由发挥整页 HTML)"
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"
>
<Sparkles className="w-3.5 h-3.5 text-slate-500" />
单页看板
</button>
<button
type="button"
onClick={onBack}
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"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
</div>
{/* 主体:对话 + 沙箱(复用第 2 步分栏,与模式一一致) */}
<div className="flex-1 min-h-0 p-3">
<section className="flex h-full min-w-0 flex-col rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? dashThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
>
<div className="flex h-full min-h-0 flex-col">
{/* 对话流 */}
<div
ref={scrollRef}
className="custom-scrollbar flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4"
>
{displayDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 text-slate-400">
<Loader2 className="w-7 h-7 animate-spin text-primary" />
<span className="text-xs font-medium">正在初始化大屏绘制…</span>
</div>
)}
{displayDialogues.map((d) => (
<div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(d.avatarType ?? "blue", "h-7 w-7")}</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-center gap-2 text-xs font-semibold text-slate-700">
{d.sender}
<span
className={cn(
"rounded-full px-1.5 py-px text-[9.5px] font-bold",
d.streaming
? "bg-primary/10 text-primary"
: "bg-slate-100 text-slate-400",
)}
>
{d.confidence}
</span>
<span className="text-[9.5px] font-mono text-slate-300">{d.time}</span>
</div>
<div className="prose prose-sm max-w-none break-words text-[12.5px] leading-relaxed text-slate-700">
<MarkdownContent>{d.content || "…"}</MarkdownContent>
{d.streaming && (
<span className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse rounded bg-primary align-middle" />
)}
</div>
</div>
</div>
))}
{error && phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] text-rose-600">
{error}
</div>
)}
</div>
{/* 修改要求输入框(与模式一同款交互:随时可发,busy 时先打断再续) */}
<form
onSubmit={handleSubmit}
className="shrink-0 border-t border-slate-100 bg-slate-50/60 px-4 py-3"
>
<div className="flex items-end gap-2">
<textarea
value={revision}
onChange={(e) => setRevision(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
rows={2}
placeholder="输入修改要求,例如:把 KPI 换成成本相关指标 / 风险图改成雷达图 / 增加一个验收阶段…"
className="flex-1 resize-none rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs outline-none transition focus:border-primary focus:ring-1 focus:ring-primary/30"
/>
<button
type="submit"
disabled={!revision.trim()}
className="shrink-0 flex h-9 items-center gap-1.5 rounded-xl bg-primary px-4 text-xs font-bold text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="size-3.5" />
发送
</button>
</div>
</form>
</div>
</Step2SandboxLayout>
</section>
</div>
</div>
);
}