Step3 总结报告与顶栏主题背景图

新增 roundtable-summary 智能体及前后端 Step3 Markdown 总结流程;顶栏在浅/深主题下分别使用红/深蓝横幅背景。
This commit is contained in:
fengxiangdebaozi committed 2026-06-11 21:51:59 +08:00
1 parent 10047de745
commit 2d74f8883e
14 files changed
+2060 -18

No files matched your search

@@ -0,0 +1,933 @@
/**
* Step 3「总结报告」编排 hook —— 驱动内置的方案总结报告智能体
* (`roundtable-summary`,与画 HTML 的 `roundtable-report` 并存的另一个内置单例)
* 把各席位交付 + 总控共识总结成一份 **Markdown 报告**(写入
* `/mnt/user-data/outputs/方案总结报告.md`),并把报告正文同时输出到对话里。
*
* 与 `useStep3Report` 的关键差异:
* - 产物是 **Markdown 文件**(非 HTML 看板),路径/标签/匹配都用 `.md`;
* - 注入 **业务链总控编排提示**(coordinatorPrompt)作为最高优先级口径;
* - 多了 **`ask(message)`**:用户在输入框里继续对话——**复用同一条真线程**续问,
* 让智能体自行判断是「增量改报告(整文件 write_file 重写)」还是「问答(只回正文)」。
*
* 其余(流式三段气泡、write_file stub→虚拟 URL→沙箱预览、收尾对账回灌)全部沿用
* `useStep3Report` 的同款机制,与第 2 步 `Step2SandboxLayout` + `Step2Message` 同源。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import type {
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import {
buildArtifactDownloadUrl,
initSummaryThread,
listSessionArtifacts,
streamMultiAgent,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type {
ReportIntent,
ReportSeatDelivery,
} from "@/roundtable-planning/hooks/useStep3Report";
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
/** 内置单例 id —— 恢复草稿后续问时无 init 返回值,用它兜底拼 dAgentThreadId。 */
const SUMMARY_AGENT_NAME = "roundtable-summary";
export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error";
interface UseStep3SummaryOptions {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
selectedModel: string;
/** 业务链写给总控的编排提示 —— 作为最高优先级口径注入总结智能体。 */
coordinatorPrompt?: string | null;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
}
export interface UseStep3SummaryReturn {
phase: SummaryPhase;
error: string | null;
summaryThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
activeArtifactThreadId: string | null;
/** 是否可发送追问/修改(非流式中、且已有线程或可新建)。 */
canAsk: boolean;
/** 启动总结(幂等:进行中重复调用会被忽略)。 */
start: () => void;
/** 从草稿快照恢复展示(不重新生成;保留 threadId 以便继续问答)。 */
hydrateSaved: (snapshot: DraftStep3SummarySnapshot) => void;
/** 重新生成(先中断在途请求再重跑)。 */
redraw: () => void;
/** 用户在输入框里继续对话:续同一线程,智能体自判改报告 or 问答。 */
ask: (message: string) => void;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
openArtifactPreview: (threadId: string, path: string) => void;
}
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
function buildMaterials(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
}): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt } = opts;
const parts: string[] = [];
const cp = coordinatorPrompt?.trim();
if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
}
if (intentReady?.objective) {
parts.push("# 一、任务意图");
parts.push(`目标:${intentReady.objective}`);
if (intentReady.constraints?.length) {
parts.push("约束:");
for (const c of intentReady.constraints) parts.push(`- ${c}`);
}
if (intentReady.assumptions?.length) {
parts.push("关键假设:");
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
}
parts.push("");
}
if (seatDeliveries.length) {
parts.push("# 二、各席位最终交付");
seatDeliveries.forEach((s, i) => {
parts.push(`## ${i + 1}. ${s.sender}`);
parts.push(s.content?.trim() || "(无内容)");
parts.push("");
});
}
if (consensusText?.trim()) {
parts.push("# 三、总控最终共识结论");
parts.push(consensusText.trim());
parts.push("");
}
return parts.join("\n");
}
/** 首轮总结 prompt。 */
function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
}): string {
const parts: string[] = [];
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
);
parts.push("");
parts.push(buildMaterials(opts));
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、叙事型结构" +
"(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、全程简体中文;" +
"若上面给了「业务链总控编排提示」,以它的要求优先组织报告。",
);
return parts.join("\n");
}
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
}): string {
const { previousMd, userMessage, ...materials } = opts;
const parts: string[] = [];
parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
"然后是用户的新消息——请自行判断:若是修改意见,就在旧报告基础上增量修改并用 write_file " +
"整文件重写 /mnt/user-data/outputs/方案总结报告.md,再把更新后的报告正文输出到对话;" +
"若只是提问,就只用文字回答、不要改写文件。",
);
parts.push("");
parts.push(buildMaterials(materials));
if (previousMd.trim()) {
parts.push("# 你之前已生成的报告(方案总结报告.md 当前内容)");
parts.push("```markdown");
parts.push(previousMd.trim());
parts.push("```");
parts.push("");
}
parts.push("# 用户的新消息");
parts.push(userMessage.trim());
return parts.join("\n");
}
/** 把 SSE 阶段映射成中文状态标签。 */
function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "write_file" || toolName === "str_replace")
return "正在撰写报告";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return "正在输出";
default:
return "正在输出…";
}
}
export function useStep3Summary(
options: UseStep3SummaryOptions,
): UseStep3SummaryReturn {
const {
intentReady,
seatDeliveries,
consensusText,
selectedModel,
coordinatorPrompt,
savedSummary,
onSummarySaved,
} = options;
const onSummarySavedRef = useRef(onSummarySaved);
useEffect(() => {
onSummarySavedRef.current = onSummarySaved;
}, [onSummarySaved]);
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
} = useArtifacts();
const [phase, setPhase] = useState<SummaryPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<
string | null
>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
const liveThreadRef = useRef<string | null>(null);
// 当前报告 md 全文(续问写底 / 存草稿用)。
const currentMdRef = useRef<string>("");
const lastAutoOpenedUrlRef = useRef<string | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
(card: { sender: string; avatarType: string; agentThreadId?: string }): number => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[],
);
/** 追加一条用户(指挥官)气泡 —— ask() 时先回显用户输入。 */
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
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 appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
},
[],
);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(已写入报告文件,无额外正文)";
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
},
[],
);
// ── stub 写入(幂等,按 (toolName, path) 去重,后续帧覆盖 args)—— 仅 write_file ──
const upsertStub = useCallback(
(threadId: string, toolName: string, args: Record<string, unknown>) => {
const path = typeof args.path === "string" ? args.path : "";
const current = stubRef.current[threadId] ?? [];
const base: StubAiMessage = current[0] ?? {
id: `roundtable-summary-stub-${threadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const idx = base.tool_calls.findIndex(
(tc) =>
tc.name === toolName &&
String((tc.args as { path?: unknown })?.path ?? "") === path,
);
const toolCallId =
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
// 防抹空:重写早期帧 content 可能为空,保留旧非空内容直到吐出新非空内容。
let storeArgs = args;
if (idx >= 0) {
const newContent = typeof args.content === "string" ? args.content : "";
const prevContent =
typeof (base.tool_calls[idx].args as { content?: unknown })?.content === "string"
? ((base.tool_calls[idx].args as { content?: string }).content as string)
: "";
if (!newContent.trim() && prevContent.trim()) {
storeArgs = { ...args, content: prevContent };
}
}
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
const nextToolCalls =
idx >= 0
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
: [...base.tool_calls, nextTc];
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
// 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content;
}
return { messageId: nextMsg.id, toolCallId };
},
[],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
const openArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoOpenedUrlRef.current = virtualUrl;
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
[openArtifact],
);
const findStubSource = useCallback(
(
rawPath: string,
): { threadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { threadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
// 流式期间:phase==="tool_calling" 且 write_file 有可读 path → 写 stub + 自动开沙箱。
const handleArtifactToolPhase = useCallback(
(
streamPhase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
threadId: string,
) => {
if (streamPhase !== "tool_calling") return;
if (toolName !== "write_file") return; // str_replace 无 content,不驱动预览
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
const basename = path.split("/").pop() ?? path;
if (!basename.includes(".")) return; // 半截路径(无扩展名)忽略
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
autoOpenArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
);
const handleUpstreamFrame = useCallback(
(frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined;
const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const o = tc as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name : "";
if (name !== "write_file") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
);
const openArtifactPreview = useCallback(
(threadId: string, path: string) => {
const normalized = path.trim();
if (!normalized) return;
const source = findStubSource(normalized);
if (source) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
source.messageId,
source.toolCallId,
);
openArtifact(source.threadId, virtualUrl);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
setArtifacts([normalized]);
selectArtifact(normalized, false);
setArtifactsOpen(true);
},
[
findStubSource,
buildWriteFileVirtualUrl,
openArtifact,
setArtifacts,
selectArtifact,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
const path = step.args?.path;
if (typeof path !== "string" || !path) return;
if (toolName === "write_file") {
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
} else if (toolName === "str_replace") {
openArtifactPreview(threadId, path);
}
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
);
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
try {
const res = await listSessionArtifacts(threadId);
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
if (!file) return;
setActiveArtifactThreadId(threadId);
setArtifacts([file.path]);
selectArtifact(file.path, false);
setArtifactsOpen(true);
} catch {
/* 列文件失败就保持当前状态 */
}
},
[setArtifacts, selectArtifact, setArtifactsOpen],
);
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
const reconcileStubFromRealFile = useCallback(
async (
threadId: string,
opts?: { signal?: AbortSignal; force?: boolean },
): Promise<string> => {
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const stubPath =
typeof (writeTc?.args as { path?: unknown })?.path === "string"
? ((writeTc!.args as { path?: string }).path as string)
: SUMMARY_OUTPUT_PATH;
const stubContent =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
try {
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
if (!mdFile) return stubContent;
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
signal: opts?.signal,
});
if (!res.ok) return stubContent;
const real = await res.text();
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取失败退回 stub 内容 */
}
return stubContent;
},
[upsertStub],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
/** 把当前报告 md 种进某线程 stub + 开沙箱(续问时给 reconcile/预览一个锚点)。 */
const seedStubWithCurrentMd = useCallback(
(threadId: string) => {
const md = currentMdRef.current;
if (!md.trim()) return;
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
path: SUMMARY_OUTPUT_PATH,
content: md,
});
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
);
/**
* 公共流式过程:在 `bubbleId` 气泡上跑一轮 special run 并对账落档。
* `seedThreadId` 提供则复用(ask 续问 / 恢复),否则 init 新线程(首轮 / 新建续问)。
*/
const streamOnce = useCallback(
async (args: {
bubbleId: number;
prompt: string;
aborter: AbortController;
seedThreadId?: string | null;
forceReconcile?: boolean;
}): Promise<void> => {
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile } = args;
let threadId = seedThreadId ?? null;
let agentName = SUMMARY_AGENT_NAME;
if (!threadId) {
const init = await initSummaryThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
agentName = init.agentName;
}
liveThreadRef.current = threadId;
setSummaryThreadId(threadId);
setActiveArtifactThreadId(threadId);
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
);
// 续问/恢复时把当前报告种进本线程 stub(续问 str_replace 被禁、整文件重写,但仍给预览锚点)。
if (seedThreadId) seedStubWithCurrentMd(threadId);
setPhase("streaming");
const res = await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结报告",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, pArgs) => {
const label = summaryPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, pArgs);
handleArtifactToolPhase(p, toolName, pArgs, threadId!);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId!),
});
if (aborter.signal.aborted) return;
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile && !seedThreadId) await openRealArtifactFallback(threadId);
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
const md = await reconcileStubFromRealFile(threadId, {
signal: aborter.signal,
force: forceReconcile,
});
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, res.content, "总结完成");
setPhase("done");
// 报告有更新就存草稿(问答轮不写文件 → md 不变也无妨,仍带最新 closing)。
const finalMd = md.trim() || currentMdRef.current;
if (finalMd.trim()) {
currentMdRef.current = finalMd;
onSummarySavedRef.current?.({
md: finalMd,
threadId,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: res.content || "",
});
}
},
[
selectedModel,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
finalizeDialogue,
seedStubWithCurrentMd,
],
);
const run = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
setError(null);
setPhase("creating");
setDialogues([]);
lastAutoOpenedUrlRef.current = null;
stubRef.current = {};
currentMdRef.current = "";
liveThreadRef.current = null;
setSeatStubMessages({});
const aborter = new AbortController();
aborterRef.current = aborter;
const bubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
});
try {
const prompt = buildSummaryPrompt({
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
});
await streamOnce({ bubbleId, 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 {
runningRef.current = false;
}
}, [
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
]);
const ask = useCallback(
async (message: string) => {
const text = message.trim();
if (!text || runningRef.current) return;
runningRef.current = true;
setError(null);
const aborter = new AbortController();
aborterRef.current = aborter;
appendUserDialogue(text);
const bubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
});
setPhase("streaming");
try {
const live = liveThreadRef.current;
if (live) {
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
await streamOnce({
bubbleId,
prompt: text,
aborter,
seedThreadId: live,
forceReconcile: true,
});
} else {
// 恢复草稿后无 live thread → 新建线程,prompt 带上旧报告做底。
const prompt = buildResumeAskPrompt({
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
previousMd: currentMdRef.current,
userMessage: text,
});
await streamOnce({ bubbleId, prompt, aborter, forceReconcile: true });
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
setPhase("error");
} finally {
runningRef.current = false;
}
},
[
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
appendUserDialogue,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
],
);
const start = useCallback(() => {
void run();
}, [run]);
const redraw = useCallback(() => {
abort();
void run();
}, [abort, run]);
// 从草稿快照恢复:不跑 SSE,用保存的 md 重建一条「完成」气泡 + 合成 stub,并打开沙箱预览。
// 保留 threadId 到 liveThreadRef —— 若快照带真线程 id,ask() 能直接续问(后端线程仍在)。
const hydrateSaved = useCallback(
(snap: DraftStep3SummarySnapshot) => {
abort();
runningRef.current = false;
const realThreadId = snap.threadId?.trim() || null;
const synthThreadId = realThreadId ?? "roundtable-summary-restored";
const messageId = `roundtable-summary-stub-${synthThreadId}`;
const toolCallId = `tc-${synthThreadId}-0`;
const stub: Record<string, StubAiMessage[]> = {
[synthThreadId]: [
{
id: messageId,
type: "ai",
content: "",
tool_calls: [
{
id: toolCallId,
name: "write_file",
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
},
],
},
],
};
stubRef.current = stub;
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
lastAutoOpenedUrlRef.current = null;
setSeatStubMessages(stub);
setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "方案总结报告智能体",
avatarType: "blue",
content: snap.closing?.trim() || "已从历史记录恢复上次生成的方案总结报告。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已撰写报告 Markdown",
ts: nowTime(),
args: { path: SUMMARY_OUTPUT_PATH },
},
],
agentThreadId: synthThreadId,
},
]);
setError(null);
setPhase("done");
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(synthThreadId, virtualUrl);
},
[abort, buildWriteFileVirtualUrl, openArtifact],
);
const busy = phase === "creating" || phase === "streaming";
const canAsk = !busy && (phase === "done" || phase === "error");
return {
phase,
error,
summaryThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
canAsk,
start,
hydrateSaved,
redraw,
ask,
abort,
openArtifactFromStep,
openArtifactPreview,
};
}