圆桌第三步流程图模式 + 后台 DAG 任务编排
- 方案总结报告:用户要求结构化输出/流程图时跳过 md 直出 flow-json 流程图, 报告用文字展示;报告与流程图均可多轮修改、互相切换(detectFlowIntent 识别意图) - 流程图画布放右侧沙箱位(sidePanel 插槽)不覆盖消息列表,可与 md 预览切换; 节点随容器宽度自适应缩放并 fitView 居中 - 后台挂起支持真 DAG 编排(分层并行/阶段目标/总控提示词),新增 orchestration_plan 持久化列 + 迁移;大弹窗流程图按 DAG 分层渲染 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b33426416b
commit
086c280e5d
25 files changed
+880
-149
No files matched your search
@@ -40,6 +40,7 @@ import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { detectFlowIntent } from "@/roundtable-planning/lib/flow-graph";
|
||||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
|
||||
@@ -149,31 +150,60 @@ function buildMaterials(opts: {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** 首轮总结 prompt。 */
|
||||
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
|
||||
function buildSummaryPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
coordinatorPrompt?: string | null;
|
||||
flowMode?: boolean;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
|
||||
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
|
||||
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
|
||||
);
|
||||
if (opts.flowMode) {
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
|
||||
"用户已明确要求结构化输出/流程图,因此本轮:" +
|
||||
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
|
||||
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求);" +
|
||||
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
|
||||
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
|
||||
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
|
||||
);
|
||||
}
|
||||
parts.push("");
|
||||
parts.push(buildMaterials(opts));
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
|
||||
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
|
||||
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
|
||||
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
|
||||
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
|
||||
);
|
||||
if (opts.flowMode) {
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:全程简体中文。" +
|
||||
"flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
|
||||
"每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
|
||||
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
|
||||
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
|
||||
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
|
||||
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
|
||||
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
|
||||
);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息命中结构化/流程图意图时追加给智能体的系统指令(live 线程续问轮直接拼在消息后)。
|
||||
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
|
||||
*/
|
||||
const FLOW_ASK_INSTRUCTION =
|
||||
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
|
||||
"在回复正文给出简短的文字说明/总结即可,并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
|
||||
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
|
||||
|
||||
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
|
||||
function buildResumeAskPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
@@ -182,8 +212,10 @@ function buildResumeAskPrompt(opts: {
|
||||
coordinatorPrompt?: string | null;
|
||||
previousMd: string;
|
||||
userMessage: string;
|
||||
/** 用户消息命中结构化/流程图意图 → 指示不写文件、直出 flow-json。 */
|
||||
flowIntent?: boolean;
|
||||
}): string {
|
||||
const { previousMd, userMessage, ...materials } = opts;
|
||||
const { previousMd, userMessage, flowIntent, ...materials } = opts;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
|
||||
@@ -202,6 +234,10 @@ function buildResumeAskPrompt(opts: {
|
||||
}
|
||||
parts.push("# 用户的新消息");
|
||||
parts.push(userMessage.trim());
|
||||
if (flowIntent) {
|
||||
parts.push("");
|
||||
parts.push(FLOW_ASK_INSTRUCTION);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -649,15 +685,19 @@ export function useStep3Summary(
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** 把当前报告 md 种进某线程 stub + 开沙箱(续问时给 reconcile/预览一个锚点)。 */
|
||||
/**
|
||||
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
|
||||
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
|
||||
*/
|
||||
const seedStubWithCurrentMd = useCallback(
|
||||
(threadId: string) => {
|
||||
(threadId: string, opts?: { open?: boolean }) => {
|
||||
const md = currentMdRef.current;
|
||||
if (!md.trim()) return;
|
||||
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
|
||||
path: SUMMARY_OUTPUT_PATH,
|
||||
content: md,
|
||||
});
|
||||
if (opts?.open === false) return;
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -676,8 +716,10 @@ export function useStep3Summary(
|
||||
seedThreadId?: string | null;
|
||||
forceReconcile?: boolean;
|
||||
files?: RunFileRef[];
|
||||
/** 流程图轮:不自动打开 md 沙箱预览(用户要看的是流程图画布)。 */
|
||||
flowRound?: boolean;
|
||||
}): Promise<void> => {
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files } = args;
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
|
||||
|
||||
let threadId = seedThreadId ?? null;
|
||||
let agentName = SUMMARY_AGENT_NAME;
|
||||
@@ -694,7 +736,8 @@ export function useStep3Summary(
|
||||
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
|
||||
);
|
||||
// 续问/恢复时把当前报告种进本线程 stub(续问 str_replace 被禁、整文件重写,但仍给预览锚点)。
|
||||
if (seedThreadId) seedStubWithCurrentMd(threadId);
|
||||
// 流程图轮只种不开沙箱——md 保留着供用户随时切回去看。
|
||||
if (seedThreadId) seedStubWithCurrentMd(threadId, { open: !flowRound });
|
||||
setPhase("streaming");
|
||||
|
||||
const res = await streamMultiAgent({
|
||||
@@ -718,7 +761,8 @@ export function useStep3Summary(
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
||||
if (!hasStubFile && !seedThreadId) await openRealArtifactFallback(threadId);
|
||||
// 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。
|
||||
if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId);
|
||||
|
||||
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
|
||||
const md = await reconcileStubFromRealFile(threadId, {
|
||||
@@ -731,9 +775,10 @@ export function useStep3Summary(
|
||||
setPhase("done");
|
||||
|
||||
// 报告有更新就存草稿(问答轮不写文件 → md 不变也无妨,仍带最新 closing)。
|
||||
// 流程图模式可能全程没有 md——只要 closing 有内容(含 flow-json)也要存,恢复时才能重画流程图。
|
||||
const finalMd = md.trim() || currentMdRef.current;
|
||||
if (finalMd.trim()) {
|
||||
currentMdRef.current = finalMd;
|
||||
if (finalMd.trim()) currentMdRef.current = finalMd;
|
||||
if (finalMd.trim() || (res.content ?? "").trim()) {
|
||||
onSummarySavedRef.current?.({
|
||||
md: finalMd,
|
||||
threadId,
|
||||
@@ -778,13 +823,16 @@ export function useStep3Summary(
|
||||
});
|
||||
|
||||
try {
|
||||
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
|
||||
const flowMode = detectFlowIntent(coordinatorPrompt);
|
||||
const prompt = buildSummaryPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
flowMode,
|
||||
});
|
||||
await streamOnce({ bubbleId, prompt, aborter });
|
||||
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -817,6 +865,9 @@ export function useStep3Summary(
|
||||
abort();
|
||||
if (wasBusy) freezeStreaming("已打断");
|
||||
|
||||
// 输入框消息命中结构化/流程图意图 → 本轮直出 flow-json、不写 md(md 留在沙箱可切回)。
|
||||
const flowIntent = detectFlowIntent(text);
|
||||
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
const aborter = new AbortController();
|
||||
@@ -860,8 +911,10 @@ export function useStep3Summary(
|
||||
|
||||
if (hadLiveThread) {
|
||||
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
|
||||
const prompt =
|
||||
// 命中流程图意图时附上系统指令(不写文件、末尾输出全量 flow-json)。
|
||||
const base =
|
||||
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
|
||||
const prompt = flowIntent ? `${base}\n\n${FLOW_ASK_INSTRUCTION}` : base;
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
prompt,
|
||||
@@ -869,6 +922,7 @@ export function useStep3Summary(
|
||||
seedThreadId: liveThreadRef.current,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
});
|
||||
} else {
|
||||
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
|
||||
@@ -881,6 +935,7 @@ export function useStep3Summary(
|
||||
previousMd: currentMdRef.current,
|
||||
userMessage:
|
||||
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
|
||||
flowIntent,
|
||||
});
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
@@ -889,6 +944,7 @@ export function useStep3Summary(
|
||||
seedThreadId: threadId,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -933,30 +989,35 @@ export function useStep3Summary(
|
||||
|
||||
// 从草稿快照恢复:不跑 SSE,用保存的 md 重建一条「完成」气泡 + 合成 stub,并打开沙箱预览。
|
||||
// 保留 threadId 到 liveThreadRef —— 若快照带真线程 id,ask() 能直接续问(后端线程仍在)。
|
||||
// 流程图模式的快照可能没有 md(只有含 flow-json 的 closing):跳过 stub/沙箱,只重建气泡,
|
||||
// 流程图由组件从气泡内容里重新解析绘制。
|
||||
const hydrateSaved = useCallback(
|
||||
(snap: DraftStep3SummarySnapshot) => {
|
||||
abort();
|
||||
runningRef.current = false;
|
||||
const hasMd = snap.md.trim().length > 0;
|
||||
const realThreadId = snap.threadId?.trim() || null;
|
||||
const synthThreadId = realThreadId ?? "roundtable-summary-restored";
|
||||
const messageId = `roundtable-summary-stub-${synthThreadId}`;
|
||||
const toolCallId = `tc-${synthThreadId}-0`;
|
||||
const stub: Record<string, StubAiMessage[]> = {
|
||||
[synthThreadId]: [
|
||||
{
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
const stub: Record<string, StubAiMessage[]> = hasMd
|
||||
? {
|
||||
[synthThreadId]: [
|
||||
{
|
||||
id: toolCallId,
|
||||
name: "write_file",
|
||||
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolCallId,
|
||||
name: "write_file",
|
||||
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
: {};
|
||||
stubRef.current = stub;
|
||||
currentMdRef.current = snap.md;
|
||||
liveThreadRef.current = realThreadId;
|
||||
@@ -975,23 +1036,27 @@ export function useStep3Summary(
|
||||
time: nowTime(),
|
||||
confidence: "历史记录",
|
||||
streaming: false,
|
||||
steps: [
|
||||
{
|
||||
id: `${bubbleId}-0`,
|
||||
key: "tool_calling:write_file",
|
||||
label: "已撰写报告 Markdown",
|
||||
ts: nowTime(),
|
||||
args: { path: SUMMARY_OUTPUT_PATH },
|
||||
},
|
||||
],
|
||||
steps: hasMd
|
||||
? [
|
||||
{
|
||||
id: `${bubbleId}-0`,
|
||||
key: "tool_calling:write_file",
|
||||
label: "已撰写报告 Markdown",
|
||||
ts: nowTime(),
|
||||
args: { path: SUMMARY_OUTPUT_PATH },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
agentThreadId: synthThreadId,
|
||||
},
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
if (hasMd) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
}
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user