模型出问题时按 config.yaml models[] 顺序自动切到下一个模型继续,并告知用户 「『X』模型异常 → 已切换『Y』继续」。前台实时 + 后台挂起两条路径都覆盖。 - 中间件兜底/熔断 AIMessage 打 LLM_ERROR_MARKER 标记,is_llm_error_message 可识别 - roundtable_model_fallback: fallback_model_chain 候选链 + reason_text + 总开关 env ROUNDTABLE_MODEL_FALLBACK - 触发: 异常(start_run失败/idle卡死) / 兜底错误文案 / 交付为空(仅seat/leader) - 后台 InProcessRoundtableGateway._run_turn_with_fallback 包住每轮 run - 前台 _special_run/_leader_run 收尾检测失败→model_switch 帧+换模型重流 - 结构化抽取 flow_extract round1 astream 同款换模型循环 - Step2 单席位异常隔离: 单席位失败不拖垮整批/整链/整个作业 - 时间线插「系统提示」气泡 + toast + 诊断 model_switch 事件 - Tests: test_roundtable_model_fallback.py Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
610 lines
25 KiB
TypeScript
610 lines
25 KiB
TypeScript
/**
|
||
* Step 3「结果绘制 · 大屏多页(模式二)」编排 hook —— 与模式一同款交互(2026-06-13)。
|
||
*
|
||
* 链路与模式一/总结报告完全同源(`initReportThread` + `streamMultiAgent`,
|
||
* 已验证可用):流式对话气泡 + 右侧沙箱预览 + 输入框继续修改。区别在产物:
|
||
* - 模型只输出 ```report-json 结构化数据(buildDashboardPrompt,禁工具禁文件);
|
||
* - 前端 `renderDashboardHtml` 模板渲染成大屏 HTML,**种成 write_file 虚拟 stub**
|
||
* (path = DASHBOARD_OUTPUT_PATH)→ 走与模式一相同的沙箱 HTML 预览(含代码/预览
|
||
* 切换、下载、新窗口打开);
|
||
* - `ask(message)`:同线程续轮,要求模型输出**修订后的全量** report-json → 重渲染
|
||
* → 更新 stub 与草稿快照。
|
||
*/
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||
import {
|
||
initDashboardThread,
|
||
streamMultiAgent,
|
||
type StreamPhase,
|
||
} from "@/roundtable-planning/api/multi-agent";
|
||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||
import type {
|
||
Step2Dialogue,
|
||
StubAiMessage,
|
||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||
import {
|
||
buildDashboardPrompt,
|
||
DASHBOARD_OUTPUT_PATH,
|
||
extractReportData,
|
||
isDashboardHtml,
|
||
parseReportJson,
|
||
renderDashboardHtml,
|
||
type ReportData,
|
||
} from "@/roundtable-planning/lib/report-template";
|
||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||
|
||
const DASH_AGENT_FALLBACK = "roundtable-dashboard";
|
||
|
||
export type DashboardPhase = "idle" | "creating" | "streaming" | "done" | "error";
|
||
|
||
interface UseStep3DashboardOptions {
|
||
intentReady: ReportIntent | null;
|
||
seatDeliveries: Array<{ sender: string; content: string }>;
|
||
consensusText: string;
|
||
selectedModel: string;
|
||
/** 当前界面主题 —— 大屏按它取色(light=浅红色 UI 规范 / dark=深蓝色 UI 规范)。 */
|
||
themeMode: "light" | "dark";
|
||
savedReport?: DraftStep3Snapshot | null;
|
||
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
||
/**
|
||
* 自定义初始 prompt 构造(独立大屏页用:输入是任务原始 JSON,让智能体分析转换)。
|
||
* 返回空/未提供时回退默认的 buildDashboardPrompt(圆桌研讨成果模式)。
|
||
*/
|
||
buildInitialPrompt?: () => string | null | undefined;
|
||
}
|
||
|
||
/** 恢复大屏时可带上的历史生成过程对话(文字 + 步骤条)+ stub —— 整段回显用。 */
|
||
export interface Step3DashRestore {
|
||
dialogues?: unknown[];
|
||
seatStubMessages?: Record<string, unknown[]>;
|
||
}
|
||
|
||
export interface UseStep3DashboardReturn {
|
||
phase: DashboardPhase;
|
||
error: string | null;
|
||
dashThreadId: string | null;
|
||
dialogues: Step2Dialogue[];
|
||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||
activeArtifactThreadId: string | null;
|
||
busy: boolean;
|
||
/** 当前大屏 HTML(已渲染;无则 null)。 */
|
||
html: string | null;
|
||
/** 当前大屏的结构化数据(report-json 解析结果)—— 供应用内 React 大屏直接渲染。 */
|
||
data: ReportData | null;
|
||
start: () => void;
|
||
redraw: () => void;
|
||
/** 用户输入修改要求 → 同线程续轮,输出修订版全量数据并重渲染。 */
|
||
ask: (message: string) => void;
|
||
abort: () => void;
|
||
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
|
||
setPreviewEnabled: (enabled: boolean) => void;
|
||
/**
|
||
* 从草稿恢复(带 marker 的大屏 HTML,旧版存稿):种 stub + 气泡,不自动开沙箱。
|
||
* `saved` 带历史完整对话(文字 + 步骤条) + stub 时整段回显,否则重建单条「历史记录」气泡。
|
||
*/
|
||
hydrateSaved: (html: string, saved?: Step3DashRestore) => void;
|
||
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
|
||
hydrateFromJson: (jsonStr: string, saved?: Step3DashRestore) => void;
|
||
/** 从持久化恢复**真实聊天记录** + 结构化数据(独立大屏页按 taskId 取回时用)。 */
|
||
hydrateTranscript: (dialogues: Step2Dialogue[], dashboardJson: string | null) => void;
|
||
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
|
||
openDashboardPreview: () => void;
|
||
}
|
||
|
||
function formatNow(): string {
|
||
const d = new Date();
|
||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
function dashPhaseLabel(phase: StreamPhase): string {
|
||
switch (phase) {
|
||
case "connecting":
|
||
return "调度中";
|
||
case "connected":
|
||
return "已连接,等待响应";
|
||
case "streaming":
|
||
return "正在提炼大屏数据";
|
||
default:
|
||
return "正在输出…";
|
||
}
|
||
}
|
||
|
||
export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3DashboardReturn {
|
||
const {
|
||
intentReady,
|
||
seatDeliveries,
|
||
consensusText,
|
||
selectedModel,
|
||
themeMode,
|
||
savedReport,
|
||
onReportSaved,
|
||
buildInitialPrompt,
|
||
} = options;
|
||
|
||
const onSavedRef = useRef(onReportSaved);
|
||
useEffect(() => {
|
||
onSavedRef.current = onReportSaved;
|
||
}, [onReportSaved]);
|
||
|
||
const buildInitialPromptRef = useRef(buildInitialPrompt);
|
||
useEffect(() => {
|
||
buildInitialPromptRef.current = buildInitialPrompt;
|
||
}, [buildInitialPrompt]);
|
||
|
||
const { select: selectArtifact, setOpen: setArtifactsOpen, open: artifactsOpen } = useArtifacts();
|
||
const artifactsOpenRef = useRef(artifactsOpen);
|
||
useEffect(() => {
|
||
artifactsOpenRef.current = artifactsOpen;
|
||
}, [artifactsOpen]);
|
||
|
||
const [phase, setPhase] = useState<DashboardPhase>("idle");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [dashThreadId, setDashThreadId] = useState<string | null>(null);
|
||
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
|
||
const [seatStubMessages, setSeatStubMessages] = useState<Record<string, StubAiMessage[]>>({});
|
||
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(null);
|
||
const [html, setHtml] = useState<string | null>(null);
|
||
const [data, setData] = useState<ReportData | null>(null);
|
||
|
||
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
|
||
const runningRef = useRef(false);
|
||
const aborterRef = useRef<AbortController | null>(null);
|
||
const liveThreadRef = useRef<string | null>(null);
|
||
const agentNameRef = useRef<string>(DASH_AGENT_FALLBACK);
|
||
const htmlRef = useRef<string | null>(null);
|
||
// 当前大屏的结构化数据(最近一次成功解析/恢复)——续轮修改时原样喂回模型,
|
||
// 让它在真实当前状态上做**最小改动**(否则模型凭线程记忆臆测、改标题等局部要求常失效)。
|
||
const dataRef = useRef<ReportData | null>(null);
|
||
|
||
// ── 气泡三段式(append → delta → finalize),对齐 useStep3Summary ──
|
||
const appendStreamingDialogue = useCallback((threadId?: string): number => {
|
||
const id = Date.now() + Math.random();
|
||
setDialogues((prev) => [
|
||
...prev,
|
||
{
|
||
id,
|
||
sender: "大屏绘制智能体",
|
||
avatarType: "blue",
|
||
content: "",
|
||
time: nowTime(),
|
||
confidence: "调度中",
|
||
streaming: true,
|
||
steps: [],
|
||
agentThreadId: threadId,
|
||
},
|
||
]);
|
||
return id;
|
||
}, []);
|
||
|
||
const appendUserDialogue = useCallback((text: string) => {
|
||
setDialogues((prev) => [
|
||
...prev,
|
||
{
|
||
id: Date.now() + Math.random(),
|
||
sender: "指挥官(修改要求)",
|
||
avatarType: "human",
|
||
content: text,
|
||
time: nowTime(),
|
||
confidence: "已发送",
|
||
streaming: false,
|
||
steps: [],
|
||
},
|
||
]);
|
||
}, []);
|
||
|
||
const appendToDialogue = useCallback((id: number, deltaText: string) => {
|
||
if (!deltaText) return;
|
||
setDialogues((prev) =>
|
||
prev.map((d) => (d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d)),
|
||
);
|
||
}, []);
|
||
|
||
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
|
||
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, confidence } : d)));
|
||
}, []);
|
||
|
||
const finalizeDialogue = useCallback((id: number, finalContent: string, confidence?: string) => {
|
||
setDialogues((prev) =>
|
||
prev.map((d) => {
|
||
if (d.id !== id) return d;
|
||
const next = finalContent?.trim() ? finalContent : d.content || "(无文本输出)";
|
||
return { ...d, content: next, confidence: confidence ?? d.confidence, streaming: false };
|
||
}),
|
||
);
|
||
}, []);
|
||
|
||
// ── 大屏 stub(write_file 虚拟产物 → 沙箱 HTML 预览) ──
|
||
const upsertDashStub = useCallback((threadId: string, htmlContent: string) => {
|
||
const messageId = `roundtable-dashboard-stub-${threadId}`;
|
||
const toolCallId = `tc-${threadId}-0`;
|
||
const msg: StubAiMessage = {
|
||
id: messageId,
|
||
type: "ai",
|
||
content: "",
|
||
tool_calls: [
|
||
{ id: toolCallId, name: "write_file", args: { path: DASHBOARD_OUTPUT_PATH, content: htmlContent } },
|
||
],
|
||
};
|
||
const nextMap = { ...stubRef.current, [threadId]: [msg] };
|
||
stubRef.current = nextMap;
|
||
setSeatStubMessages(nextMap);
|
||
return { messageId, toolCallId };
|
||
}, []);
|
||
|
||
// 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。
|
||
const previewEnabledRef = useRef(false);
|
||
const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
|
||
null,
|
||
);
|
||
|
||
const openDashboardArtifact = useCallback(
|
||
(threadId: string, messageId: string, toolCallId: string, opts?: { force?: boolean }) => {
|
||
lastAutoOpenRef.current = { threadId, messageId, toolCallId };
|
||
if (!opts?.force && !previewEnabledRef.current) return; // 不在前台只记录不抢
|
||
const virtualUrl = new URL(
|
||
`write-file:${DASHBOARD_OUTPUT_PATH}?message_id=${encodeURIComponent(messageId)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||
).toString();
|
||
setActiveArtifactThreadId(threadId);
|
||
selectArtifact(virtualUrl, false);
|
||
setArtifactsOpen(true);
|
||
},
|
||
[selectArtifact, setArtifactsOpen],
|
||
);
|
||
|
||
const setPreviewEnabled = useCallback(
|
||
(enabled: boolean) => {
|
||
previewEnabledRef.current = enabled;
|
||
// 进入大屏「交互区」即视为用户想看抽取过程:有已记录的预览锚点(流式 stub /
|
||
// 已渲染 HTML)就**强制接管沙箱**展示本流产物(之前只在面板已开时接管,会导致
|
||
// 中途进交互区看不到抽取过程的空白)。离开交互区(enabled=false)只让出预览权,
|
||
// 不主动关面板(沙箱可继续停留,切回经典/其它流时各自接管)。
|
||
const last = lastAutoOpenRef.current;
|
||
if (enabled && last) {
|
||
openDashboardArtifact(last.threadId, last.messageId, last.toolCallId, { force: true });
|
||
}
|
||
},
|
||
[openDashboardArtifact],
|
||
);
|
||
|
||
const openDashboardPreview = useCallback(() => {
|
||
const tid = liveThreadRef.current ?? activeArtifactThreadId ?? "roundtable-dashboard-restored";
|
||
const current = htmlRef.current;
|
||
if (!current) return;
|
||
const { messageId, toolCallId } = upsertDashStub(tid, current);
|
||
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
|
||
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]);
|
||
|
||
const abort = useCallback(() => {
|
||
aborterRef.current?.abort();
|
||
aborterRef.current = null;
|
||
runningRef.current = false;
|
||
}, []);
|
||
|
||
/** 一轮流式:发 message 给大屏绘制线程,**生成过程实时进沙箱**(结构化数据边出
|
||
* 边以代码态显示,对齐模式一"看着 HTML 被写出来"的体验),结束后解析
|
||
* report-json → 按当前主题渲染 → 沙箱自动切预览。 */
|
||
const streamOnce = useCallback(
|
||
async (args: { bubbleId: number; message: string; aborter: AbortController }) => {
|
||
const { bubbleId, message, aborter } = args;
|
||
let threadId = liveThreadRef.current;
|
||
if (!threadId) {
|
||
const init = await initDashboardThread({ signal: aborter.signal });
|
||
if (aborter.signal.aborted) return;
|
||
threadId = init.threadId;
|
||
agentNameRef.current = init.agentName || DASH_AGENT_FALLBACK;
|
||
liveThreadRef.current = threadId;
|
||
setDashThreadId(threadId);
|
||
}
|
||
setActiveArtifactThreadId(threadId);
|
||
setDialogues((prev) =>
|
||
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
|
||
);
|
||
setPhase("streaming");
|
||
|
||
// 生成开始就打开沙箱:流式期间 stub 内容=模型已输出的结构化数据(代码态展示),
|
||
// 完成后替换成渲染好的大屏 HTML(isLoading 翻 false → 预览自动切换)。
|
||
let acc = "";
|
||
let lastFlush = 0;
|
||
const initial = upsertDashStub(threadId, "(正在连接模型,结构化数据稍后在此实时显示…)");
|
||
openDashboardArtifact(threadId, initial.messageId, initial.toolCallId);
|
||
const flushStub = (force = false) => {
|
||
const now = Date.now();
|
||
if (!force && now - lastFlush < 200) return;
|
||
lastFlush = now;
|
||
upsertDashStub(threadId!, acc);
|
||
};
|
||
|
||
const res = await streamMultiAgent({
|
||
agentType: "special",
|
||
agentName: agentNameRef.current,
|
||
dAgentThreadId: { [agentNameRef.current]: threadId },
|
||
displayName: "大屏绘制",
|
||
newMessage: message,
|
||
model: selectedModel || undefined,
|
||
signal: aborter.signal,
|
||
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试的半截累计(acc + 气泡)。
|
||
onModelSwitch: (info) => {
|
||
acc = "";
|
||
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
|
||
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}(${info.reason})`);
|
||
},
|
||
onTextDelta: (text) => {
|
||
appendToDialogue(bubbleId, text);
|
||
acc += text;
|
||
flushStub();
|
||
},
|
||
onPhaseChange: (p) => setDialogueConfidence(bubbleId, dashPhaseLabel(p)),
|
||
});
|
||
if (aborter.signal.aborted) return;
|
||
acc = res.content || acc;
|
||
flushStub(true);
|
||
|
||
const data = extractReportData(res.content);
|
||
if (!data) {
|
||
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
|
||
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current);
|
||
finalizeDialogue(
|
||
bubbleId,
|
||
(res.content || "").trim() ||
|
||
"(模型没有输出有效的 report-json 数据,请点「重新绘制」或输入更明确的修改要求重试)",
|
||
"数据解析失败",
|
||
);
|
||
setError("模型没有输出有效的 report-json 结构化数据");
|
||
setPhase("error");
|
||
return;
|
||
}
|
||
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
|
||
htmlRef.current = rendered;
|
||
setHtml(rendered);
|
||
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
|
||
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
|
||
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
|
||
openDashboardArtifact(threadId, messageId, toolCallId);
|
||
|
||
finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
|
||
setPhase("done");
|
||
setError(null);
|
||
// **只存结构化数据**(dashboardJson),不写 step3.html —— html 槽位归经典模式一,
|
||
// 两者并行绘制互不覆盖;查看时按当前主题重新渲染,主题切换天然适配。
|
||
onSavedRef.current?.({ dashboardJson: JSON.stringify(data) });
|
||
},
|
||
[
|
||
selectedModel,
|
||
themeMode,
|
||
appendToDialogue,
|
||
setDialogueConfidence,
|
||
finalizeDialogue,
|
||
upsertDashStub,
|
||
openDashboardArtifact,
|
||
],
|
||
);
|
||
|
||
const run = useCallback(async () => {
|
||
if (runningRef.current) return;
|
||
runningRef.current = true;
|
||
setError(null);
|
||
setPhase("creating");
|
||
setDialogues([]);
|
||
stubRef.current = {};
|
||
setSeatStubMessages({});
|
||
liveThreadRef.current = null;
|
||
const aborter = new AbortController();
|
||
aborterRef.current = aborter;
|
||
const bubbleId = appendStreamingDialogue();
|
||
try {
|
||
const intentText = [
|
||
intentReady?.objective ?? "",
|
||
...(intentReady?.constraints ?? []).map((c) => `约束:${c}`),
|
||
...(intentReady?.assumptions ?? []).map((a) => `假设:${a}`),
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
// 独立大屏页:用自定义 prompt(输入任务原始 JSON,让智能体分析转换);
|
||
// 否则回退默认的圆桌研讨成果 prompt。
|
||
const custom = buildInitialPromptRef.current?.();
|
||
const prompt =
|
||
custom && custom.trim()
|
||
? custom
|
||
: buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
|
||
await streamOnce({ bubbleId, message: prompt, aborter });
|
||
} catch (err) {
|
||
if (aborter.signal.aborted) return;
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
setError(msg);
|
||
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
|
||
setPhase("error");
|
||
} finally {
|
||
if (aborterRef.current === aborter) runningRef.current = false;
|
||
}
|
||
}, [intentReady, seatDeliveries, consensusText, appendStreamingDialogue, streamOnce, finalizeDialogue]);
|
||
|
||
const ask = useCallback(
|
||
async (message: string) => {
|
||
const text = message.trim();
|
||
if (!text) return;
|
||
const wasBusy = runningRef.current;
|
||
abort();
|
||
if (wasBusy) {
|
||
setDialogues((prev) =>
|
||
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: "已打断" } : d)),
|
||
);
|
||
}
|
||
runningRef.current = true;
|
||
setError(null);
|
||
const aborter = new AbortController();
|
||
aborterRef.current = aborter;
|
||
appendUserDialogue(text);
|
||
const bubbleId = appendStreamingDialogue(liveThreadRef.current ?? undefined);
|
||
setPhase("streaming");
|
||
try {
|
||
// 把当前大屏的真实数据原样喂回模型,要求**只改用户点名的部分、其余字段一字不动**——
|
||
// 否则模型只靠线程记忆臆测当前状态,局部修改(如改标题)经常被整段重写带跑偏。
|
||
const current = dataRef.current;
|
||
const currentBlock = current
|
||
? "\n\n【当前大屏的完整数据(修改基线,未被点名的字段必须原样保留)】\n```report-json\n" +
|
||
JSON.stringify(current) +
|
||
"\n```"
|
||
: "";
|
||
const prompt =
|
||
`用户修改要求:${text}${currentBlock}\n\n(系统指令:在上面给出的“当前大屏完整数据”基础上,` +
|
||
"**只修改用户点名要改的字段**,其余字段(标题/各席位/图表/共识/时间线等)必须逐字保留、不得改写或删减;" +
|
||
"在回复最末尾输出**修订后的完整全量** ```report-json 代码块(不是增量、必须包含全部字段)," +
|
||
"正文只给一两句修改说明;不要调用任何工具、不要写文件、不要输出 HTML。)";
|
||
await streamOnce({ bubbleId, message: prompt, aborter });
|
||
} catch (err) {
|
||
if (aborter.signal.aborted) return;
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
setError(msg);
|
||
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
|
||
setPhase("error");
|
||
} finally {
|
||
if (aborterRef.current === aborter) runningRef.current = false;
|
||
}
|
||
},
|
||
[abort, appendUserDialogue, appendStreamingDialogue, streamOnce, finalizeDialogue],
|
||
);
|
||
|
||
const hydrateSaved = useCallback(
|
||
(savedHtml: string, saved?: Step3DashRestore) => {
|
||
abort();
|
||
runningRef.current = false;
|
||
htmlRef.current = savedHtml;
|
||
setHtml(savedHtml);
|
||
// 纯 HTML 恢复路径(旧版 marker 存稿):清掉上一条草稿残留的结构化 data,
|
||
// 否则应用内 React 大屏的 dashboardData memo 会优先用旧 data、不随切换刷新。
|
||
// (hydrateFromJson 会在调用本函数后再 setData(parsed) 覆盖回正确数据。)
|
||
setData(null);
|
||
dataRef.current = null; // 纯 HTML 恢复无结构化基线;hydrateFromJson 随后会写回
|
||
const synthThreadId = "roundtable-dashboard-restored";
|
||
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
|
||
setDashThreadId(synthThreadId);
|
||
setActiveArtifactThreadId(synthThreadId);
|
||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
|
||
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
|
||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
|
||
const savedDialogues = Array.isArray(saved?.dialogues)
|
||
? (saved!.dialogues as Step2Dialogue[])
|
||
: [];
|
||
const savedStubs =
|
||
saved?.seatStubMessages && typeof saved.seatStubMessages === "object"
|
||
? (saved.seatStubMessages as Record<string, StubAiMessage[]>)
|
||
: null;
|
||
if (savedStubs && Object.keys(savedStubs).length) {
|
||
// 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。
|
||
const merged = { ...stubRef.current, ...savedStubs };
|
||
stubRef.current = merged;
|
||
setSeatStubMessages(merged);
|
||
}
|
||
if (savedDialogues.length) {
|
||
setDialogues(savedDialogues);
|
||
} else {
|
||
const bubbleId = Date.now() + Math.random();
|
||
setDialogues([
|
||
{
|
||
id: bubbleId,
|
||
sender: "大屏绘制智能体",
|
||
avatarType: "blue",
|
||
content:
|
||
"已从历史记录恢复上次生成的大屏。点击下方「打开大屏预览」查看,或输入修改要求重新生成。",
|
||
time: nowTime(),
|
||
confidence: "历史记录",
|
||
streaming: false,
|
||
steps: [],
|
||
agentThreadId: synthThreadId,
|
||
},
|
||
]);
|
||
}
|
||
setError(null);
|
||
setPhase("done");
|
||
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
|
||
},
|
||
[abort, upsertDashStub],
|
||
);
|
||
|
||
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
|
||
const hydrateFromJson = useCallback(
|
||
(jsonStr: string, saved?: Step3DashRestore) => {
|
||
const parsed = parseReportJson(jsonStr);
|
||
if (!parsed) return;
|
||
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
|
||
hydrateSaved(rendered, saved); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)+ 历史对话
|
||
setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效)
|
||
dataRef.current = parsed; // 续轮修改的基线 → 从历史恢复后也能精准局部改
|
||
},
|
||
[themeMode, hydrateSaved],
|
||
);
|
||
|
||
/** 从持久化恢复真实聊天记录 + 结构化数据(独立大屏页按 taskId 取回时用)。
|
||
* 与 hydrateFromJson 的区别:保留**用户真实的历史对话气泡**,而非造一条合成提示。 */
|
||
const hydrateTranscript = useCallback(
|
||
(savedDialogues: Step2Dialogue[], dashboardJson: string | null) => {
|
||
abort();
|
||
runningRef.current = false;
|
||
const synthThreadId = "roundtable-dashboard-restored";
|
||
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
|
||
setDashThreadId(synthThreadId);
|
||
setActiveArtifactThreadId(synthThreadId);
|
||
const parsed = dashboardJson ? parseReportJson(dashboardJson) : null;
|
||
if (parsed) {
|
||
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
|
||
htmlRef.current = rendered;
|
||
setHtml(rendered);
|
||
setData(parsed);
|
||
dataRef.current = parsed; // 续轮修改的基线
|
||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
|
||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||
} else {
|
||
htmlRef.current = null;
|
||
setHtml(null);
|
||
setData(null);
|
||
dataRef.current = null;
|
||
}
|
||
setDialogues(Array.isArray(savedDialogues) ? savedDialogues : []);
|
||
setError(null);
|
||
setPhase(parsed ? "done" : "idle");
|
||
},
|
||
[abort, themeMode, upsertDashStub],
|
||
);
|
||
|
||
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
|
||
void savedReport;
|
||
void isDashboardHtml;
|
||
|
||
const start = useCallback(() => {
|
||
void run();
|
||
}, [run]);
|
||
|
||
const redraw = useCallback(() => {
|
||
abort();
|
||
liveThreadRef.current = null; // 全新线程重画(避免旧上下文把修订当底稿)
|
||
void run();
|
||
}, [abort, run]);
|
||
|
||
const busy = phase === "creating" || phase === "streaming";
|
||
|
||
return {
|
||
phase,
|
||
error,
|
||
dashThreadId,
|
||
dialogues,
|
||
seatStubMessages,
|
||
activeArtifactThreadId,
|
||
busy,
|
||
html,
|
||
data,
|
||
start,
|
||
redraw,
|
||
ask: (m: string) => void ask(m),
|
||
abort,
|
||
setPreviewEnabled,
|
||
hydrateSaved,
|
||
hydrateFromJson,
|
||
hydrateTranscript,
|
||
openDashboardPreview,
|
||
};
|
||
}
|