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:
1 parent
b04b830039
commit
19028ba627
8 files changed
+458
-7
No files matched your search
@@ -173,6 +173,11 @@ export interface DraftStep3SummarySnapshot {
|
||||
dialogues?: unknown[];
|
||||
/** 各席位 write_file 调用重建的 stub(threadId → [stub ai message]),供加载后点开产物的虚拟预览。 */
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
/**
|
||||
* 入库状态(idle/asking/running/done/later/declined)。存它使重载草稿后不重复询问
|
||||
* 已入库/已选稍后的会商;恢复展示用。Optional —— 旧草稿无该字段。
|
||||
*/
|
||||
ingestStatus?: string;
|
||||
}
|
||||
|
||||
/** Lightweight metadata row returned by the list endpoint. */
|
||||
|
||||
@@ -46,6 +46,33 @@ export async function classifyFlowIntent(req: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「稍后入库」待命时输入框消息的入库意图(与后端 /ingest-intent 对应)。 */
|
||||
export type IngestIntent = "start_ingest" | "add_data" | "other" | "unclear";
|
||||
|
||||
/**
|
||||
* 识别「稍后入库」后用户在输入框的入库意图(一次模型调用)。
|
||||
* 失败回退 "unclear"(前端发起协助让用户自己选)。
|
||||
*/
|
||||
export async function classifyIngestIntent(req: {
|
||||
message: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ intent: IngestIntent; reason: string }> {
|
||||
try {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/ingest-intent`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: req.message, model: req.model }),
|
||||
signal: req.signal,
|
||||
});
|
||||
if (!res.ok) return { intent: "unclear", reason: "" };
|
||||
const data = (await res.json()) as { intent?: string; reason?: string };
|
||||
return { intent: (data.intent ?? "unclear") as IngestIntent, reason: data.reason ?? "" };
|
||||
} catch {
|
||||
return { intent: "unclear", reason: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlowExtractRequest {
|
||||
/** 报告 Markdown(优先数据来源)。 */
|
||||
reportMd?: string | null;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 第三步「入库」功能门控。
|
||||
*
|
||||
* 入库询问 / 意图识别 / 手动入库按钮只有在**入库技能存在且启用**时才出现
|
||||
* (对应需求「如果有了调用技能入库的技能后…」)。当前是示例技能 `knowledge-base-ingest`
|
||||
* (演示用);后续替换为真实入库技能时只要沿用同一个 name,本门控即继续生效。
|
||||
*/
|
||||
import { loadSkills } from "@/core/skills/api";
|
||||
|
||||
/** 入库技能名(示例/真实共用同一名字,方便无缝替换)。 */
|
||||
export const INGEST_SKILL_NAME = "knowledge-base-ingest";
|
||||
|
||||
/** 入库技能是否存在且启用。失败回退 false(不弹入库询问)。 */
|
||||
export async function isIngestSkillAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const skills = await loadSkills();
|
||||
return skills.some((s) => s.name === INGEST_SKILL_NAME && s.enabled);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
@@ -414,6 +415,12 @@ export function Step3SummaryMode({
|
||||
onRegenerateFlow={summary.regenerateFlow}
|
||||
pendingClarification={summary.pendingFlowClarification}
|
||||
onResolveClarification={summary.resolveFlowClarification}
|
||||
ingestAvailable={summary.ingestAvailable}
|
||||
ingestStatus={summary.ingestStatus}
|
||||
pendingIngestClarification={summary.pendingIngestClarification}
|
||||
onResolveIngestChoice={summary.resolveIngestChoice}
|
||||
onResolveIngestClarification={summary.resolveIngestClarification}
|
||||
onManualIngest={summary.runIngest}
|
||||
onRetry={redraw}
|
||||
onAsk={handleAsk}
|
||||
onPause={pause}
|
||||
@@ -560,6 +567,12 @@ function AgentProgressPanel({
|
||||
onRegenerateFlow,
|
||||
pendingClarification,
|
||||
onResolveClarification,
|
||||
ingestAvailable,
|
||||
ingestStatus,
|
||||
pendingIngestClarification,
|
||||
onResolveIngestChoice,
|
||||
onResolveIngestClarification,
|
||||
onManualIngest,
|
||||
onRetry,
|
||||
onAsk,
|
||||
onPause,
|
||||
@@ -583,6 +596,13 @@ function AgentProgressPanel({
|
||||
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
|
||||
pendingClarification: string | null;
|
||||
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
|
||||
/** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */
|
||||
ingestAvailable: boolean;
|
||||
ingestStatus: UseStep3SummaryReturn["ingestStatus"];
|
||||
pendingIngestClarification: string | null;
|
||||
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
|
||||
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
|
||||
onManualIngest: UseStep3SummaryReturn["runIngest"];
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string, files?: File[]) => void;
|
||||
onPause: () => void;
|
||||
@@ -784,6 +804,60 @@ function AgentProgressPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
|
||||
{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">
|
||||
{(
|
||||
[
|
||||
{ k: "now", t: "现在入库" },
|
||||
{ k: "later", t: "稍后入库" },
|
||||
{ k: "never", t: "不入库" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.k}
|
||||
type="button"
|
||||
onClick={() => onResolveIngestChoice(o.k)}
|
||||
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-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">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
|
||||
没太理解你的入库意思,你是想——
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ k: "start_ingest", t: "开始入库" },
|
||||
{ k: "add_data", t: "补充内容后再入库" },
|
||||
{ k: "other", t: "只是提问 / 改报告" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.k}
|
||||
type="button"
|
||||
onClick={() => onResolveIngestClarification(o.k)}
|
||||
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
|
||||
>
|
||||
{o.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
|
||||
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
|
||||
{askFiles.length > 0 && (
|
||||
@@ -830,6 +904,22 @@ function AgentProgressPanel({
|
||||
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */}
|
||||
{ingestAvailable &&
|
||||
ingestStatus !== "asking" &&
|
||||
ingestStatus !== "running" &&
|
||||
ingestStatus !== "idle" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onManualIngest()}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
|
||||
<button
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -86,6 +86,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 { stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import {
|
||||
BUILTIN_AGENT_META,
|
||||
@@ -257,6 +258,11 @@ export default function RoundtablePlanningPage() {
|
||||
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
|
||||
const [step3Report, setStep3Report] = useState<DraftStep3Snapshot | null>(null);
|
||||
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
|
||||
// 入库技能是否可用(决定第三步生成流程图后是否询问入库 + 显示手动入库按钮)。挂载查一次。
|
||||
const [ingestAvailable, setIngestAvailable] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
void isIngestSkillAvailable().then(setIngestAvailable).catch(() => setIngestAvailable(false));
|
||||
}, []);
|
||||
const step2RunsRef = useRef<DraftStep2RunSnapshot[]>([]);
|
||||
const activeStep2RunIdRef = useRef<string | null>(null);
|
||||
// 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径,
|
||||
@@ -560,10 +566,15 @@ export default function RoundtablePlanningPage() {
|
||||
// 第三步三条生成流(总结报告 / 经典看板 / 大屏)各自生成过程的实时对话(中间文字 + 步骤条)。
|
||||
// 在 render 阶段镜像进 ref(这些 flow 在本 hook 之后才定义,故用 ref 跨过 TDZ;render 期赋值,
|
||||
// 保证 autoSave effect 读到的是本次提交已 flush 的最终对话,规避 effect 执行顺序问题)。
|
||||
const emptyConvo = { dialogues: [] as unknown[], seatStubMessages: {} as Record<string, unknown[]> };
|
||||
const step3SummaryConvoRef = useRef<typeof emptyConvo>({ ...emptyConvo });
|
||||
const step3ReportConvoRef = useRef<typeof emptyConvo>({ ...emptyConvo });
|
||||
const step3DashConvoRef = useRef<typeof emptyConvo>({ ...emptyConvo });
|
||||
type Step3Convo = {
|
||||
dialogues: unknown[];
|
||||
seatStubMessages: Record<string, unknown[]>;
|
||||
/** 仅总结流用:入库状态,随快照落库。 */
|
||||
ingestStatus?: string;
|
||||
};
|
||||
const step3SummaryConvoRef = useRef<Step3Convo>({ dialogues: [], seatStubMessages: {} });
|
||||
const step3ReportConvoRef = useRef<Step3Convo>({ dialogues: [], seatStubMessages: {} });
|
||||
const step3DashConvoRef = useRef<Step3Convo>({ dialogues: [], seatStubMessages: {} });
|
||||
|
||||
// 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
|
||||
const drafts = useDraftPersistence({
|
||||
@@ -590,6 +601,7 @@ export default function RoundtablePlanningPage() {
|
||||
seatStubMessages: Object.keys(sumLive.seatStubMessages).length
|
||||
? sumLive.seatStubMessages
|
||||
: sr.seatStubMessages,
|
||||
ingestStatus: sumLive.ingestStatus ?? sr.ingestStatus,
|
||||
};
|
||||
}
|
||||
const repLive = step3ReportConvoRef.current;
|
||||
@@ -1063,11 +1075,15 @@ export default function RoundtablePlanningPage() {
|
||||
autoFlow: autoSummaryFlow,
|
||||
savedSummary: step3Report?.summaryReport ?? null,
|
||||
onSummarySaved: handleSummarySaved,
|
||||
ingestAvailable,
|
||||
// 入库仅深链(taskId)模式(会商 rwfx-A 等)才有;taskId 入库时必传给技能。
|
||||
taskId: prefillRef.current?.taskId ?? null,
|
||||
});
|
||||
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
|
||||
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub + 入库状态,供 getStep3Snapshot 落库。
|
||||
step3SummaryConvoRef.current = {
|
||||
dialogues: summaryFlow.dialogues,
|
||||
seatStubMessages: summaryFlow.seatStubMessages,
|
||||
ingestStatus: summaryFlow.ingestStatus,
|
||||
};
|
||||
const reportFlow = useStep3Report({
|
||||
intentReady: step1.intentReady,
|
||||
|
||||
@@ -711,3 +711,54 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo
|
||||
except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定
|
||||
logger.warning("flow intent classify failed", exc_info=True)
|
||||
return {"intent": "unclear", "reason": "意图识别失败"}
|
||||
|
||||
|
||||
# ── 入库意图识别(「稍后入库」后用户在输入框的自由文字)──────────────────────────
|
||||
|
||||
|
||||
class IngestIntentRequest(BaseModel):
|
||||
"""第三步「稍后入库」待命时,识别输入框消息是否要开始入库 / 补充内容后入库 / 其他。"""
|
||||
|
||||
message: str = ""
|
||||
model: str | None = Field(default=None)
|
||||
|
||||
|
||||
_INGEST_INTENTS = {"start_ingest", "add_data", "other", "unclear"}
|
||||
|
||||
_INGEST_INTENT_SYS = (
|
||||
"你是圆桌「方案总结」第三步**入库意图识别器**。流程图已生成、并且用户此前选择了「稍后入库」,"
|
||||
"现在他在输入框发了一条消息。请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
|
||||
'{"intent":"<枚举>","reason":"<一句话依据>"}\n'
|
||||
"intent 只能取其一:\n"
|
||||
"- start_ingest:现在就把当前方案入库(如「入库吧」「开始入库」「现在入库」「把方案存进知识库」);\n"
|
||||
"- add_data:想先补充/追加一些内容再入库(如「补一段背景再入库」「加上风险说明后入库」「把这段也一起入库」);\n"
|
||||
"- other:与入库无关,是改报告/改流程图/提问等其它诉求(如「报告再写细点」「流程图加个验收节点」「为什么选这个方案」);\n"
|
||||
"- unclear:和入库相关但说不清是现在入库还是要补内容(如只说「入库」却又像没说完、「那个再说」之类模糊表达)。\n"
|
||||
"示例:\n"
|
||||
'「现在入库」→ {"intent":"start_ingest","reason":"明确要求立即入库"}\n'
|
||||
'「我再补一段实施风险,然后入库」→ {"intent":"add_data","reason":"先补内容再入库"}\n'
|
||||
'「报告执行部分写细一点」→ {"intent":"other","reason":"是改报告,与入库无关"}\n'
|
||||
'「入库……嗯」→ {"intent":"unclear","reason":"提到入库但表达不完整"}'
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ingest-intent")
|
||||
async def classify_ingest_intent(request: Request, payload: IngestIntentRequest = Body(...)) -> dict[str, str]:
|
||||
"""识别「稍后入库」待命时输入框消息的入库意图(一次模型调用)。失败/解析不出 → unclear,前端发起协助。"""
|
||||
msg = (payload.message or "").strip()
|
||||
if not msg:
|
||||
return {"intent": "unclear", "reason": "空消息"}
|
||||
try:
|
||||
model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False)
|
||||
res = await model.ainvoke(
|
||||
[SystemMessage(content=_INGEST_INTENT_SYS), HumanMessage(content="用户消息:" + msg + "\n\n请输出 JSON:")]
|
||||
)
|
||||
text = res.content if isinstance(res.content, str) else _as_str(getattr(res, "text", ""))
|
||||
obj = _parse_intent_json(text) or {}
|
||||
intent = _as_str(obj.get("intent")).lower()
|
||||
if intent not in _INGEST_INTENTS:
|
||||
intent = "unclear"
|
||||
return {"intent": intent, "reason": _as_str(obj.get("reason"))}
|
||||
except Exception: # noqa: BLE001 — 分类失败不致命,回退协助让用户自己定
|
||||
logger.warning("ingest intent classify failed", exc_info=True)
|
||||
return {"intent": "unclear", "reason": "意图识别失败"}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: knowledge-base-ingest
|
||||
description: 把会商/方案的最终产物(总结报告 Markdown + 结构化流程图)入库到知识库。当用户在第三步生成流程图后表示「现在入库 / 开始入库 / 把方案入库 / 补充内容后入库」时使用本 Skill 触发入库。
|
||||
allowed-tools:
|
||||
- read_file
|
||||
---
|
||||
|
||||
# 知识库入库 Skill(示例 · 演示用)
|
||||
|
||||
> ⚠️ **这是一个示例(演示)技能,仅用于打通第三步「生成流程图 → 入库」的交互流程。**
|
||||
> 真正的入库技能(对接实际知识库写入接口)后续会替换本技能;届时只需保持
|
||||
> `name: knowledge-base-ingest` 不变,前端的入库询问 / 意图识别 / 手动入库按钮即可
|
||||
> 无缝切到真实实现。
|
||||
|
||||
## 用途
|
||||
|
||||
在多智能体会商的第三步,方案总结报告与结构化流程图生成完成后,把这份成果作为一条
|
||||
记录沉淀进知识库,便于后续检索与复用。
|
||||
|
||||
## 何时触发
|
||||
|
||||
- 流程图生成完成后,系统会询问用户「现在入库 / 稍后入库 / 不入库」;
|
||||
- 用户选择「现在入库」、点击「入库」按钮,或在输入框中表达「开始入库 / 把方案入库 /
|
||||
补充 xxx 内容后再入库」时触发。
|
||||
|
||||
## 适用范围
|
||||
|
||||
仅**深链(taskId)模式**(会商 `rwfx-A` / `3qfx-A` / `xdfx-A` 等)才提供入库;普通会商
|
||||
不出现入库询问/按钮。
|
||||
|
||||
## 入库内容
|
||||
|
||||
- **taskId**:深链外部任务 id —— **技能必需信息**,入库时一并传入;
|
||||
- 标题:本次会商的任务目标(intent objective);
|
||||
- 正文:方案总结报告(Markdown)+ 结构化流程图数据(**以用户最后修改后的流程图为准**);
|
||||
- 可选:用户在「补充内容后再入库」时追加的说明。
|
||||
|
||||
## 调用过程(演示)
|
||||
|
||||
本示例技能不写真实知识库,仅在消息列表中以「调度入库技能 → 调用入库技能 → 提交方案
|
||||
数据 → 入库完成」的步骤条演示调用过程。真实技能将把上述内容提交到知识库写入接口
|
||||
(如 `POST /api/knowledge-ingest/publish`,按智能体 `config.yaml` 的 `template_name`
|
||||
解析入库模板)。
|
||||
Reference in new issue
Block a user