Files
deerflow-code/frontend-web/src/roundtable-planning/hooks/useRoundtableStep3Flows.ts
T
2026-07-19 16:08:40 +08:00

268 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { useStep3Report, type ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
const STEP3_FLOW_STAGGER_MS = 300;
export interface Step3ConversationSnapshot {
dialogues: unknown[];
seatStubMessages: Record<string, unknown[]>;
ingestStatus?: string;
}
export interface UseRoundtableStep3FlowsOptions {
intentReady: ReportIntent | null;
step2RoundtableDialogues: Step2Dialogue[];
consensusText: string;
selectedModel: string;
orchestrationPlan: OrchestrationPlan | null;
step3Report: DraftStep3Snapshot | null;
onReportSaved: (snapshot: DraftStep3Snapshot) => void;
onSummarySaved: (snapshot: NonNullable<DraftStep3Snapshot["summaryReport"]>) => void;
ingestAvailable: boolean;
ingestSkill: string;
ingestBusinessCode: string | null;
taskId: string | null;
chainTitle: string | null;
isDark: boolean;
currentStep: number;
draftId: string | null;
summaryConvoRef: MutableRefObject<Step3ConversationSnapshot>;
reportConvoRef: MutableRefObject<Step3ConversationSnapshot>;
dashboardConvoRef: MutableRefObject<Step3ConversationSnapshot>;
}
export function useRoundtableStep3Flows({
intentReady,
step2RoundtableDialogues,
consensusText,
selectedModel,
orchestrationPlan,
step3Report,
onReportSaved: handleReportSaved,
onSummarySaved: handleSummarySaved,
ingestAvailable,
ingestSkill,
ingestBusinessCode,
taskId,
chainTitle,
isDark,
currentStep,
draftId,
summaryConvoRef: step3SummaryConvoRef,
reportConvoRef: step3ReportConvoRef,
dashboardConvoRef: step3DashConvoRef,
}: UseRoundtableStep3FlowsOptions) {
// ── Step3 三个生成流提升到页面层(2026-06-13)────────────────────────────
// 总结报告 / 结果绘制(经典) / 大屏多页的流式 run **不随 Step3 子视图卸载而中断**:
// 用户切回第二步、在总结/绘制/最终方案视图间往返,流都在页面层继续;
// 回头进入对应视图即可续看进行中的过程或最终结果。
const step3SeatDeliveries = useMemo(
() =>
step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
content: stripReasoning(String(d.content ?? "")),
})),
[step2RoundtableDialogues],
);
// 业务链条层级(取 stages 的「本阶段目标」goal,有序)——taskId 深链模式自动结构化输出时
// 作流程图层级骨架(任务 → 层级1 → …)。memo 稳定数组身份,避免 run useCallback 频繁失效。
const chainLevels = useMemo(
() =>
(orchestrationPlan?.stages ?? [])
.map((s) => s.goal?.trim() ?? "")
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
const autoSummaryFlow = true;
const summaryFlow = useStep3Summary({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
ingestAvailable,
// 入库仅深链(taskId)模式(会商 rwfx-A 等)才有;taskId 入库时必传给技能。
taskId: taskId,
// 入库场景线索:适用性判定选中的技能 + 业务码/链名,入库时引导智能体挑对入库技能。
ingestSkill,
ingestBusinessCode,
ingestChainName: chainTitle,
});
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub + 入库状态,供 getStep3Snapshot 落库。
step3SummaryConvoRef.current = {
dialogues: summaryFlow.dialogues,
seatStubMessages: summaryFlow.seatStubMessages,
ingestStatus: summaryFlow.ingestStatus,
};
const reportFlow = useStep3Report({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像经典看板的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3ReportConvoRef.current = {
dialogues: reportFlow.dialogues,
seatStubMessages: reportFlow.seatStubMessages,
};
const dashboardFlow = useStep3Dashboard({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
themeMode: isDark ? "dark" : "light",
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像大屏的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3DashConvoRef.current = {
dialogues: dashboardFlow.dialogues,
seatStubMessages: dashboardFlow.seatStubMessages,
};
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
// 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。
const step3EnteredRef = useRef(false);
// 进第三步「错峰起跑」定时器。三流虽互无依赖、要并行展示进度,但若**同一刻**一起
// 调 start()(各自 init thread + run/stream),会把单 worker 网关 + 单连接 sqlite
// checkpoint 锁的瞬时压力顶满,回环 /runs/stream 概率性返回非 200 → 前端冒 502。
// 这里把后两个流的**起跑**各往后挪一点点(仅错开 start_run 的写尖峰,不是串行等前一个
// 跑完),削峰即可。定时器存 ref,切草稿 / 重新 hydrate / 新建任务时统一清掉,避免迟发的
// start() 打到已被复位/中断的上下文。
const step3StartTimersRef = useRef<number[]>([]);
const clearStep3StartTimers = useCallback(() => {
for (const t of step3StartTimersRef.current) {
if (typeof window !== "undefined") window.clearTimeout(t);
}
step3StartTimersRef.current = [];
}, []);
// step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。
const step3ReportRef = useRef(step3Report);
step3ReportRef.current = step3Report;
// 切草稿即在**渲染期**同步复位入口闸门:保证随后的 hydrate effect 能重新跑一次
// (此前闸门只在 currentStep 变化时复位,切历史记录若仍停在第三步 currentStep 不变,
// 大屏/报告就留着上一条记录的数据不刷新 —— 即本次修复的 bug 根因)。
const prevDraftIdRef = useRef(draftId);
if (prevDraftIdRef.current !== draftId) {
const wasRealDraft = prevDraftIdRef.current != null;
prevDraftIdRef.current = draftId;
// 仅「真正切到另一份草稿」才复位入口闸门重新 hydrate。草稿**首次创建**(null→id,
// 多由本会话第一次自动保存触发)不算切换——若这时复位,正在并行跑的 Step3 三流(尤其
// 大屏)会被下方 hydrate 当成「未启动」重新 start(),既丢了在途产物又反复重画。
if (wasRealDraft) step3EnteredRef.current = false;
}
// 三流 hydrate/start(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它,
// 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。
const hydrateStep3Flows = useCallback(() => {
step3EnteredRef.current = true;
// 重新编排前先清掉上一轮可能还没触发的错峰定时器(切草稿重 hydrate 时)。
clearStep3StartTimers();
const rep = step3ReportRef.current;
// 错峰起跑助手:把一个尚未起跑的流延迟 delayMs 后再 start();触发前再校验
// step3EnteredRef(被新建任务 / 切草稿复位为 false 则跳过),避免迟发的 start()
// 打到已被中断的上下文。delay=0 表示立即(同步)起跑。
const staggerStart = (start: () => void, delayMs: number) => {
if (delayMs <= 0 || typeof window === "undefined") {
start();
return;
}
const id = window.setTimeout(() => {
if (!step3EnteredRef.current) return;
start();
}, delayMs);
step3StartTimersRef.current.push(id);
};
// ① 方案总结报告(主页,立即起跑)
const savedSum = rep?.summaryReport;
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
summaryFlow.hydrateSaved(savedSum);
} else {
summaryFlow.start();
}
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
if (rep?.html && !isDashboardHtml(rep.html)) {
reportFlow.hydrateSaved(rep);
} else {
staggerStart(() => reportFlow.start(), STEP3_FLOW_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_FLOW_STAGGER_MS * 2);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]);
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
// **仅在真正切到另一份草稿时中断**——草稿首次创建(null→id)不是切换,那时三流可能正
// 跑着(尤其大屏绘制),误 abort 会让产物没存上、随后还被重新 hydrate 重画。
const abortPrevDraftIdRef = useRef(draftId);
useEffect(() => {
const prev = abortPrevDraftIdRef.current;
abortPrevDraftIdRef.current = draftId;
if (prev && draftId && prev !== draftId) {
clearStep3StartTimers(); // 切草稿:取消尚未触发的错峰 start,防打到新草稿上下文
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId]);
// 进第三步 / 切草稿后仍停留第三步:按当前草稿快照恢复或新建三流(闸门已在渲染期复位)。
useEffect(() => {
if (currentStep !== 3 || step3EnteredRef.current) return;
hydrateStep3Flows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, draftId]);
const reset = useCallback(() => {
clearStep3StartTimers();
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
step3EnteredRef.current = false;
}, [clearStep3StartTimers, summaryFlow, reportFlow, dashboardFlow]);
return {
summaryFlow,
reportFlow,
dashboardFlow,
reset,
};
}