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:
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 = {
|
||||
|
||||
Reference in new issue
Block a user