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

@@ -40,7 +40,7 @@ import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent } from "@/roundtable-planning/lib/flow-graph";
import { detectFlowIntent, hasFlowFence } from "@/roundtable-planning/lib/flow-graph";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -87,6 +87,8 @@ export interface UseStep3SummaryReturn {
pause: () => void;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
setPreviewEnabled: (enabled: boolean) => void;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
@@ -164,7 +166,8 @@ function buildSummaryPrompt(opts: {
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"用户已明确要求结构化输出/流程图,因此本轮:" +
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求);" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
@@ -201,9 +204,35 @@ function buildSummaryPrompt(opts: {
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
"在回复正文给出简短的文字说明/总结即可,并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
"在回复正文给出简短的文字说明/总结(用户未指定结构时,默认对各席位交付内容逐席位格式化输出:" +
"席位名→核心结论→关键交付要点),并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
/**
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
* 历史痛点:用户画完流程图后说「把验收环节加上」「改一下」,关键词不命中 →
* 智能体把它当改报告/问答处理,用户被迫说「重新结构化输出」才能改图。
* 这里把「上一轮主产物」上下文显式告诉智能体,让它按语义判断、太模糊就反问。
*/
const FLOW_CONTEXT_ASK_INSTRUCTION =
"(系统指令:上一轮的主产物是结构化流程图。请按语义理解用户这条消息、不要求任何关键词:" +
"若是修改/调整类意图且没明确说要改文字报告,默认视为**修改流程图**——不要写 .md 文件," +
"在回复末尾输出**完整修订版** ```flow-json 代码块(全量节点+连线,非增量);" +
"若明确要改文字报告才用 write_file 整文件重写;若只是提问就只用文字回答;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/**
* 普通报告语境下的意图引导指令(每轮续问都附上):
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
*/
const SEMANTIC_ASK_INSTRUCTION =
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
"只是提问就只用文字回答、不改文件;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
@@ -212,10 +241,10 @@ function buildResumeAskPrompt(opts: {
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
/** 用户消息命中结构化/流程图意图 → 指示不写文件、直出 flow-json。 */
flowIntent?: boolean;
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
askInstruction?: string;
}): string {
const { previousMd, userMessage, flowIntent, ...materials } = opts;
const { previousMd, userMessage, askInstruction, ...materials } = opts;
const parts: string[] = [];
parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
@@ -234,9 +263,9 @@ function buildResumeAskPrompt(opts: {
}
parts.push("# 用户的新消息");
parts.push(userMessage.trim());
if (flowIntent) {
if (askInstruction) {
parts.push("");
parts.push(FLOW_ASK_INSTRUCTION);
parts.push(askInstruction);
}
return parts.join("\n");
}
@@ -283,7 +312,12 @@ export function useStep3Summary(
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<SummaryPhase>("idle");
const [error, setError] = useState<string | null>(null);
@@ -303,6 +337,10 @@ export function useStep3Summary(
const liveThreadRef = useRef<string | null>(null);
// 当前报告 md 全文(续问写底 / 存草稿用)。
const currentMdRef = useRef<string>("");
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
// 不再要求用户说出「结构化输出/流程图」等关键词。
const flowActiveRef = useRef(false);
const lastAutoOpenedUrlRef = useRef<string | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
@@ -483,8 +521,36 @@ export function useStep3Summary(
[selectArtifact, setArtifactsOpen],
);
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
@@ -618,6 +684,7 @@ export function useStep3Summary(
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
try {
const res = await listSessionArtifacts(threadId);
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
@@ -697,7 +764,7 @@ export function useStep3Summary(
path: SUMMARY_OUTPUT_PATH,
content: md,
});
if (opts?.open === false) return;
if (opts?.open === false || !previewEnabledRef.current) return;
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
},
@@ -720,6 +787,8 @@ export function useStep3Summary(
flowRound?: boolean;
}): Promise<void> => {
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
// 本轮开始前的报告底稿——结束后对比判断「这一轮的主产物是报告还是流程图」。
const mdBefore = currentMdRef.current;
let threadId = seedThreadId ?? null;
let agentName = SUMMARY_AGENT_NAME;
@@ -771,6 +840,14 @@ export function useStep3Summary(
});
if (aborter.signal.aborted) return;
// 跟踪本轮主产物:回复带 flow-json → 流程图轮;md 有实质更新 → 报告轮;
// 都没有(纯问答轮)→ 维持上一轮状态。供下一次 ask() 选意图引导指令。
if (hasFlowFence(res.content)) {
flowActiveRef.current = true;
} else if (md.trim() && md !== mdBefore) {
flowActiveRef.current = false;
}
finalizeDialogue(bubbleId, res.content, "总结完成");
setPhase("done");
@@ -825,6 +902,7 @@ export function useStep3Summary(
try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
const flowMode = detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({
intentReady,
seatDeliveries,
@@ -865,8 +943,19 @@ export function useStep3Summary(
abort();
if (wasBusy) freezeStreaming("已打断");
// 输入框消息命中结构化/流程图意图 → 本轮直出 flow-json、不写 md(md 留在沙箱可切回)。
// 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类):
// ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md;
// ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问);
// ③ 普通报告语境 → 语义理解修改/提问/结构化意图,太模糊则反问。
const flowIntent = detectFlowIntent(text);
const flowContext = !flowIntent && flowActiveRef.current;
const askInstruction = flowIntent
? FLOW_ASK_INSTRUCTION
: flowContext
? FLOW_CONTEXT_ASK_INSTRUCTION
: SEMANTIC_ASK_INSTRUCTION;
// 流程图语境轮:不主动弹 md 沙箱预览(用户多半要看的是画布;真写了文件预览仍会自动打开)。
const flowRound = flowIntent || flowContext;
runningRef.current = true;
setError(null);
@@ -910,11 +999,11 @@ export function useStep3Summary(
setPhase("streaming");
if (hadLiveThread) {
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
// 命中流程图意图时附上系统指令(不写文件、末尾输出全量 flow-json)。
// 真线程仍在 → 续问,发原始消息 + 按语境选好的意图引导指令,
// 靠线程上下文 + SOUL 让智能体按语义自判改图 / 改报告 / 问答 / 反问澄清。
const base =
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
const prompt = flowIntent ? `${base}\n\n${FLOW_ASK_INSTRUCTION}` : base;
const prompt = `${base}\n\n${askInstruction}`;
await streamOnce({
bubbleId,
prompt,
@@ -922,7 +1011,7 @@ export function useStep3Summary(
seedThreadId: liveThreadRef.current,
forceReconcile: true,
files: fileRefs,
flowRound: flowIntent,
flowRound,
});
} else {
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
@@ -935,7 +1024,7 @@ export function useStep3Summary(
previousMd: currentMdRef.current,
userMessage:
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
flowIntent,
askInstruction,
});
await streamOnce({
bubbleId,
@@ -944,7 +1033,7 @@ export function useStep3Summary(
seedThreadId: threadId,
forceReconcile: true,
files: fileRefs,
flowRound: flowIntent,
flowRound,
});
}
} catch (err) {
@@ -1021,7 +1110,20 @@ export function useStep3Summary(
stubRef.current = stub;
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
lastAutoOpenedUrlRef.current = null;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
flowActiveRef.current = hasFlowFence(snap.closing);
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 md(防串台)。
if (hasMd) {
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
SUMMARY_OUTPUT_PATH,
messageId,
toolCallId,
);
lastAutoThreadRef.current = synthThreadId;
} else {
lastAutoOpenedUrlRef.current = null;
lastAutoThreadRef.current = null;
}
setSeatStubMessages(stub);
setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
@@ -1052,13 +1154,11 @@ export function useStep3Summary(
]);
setError(null);
setPhase("done");
if (hasMd) {
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(synthThreadId, virtualUrl);
}
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
},
[abort, buildWriteFileVirtualUrl, openArtifact],
[abort, buildWriteFileVirtualUrl],
);
const busy = phase === "creating" || phase === "streaming";
@@ -1077,6 +1177,7 @@ export function useStep3Summary(
ask,
pause,
abort,
setPreviewEnabled,
openArtifactFromStep,
openArtifactPreview,
};