feat: 第三步流程图后端抽取智能体 + 意图识别 + 问答/画布编辑

- 新增专职「结构化抽取智能体」roundtable-structure(种子资产 SOUL+config,注册进 seeder
  启动 seed+sync 进 DB,可在智能体管理页编辑、未来挂入库技能)。
- /api/flow-extract/stream 改用该 agent 的 SOUL(load_agent_soul,缺失回退内置默认) +
  模型(前端所选 > agent 配置 > 默认);抽取/校验/编辑统一。SOUL 先输出简要分析过程再出
  flow-json,模型慢时也有实时反馈(含 <think> 思考流)。
- 新增 /api/flow-extract/intent 意图识别(参考 ai_writing 一次性 JSON 范式):识别
  重做报告/改流程图/重画流程图/提问/模糊;前端按意图分流,模糊则发起澄清让用户点选,
  不再用关键词硬路由(修「说重做报告却出流程图」)。
- Phase 2 问答修改:edit 模式(当前图+指令→改图→校验);Phase 3 画布手动改/删节点。
- 修:用户消息发送即上屏(不等模型/分类返回);regenerate_report 不再清空对话历史;
  全屏 portal 到 body 工具栏恢复;解析兼容独立 edges 数组。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-17 22:19:02 +08:00
1 parent ed887f762e
commit 0890b3c375
7 files changed
+346 -62

No files matched your search

@@ -11,6 +11,41 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
/** 第三步输入框意图分类结果(与后端 /intent 对应)。 */
export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "qa" | "unclear";
/**
* 识别第三步输入框消息的意图(一次模型调用)。失败回退 "unclear"(前端发起澄清)。
* 参考 ai_writing 的一次性 JSON 意图识别范式。
*/
export async function classifyFlowIntent(req: {
message: string;
hasReport: boolean;
hasFlow: boolean;
model?: string;
signal?: AbortSignal;
}): Promise<{ intent: FlowIntent; reason: string }> {
try {
const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/intent`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: req.message,
has_report: req.hasReport,
has_flow: req.hasFlow,
model: req.model,
}),
signal: req.signal,
});
if (!res.ok) return { intent: "unclear", reason: "" };
const data = (await res.json()) as { intent?: string; reason?: string };
const intent = (data.intent ?? "unclear") as FlowIntent;
return { intent, reason: data.reason ?? "" };
} catch {
return { intent: "unclear", reason: "" };
}
}
export interface FlowExtractRequest {
/** 报告 Markdown(优先数据来源)。 */
reportMd?: string | null;
@@ -412,6 +412,8 @@ export function Step3SummaryMode({
mdAvailable={!!mdArtifact}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
pendingClarification={summary.pendingFlowClarification}
onResolveClarification={summary.resolveFlowClarification}
onRetry={redraw}
onAsk={handleAsk}
onPause={pause}
@@ -556,6 +558,8 @@ function AgentProgressPanel({
mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
onRetry,
onAsk,
onPause,
@@ -576,6 +580,9 @@ function AgentProgressPanel({
flowPhase: UseStep3SummaryReturn["flowPhase"];
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
onRegenerateFlow: () => void;
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
pendingClarification: string | null;
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
@@ -748,6 +755,35 @@ function AgentProgressPanel({
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 意图模糊 → 让用户自己定(不自作主张) */}
{pendingClarification && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 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: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(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>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
@@ -41,7 +41,7 @@ import type {
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import { streamFlowExtract } from "@/roundtable-planning/api/flow-extract";
import { classifyFlowIntent, streamFlowExtract, type FlowIntent } from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -88,6 +88,10 @@ export interface UseStep3SummaryReturn {
regenerateFlow: () => void;
/** 手动编辑流程图(Phase 3:画布上改/删节点后回写 + 持久化)。 */
setFlowGraph: (next: FlowGraphData) => void;
/** 意图模糊时挂起的原始消息(非空 → 组件展示「重做报告/改图/重画/提问」选项让用户定)。 */
pendingFlowClarification: string | null;
/** 用户在澄清里点选意图后执行。 */
resolveFlowClarification: (choice: FlowIntent) => void;
summaryThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
@@ -417,6 +421,8 @@ export function useStep3Summary(
useEffect(() => {
flowGraphRef.current = flowGraph;
}, [flowGraph]);
// 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。
const [pendingFlowClarification, setPendingFlowClarification] = useState<string | null>(null);
const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<
@@ -1149,7 +1155,7 @@ export function useStep3Summary(
]);
const ask = useCallback(
async (message: string, files?: File[]) => {
async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent }) => {
const text = message.trim();
const hasFiles = !!(files && files.length > 0);
if (!text && !hasFiles) return;
@@ -1160,38 +1166,54 @@ export function useStep3Summary(
abort();
if (wasBusy) freezeStreaming("已打断");
// 流程图相关消息 → 走后端「抽取/编辑 + 校验」端点(流式显示过程 → 校验后绘制),
// 不再在总结线程吐 flow-json 做实时绘制。判定:
// · 已有流程图 且(显式 flow 关键词 或 上一轮主产物是流程图)→ **问答修改**(带当前图+指令,Phase 2);
// · 还没有流程图 但显式要流程图 → **首次生成**。
// 普通报告修改 / 问答(无 flow 上下文)仍走下方总结线程路径。
const flowIntentNow = detectFlowIntent(text);
const curGraph = flowGraphRef.current;
if (flowIntentNow || (curGraph && flowActiveRef.current)) {
appendUserDialogue(text);
const flowAborter = new AbortController();
aborterRef.current = flowAborter;
if (curGraph) {
await runFlowExtract(flowAborter, { instruction: text, currentGraph: curGraph });
} else {
await runFlowExtract(flowAborter);
}
return;
// 用户气泡**立刻上屏**(不等意图分类 / 模型返回,避免连接卡顿时显得卡)——对齐第一/二步。
// 后续各分支都不再重复追加用户气泡。forcedIntent(澄清后点选复跑)时原气泡已在、跳过避免重复。
if (!opts?.forcedIntent) {
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
appendUserDialogue((text || "(上传了参考文件)") + fileNote);
}
// 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类):
// ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md;
// ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问);
// ③ 普通报告语境 → 语义理解修改/提问/结构化意图,太模糊则反问。
const flowIntent = detectFlowIntent(text);
const flowContext = !flowIntent && flowActiveRef.current;
const askInstruction = flowIntent
? FLOW_ASK_INSTRUCTION
: flowContext
? FLOW_CONTEXT_ASK_INSTRUCTION
: SEMANTIC_ASK_INSTRUCTION;
// 流程图语境轮:不主动弹 md 沙箱预览(用户多半要看的是画布;真写了文件预览仍会自动打开)。
const flowRound = flowIntent || flowContext;
// 意图识别(参考 ai_writing 一次性 JSON 范式):先判断用户想 重做报告 / 改流程图 /
// 重画流程图 / 提问 / 模糊待澄清,再分流——不再用关键词硬路由(避免「说重做报告却出流程图」)。
// 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。
if (text && !hasFiles) {
let intent: FlowIntent | undefined = opts?.forcedIntent;
if (!intent) {
const r = await classifyFlowIntent({
message: text,
hasReport: !!currentMdRef.current.trim(),
hasFlow: !!flowGraphRef.current,
model: selectedModel || undefined,
});
intent = r.intent;
}
if (intent === "unclear") {
setPendingFlowClarification(text); // 组件渲染选项让用户自己定
return;
}
if (intent === "regenerate_flow" || intent === "modify_flow") {
const flowAborter = new AbortController();
aborterRef.current = flowAborter;
const cur = flowGraphRef.current;
if (intent === "modify_flow" && cur) {
await runFlowExtract(flowAborter, { instruction: text, currentGraph: cur });
} else {
await runFlowExtract(flowAborter);
}
return;
}
// intent === "regenerate_report" | "qa" → 落到下方总结线程续问路径:
// **追加**用户气泡 + 在原线程上让智能体重写报告 / 增量改 / 回答(**不清空历史**)。
// regenerate_report 不再走 run()(run 会 setDialogues([]) 清空,导致"覆盖聊天记录、
// 不显示用户输入"的 bug);改报告/重写报告由总结智能体在续问里整文件重写。
// intent === "qa" → 落到下方总结线程问答路径(只答不改产物)。
}
// 走到这里只剩 regenerate_report / qa(含带附件场景)—— 流程图操作已在上面意图分流处理。
// 一律走报告/问答语义引导(让总结智能体在原线程上重写报告 / 增量改 / 回答),
// **不再**在总结线程里产 flow-json(流程图统一由后端抽取端点负责)。
const askInstruction = SEMANTIC_ASK_INSTRUCTION;
const flowRound = false;
runningRef.current = true;
setError(null);
@@ -1226,8 +1248,7 @@ export function useStep3Summary(
}));
}
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
appendUserDialogue((text || "(上传了参考文件)") + fileNote);
// 用户气泡已在 ask 开头立刻追加(不等模型/分类返回),这里不再重复。
const bubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
@@ -1287,6 +1308,7 @@ export function useStep3Summary(
seatDeliveries,
consensusText,
coordinatorPrompt,
selectedModel,
abort,
freezeStreaming,
appendUserDialogue,
@@ -1296,6 +1318,17 @@ export function useStep3Summary(
],
);
/** 澄清后用户点选意图 → 用 forcedIntent 复跑(跳过意图分类)。 */
const resolveFlowClarification = useCallback(
(choice: FlowIntent) => {
const msg = pendingFlowClarification;
setPendingFlowClarification(null);
if (!msg) return;
void ask(msg, undefined, { forcedIntent: choice });
},
[pendingFlowClarification, ask],
);
/** 暂停当前生成:中断在途请求 + 把流式气泡收尾为「已暂停」+ 状态回 done。 */
const pause = useCallback(() => {
if (!runningRef.current) return;
@@ -1413,6 +1446,8 @@ export function useStep3Summary(
flowPhase,
regenerateFlow,
setFlowGraph: updateFlowGraph,
pendingFlowClarification,
resolveFlowClarification,
summaryThreadId,
dialogues,
seatStubMessages,