新增 lightblue 主题与外观设置联动,圆桌 Step3 看板/report 统一配色;工作区侧边栏、输入框与轻应用 iframe 主题参数同步更新。 Co-authored-by: Cursor <cursoragent@cursor.com>
525 lines
21 KiB
TypeScript
525 lines
21 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 {
|
||
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,
|
||
};
|
||
}
|