feat(roundtable): 第三步「数据入库」由结构化输出智能体自行调用技能

- 入库改为让 roundtable-structure 走完整 agent 运行时(ingest run policy)
  自行挑选/读取/调用入库技能并传入 taskId,不再前端代调演示
- 新增 /api/flow-extract/ingest-eligibility(智能体判断当前业务场景是否有
  合适入库技能,决定入库按钮是否显示)+ /api/multi-agent/structure/init
- 入库前判断是否已生成流程图:无则发起「是否先生成流程图」用户协助
- 「知识库入库」更名为「数据入库」

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-19 17:58:36 +08:00
1 parent a85d2480b4
commit 03c155f8a7
10 files changed
+507 -80

No files matched your search

@@ -23,6 +23,7 @@ import type {
} from "@/roundtable-planning/api/drafts";
import {
buildArtifactDownloadUrl,
initStructureThread,
initSummaryThread,
listSessionArtifacts,
streamMultiAgent,
@@ -53,6 +54,8 @@ import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
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";
@@ -89,6 +92,15 @@ interface UseStep3SummaryOptions {
* 技能(技能必需信息)。非深链(无 taskId)时入库功能整体不出现。
*/
taskId?: string | null;
/**
* 入库适用性判定选中的技能名(来自 `/ingest-eligibility`)。入库时作为提示传给结构化
* 输出智能体,引导它优先用这个技能(它仍会自行读 SKILL.md 判断);缺省时让它自行挑选。
*/
ingestSkill?: string | null;
/** 业务码(如 6BF/rwfx-A)——入库时给智能体做场景线索,帮它挑对入库技能。 */
ingestBusinessCode?: string | null;
/** 业务链条名称——入库场景线索之一。 */
ingestChainName?: string | null;
}
/** 第三步入库状态机。 */
@@ -150,15 +162,17 @@ export interface UseStep3SummaryReturn {
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;
}
/** 演示用:步骤条之间的小停顿,让「调用入库技能」的过程看得见。 */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
function buildMaterials(opts: {
intentReady: ReportIntent | null;
@@ -417,6 +431,26 @@ function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string {
}
}
/** 入库轮的工具阶段 → 中文步骤标签(让前端步骤条体现「读技能说明 → 执行入库」真实过程)。 */
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 {
@@ -432,6 +466,9 @@ export function useStep3Summary(
onSummarySaved,
ingestAvailable,
taskId: ingestTaskId,
ingestSkill,
ingestBusinessCode,
ingestChainName,
} = options;
const onSummarySavedRef = useRef(onSummarySaved);
@@ -442,6 +479,8 @@ export function useStep3Summary(
// ── 入库状态 ──
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(() => {
@@ -458,8 +497,17 @@ export function useStep3Summary(
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,
@@ -1247,60 +1295,118 @@ export function useStep3Summary(
}, [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: "知识库入库",
sender: "数据入库",
avatarType: "blue",
agentThreadId: liveThreadRef.current ?? undefined,
});
const title = intentReady?.objective?.trim() || "会商方案";
// 入库数据**以最后修改后的为准**:读 flowGraphRef.current(画布手动改图也会同步进来)
// 与当前报告 md。taskId 是入库技能必需信息,必须随入库一起传给技能。
const flow = flowGraphRef.current;
const md = currentMdRef.current;
const taskId = ingestTaskIdRef.current;
const submitArgs: Record<string, unknown> = {
title,
taskId, // 技能必需:深链外部任务 id
hasReport: !!md.trim(),
flowNodes: flow?.nodes?.length ?? 0,
flowEdges: flow?.edges?.length ?? 0,
...(opts?.note ? { note: opts.note } : {}),
};
const steps: Array<{ label: string; key: string; args?: Record<string, unknown> }> = [
{ label: "调度入库技能", key: "tool_calling:ingest_dispatch", args: { taskId } },
{ label: "调用「知识库入库」技能", key: "tool_calling:ingest_skill", args: { skill: "knowledge-base-ingest", taskId } },
...(opts?.note
? [{ label: "并入补充内容", key: "tool_calling:ingest_append", args: { note: opts.note } }]
: []),
{ label: "提交方案数据入库(以最新流程图为准)", key: "tool_calling:ingest_submit", args: submitArgs },
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}`,
];
for (const s of steps) {
if (ingestRunRef.current !== myRun) return; // 被新一轮入库 / 中断取代
setDialogueConfidence(bubbleId, s.label);
appendStepIfNew(bubbleId, s.label, s.key, s.args);
await sleep(700);
}
if (ingestRunRef.current !== myRun) return;
finalizeDialogue(
bubbleId,
`✅ 已将「${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 || "(无结构化流程图)",
);
setIngestStatus("done");
persistSummary(); // 把入库气泡 + ingestStatus 落进草稿
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, appendStepIfNew, setDialogueConfidence, finalizeDialogue, intentReady, persistSummary],
[
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
finalizeDialogue,
setDialogues,
selectedModel,
intentReady,
persistSummary,
],
);
/** 流程图后入库询问三选一。 */
@@ -1319,6 +1425,26 @@ export function useStep3Summary(
[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();
@@ -1706,6 +1832,8 @@ export function useStep3Summary(
pendingIngestClarification,
resolveIngestChoice,
resolveIngestClarification,
pendingFlowForIngest,
resolveFlowForIngest,
runIngest: (opts?: { note?: string }) => void runIngest(opts),
};
}