268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
|
||
|