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

@@ -1,38 +1,58 @@
/**
* 第三步「入库」功能门控。
* 第三步「入库」功能门控 + 入库执行客户端。
*
* 入库询问 / 意图识别 / 手动入库按钮只有在**入库技能存在且启用**时才出现
* (对应需求「如果有了调用技能入库的技能后…」)。当前是示例技能 `knowledge-base-ingest`
* (演示用);后续替换为真实入库技能时只要沿用同一个 name,本门控即继续生效。
* 入库不再由前端硬编码「有没有含『入库』关键字的技能」判定,而是让**结构化输出智能体**
* (`roundtable-structure`)读它挂载的入库技能 + 当前业务场景,**自行判断**是否有适合本
* 场景的入库技能(技能名可能含「六步法」等场景线索)。判定为可入库才显示「入库」按钮。
*
* 真正入库时,由结构化输出智能体走**完整 agent 运行时**(`POST /api/multi-agent/run/stream`,
* role=ingest)**自己挑技能、读 SKILL.md 并调用**完成入库——而不是前端代它调技能。
*/
import { loadSkills } from "@/core/skills/api";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
/** 示例入库技能名(真实技能可改名;门控按「入库相关」关键字判断,不写死此名)。 */
export const INGEST_SKILL_NAME = "knowledge-base-ingest";
/**
* 是否为「入库相关」技能:按 name / name_zh / description 含 `ingest` 或「入库」判断。
* 这样示例技能(knowledge-base-ingest)与**后续改了名的真实入库技能**都能识别;
* 而知识库**检索**类技能(如 knowledge-base-search「知识库检索」)不含「入库」/`ingest`,不会误命中。
*/
export function isIngestRelatedSkill(s: {
name?: string;
name_zh?: string | null;
description?: string;
}): boolean {
const hay = `${s.name ?? ""} ${s.name_zh ?? ""} ${s.description ?? ""}`.toLowerCase();
return hay.includes("ingest") || hay.includes("入库");
/** `/ingest-eligibility` 返回:是否可入库 + 选中的技能名 + 一句话依据。 */
export interface IngestEligibility {
canIngest: boolean;
skill: string;
reason: string;
}
/**
* 是否**挂载了**可用的入库相关技能(存在且启用)。失败回退 false。
* 为 false 时第三步不显示入库按钮、不触发任何入库操作。
* 让结构化输出智能体判断「当前业务场景**是否有合适的入库技能**」(决定是否显示入库按钮)。
*
* 入参为业务场景线索(业务码 / 业务链名 / 总控编排提示 / 方案标题)+ taskId,由后端把挂在
* `roundtable-structure` 上、且已启用的技能交给模型对照判断。失败 / 未配置入库技能 → 不可入库。
*/
export async function isIngestSkillAvailable(): Promise<boolean> {
export async function checkIngestEligibility(req: {
taskId?: string | null;
businessCode?: string | null;
chainName?: string | null;
coordinatorPrompt?: string | null;
reportTitle?: string | null;
model?: string;
}): Promise<IngestEligibility> {
try {
const skills = await loadSkills();
return skills.some((s) => s.enabled && isIngestRelatedSkill(s));
const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/ingest-eligibility`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: req.taskId ?? null,
business_code: req.businessCode ?? null,
chain_name: req.chainName ?? null,
coordinator_prompt: req.coordinatorPrompt ?? null,
report_title: req.reportTitle ?? null,
model: req.model,
}),
});
if (!res.ok) return { canIngest: false, skill: "", reason: "" };
const data = (await res.json()) as { can_ingest?: boolean; skill?: string; reason?: string };
return {
canIngest: !!data.can_ingest,
skill: data.skill ?? "",
reason: data.reason ?? "",
};
} catch {
return false;
return { canIngest: false, skill: "", reason: "" };
}
}
@@ -885,3 +885,29 @@ export async function initSummaryThread(
const json = (await res.json()) as { agent_name: string; thread_id: string };
return { agentName: json.agent_name, threadId: json.thread_id };
}
/**
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
* a built-in singleton — the same agent that extracts the flowchart, but this run
* uses the `ingest` run policy so its attached ingest skill actually executes).
* Run it afterwards with {@link streamMultiAgent} (`agentType: "special"`,
* `agentName: "roundtable-structure"`, `dAgentThreadId: { [agentName]: threadId }`):
* the agent picks the suitable ingest skill (its name may contain「六步法」), reads
* its SKILL.md and calls it — passing the taskId + summary report + structured flow.
*/
export async function initStructureThread(
options: { signal?: AbortSignal } = {},
): Promise<ReportThreadResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/structure/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
signal: options.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init structure thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
return { agentName: json.agent_name, threadId: json.thread_id };
}
@@ -420,6 +420,8 @@ export function Step3SummaryMode({
pendingIngestClarification={summary.pendingIngestClarification}
onResolveIngestChoice={summary.resolveIngestChoice}
onResolveIngestClarification={summary.resolveIngestClarification}
pendingFlowForIngest={summary.pendingFlowForIngest}
onResolveFlowForIngest={summary.resolveFlowForIngest}
onManualIngest={summary.runIngest}
onRetry={redraw}
onAsk={handleAsk}
@@ -572,6 +574,8 @@ function AgentProgressPanel({
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry,
onAsk,
@@ -602,6 +606,9 @@ function AgentProgressPanel({
pendingIngestClarification: string | null;
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
/** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */
pendingFlowForIngest: boolean;
onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"];
onManualIngest: UseStep3SummaryReturn["runIngest"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
@@ -808,7 +815,7 @@ function AgentProgressPanel({
{ingestAvailable && ingestStatus === "asking" && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
流程图已生成,是否把本方案入库到知识库?
流程图已生成,是否进行数据入库?
</div>
<div className="flex flex-wrap gap-1.5">
{(
@@ -831,6 +838,32 @@ function AgentProgressPanel({
</div>
)}
{/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */}
{ingestAvailable && pendingFlowForIngest && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/70 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
当前还没有生成流程图,数据入库需要流程图。是否先生成流程图?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "generate", t: "生成流程图" },
{ k: "cancel", t: "暂不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveFlowForIngest(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
{ingestAvailable && pendingIngestClarification && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
@@ -912,11 +945,11 @@ function AgentProgressPanel({
<button
type="button"
onClick={() => onManualIngest()}
title="把本方案入库到知识库(调用入库技能,过程见消息列表步骤条)"
title="进行数据入库(由结构化输出智能体调用入库技能,过程见消息列表步骤条)"
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
>
<Database className="h-3.5 w-3.5" />
入库
数据入库
</button>
)}
@@ -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),
};
}
@@ -98,7 +98,7 @@ import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import { useStep3Report } from "@/roundtable-planning/hooks/useStep3Report";
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import { isIngestSkillAvailable } from "@/roundtable-planning/api/ingest";
import { checkIngestEligibility } from "@/roundtable-planning/api/ingest";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
import {
BUILTIN_AGENT_META,
@@ -328,11 +328,45 @@ export default function RoundtablePlanningPage() {
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
const [step3Report, setStep3Report] = useState<DraftStep3Snapshot | null>(null);
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
// 入库技能是否可用(决定第三步生成流程图后是否询问入库 + 显示手动入库按钮)。挂载查一次。
// 入库是否可用 + 选中的入库技能:不再前端硬编码关键字,而是让**结构化输出智能体**读它
// 挂载的入库技能 + 当前业务场景**自行判断**是否有适合本场景的入库技能(决定第三步是否询问
// 入库 + 显示手动入库按钮)。入库仅深链(taskId)模式才有意义,故仅在该模式下判定。
const [ingestAvailable, setIngestAvailable] = useState<boolean>(false);
const [ingestSkill, setIngestSkill] = useState<string>("");
const ingestTaskIdForCheck = prefillRef.current?.taskId ?? null;
const ingestBusinessCode = prefillRef.current?.businessCode ?? null;
useEffect(() => {
void isIngestSkillAvailable().then(setIngestAvailable).catch(() => setIngestAvailable(false));
}, []);
if (!ingestTaskIdForCheck) {
setIngestAvailable(false);
setIngestSkill("");
return;
}
let cancelled = false;
void checkIngestEligibility({
taskId: ingestTaskIdForCheck,
businessCode: ingestBusinessCode,
chainName: chainMeta?.title ?? null,
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
})
.then((r) => {
if (cancelled) return;
setIngestAvailable(r.canIngest);
setIngestSkill(r.skill);
})
.catch(() => {
if (cancelled) return;
setIngestAvailable(false);
setIngestSkill("");
});
return () => {
cancelled = true;
};
}, [
ingestTaskIdForCheck,
ingestBusinessCode,
chainMeta?.title,
orchestrationPlan?.coordinatorPrompt,
]);
const step2RunsRef = useRef<DraftStep2RunSnapshot[]>([]);
const activeStep2RunIdRef = useRef<string | null>(null);
// 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径,
@@ -1174,6 +1208,10 @@ export default function RoundtablePlanningPage() {
ingestAvailable,
// 入库仅深链(taskId)模式(会商 rwfx-A 等)才有;taskId 入库时必传给技能。
taskId: prefillRef.current?.taskId ?? null,
// 入库场景线索:适用性判定选中的技能 + 业务码/链名,入库时引导智能体挑对入库技能。
ingestSkill,
ingestBusinessCode,
ingestChainName: chainMeta?.title ?? null,
});
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub + 入库状态,供 getStep3Snapshot 落库。
step3SummaryConvoRef.current = {