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