Files
deerflow-code/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts
T
董宏凯andCursor c702119e66 feat(ui): 浅蓝主题、外观设置与圆桌看板主题适配,优化工作区布局
新增 lightblue 主题与外观设置联动,圆桌 Step3 看板/report 统一配色;工作区侧边栏、输入框与轻应用 iframe 主题参数同步更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 12:43:16 +08:00

525 lines
21 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.
/**
* 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 {
initReportThread,
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-report";
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;
}
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 + 完成气泡,不自动开沙箱。 */
hydrateSaved: (html: string) => void;
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
hydrateFromJson: (jsonStr: string) => 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,
} = options;
const onSavedRef = useRef(onReportSaved);
useEffect(() => {
onSavedRef.current = onReportSaved;
}, [onReportSaved]);
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 initReportThread({ 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,
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");
const prompt = 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) => {
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 };
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) => {
const parsed = parseReportJson(jsonStr);
if (!parsed) return;
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
hydrateSaved(rendered); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留)
setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效)
dataRef.current = parsed; // 续轮修改的基线 → 从历史恢复后也能精准局部改
},
[themeMode, hydrateSaved],
);
// 恢复路径辅助: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,
openDashboardPreview,
};
}