Step3 总结报告与顶栏主题背景图
新增 roundtable-summary 智能体及前后端 Step3 Markdown 总结流程;顶栏在浅/深主题下分别使用红/深蓝横幅背景。
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user