Files
deerflow-code/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts
T
董宏凯andClaude Opus 4.8 545ec178b8 fix(roundtable): 第三步重新分析前复位入库状态,避免重分析后仍问是否入库
重跑总结/重新或修改流程图前整体复位 ingest 状态为 idle(隐藏旧产物的入库
询问卡/协助卡/手动入库按钮);流程图类重分析出新图后 runFlowExtract 在 idle
时重新询问,重新生成报告则在报告重写完成后(若已有流程图)重新询问是否入库。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:43:06 +08:00

1937 lines
89 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 —— 驱动内置的方案总结报告智能体
* (`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 { uploadFiles } from "@/core/uploads/api";
import type {
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import {
buildArtifactDownloadUrl,
initStructureThread,
initSummaryThread,
listSessionArtifacts,
streamMultiAgent,
type StreamMultiAgentRequest,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
/** 一个上传文件的 run 引用(与 streamMultiAgent 的 files 入参对齐)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
import type {
ReportIntent,
ReportSeatDelivery,
} from "@/roundtable-planning/hooks/useStep3Report";
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import {
classifyFlowIntent,
classifyIngestIntent,
streamFlowExtract,
type FlowIntent,
} from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
import { readActionDetail } from "@/core/auth/task-deeplink";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
/** 内置单例 id —— 恢复草稿后续问时无 init 返回值,用它兜底拼 dAgentThreadId。 */
const SUMMARY_AGENT_NAME = "roundtable-summary";
/** 结构化抽取智能体 id —— 入库轮由它(走 ingest run policy)真正调用入库技能。 */
const STRUCTURE_AGENT_NAME = "roundtable-structure";
export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error";
interface UseStep3SummaryOptions {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
selectedModel: string;
/** 业务链写给总控的编排提示 —— 作为最高优先级口径注入总结智能体。 */
coordinatorPrompt?: string | null;
/**
* 业务链条层级(取自 stages 的「本阶段目标」goal,有序)—— taskId 深链模式下
* 自动结构化输出时,作流程图的层级骨架(任务 → 层级1 → 层级2 → …)。
* 例:链层级 目的→行为体→驱动因素 → 流程图 任务→目的→行为体→驱动因素。
*/
chainLevels?: string[];
/**
* taskId/深链模式:生成 md 报告后**自动**续跑一轮结构化输出(流程图)。
* 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。
*/
autoFlow?: boolean;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/**
* 入库技能是否可用(示例/真实入库技能存在且启用)。入库功能**仅在深链(taskId)模式**
* (会商 rwfx-A 等)且本项为 true 时启用:流程图生成完成后自动询问是否入库,并启用
* 「稍后入库」自由文字意图识别 + 手动入库按钮。
*/
ingestAvailable?: boolean;
/**
* 深链外部任务 id(rwfx-A 等)。入库功能仅深链模式才有;入库时该 taskId 会传给入库
* 技能(技能必需信息)。非深链(无 taskId)时入库功能整体不出现。
*/
taskId?: string | null;
/**
* 入库适用性判定选中的技能名(来自 `/ingest-eligibility`)。入库时作为提示传给结构化
* 输出智能体,引导它优先用这个技能(它仍会自行读 SKILL.md 判断);缺省时让它自行挑选。
*/
ingestSkill?: string | null;
/** 业务码(如 6BF/rwfx-A)——入库时给智能体做场景线索,帮它挑对入库技能。 */
ingestBusinessCode?: string | null;
/** 业务链条名称——入库场景线索之一。 */
ingestChainName?: string | null;
}
/** 第三步入库状态机。 */
export type IngestStatus = "idle" | "asking" | "running" | "done" | "later" | "declined";
export interface UseStep3SummaryReturn {
phase: SummaryPhase;
error: string | null;
/** taskId/深链模式:md 报告生成后自动续跑流程图(组件据此让流程图画布自动聚焦)。 */
autoFlow: boolean;
/** 后端「抽取+校验」得到的校验后结构化流程图(Phase 1)。done 时据此绘制。 */
flowGraph: FlowGraphData | null;
/** 流程图生成阶段:idle / extracting(抽取流式) / validating(校验中) / done / error。 */
flowPhase: "idle" | "extracting" | "validating" | "done" | "error";
/** 仅重新生成流程图(复用当前 md/席位交付,不重跑报告)——模型挂了切模型后重试用。 */
regenerateFlow: () => void;
/** 手动编辑流程图(Phase 3:画布上改/删节点后回写 + 持久化)。 */
setFlowGraph: (next: FlowGraphData) => void;
/** 意图模糊时挂起的原始消息(非空 → 组件展示「重做报告/改图/重画/提问」选项让用户定)。 */
pendingFlowClarification: string | null;
/** 用户在澄清里点选意图后执行。 */
resolveFlowClarification: (choice: FlowIntent) => void;
summaryThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
activeArtifactThreadId: string | null;
/** 是否正在生成中(撰写/初始化/上传)—— 驱动暂停按钮与发送态。 */
busy: boolean;
/** 启动总结(幂等:进行中重复调用会被忽略)。 */
start: () => void;
/** 从草稿快照恢复展示(不重新生成;保留 threadId 以便继续问答)。 */
hydrateSaved: (snapshot: DraftStep3SummarySnapshot) => void;
/** 重新生成(先中断在途请求再重跑)。 */
redraw: () => void;
/**
* 用户在输入框里继续对话 —— **随时可发**:生成中发送会先打断当前轮再续同一线程,
* 智能体自判改报告 or 问答;可携带上传文件(先传到总结线程再随消息注入)。
*/
ask: (message: string, files?: File[]) => void;
/** 暂停当前生成(中断在途请求并把当前气泡收尾为「已暂停」)。 */
pause: () => void;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 本流是否允许**自动**打开沙箱预览(视图挂载=true / 卸载=false;并行流防抢沙箱)。 */
setPreviewEnabled: (enabled: boolean) => void;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
openArtifactPreview: (threadId: string, path: string) => void;
// ── 入库(生成流程图后询问 / 稍后入库意图 / 手动入库)──
/** 入库技能是否可用(回显 option,组件据此决定是否显示入库按钮/卡片)。 */
ingestAvailable: boolean;
/** 入库状态机。 */
ingestStatus: IngestStatus;
/** 「稍后入库」自由文字意图模糊时挂起的原始消息(非空 → 组件展示入库协助选项)。 */
pendingIngestClarification: string | null;
/** 流程图后入库询问的三选一(现在/稍后/不入库)。 */
resolveIngestChoice: (choice: "now" | "later" | "never") => void;
/** 入库协助选项点选(开始入库/补充内容后再入库/只是提问→转交普通对话)。 */
resolveIngestClarification: (choice: "start_ingest" | "add_data" | "other") => void;
/**
* 入库时尚无流程图 → 挂起的「是否先生成流程图」协助(非空 → 组件展示协助选项)。
* 数据入库以结构化流程图为核心内容,没有流程图时先问用户要不要生成。
*/
pendingFlowForIngest: boolean;
/** 「无流程图」协助点选:生成流程图(生成后再走入库询问)/ 暂不入库。 */
resolveFlowForIngest: (choice: "generate" | "cancel") => void;
/** 手动入库(按钮 / 直接触发):让结构化输出智能体走完整运行时自行调用入库技能。 */
runIngest: (opts?: { note?: string }) => void;
}
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
function buildMaterials(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
/** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */
actionName?: string | null;
}): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts;
const parts: string[] = [];
const an = actionName?.trim();
if (an) {
parts.push("# 本次行动");
parts.push(an);
parts.push("");
}
const cp = coordinatorPrompt?.trim();
if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
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");
}
/**
* flow-json 形状契约(内联)。roundtable-summary 的 runtime SOUL 为空,模型拿不到契约,
* 所有要画流程图的 prompt 都得带上它,否则模型产出的 JSON 可能解析不出节点。
*/
const FLOW_JSON_SHAPE =
"flow-json 严格用下面这个形状(一个 JSON 对象,`nodes` 是节点数组;示例仅示意结构、勿照抄内容):\n" +
"```flow-json\n" +
'{"title":"流程图标题","nodes":[\n' +
' {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标的完整描述"},\n' +
' {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"该目的的完整内容:名称 + 理由全文","from":["n1"]},\n' +
' {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"该行为体的完整内容:名称 + 理由全文","from":["n2"]}\n' +
"]}\n" +
"```\n" +
"字段:`id`(n1/n2… 唯一短串)、`label`(≤12 字,节点标题,取条目核心短语)、" +
"`type`(取 start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、" +
"**`group`(该节点所在的层级名称,如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事——画布按它给节点打标签,必须填)**、" +
"**`detail`(该条目的名称 + 理由 / 说明,点击节点时展示——要把要点表达完整,但控制在约 2–4 句,**不必**逐字照抄报告整段)**、" +
"`from`(前驱节点 id 列表,只引用**前面已出现**的节点)。" +
"节点按先后排列、输出**全量**数据、全程简体中文。";
/**
* 「本轮只画流程图、不写报告」的强约束。关键:有的业务链 coordinatorPrompt 自身写着
* 「输出结构化结论 / 按阶段产出报告 / 最终方案总结报告按层级结构输出」,模型在同一线程里
* 会被它锚定,把用户的「结构化输出」当成「继续写结构化的文字报告」→ 产出 Markdown 而非
* flow-json(实测 deepseek-chat 在这类链上必现)。所以必须显式声明本轮压过那些报告措辞。
*/
const FLOW_ONLY_RULE =
"【硬性·本轮只画流程图】本轮唯一产物是一张**流程图**(由节点 + 连线组成的图),不是文字、不是报告、" +
"不是章节、不是结论清单、不是 Markdown:①**不要**调用 write_file、**不要**写或改任何 .md 文件;" +
"②回复里**不要**再出现任何报告正文 / 章节标题(如「## 四…」)/ 编号结论,正文最多一句话说明;" +
"③**忽略**此前关于「方案总结报告」「按层级结构输出报告」「输出结构化结论」「分阶段产出」的一切措辞——" +
"那些是给上一轮文字报告用的,本轮一律不适用;④在回复**最末尾**输出一个**完整**的 ```flow-json 代码块。";
/**
* 流程图的层级画法(修「结构画错」):直接从已有报告/席位交付里**提取实体**,
* 根=任务名称,每层取具体条目当节点,按报告里的对应关系串成分支,排除汇总/阶段名等无关段落。
*/
const FLOW_STRUCTURE_RULE =
"【流程图画法·严格遵守】\n" +
"① 根节点(n1) = 本次任务本身(即报告「任务总览」里的任务名称 / 目标),label 用任务的简短名称(≤12 字)," +
"**不要**用「任务」「目标」这种泛称当 label;其 `group` 填「任务」;\n" +
"② 从第二层起按层级顺序逐层展开:每个层级在报告/交付里通常列了**多条具体条目**" +
"(如『目的』层有 目的1 / 目的2 / 目的3,『行为体』层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…)," +
"把**每一条具体条目各画成一个节点**,label 取该条目核心短语(≤12 字);**不要**把一整层合并成一个笼统节点;\n" +
"③ **每个节点的 `group` 必须填它所在层级的名称**(任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…,取自报告里该层级的真实名称)——画布据此给节点打分类标签;\n" +
"④ **每个节点的 `detail`** 放该条目的名称 + 理由 / 说明,把要点表达完整、约 2–4 句即可(不必逐字照抄报告整段);" +
"**`detail` 必须写成一行**——内部不要出现真实换行,需要分隔就用「;」或「/」,避免 JSON 里出现裸换行;\n" +
"⑤ 连边严格按报告里的**对应关系**串成分支:根任务 → 各『目的』节点;每个『目的』→ 它对应的『行为体』节点;" +
"每个『行为体』→ 它对应的『预期行为·驱动因素』节点 → 对应的『叙事』节点……每一组各成一条自上而下的分支" +
"(报告里有几组就有几条分支,组与组之间不要混连);\n" +
"⑦ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**——" +
"典型规模约 13–16 个节点(如 任务1 + 目的3 + 行为体3 + 预期行为·驱动因素3 + 叙事3);\n" +
"⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" +
"以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。";
// 注:原 buildAutoFlowPrompt(在总结线程里续问吐 flow-json)已废弃 —— Phase 1 起自动结构化
// 输出改走后端独立端点 /api/flow-extract/stream(streamFlowExtract),抽取+校验在后端做。
// FLOW_ONLY_RULE / FLOW_STRUCTURE_RULE / FLOW_JSON_SHAPE 仍由手动 FLOW_ASK_INSTRUCTION 引用。
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
actionName?: string | null;
flowMode?: boolean;
}): string {
const parts: string[] = [];
if (opts.flowMode) {
parts.push(
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"用户已明确要求结构化输出/流程图,因此本轮:" +
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
);
}
parts.push("");
parts.push(buildMaterials(opts));
// 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)——两种模式通用,与后端 build_summary_prompt 同义。
parts.push("");
parts.push(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" +
"你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" +
"用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" +
"再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" +
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" +
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" +
"**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" +
"最终产出要明显比各席位交付的简单拼接更扣用户意图。",
);
if (opts.flowMode) {
parts.push(
"请严格遵循你的职责设定:全程简体中文。" +
"flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
"每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
);
} else {
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
}
return parts.join("\n");
}
/**
* 用户消息命中结构化/流程图意图时追加给智能体的系统指令(live 线程续问轮直接拼在消息后)。
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出 / 流程图——这里的「结构化输出」指的是画一张**流程图**," +
"不是写结构化的文字报告。**有报告就直接基于报告提取层级条目**(报告已总结过各席位交付,不要重复总结);" +
"没有报告则基于各席位交付。\n" +
FLOW_ONLY_RULE +
"\n" +
FLOW_STRUCTURE_RULE +
"\n" +
FLOW_JSON_SHAPE +
")";
/**
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
* 历史痛点:用户画完流程图后说「把验收环节加上」「改一下」,关键词不命中 →
* 智能体把它当改报告/问答处理,用户被迫说「重新结构化输出」才能改图。
* 这里把「上一轮主产物」上下文显式告诉智能体,让它按语义判断、太模糊就反问。
*/
const FLOW_CONTEXT_ASK_INSTRUCTION =
"(系统指令:上一轮的主产物是结构化流程图。请按语义理解用户这条消息、不要求任何关键词:" +
"若是修改/调整类意图且没明确说要改文字报告,默认视为**修改流程图**——不要写 .md 文件," +
"在回复末尾输出**完整修订版** ```flow-json 代码块(全量节点+连线,非增量);" +
"若明确要改文字报告才用 write_file 整文件重写;若只是提问就只用文字回答;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/**
* 普通报告语境下的意图引导指令(每轮续问都附上):
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
*/
const SEMANTIC_ASK_INSTRUCTION =
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
"只是提问就只用文字回答、不改文件;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
askInstruction?: string;
}): string {
const { previousMd, userMessage, askInstruction, ...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());
if (askInstruction) {
parts.push("");
parts.push(askInstruction);
}
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 "正在输出…";
}
}
/** 入库轮的工具阶段 → 中文步骤标签(让前端步骤条体现「读技能说明 → 执行入库」真实过程)。 */
function ingestPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度入库技能";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "read_file") return "读取入库技能说明(SKILL.md)";
if (toolName === "bash" || toolName === "write_file") 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,
chainLevels,
autoFlow,
savedSummary,
onSummarySaved,
ingestAvailable,
taskId: ingestTaskId,
ingestSkill,
ingestBusinessCode,
ingestChainName,
} = options;
const onSummarySavedRef = useRef(onSummarySaved);
useEffect(() => {
onSummarySavedRef.current = onSummarySaved;
}, [onSummarySaved]);
// ── 入库状态 ──
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
const [pendingIngestClarification, setPendingIngestClarification] = useState<string | null>(null);
// 入库时尚无流程图 → 挂起「是否先生成流程图」协助(数据入库以结构化流程图为核心内容)。
const [pendingFlowForIngest, setPendingFlowForIngest] = useState<boolean>(false);
// 镜像供 render 外的回调(runFlowExtract 完成、ask 拦截)同步读到最新值。
const ingestStatusRef = useRef<IngestStatus>("idle");
useEffect(() => {
ingestStatusRef.current = ingestStatus;
}, [ingestStatus]);
// 重新分析(重跑总结 / 重新或修改流程图)前把入库状态整体复位为 idle —— 旧报告/旧流程图的
// 「是否入库」询问卡、入库协助卡、手动入库按钮全部初始化隐藏,避免「都重新分析了还问是否入库」
// 的歧义。流程图类重分析完成后 runFlowExtract 会在 idle 时重新发起入库询问。
const resetIngestState = useCallback(() => {
setIngestStatus("idle");
setPendingIngestClarification(null);
setPendingFlowForIngest(false);
}, []);
// 入库功能「有效可用」= 技能可用 **且** 处于深链(taskId)模式。
const ingestEnabled = !!ingestAvailable && !!ingestTaskId;
const ingestAvailableRef = useRef<boolean>(ingestEnabled);
useEffect(() => {
ingestAvailableRef.current = ingestEnabled;
}, [ingestEnabled]);
// 深链 taskId(入库必传给技能)。
const ingestTaskIdRef = useRef<string | null>(ingestTaskId ?? null);
useEffect(() => {
ingestTaskIdRef.current = ingestTaskId ?? null;
}, [ingestTaskId]);
// 入库运行序号:新一轮入库 / 中断会自增,旧循环据此自停(防重复入库)。
const ingestRunRef = useRef(0);
// 入库场景线索(适用性判定选中的技能 + 业务码/链名)——镜像供 runIngest 回调读最新值。
const ingestSkillRef = useRef<string | null>(ingestSkill ?? null);
const ingestBusinessCodeRef = useRef<string | null>(ingestBusinessCode ?? null);
const ingestChainNameRef = useRef<string | null>(ingestChainName ?? null);
useEffect(() => {
ingestSkillRef.current = ingestSkill ?? null;
ingestBusinessCodeRef.current = ingestBusinessCode ?? null;
ingestChainNameRef.current = ingestChainName ?? null;
}, [ingestSkill, ingestBusinessCode, ingestChainName]);
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<SummaryPhase>("idle");
const [error, setError] = useState<string | null>(null);
// 后端「抽取+校验」流程图(Phase 1):与 SummaryPhase 解耦(md 报告完成后才跑流程图)。
const [flowGraph, setFlowGraphState] = useState<FlowGraphData | null>(null);
const [flowPhase, setFlowPhase] = useState<"idle" | "extracting" | "validating" | "done" | "error">("idle");
// 镜像 flowGraph 供 ask()(问答修改)在 render 外读到最新图作 edit 入参。
const flowGraphRef = useRef<FlowGraphData | null>(null);
useEffect(() => {
flowGraphRef.current = flowGraph;
}, [flowGraph]);
// 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。
const [pendingFlowClarification, setPendingFlowClarification] = 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>("");
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
// 不再要求用户说出「结构化输出/流程图」等关键词。
const flowActiveRef = useRef(false);
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 relabelDialogue = useCallback((id: number, sender: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : 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],
);
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
return;
}
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) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
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;
}, []);
/** 把仍在流式的气泡冻结(停转圈),并打上状态标签(打断 / 暂停时用)。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
/**
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
*/
const seedStubWithCurrentMd = useCallback(
(threadId: string, opts?: { open?: boolean }) => {
const md = currentMdRef.current;
if (!md.trim()) return;
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
path: SUMMARY_OUTPUT_PATH,
content: md,
});
if (opts?.open === false || !previewEnabledRef.current) return;
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;
files?: RunFileRef[];
/** 流程图轮:不自动打开 md 沙箱预览(用户要看的是流程图画布)。 */
flowRound?: boolean;
}): Promise<void> => {
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
// 本轮开始前的报告底稿——结束后对比判断「这一轮的主产物是报告还是流程图」。
const mdBefore = currentMdRef.current;
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 被禁、整文件重写,但仍给预览锚点)。
// 流程图轮只种不开沙箱——md 保留着供用户随时切回去看。
if (seedThreadId) seedStubWithCurrentMd(threadId, { open: !flowRound });
setPhase("streaming");
const res = await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结报告",
newMessage: prompt,
// 深链接业务码:后端为 summary 单例注入「业务链完整性核对」(核对产出相对业务链完不完整)。
businessCode: ingestBusinessCodeRef.current,
model: selectedModel || undefined,
files: files && files.length > 0 ? files : undefined,
signal: aborter.signal,
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试的半截正文 + 在步骤卡留一条可见提示。
onModelSwitch: (info) => {
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
appendStepIfNew(
bubbleId,
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
`model_switch:${info.nextModel}`,
);
},
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 && !flowRound) await openRealArtifactFallback(threadId);
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
const md = await reconcileStubFromRealFile(threadId, {
signal: aborter.signal,
force: forceReconcile,
});
if (aborter.signal.aborted) return;
// 跟踪本轮主产物:回复带 flow-json → 流程图轮;md 有实质更新 → 报告轮;
// 都没有(纯问答轮)→ 维持上一轮状态。供下一次 ask() 选意图引导指令。
if (hasFlowFence(res.content)) {
flowActiveRef.current = true;
} else if (md.trim() && md !== mdBefore) {
flowActiveRef.current = false;
}
finalizeDialogue(bubbleId, res.content, "总结完成");
setPhase("done");
// 报告有更新就存草稿(问答轮不写文件 → md 不变也无妨,仍带最新 closing)。
// 流程图模式可能全程没有 md——只要 closing 有内容(含 flow-json)也要存,恢复时才能重画流程图。
const finalMd = md.trim() || currentMdRef.current;
if (finalMd.trim()) currentMdRef.current = finalMd;
if (finalMd.trim() || (res.content ?? "").trim()) {
onSummarySavedRef.current?.({
md: finalMd,
threadId,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: res.content || "",
});
}
},
[
selectedModel,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
finalizeDialogue,
seedStubWithCurrentMd,
],
);
/**
* Phase 1:后端「抽取+校验」生成流程图。独立调 `/api/flow-extract/stream`(不进总结线程,
* 避开 coordinator_prompt 的报告锚定):抽取阶段把 JSON 流式吐进对话气泡(用户实时看),
* 校验完成后用校验后的结构化图绘制画布(done)。模型沿用第三步所选(selectedModel)。
*/
const runFlowExtract = useCallback(
async (
aborter: AbortController,
opts?: { instruction?: string; currentGraph?: FlowGraphData | null; bubbleId?: number },
): Promise<void> => {
const isEdit = !!opts?.instruction && !!opts?.currentGraph;
setFlowPhase("extracting");
if (!isEdit) setFlowGraphState(null); // 编辑模式保留旧图直到新图就绪(防画布闪空)
// 两个气泡:抽取/修改 + 校验——把过程(思考 `<think>` + 文字 + flow-json 代码块)都留着展示,
// 由 Step2Message 渲染(思考折叠块 + markdown 代码块),不折叠成一句话。
// 传入 bubbleId(意图分类期间已建的「调度中」气泡)则复用并改名,避免空窗 + 不重复建气泡。
const extractSender = isEdit ? "结构化流程图 · 修改" : "结构化流程图 · 抽取";
let extractBubbleId: number;
if (opts?.bubbleId != null) {
extractBubbleId = opts.bubbleId;
relabelDialogue(extractBubbleId, extractSender);
} else {
extractBubbleId = appendStreamingDialogue({ sender: extractSender, avatarType: "blue" });
}
let validateBubbleId: number | null = null;
try {
const graph = await streamFlowExtract({
reportMd: currentMdRef.current,
seatDeliveries,
chainLevels,
coordinatorPrompt,
// 深链接业务码:后端按业务链抽取规范强制注入完整层级(根治"无论什么业务都按目的→行为体抽")。
businessCode: ingestBusinessCodeRef.current,
// xdfx(7BF):根节点用「行动名称」(流程图根节点显示当前行动,而非任务名)。
rootLabel:
ingestBusinessCodeRef.current === "7BF" ? readActionDetail()?.actionName ?? null : null,
model: selectedModel || undefined,
currentGraph: opts?.currentGraph ?? null,
instruction: opts?.instruction,
signal: aborter.signal,
// 模型自动容错:后端切到下一个模型重抽 → 清空抽取气泡里失败尝试的半截内容 + 留可见提示。
onModelSwitch: (info) => {
setDialogues((prev) => prev.map((d) => (d.id === extractBubbleId ? { ...d, content: "" } : d)));
setDialogueConfidence(extractBubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
appendStepIfNew(
extractBubbleId,
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
`model_switch:${info.nextModel}`,
);
},
onExtractDelta: (t) => appendToDialogue(extractBubbleId, t),
onValidating: () => {
setFlowPhase("validating");
// 抽取气泡收尾(保留已流出的内容),另起校验气泡。
finalizeDialogue(extractBubbleId, "", "抽取完成");
validateBubbleId = appendStreamingDialogue({ sender: "结构化流程图 · 校验", avatarType: "blue" });
},
onValidateDelta: (t) => {
if (validateBubbleId != null) appendToDialogue(validateBubbleId, t);
},
});
if (aborter.signal.aborted) return;
setFlowGraphState(graph);
setFlowPhase("done");
flowActiveRef.current = true;
// 收尾两个气泡(保留过程内容)。校验后的图序列化进草稿快照 closing,恢复时重建画布。
if (validateBubbleId != null) finalizeDialogue(validateBubbleId, "", "流程图完成");
else finalizeDialogue(extractBubbleId, "", "流程图完成");
onSummarySavedRef.current?.({
md: currentMdRef.current,
threadId: liveThreadRef.current ?? "",
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: serializeFlowGraph(graph),
});
// 流程图(实时生成完成)后,若入库技能可用且尚未处理过入库 → 询问是否入库。
// 仅实时生成走这里;hydrate 恢复不触发(不打扰已看历史的用户)。
if (ingestAvailableRef.current && ingestStatusRef.current === "idle") {
setIngestStatus("asking");
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setFlowPhase("error");
finalizeDialogue(
validateBubbleId ?? extractBubbleId,
`(流程图生成失败) ${msg}\n模型可能不稳定——可在上方切换模型后点「重新生成流程图」重试。`,
"流程图失败",
);
}
},
[
seatDeliveries,
chainLevels,
coordinatorPrompt,
selectedModel,
appendStreamingDialogue,
appendToDialogue,
finalizeDialogue,
relabelDialogue,
],
);
/** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */
const regenerateFlow = useCallback(() => {
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
aborterRef.current?.abort();
// 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。
resetIngestState();
const aborter = new AbortController();
aborterRef.current = aborter;
void runFlowExtract(aborter);
}, [runFlowExtract, seatDeliveries, resetIngestState]);
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
const updateFlowGraph = useCallback(
(next: FlowGraphData) => {
setFlowGraphState(next);
setFlowPhase("done");
flowActiveRef.current = true;
const serialized = serializeFlowGraph(next);
onSummarySavedRef.current?.({
md: currentMdRef.current,
threadId: liveThreadRef.current ?? "",
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: serialized,
});
},
[selectedModel],
);
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({});
setFlowGraphState(null);
setFlowPhase("idle");
// 整体重跑(重试 / 重新生成)= 重新分析 → 复位入库状态,旧产物的入库询问/按钮先隐藏;
// 重跑出新流程图后 runFlowExtract 会重新询问是否入库。
resetIngestState();
const aborter = new AbortController();
aborterRef.current = aborter;
const bubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
});
try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
// 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。
const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
// xdfx(7BF):把本次行动名称喂进总结材料,让报告围绕该行动展开。
actionName:
ingestBusinessCodeRef.current === "7BF" ? readActionDetail()?.actionName ?? null : null,
flowMode,
});
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
// taskId/深链模式:报告生成完,**自动**用后端「抽取+校验」端点生成流程图(Phase 1)。
// 不再在总结线程里续问吐 flow-json(那会被 coordinator_prompt 的报告锚定带偏、不稳);
// 改走独立端点:流式抽取(实时显示) → 校验修复 → 用校验后结构化图绘制。
// 有报告或有席位交付即触发(无报告也能据席位交付出图)。
if (
autoFlow &&
!aborter.signal.aborted &&
(currentMdRef.current.trim() || seatDeliveries.length > 0)
) {
await runFlowExtract(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 {
// 只有「本轮仍是当前轮」才复位 running —— 被新一轮(干预/重新生成)抢占时不要误清。
if (aborterRef.current === aborter) runningRef.current = false;
}
}, [
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
autoFlow,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
runFlowExtract,
resetIngestState,
]);
/**
* 触发一次草稿落库:重发当前总结快照(md + 流程图 closing 原样),让上层 step3SavedTick
* 自增 → autoSave 把最新对话(含入库气泡)+ ingestStatus 一并存进草稿。入库/选择稍后等
* 不经 onSummarySaved 的状态变化靠它落库。closing 用当前流程图序列化保持与流程图收尾一致。
*/
const persistSummary = useCallback(() => {
onSummarySavedRef.current?.({
md: currentMdRef.current,
threadId: liveThreadRef.current ?? "",
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: flowGraphRef.current ? serializeFlowGraph(flowGraphRef.current) : "",
});
}, [selectedModel]);
/**
* 入库(真执行):让**结构化输出智能体**(`roundtable-structure`,走 ingest run policy
* 的完整 agent 运行时)**自己挑选合适的入库技能、读 SKILL.md 并调用**完成入库——
* 而不是前端代它调技能。出一条「数据入库」气泡,实时显示它读技能说明 / 执行入库的
* 真实工具步骤与最终汇报。
*
* 入库内容**以最后修改后的为准**:当前报告 md + 最新流程图(画布手动改也同步进来),
* 并把 **taskId**(深链外部任务 id,入库技能必需信息)随消息一起传给智能体。
*/
const runIngest = useCallback(
async (opts?: { note?: string }) => {
// 未挂载/不适用入库技能(或非深链模式)→ 不执行任何入库操作。
if (!ingestAvailableRef.current) return;
setPendingIngestClarification(null);
// 数据入库以**结构化流程图**为核心内容。意图理解后先判断是否已生成流程图:
// 没有 → 不入库,挂起「是否先生成流程图」协助;有 → 正常调用智能体入库。
if (!flowGraphRef.current) {
setPendingFlowForIngest(true);
return;
}
setPendingFlowForIngest(false);
setIngestStatus("running");
const myRun = ++ingestRunRef.current;
const aborter = new AbortController();
const bubbleId = appendStreamingDialogue({
sender: "数据入库",
avatarType: "blue",
});
const title = intentReady?.objective?.trim() || "会商方案";
const flow = flowGraphRef.current;
const md = currentMdRef.current;
const taskId = ingestTaskIdRef.current;
const skillHint = ingestSkillRef.current;
const businessCode = ingestBusinessCodeRef.current;
const chainName = ingestChainNameRef.current;
const flowJson = flow ? serializeFlowGraph(flow) : "";
// 入库指令:把场景线索 + taskId + 报告 + 结构化流程图全量给智能体,让它按 SOUL 的
// 「五、入库」流程自行挑技能、读 SKILL.md、按契约调用(taskId 必传)。
const promptParts: string[] = [
"【任务】本轮请执行「入库」:把本次会商成果沉淀进知识库。请按你的设定(SOUL「五、入库」)",
"自行挑选**适合当前业务场景**的入库技能(技能名可能含「六步法」等场景线索),用 read_file 读它的",
"SKILL.md,再严格按其契约调用完成入库。**taskId 是入库技能的必需信息,务必随入库一起传入。**",
"",
"【业务场景】",
`- 方案标题:${title}`,
];
if (businessCode) promptParts.push(`- 业务码:${businessCode}`);
if (chainName) promptParts.push(`- 业务链名称:${chainName}`);
if (skillHint) promptParts.push(`- 适用性判定建议优先使用的入库技能:${skillHint}(仍请你读其 SKILL.md 确认)`);
promptParts.push(
`- taskId(必传给入库技能):${taskId ?? "(未提供)"}`,
"",
"【入库内容(以下为最终、用户最后修改后的成果,入库以此为准)】",
"—— 方案总结报告(Markdown)——",
md.trim() || "(无报告正文)",
"",
"—— 结构化流程图(flow-json,含节点/连线/层级)——",
flowJson || "(无结构化流程图)",
);
if (opts?.note?.trim()) {
promptParts.push("", "【用户补充内容(请一并纳入入库)】", opts.note.trim());
}
const prompt = promptParts.join("\n");
try {
const init = await initStructureThread({ signal: aborter.signal });
if (ingestRunRef.current !== myRun || aborter.signal.aborted) return;
const threadId = init.threadId;
const agentName = init.agentName;
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)),
);
const res = await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "数据入库",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => {
if (ingestRunRef.current === myRun) appendToDialogue(bubbleId, text);
},
onPhaseChange: (p, toolName) => {
if (ingestRunRef.current !== myRun) return;
const label = ingestPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`);
},
});
if (ingestRunRef.current !== myRun || aborter.signal.aborted) return;
finalizeDialogue(bubbleId, res.content || `✅ 已完成「${title}」数据入库。`, "入库完成");
setIngestStatus("done");
persistSummary(); // 把入库气泡 + ingestStatus 落进草稿
} catch (err) {
if (ingestRunRef.current !== myRun || aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
finalizeDialogue(bubbleId, `入库失败:${msg}`, "入库失败");
setIngestStatus("asking"); // 失败回到可重试(按钮/询问仍在)
}
},
[
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
finalizeDialogue,
setDialogues,
selectedModel,
intentReady,
persistSummary,
],
);
/** 流程图后入库询问三选一。 */
const resolveIngestChoice = useCallback(
(choice: "now" | "later" | "never") => {
if (choice === "now") {
void runIngest();
} else if (choice === "later") {
setIngestStatus("later");
persistSummary();
} else {
setIngestStatus("declined");
persistSummary();
}
},
[runIngest, persistSummary],
);
/**
* 「入库时无流程图」协助点选:
* - generate → 关闭协助、生成流程图(生成完成后会自动弹出「是否入库」询问,再由用户入库);
* - cancel → 关闭协助、置「稍后入库」(手动入库按钮仍在,可生成流程图后再入库)。
*/
const resolveFlowForIngest = useCallback(
(choice: "generate" | "cancel") => {
setPendingFlowForIngest(false);
if (choice === "generate") {
// 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。
setIngestStatus("idle");
regenerateFlow();
} else {
setIngestStatus("later");
persistSummary();
}
},
[regenerateFlow, persistSummary],
);
const ask = useCallback(
async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent; skipIngest?: boolean }) => {
const text = message.trim();
const hasFiles = !!(files && files.length > 0);
if (!text && !hasFiles) return;
// 「随时干预」:生成中也能发——先打断当前轮(后端 multitask=rollback 抢占旧 run),
// 并把当前流式气泡冻结成「已打断」,再续上同一线程跑新一轮。
const wasBusy = runningRef.current;
abort();
if (wasBusy) freezeStreaming("已打断");
// 用户气泡**立刻上屏**(不等意图分类 / 模型返回,避免连接卡顿时显得卡)——对齐第一/二步。
// 后续各分支都不再重复追加用户气泡。forcedIntent(澄清后点选复跑)时原气泡已在、跳过避免重复。
if (!opts?.forcedIntent) {
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
appendUserDialogue((text || "(上传了参考文件)") + fileNote);
}
// 入库优先意图:流程图后入库询问中 / 选了「稍后入库」待命时,先判断用户是否想
// 开始入库 / 补充内容后入库;模糊则发起入库协助。其它(other)落到下方报告/流程图分流。
const ingestPending =
!opts?.skipIngest &&
!opts?.forcedIntent &&
(ingestStatusRef.current === "later" || ingestStatusRef.current === "asking");
if (text && !hasFiles && ingestPending) {
const ir = await classifyIngestIntent({ message: text, model: selectedModel || undefined });
if (ir.intent === "start_ingest") {
await runIngest();
return;
}
if (ir.intent === "add_data") {
await runIngest({ note: text });
return;
}
if (ir.intent === "unclear") {
setPendingIngestClarification(text);
return;
}
// ir.intent === "other" → 继续走下方报告/流程图意图分流。
}
// 意图识别(参考 ai_writing 一次性 JSON 范式):先判断用户想 重做报告 / 改流程图 /
// 重画流程图 / 提问 / 模糊待澄清,再分流——不再用关键词硬路由(避免「说重做报告却出流程图」)。
// 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。
// reuseBubbleId:意图分类期间先建的「调度中」气泡,定了意图后由对应分支复用(不等接口返回、像第一步)。
let reuseBubbleId: number | null = null;
// 「重新生成报告」复位入库状态后,待报告重写完成、若已有流程图则重新询问是否入库。
let reaskIngestAfter = false;
if (text && !hasFiles) {
let intent: FlowIntent | undefined = opts?.forcedIntent;
let dispatchBubbleId: number | null = null;
if (!intent) {
// **立刻**显示「智能体调度中」气泡(不等意图接口返回),消除连接卡顿时的"卡住"感。
dispatchBubbleId = appendStreamingDialogue({ sender: "智能体", avatarType: "blue" });
// 入库可用(按钮已出现、未入库 / 未在跑)时把 canIngest 传给意图识别——让「帮我入库」被识别为
// ingest 而不是落到 qa/unclear(之前主分类器不懂入库,只有「稍后入库待命」时才识别入库,是个缺口)。
const canIngestNow =
ingestAvailableRef.current &&
ingestStatusRef.current !== "running" &&
ingestStatusRef.current !== "done";
const r = await classifyFlowIntent({
message: text,
hasReport: !!currentMdRef.current.trim(),
hasFlow: !!flowGraphRef.current,
canIngest: canIngestNow,
model: selectedModel || undefined,
});
intent = r.intent;
}
if (intent === "unclear") {
if (dispatchBubbleId != null) {
finalizeDialogue(dispatchBubbleId, "意图不太明确,请在下方选择你想做的:", "待确认");
}
setPendingFlowClarification(text); // 组件渲染选项让用户自己定
return;
}
if (intent === "ingest") {
// 识别为入库(后端已保证仅 can_ingest 时才会返回 ingest)→ 直接入库;复用调度气泡。
if (dispatchBubbleId != null) finalizeDialogue(dispatchBubbleId, "正在入库…", "入库中");
await runIngest();
return;
}
if (intent === "regenerate_flow" || intent === "modify_flow") {
// 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮),
// runFlowExtract 出新图后会在 idle 时重新询问是否入库。
resetIngestState();
const flowAborter = new AbortController();
aborterRef.current = flowAborter;
const cur = flowGraphRef.current;
const reuse = dispatchBubbleId ?? undefined;
if (intent === "modify_flow" && cur) {
await runFlowExtract(flowAborter, { instruction: text, currentGraph: cur, bubbleId: reuse });
} else {
await runFlowExtract(flowAborter, { bubbleId: reuse });
}
return;
}
// regenerate_report | qa → 复用调度气泡走下方总结线程路径。
reuseBubbleId = dispatchBubbleId;
// 重新生成报告 = 重新分析 → 复位入库状态先隐藏旧的入库询问/按钮(qa 仅提问不复位);
// 报告重写完后若已有流程图,再重新询问是否入库(见 streamOnce 收尾后的 reaskIngestAfter)。
if (intent === "regenerate_report") {
resetIngestState();
reaskIngestAfter = true;
}
// intent === "regenerate_report" | "qa" → 落到下方总结线程续问路径:
// **追加**用户气泡 + 在原线程上让智能体重写报告 / 增量改 / 回答(**不清空历史**)。
// regenerate_report 不再走 run()(run 会 setDialogues([]) 清空,导致"覆盖聊天记录、
// 不显示用户输入"的 bug);改报告/重写报告由总结智能体在续问里整文件重写。
// intent === "qa" → 落到下方总结线程问答路径(只答不改产物)。
}
// 走到这里只剩 regenerate_report / qa(含带附件场景)—— 流程图操作已在上面意图分流处理。
// 一律走报告/问答语义引导(让总结智能体在原线程上重写报告 / 增量改 / 回答),
// **不再**在总结线程里产 flow-json(流程图统一由后端抽取端点负责)。
const askInstruction = SEMANTIC_ASK_INSTRUCTION;
const flowRound = false;
runningRef.current = true;
setError(null);
const aborter = new AbortController();
aborterRef.current = aborter;
// 续问前是否已有真线程:决定 prompt 形态——有则发原始消息(上下文在线程里),
// 没有(恢复后 / 首轮还没建好就被打断)则用带研讨原料 + 旧报告的完整 prompt。
const hadLiveThread = !!liveThreadRef.current;
try {
// 有附件:先确保有线程(首轮还没建好则新建),再把文件上传到该线程。
let threadId = liveThreadRef.current;
if (hasFiles && !threadId) {
setPhase("creating");
const init = await initSummaryThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
liveThreadRef.current = threadId;
setSummaryThreadId(threadId);
setActiveArtifactThreadId(threadId);
}
let fileRefs: RunFileRef[] | undefined;
if (hasFiles && threadId) {
setPhase("creating");
const up = await uploadFiles(threadId, files!);
if (aborter.signal.aborted) return;
fileRefs = up.files.map((f) => ({
filename: f.filename,
size: f.size,
path: f.virtual_path,
status: "uploaded" as const,
}));
}
// 用户气泡已在 ask 开头立刻追加。智能体气泡:复用意图分类期间的「调度中」气泡(改名),
// 没有则新建——这样提问/重做报告时分类阶段就有「调度中」反馈、不等模型返回(像第一步)。
let bubbleId: number;
if (reuseBubbleId != null) {
bubbleId = reuseBubbleId;
relabelDialogue(bubbleId, "方案总结报告智能体");
} else {
bubbleId = appendStreamingDialogue({ sender: "方案总结报告智能体", avatarType: "blue" });
}
setPhase("streaming");
if (hadLiveThread) {
// 真线程仍在 → 续问,发原始消息 + 按语境选好的意图引导指令,
// 靠线程上下文 + SOUL 让智能体按语义自判改图 / 改报告 / 问答 / 反问澄清。
const base =
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
const prompt = `${base}\n\n${askInstruction}`;
await streamOnce({
bubbleId,
prompt,
aborter,
seedThreadId: liveThreadRef.current,
forceReconcile: true,
files: fileRefs,
flowRound,
});
} else {
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
// 若上面为上传文件已建好线程(threadId)就复用它,否则交给 streamOnce 新建。
const prompt = buildResumeAskPrompt({
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
previousMd: currentMdRef.current,
userMessage:
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
askInstruction,
});
await streamOnce({
bubbleId,
prompt,
aborter,
seedThreadId: threadId,
forceReconcile: true,
files: fileRefs,
flowRound,
});
}
if (aborter.signal.aborted) return;
// 报告重写完成、且入库可用 + 已有流程图 → 重新询问是否入库(复位后再问,针对新报告)。
if (reaskIngestAfter && ingestAvailableRef.current && flowGraphRef.current) {
setIngestStatus("asking");
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
freezeStreaming("处理失败");
setPhase("error");
} finally {
if (aborterRef.current === aborter) runningRef.current = false;
}
},
[
intentReady,
seatDeliveries,
consensusText,
coordinatorPrompt,
selectedModel,
abort,
freezeStreaming,
appendUserDialogue,
appendStreamingDialogue,
relabelDialogue,
finalizeDialogue,
streamOnce,
runFlowExtract,
runIngest,
resetIngestState,
],
);
/** 入库协助点选:开始入库 / 补充内容后入库 / 只是提问(转交普通对话,跳过入库拦截)。 */
const resolveIngestClarification = useCallback(
(choice: "start_ingest" | "add_data" | "other") => {
const msg = pendingIngestClarification;
setPendingIngestClarification(null);
if (choice === "start_ingest") void runIngest();
else if (choice === "add_data") void runIngest({ note: msg ?? undefined });
else if (msg) void ask(msg, undefined, { skipIngest: true });
},
[pendingIngestClarification, runIngest, ask],
);
/** 澄清后用户点选意图 → 用 forcedIntent 复跑(跳过意图分类)。 */
const resolveFlowClarification = useCallback(
(choice: FlowIntent) => {
const msg = pendingFlowClarification;
setPendingFlowClarification(null);
if (!msg) return;
void ask(msg, undefined, { forcedIntent: choice });
},
[pendingFlowClarification, ask],
);
/** 暂停当前生成:中断在途请求 + 把流式气泡收尾为「已暂停」+ 状态回 done。 */
const pause = useCallback(() => {
if (!runningRef.current) return;
abort();
freezeStreaming("已暂停");
setPhase((p) => (p === "creating" || p === "streaming" ? "done" : p));
}, [abort, freezeStreaming]);
const start = useCallback(() => {
void run();
}, [run]);
const redraw = useCallback(() => {
abort();
void run();
}, [abort, run]);
// 从草稿快照恢复:不跑 SSE,用保存的 md 重建一条「完成」气泡 + 合成 stub,并打开沙箱预览。
// 保留 threadId 到 liveThreadRef —— 若快照带真线程 id,ask() 能直接续问(后端线程仍在)。
// 流程图模式的快照可能没有 md(只有含 flow-json 的 closing):跳过 stub/沙箱,只重建气泡,
// 流程图由组件从气泡内容里重新解析绘制。
const hydrateSaved = useCallback(
(snap: DraftStep3SummarySnapshot) => {
abort();
runningRef.current = false;
// 恢复流程图:快照 closing 里若存了 flow-json,解析成结构化图填进 flowGraph 状态,
// 让画布直接用它绘制(也供 Phase 3 手动编辑)。解析不出则回退到对话气泡解析。
const restoredGraph = snap.closing ? extractFlowGraph(snap.closing) : null;
setFlowGraphState(restoredGraph);
setFlowPhase(restoredGraph ? "done" : "idle");
const hasMd = snap.md.trim().length > 0;
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[]> = hasMd
? {
[synthThreadId]: [
{
id: messageId,
type: "ai",
content: "",
tool_calls: [
{
id: toolCallId,
name: "write_file",
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
},
],
},
],
}
: {};
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
const savedDialogues = Array.isArray(snap.dialogues)
? (snap.dialogues as Step2Dialogue[])
: [];
const savedStubs =
snap.seatStubMessages && typeof snap.seatStubMessages === "object"
? (snap.seatStubMessages as Record<string, StubAiMessage[]>)
: null;
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub;
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
flowActiveRef.current = hasFlowFence(snap.closing);
// 记录预览锚点(不打开):进入本视图且沙箱已开着时可立即接管显示自家 md(防串台)。
if (hasMd) {
lastAutoOpenedUrlRef.current = buildWriteFileVirtualUrl(
SUMMARY_OUTPUT_PATH,
messageId,
toolCallId,
);
lastAutoThreadRef.current = synthThreadId;
} else {
lastAutoOpenedUrlRef.current = null;
lastAutoThreadRef.current = null;
}
setSeatStubMessages(effectiveStub);
setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
if (savedDialogues.length) {
// 整段回显历史研讨过程(每条气泡的文字内容 + 步骤条都在),与第二步一致。
setDialogues(savedDialogues);
} else {
// 旧草稿没存对话 → 回退重建单条「历史记录」气泡(仅最终产物)。
const bubbleId = Date.now() + Math.random();
setDialogues([
{
id: bubbleId,
sender: "方案总结报告智能体",
avatarType: "blue",
content: snap.closing?.trim() || "已从历史记录恢复上次生成的方案总结报告。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: hasMd
? [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已撰写报告 Markdown",
ts: nowTime(),
args: { path: SUMMARY_OUTPUT_PATH },
},
]
: [],
agentThreadId: synthThreadId,
},
]);
}
setError(null);
setPhase("done");
// 恢复入库状态(done/later/declined 不再重复询问;旧草稿无该字段 → idle 且不自动询问)。
setIngestStatus((snap.ingestStatus as IngestStatus | undefined) ?? "idle");
setPendingIngestClarification(null);
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
},
[abort, buildWriteFileVirtualUrl],
);
const busy = phase === "creating" || phase === "streaming";
return {
phase,
error,
autoFlow: !!autoFlow,
flowGraph,
flowPhase,
regenerateFlow,
setFlowGraph: updateFlowGraph,
pendingFlowClarification,
resolveFlowClarification,
summaryThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
busy,
start,
hydrateSaved,
redraw,
ask,
pause,
abort,
setPreviewEnabled,
openArtifactFromStep,
openArtifactPreview,
// 入库(仅深链 taskId 模式 + 技能可用时有效)
ingestAvailable: ingestEnabled,
ingestStatus,
pendingIngestClarification,
resolveIngestChoice,
resolveIngestClarification,
pendingFlowForIngest,
resolveFlowForIngest,
runIngest: (opts?: { note?: string }) => void runIngest(opts),
};
}