岗位会商写操作引入 command_id 幂等与版本锁,前端支持同键重试;圆桌后台作业改为 DB 租约 dispatcher、去重与草稿 outbox 补偿,并持久化 intent turns。 Co-authored-by: Cursor <cursoragent@cursor.com>
747 lines
29 KiB
TypeScript
747 lines
29 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;
|
|
/** Optional built-in agent override for a workspace with a distinct schema. */
|
|
intentAgentId?: string;
|
|
/** Optional hidden task/business context for each intent-agent turn. */
|
|
taskContext?: Record<string, unknown> | null;
|
|
/** Optional task-aware welcome bubble text before the first user turn. */
|
|
initialWelcomeText?: string;
|
|
/** Keep the dialogue visually empty while its intent thread is initialising. */
|
|
showInitialWelcome?: boolean;
|
|
/** 主页面统一的 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;
|
|
isIntentInitializing: boolean;
|
|
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;
|
|
clearInteractiveMessages: () => 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,
|
|
intentAgentId,
|
|
taskContext,
|
|
initialWelcomeText,
|
|
showInitialWelcome = true,
|
|
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 [isIntentInitializing, setIsIntentInitializing] = useState<boolean>(false);
|
|
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 intentAgentIdRef = useRef<string | undefined>(intentAgentId);
|
|
useEffect(() => {
|
|
intentAgentIdRef.current = intentAgentId;
|
|
}, [intentAgentId]);
|
|
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
|
useEffect(() => {
|
|
taskContextRef.current = taskContext ?? null;
|
|
}, [taskContext]);
|
|
const initialWelcomeTextRef = useRef(initialWelcomeText);
|
|
useEffect(() => {
|
|
initialWelcomeTextRef.current = initialWelcomeText;
|
|
}, [initialWelcomeText]);
|
|
|
|
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);
|
|
setIsIntentInitializing(true);
|
|
setPendingIntentClarificationMsgId(null);
|
|
setIntentClarificationSelections(new Map());
|
|
setTimelineEntries([]);
|
|
setInteractiveMessages(
|
|
showInitialWelcome
|
|
? [
|
|
{
|
|
id: 1,
|
|
sender: "agent",
|
|
senderName: "任务理解智能体",
|
|
content: initialWelcomeTextRef.current || 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,
|
|
taskContextRef.current,
|
|
intentAgentIdRef.current,
|
|
);
|
|
if (step1InitGenRef.current !== myGen) return;
|
|
setIntentThreadId(res.thread_id);
|
|
setIsIntentInitializing(false);
|
|
setTimelineEntries([
|
|
{
|
|
id: 1,
|
|
time: nowTime(),
|
|
title: "任务理解智能体已就绪",
|
|
desc: `agent_id=${res.agent_id}`,
|
|
},
|
|
]);
|
|
} catch (err) {
|
|
if (step1InitGenRef.current !== myGen) return;
|
|
setIsIntentInitializing(false);
|
|
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);
|
|
}
|
|
}
|
|
})();
|
|
}, [showInitialWelcome]);
|
|
|
|
const resetIntent = useCallback(() => {
|
|
stopIntentStream();
|
|
step1InitRef.current = false;
|
|
step1InitGenRef.current += 1;
|
|
setIntentThreadId(null);
|
|
setIsIntentInitializing(false);
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
|
|
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
|
const clientTurnId =
|
|
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
|
|
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
const res = await streamIntent({
|
|
threadId: intentThreadId,
|
|
message: trimmed,
|
|
model: selectedModelRef.current || undefined,
|
|
intentAgentId: intentAgentIdRef.current,
|
|
taskContext: taskContextRef.current,
|
|
clientTurnId,
|
|
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) => {
|
|
// 切换历史会话时,旧 thread 可能仍在流式回复。先终止它并清掉仅属于
|
|
// 上一个会话的“发送中/待澄清”瞬时状态,避免新会话输入框被锁住,
|
|
// 或继续显示旧会话的澄清选择。
|
|
stopIntentStream();
|
|
setIsIntentSending(false);
|
|
setIsIntentInitializing(false);
|
|
setPendingIntentClarificationMsgId(null);
|
|
setIntentClarificationSelections(new Map());
|
|
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;
|
|
},
|
|
[stopIntentStream],
|
|
);
|
|
|
|
const appendLocalMessage = useCallback((msg: InteractiveMessage) => {
|
|
setInteractiveMessages((prev) => [...prev, msg]);
|
|
}, []);
|
|
|
|
const clearInteractiveMessages = useCallback(() => {
|
|
setInteractiveMessages([]);
|
|
setPendingIntentClarificationMsgId(null);
|
|
setIntentClarificationSelections(new Map());
|
|
}, []);
|
|
|
|
const updateIntentReady = useCallback((next: IntentSummary) => {
|
|
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
|
|
setIntentReady(next);
|
|
setPendingIntentClarificationMsgId(null);
|
|
}, []);
|
|
|
|
return {
|
|
intentThreadId,
|
|
intentReady,
|
|
intentInitError,
|
|
intentInitErrorDetail,
|
|
isIntentInitializing,
|
|
isIntentSending,
|
|
pendingIntentClarificationMsgId,
|
|
intentClarificationSelections,
|
|
interactiveMessages,
|
|
timelineEntries,
|
|
setIntentClarificationSelections,
|
|
runStep1Init,
|
|
resetIntent,
|
|
sendIntentMessage,
|
|
submitIntentClarifications,
|
|
updateIntentReady,
|
|
stopIntentStream,
|
|
hydrateFromDraft,
|
|
appendLocalMessage,
|
|
clearInteractiveMessages,
|
|
};
|
|
}
|