feat(roundtable): 第三步流程图后入库(示例技能 · 仅深链 taskId 模式)

- 流程图生成完成后,发起用户协助询问是否入库:现在 / 稍后 / 不入库。
- 仅**深链(taskId)模式**(会商 rwfx-A / 3qfx-A / xdfx-A 等)且入库技能启用时才出现入库
  询问 / 协助卡 / 手动入库按钮;普通会商不受影响。
- 「稍后入库」后输入框自由文字做意图识别(后端 /api/flow-extract/ingest-intent:
  start_ingest / add_data / other / unclear),模糊则发起入库协助;另提供手动入库按钮。
- 入库执行复用总结智能体的同一线程,在消息列表出一条「知识库入库」气泡并显示步骤条
  (调度 → 调用入库技能 →(补充内容)→ 提交 → 完成),与第二步同款。
- 入库数据以**用户最后修改后的流程图**为准;并把深链 **taskId 一并传给入库技能**(技能必需)。
- 入库状态(asking/later/done 等)随第三步草稿存取,重载不重复询问。
- 入库技能先用示例(演示用):skills/public/knowledge-base-ingest/SKILL.md(force-add,
  skills/ 目录默认 gitignore),后续替换为真实入库技能时沿用同名即无缝切换。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-18 16:49:36 +08:00
1 parent b04b830039
commit 19028ba627
8 files changed
+458 -7

No files matched your search

@@ -41,7 +41,12 @@ import type {
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import { classifyFlowIntent, streamFlowExtract, type FlowIntent } from "@/roundtable-planning/api/flow-extract";
import {
classifyFlowIntent,
classifyIngestIntent,
streamFlowExtract,
type FlowIntent,
} from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -73,8 +78,22 @@ interface UseStep3SummaryOptions {
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
/**
* 入库技能是否可用(示例/真实入库技能存在且启用)。入库功能**仅在深链(taskId)模式**
* (会商 rwfx-A 等)且本项为 true 时启用:流程图生成完成后自动询问是否入库,并启用
* 「稍后入库」自由文字意图识别 + 手动入库按钮。
*/
ingestAvailable?: boolean;
/**
* 深链外部任务 id(rwfx-A 等)。入库功能仅深链模式才有;入库时该 taskId 会传给入库
* 技能(技能必需信息)。非深链(无 taskId)时入库功能整体不出现。
*/
taskId?: string | null;
}
/** 第三步入库状态机。 */
export type IngestStatus = "idle" | "asking" | "running" | "done" | "later" | "declined";
export interface UseStep3SummaryReturn {
phase: SummaryPhase;
error: string | null;
@@ -120,6 +139,24 @@ export interface UseStep3SummaryReturn {
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;
/** 手动入库(按钮 / 直接触发):在总结线程跑入库技能并显示步骤条。 */
runIngest: (opts?: { note?: string }) => void;
}
/** 演示用:步骤条之间的小停顿,让「调用入库技能」的过程看得见。 */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
@@ -393,6 +430,8 @@ export function useStep3Summary(
autoFlow,
savedSummary,
onSummarySaved,
ingestAvailable,
taskId: ingestTaskId,
} = options;
const onSummarySavedRef = useRef(onSummarySaved);
@@ -400,6 +439,28 @@ export function useStep3Summary(
onSummarySavedRef.current = onSummarySaved;
}, [onSummarySaved]);
// ── 入库状态 ──
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
const [pendingIngestClarification, setPendingIngestClarification] = useState<string | null>(null);
// 镜像供 render 外的回调(runFlowExtract 完成、ask 拦截)同步读到最新值。
const ingestStatusRef = useRef<IngestStatus>("idle");
useEffect(() => {
ingestStatusRef.current = ingestStatus;
}, [ingestStatus]);
// 入库功能「有效可用」= 技能可用 **且** 处于深链(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);
const {
setArtifacts,
select: selectArtifact,
@@ -1046,6 +1107,11 @@ export function useStep3Summary(
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);
@@ -1165,8 +1231,94 @@ export function useStep3Summary(
runFlowExtract,
]);
/**
* 触发一次草稿落库:重发当前总结快照(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]);
/**
* 入库(示例 · 演示用):在**总结线程**上出一条「知识库入库」气泡,按
* 调度 → 调用入库技能 →(补充内容)→ 提交 → 完成 的步骤条演示调用过程,与第二步同款。
* 真实入库技能后续替换这里(改为真的跑技能 / 调入库接口)。
*/
const runIngest = useCallback(
async (opts?: { note?: string }) => {
setPendingIngestClarification(null);
setIngestStatus("running");
const myRun = ++ingestRunRef.current;
const bubbleId = appendStreamingDialogue({
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 },
];
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}」入库到知识库(示例技能 · 演示用,后续接入真实入库技能)。`,
"入库完成",
);
setIngestStatus("done");
persistSummary(); // 把入库气泡 + ingestStatus 落进草稿
},
[appendStreamingDialogue, appendStepIfNew, setDialogueConfidence, finalizeDialogue, 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],
);
const ask = useCallback(
async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent }) => {
async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent; skipIngest?: boolean }) => {
const text = message.trim();
const hasFiles = !!(files && files.length > 0);
if (!text && !hasFiles) return;
@@ -1184,6 +1336,29 @@ export function useStep3Summary(
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 跳过分类。
@@ -1343,9 +1518,22 @@ export function useStep3Summary(
finalizeDialogue,
streamOnce,
runFlowExtract,
runIngest,
],
);
/** 入库协助点选:开始入库 / 补充内容后入库 / 只是提问(转交普通对话,跳过入库拦截)。 */
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) => {
@@ -1474,6 +1662,9 @@ export function useStep3Summary(
}
setError(null);
setPhase("done");
// 恢复入库状态(done/later/declined 不再重复询问;旧草稿无该字段 → idle 且不自动询问)。
setIngestStatus((snap.ingestStatus as IngestStatus | undefined) ?? "idle");
setPendingIngestClarification(null);
// 不再自动打开沙箱预览(2026-06-12):进入第三步默认关闭沙箱,只有开始
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
@@ -1507,5 +1698,12 @@ export function useStep3Summary(
setPreviewEnabled,
openArtifactFromStep,
openArtifactPreview,
// 入库(仅深链 taskId 模式 + 技能可用时有效)
ingestAvailable: ingestEnabled,
ingestStatus,
pendingIngestClarification,
resolveIngestChoice,
resolveIngestClarification,
runIngest: (opts?: { note?: string }) => void runIngest(opts),
};
}