Files
deerflow-code/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts
T

676 lines
26 KiB
TypeScript

/**
* Step 1(任务意图理解)状态机 hook。
*
* 这是从原 `RoundtablePlanningPage.tsx` 里抽出来的封装 —— 收纳 Step 1 的
* 所有 state、refs、effect 与 handlers,主页面只持有 currentStep 与跨步的
* 编排,任何 Step 1 的内部细节都不再泄露到主页。
*
* 历史踩坑请保留:
* - `step1InitGenRef` 单调代数:解决 React 18 StrictMode 双 mount 期间
* closure cancelled 标志失效的问题 —— 任何 setState 完成前都要校验
* `myGen === step1InitGenRef.current`。
* - intent stream 暂停走 AbortController.abort(),AbortError 当作正常分支
* 在气泡尾部追加「(已暂停)」并 toast,而不是当错误处理。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
IntentInitError,
initIntent,
streamIntent,
type IntentErrorDetail,
type IntentFileRef,
type IntentStreamPhase,
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import {
clarificationKey,
formatIntentClarificationAnswer,
type IntentClarificationSelection,
} from "@/roundtable-planning/components/IntentClarificationCard";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import {
nowTime,
splitIntentReadyBlock,
} from "@/roundtable-planning/lib/roundtable-constants";
/** Step 1 对话气泡。`streaming` / `phase` / `clarification` / `steps` 是流式与澄清扩展。 */
export type InteractiveMessage = {
id: number;
sender: string;
senderName: string;
content: string;
time: string;
streaming?: boolean;
phase?: string;
/** 用户气泡里附带的上传文件(仅展示用:文件名 + 大小)。 */
files?: Array<{ filename: string; size: number }>;
/** First clarification (back-compat); prefer `clarifications`. */
clarification?: ClarificationPayload;
/**
* All `ask_clarification` questions the agent asked this turn (the agent may
* ask several at once). Populated live from the stream so each question card
* appears in real-time. Each carries a stable `id` (tool_call_id) for keying
* per-question selections.
*/
clarifications?: ClarificationPayload[];
/**
* ChainOfThought 折叠卡的步骤累积。每次 `onPhaseChange` 都会把当前 phase + toolName
* 拼成 `phase:toolName` 作为 key,与 Step 2 的 `Step2Dialogue.steps` 同形。
* 渲染走 `MessageStepsCard`,与主聊天 MessageGroup 视觉对齐。
*/
steps?: Array<{
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
}>;
};
/** 右侧「澄清记录」时间线一条目。 */
export type TimelineEntry = {
id: number;
time: string;
title: string;
desc: string;
};
/** 草稿持久化用的 Step 1 快照(`useDraftPersistence` 用同一份形状)。 */
export type Step1Snapshot = {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: InteractiveMessage[];
timelineEntries: TimelineEntry[];
};
export interface UseStep1IntentOptions {
/** 当前页面 currentStep,只在 ===1 且未 init 时才会自动 runStep1Init。 */
currentStep: number;
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
selectedModel: string;
/** 主页面统一的 toast 入口。 */
triggerToast: (msg: string) => void;
}
export interface UseStep1IntentReturn {
// ─── State (read-only from consumer) ─────────────────────────────────
intentThreadId: string | null;
intentReady: IntentSummary | null;
intentInitError: string | null;
intentInitErrorDetail: IntentErrorDetail | null;
isIntentSending: boolean;
pendingIntentClarificationMsgId: number | null;
/** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */
intentClarificationSelections: Map<string, IntentClarificationSelection>;
interactiveMessages: InteractiveMessage[];
timelineEntries: TimelineEntry[];
// ─── Mutators ────────────────────────────────────────────────────────
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<string, IntentClarificationSelection>>
>;
// ─── Handlers ────────────────────────────────────────────────────────
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
runStep1Init: () => void;
/** 清空当前状态但不创建线程,供宿主切换任务后重新进入 Step 1。 */
resetIntent: () => void;
/**
* 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。
* `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端
* 注入 additional_kwargs.files,意图智能体即可 read_file 读取)。允许「仅文件无文本」。
*/
sendIntentMessage: (userText: string, files?: File[]) => Promise<void>;
/**
* 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
*
* `overrideByKey`:单题「点击即发送」用 —— 单选卡点击选项时选择还没写进 state
* map(setState 异步),把该题的即时选择按 `clarificationKey` 传进来直接用。
*/
submitIntentClarifications: (
msgId: number,
clarifications: ClarificationPayload[],
overrideByKey?: Record<string, IntentClarificationSelection>,
) => void;
/**
* 用户在「当前意图摘要」卡里手动编辑后保存:直接覆盖 `intentReady`。下游(Step 2 seed
* `buildInitialIntent`、左右侧栏摘要卡、step2Locked 判定)都读 `intentReady`,改这里即全局生效。
* 持久化由调用方(页面)在更新后触发草稿保存。
*/
updateIntentReady: (next: IntentSummary) => void;
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
stopIntentStream: () => void;
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
hydrateFromDraft: (snapshot: Step1Snapshot) => void;
/** 给「跳过此项」按钮 / 「继续追问」按钮等本地 demo 操作用。 */
appendLocalMessage: (msg: InteractiveMessage) => void;
}
const INTENT_WELCOME_TEXT =
"你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?";
function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "ask_clarification") return "正在生成追问";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "streaming":
return "正在思考问题";
default:
return "正在思考";
}
}
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
const { currentStep, selectedModel, triggerToast } = options;
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null);
const [intentInitErrorDetail, setIntentInitErrorDetail] =
useState<IntentErrorDetail | null>(null);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
useState<number | null>(null);
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
Map<string, IntentClarificationSelection>
>(new Map());
// 原始页面带了一段写死的 demo 对话,只是首次进入 Step 1 还没初始化前的占位;
// runStep1Init 会立刻覆盖成新的欢迎气泡,因此这里直接以欢迎气泡作为初值。
const [interactiveMessages, setInteractiveMessages] = useState<InteractiveMessage[]>(
[],
);
const [timelineEntries, setTimelineEntries] = useState<TimelineEntry[]>([]);
const step1InitRef = useRef<boolean>(false);
const step1InitGenRef = useRef<number>(0);
const intentAborterRef = useRef<AbortController | null>(null);
// selectedModel 闭包问题:sendIntentMessage 在 streamIntent 调用时需要拿到最新值,
// 但 useCallback 把它捕获在闭包里。改用 ref 让 sendIntentMessage 始终读最新模型,
// 同时把 selectedModel 从 useCallback 依赖里去掉,避免每次模型切换都重建函数引用。
const selectedModelRef = useRef<string>(selectedModel);
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
const stopIntentStream = useCallback(() => {
if (intentAborterRef.current) {
try {
intentAborterRef.current.abort();
} catch {
/* noop */
}
intentAborterRef.current = null;
}
}, []);
const runStep1Init = useCallback(() => {
step1InitRef.current = true;
const myGen = ++step1InitGenRef.current;
setIntentReady(null);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setIntentThreadId(null);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setTimelineEntries([]);
setInteractiveMessages([
{
id: 1,
sender: "agent",
senderName: "任务理解智能体",
content: INTENT_WELCOME_TEXT,
time: nowTime(),
},
]);
(async () => {
try {
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
const res = await initIntent(selectedModelRef.current || undefined);
if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id);
setTimelineEntries([
{
id: 1,
time: nowTime(),
title: "任务理解智能体已就绪",
desc: `agent_id=${res.agent_id}`,
},
]);
} catch (err) {
if (step1InitGenRef.current !== myGen) return;
console.error("init intent failed:", err);
if (err instanceof IntentInitError) {
setIntentInitError(err.detail?.message || err.message);
setIntentInitErrorDetail(err.detail);
} else {
setIntentInitError((err as Error).message);
setIntentInitErrorDetail(null);
}
}
})();
}, []);
const resetIntent = useCallback(() => {
stopIntentStream();
step1InitRef.current = false;
step1InitGenRef.current += 1;
setIntentThreadId(null);
setIntentReady(null);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setInteractiveMessages([]);
setTimelineEntries([]);
}, [stopIntentStream]);
// 离开 Step 1 自动中断流;`currentStep === 1` 但还没初始化 → runStep1Init。
useEffect(() => {
if (currentStep !== 1) {
stopIntentStream();
return;
}
if (step1InitRef.current) return;
runStep1Init();
}, [currentStep, runStep1Init, stopIntentStream]);
// [INTENT_READY] 同步兜底:某些终态帧没带 summary,但气泡正文里已经有
// 标记块 —— 主动解析最近一条 agent 气泡并把 intentReady 补上。
useEffect(() => {
if (intentReady) return;
for (let i = interactiveMessages.length - 1; i >= 0; i--) {
const msg = interactiveMessages[i];
if (msg.sender === "user") continue;
const { summary } = splitIntentReadyBlock(msg.content ?? "");
if (summary) {
setIntentReady(summary);
setPendingIntentClarificationMsgId(null);
break;
}
}
}, [interactiveMessages, intentReady]);
const sendIntentMessage = useCallback(
async (userText: string, files?: File[]) => {
const trimmed = userText.trim();
const hasFiles = !!(files && files.length > 0);
if (!trimmed && !hasFiles) return;
if (!intentThreadId) {
triggerToast("任务理解智能体尚未就绪,请稍候");
return;
}
// 重入保护用 ref 而不是 state:isIntentSending 是 React state,
// 同一 event-loop tick 内闭包还看到旧值。极速双击 / 输入法回车连击
// 会两次都通过 state 检查,结果发两次答案、跑两路 stream,
// 回来两张几乎一样的澄清卡——这正是用户报告的"同样选项又出现一次"。
// intentAborterRef 在 stream 完成 / abort 后会被清空,正好用来标记"在途"。
if (intentAborterRef.current) {
triggerToast("⏳ 智能体正在回复,请稍候");
return;
}
const aborter = new AbortController();
intentAborterRef.current = aborter;
setPendingIntentClarificationMsgId(null);
const userMsgTime = nowTime();
const userMsgId = Date.now() + Math.random();
const userMsg: InteractiveMessage = {
id: userMsgId,
sender: "user",
senderName: "用户",
content: trimmed,
time: userMsgTime,
files: hasFiles
? files!.map((f) => ({ filename: f.name, size: f.size }))
: undefined,
};
setInteractiveMessages((prev) => [...prev, userMsg]);
const timelineDesc = trimmed
? trimmed.length > 25
? trimmed.substring(0, 25) + "..."
: trimmed
: `上传了 ${files!.length} 个文件`;
setTimelineEntries((prev) => [
...prev,
{
id: prev.length + 1,
time: userMsgTime,
title: "用户实时反馈",
desc: timelineDesc,
},
]);
const agentMsgId = Date.now() + Math.random() + 0.01;
setInteractiveMessages((prev) => [
...prev,
{
id: agentMsgId,
sender: "agent",
senderName: "任务理解智能体",
content: "",
time: nowTime(),
streaming: true,
phase: "调度中",
},
]);
setIsIntentSending(true);
try {
// 先把本地文件上传到 intent thread —— 与普通聊天同一上传接口/技能链路。
// 拿到 virtual_path 后随消息提交,后端注入 additional_kwargs.files。
let fileRefs: IntentFileRef[] | undefined;
if (hasFiles) {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId ? { ...m, phase: "正在上传文件" } : m,
),
);
try {
const up = await uploadFiles(intentThreadId, files!);
fileRefs = up.files.map((f) => ({
filename: f.filename,
size: f.size,
path: f.virtual_path,
status: "uploaded" as const,
}));
} catch (err) {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? {
...m,
content: `(文件上传失败) ${(err as Error).message}`,
streaming: false,
}
: m,
),
);
triggerToast(`文件上传失败: ${(err as Error).message}`);
if (intentAborterRef.current === aborter) {
intentAborterRef.current = null;
}
setIsIntentSending(false);
return;
}
}
const res = await streamIntent({
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
files: fileRefs,
signal: aborter.signal,
onTextDelta: (delta) => {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? { ...m, content: (m.content ?? "") + delta }
: m,
),
);
},
onPhaseChange: (phase, toolName, args) => {
const label = intentPhaseLabel(phase, toolName);
const stepKey = `${phase}:${toolName ?? ""}`;
setInteractiveMessages((prev) =>
prev.map((m) => {
if (m.id !== agentMsgId) return m;
// 把 phase 累积进 message.steps,与 Step 2 / 主聊天的 MessageGroup
// 共用 MessageStepsCard。同 key 连续触发时只更新最新一行(若 args
// 比之前完整就替换),避免 streaming 帧反复 push 出同行噪声。
const steps = m.steps ?? [];
const last = steps[steps.length - 1];
let nextSteps = steps;
if (last && last.key === stepKey) {
if (args && !last.args) {
nextSteps = [...steps];
nextSteps[nextSteps.length - 1] = { ...last, args };
}
} else {
nextSteps = [
...steps,
{
id: `${agentMsgId}-${steps.length}`,
key: stepKey,
label,
ts: nowTime(),
args,
},
];
}
return { ...m, phase: label, steps: nextSteps };
}),
);
},
onClarification: (clarification, index) => {
// 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现,
// 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条
// 「追问 N:<问题>」,与「强化步骤链」诉求对齐。
const clarId = clarification.id ?? `idx-${index}`;
const stepKey = `clarification:${clarId}`;
const qLabel = clarification.question
? `追问 ${index + 1}:${
clarification.question.length > 24
? clarification.question.slice(0, 24) + "…"
: clarification.question
}`
: `追问 ${index + 1}`;
setInteractiveMessages((prev) =>
prev.map((m) => {
if (m.id !== agentMsgId) return m;
const list = m.clarifications ?? [];
// 按 id 去重 / 覆盖,流式重发同一条时不重复 push。
const existingIdx = list.findIndex(
(c) => (c.id ?? "") === (clarification.id ?? ""),
);
const nextList =
existingIdx >= 0 && clarification.id
? list.map((c, i) => (i === existingIdx ? clarification : c))
: [...list, clarification];
const steps = m.steps ?? [];
const hasStep = steps.some((s) => s.key === stepKey);
const nextSteps = hasStep
? steps
: [
...steps,
{
id: `${agentMsgId}-clar-${clarId}`,
key: stepKey,
label: qLabel,
ts: nowTime(),
},
];
return { ...m, clarifications: nextList, steps: nextSteps };
}),
);
},
});
const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
const resolvedSummary = res.summary ?? parsedFromContent;
const finalClarifications = resolvedSummary ? [] : res.clarifications;
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? {
...m,
content: res.content || m.content || "(无文本输出)",
streaming: false,
// 终态以 res.clarifications 为权威集合(覆盖流式期增量构建的),
// 既保证多问题完整,也在解析出 summary 时清空澄清卡。
clarification: resolvedSummary
? undefined
: res.clarification ?? undefined,
clarifications:
finalClarifications.length > 0 ? finalClarifications : undefined,
}
: m,
),
);
if (resolvedSummary) {
setIntentReady(resolvedSummary);
setPendingIntentClarificationMsgId(null);
setTimelineEntries((prev) => [
...prev,
{
id: prev.length + 1,
time: nowTime(),
title: "任务意图已明确",
desc:
resolvedSummary.objective.length > 25
? resolvedSummary.objective.substring(0, 25) + "..."
: resolvedSummary.objective,
},
]);
triggerToast("任务理解完成,可进入多智能体圆桌研讨");
} else if (res.status === "clarification" && res.clarifications.length > 0) {
setPendingIntentClarificationMsgId(agentMsgId);
setIntentClarificationSelections((prev) => {
const next = new Map(prev);
res.clarifications.forEach((c, i) => {
const key = clarificationKey(agentMsgId, c.id, i);
if (!next.has(key)) next.set(key, { selectedIds: [], custom: "" });
});
return next;
});
} else if (res.status === "error") {
if (res.errorDetail) {
setIntentInitError(res.errorDetail.message || res.content);
setIntentInitErrorDetail(res.errorDetail);
} else {
setIntentInitError(res.content);
setIntentInitErrorDetail(null);
}
triggerToast(`智能体调用失败: ${res.content}`);
}
} catch (err) {
if ((err as Error).name === "AbortError" || aborter.signal.aborted) {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? {
...m,
content: (m.content ?? "").trim()
? `${m.content ?? ""}\n\n(已暂停)`
: "(已暂停)",
streaming: false,
}
: m,
),
);
triggerToast("⏸️ 已暂停任务理解回复");
} else {
console.error("intent stream failed:", err);
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? {
...m,
content: `(错误) ${(err as Error).message}`,
streaming: false,
}
: m,
),
);
triggerToast(`发送失败: ${(err as Error).message}`);
}
} finally {
if (intentAborterRef.current === aborter) {
intentAborterRef.current = null;
}
setIsIntentSending(false);
}
},
[intentThreadId, triggerToast],
);
const submitIntentClarifications = useCallback(
(
msgId: number,
clarifications: ClarificationPayload[],
overrideByKey?: Record<string, IntentClarificationSelection>,
) => {
// 逐题取选择并格式化;只把答了的题拼进去。
const answered = clarifications
.map((payload, index) => {
const key = clarificationKey(msgId, payload.id, index);
const selection =
overrideByKey?.[key] ??
intentClarificationSelections.get(key) ?? { selectedIds: [], custom: "" };
const answer = formatIntentClarificationAnswer(payload, selection);
return answer ? { question: payload.question, answer } : null;
})
.filter((x): x is { question: string; answer: string } => x !== null);
if (answered.length === 0) {
triggerToast("请至少回答一个问题");
return;
}
// 单题:直接发答案(与旧行为一致)。多题:带上问题做编号 Q/A,合并成一条消息。
const combined =
answered.length === 1
? answered[0].answer
: answered
.map((qa, i) => `${i + 1}. ${qa.question}\n答:${qa.answer}`)
.join("\n\n");
void sendIntentMessage(combined);
},
[intentClarificationSelections, sendIntentMessage, triggerToast],
);
const hydrateFromDraft = useCallback((snapshot: Step1Snapshot) => {
setIntentThreadId(snapshot.intentThreadId);
setIntentReady(snapshot.intentReady);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setInteractiveMessages(snapshot.interactiveMessages);
setTimelineEntries(snapshot.timelineEntries);
step1InitRef.current = true;
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
step1InitGenRef.current += 1;
}, []);
const appendLocalMessage = useCallback((msg: InteractiveMessage) => {
setInteractiveMessages((prev) => [...prev, msg]);
}, []);
const updateIntentReady = useCallback((next: IntentSummary) => {
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
setIntentReady(next);
setPendingIntentClarificationMsgId(null);
}, []);
return {
intentThreadId,
intentReady,
intentInitError,
intentInitErrorDetail,
isIntentSending,
pendingIntentClarificationMsgId,
intentClarificationSelections,
interactiveMessages,
timelineEntries,
setIntentClarificationSelections,
runStep1Init,
resetIntent,
sendIntentMessage,
submitIntentClarifications,
updateIntentReady,
stopIntentStream,
hydrateFromDraft,
appendLocalMessage,
};
}