/** * 智能体聊天(六步法 / 意图编排 / 专家匹配)的前端 API 封装模块。 * * 主要职责: * - 解析后端 SSE(`event:` / `data:` 行协议),将 POST 流式响应转为异步可迭代的 `{ event, data }`。 * - 把后端 `structured_outputs`、图节点等数据映射为前端卡片类型(与 `./types` 对齐)。 * - 暴露 `runSixStepDeduction`(直连编排流)、`runIntentOrchestrateStream`(意图识别 + 多分支), * 以及 `runExpertSearch` / `runExpertCreate`(单 MCP 同步调用)。 * - 使用分块写入模拟「打字机」效果,使真实接口下的 UI 节奏与 `mockData` 接近。 */ import type { ActorCard, BackendStepResult, BehaviorDriverCard, ExpertCard, ExpertMatchData, MainStepKey, NarrativeCard, PurposeCard, SelectableMcpKey, SixStepMessage, StreamErrorItem, SubStepKey, } from './types'; import type { SixStepMockController } from './mockData'; // ------------------------------------------------------------------ // // SSE:POST + ReadableStream → 异步生成器,逐条产出解析后的事件 // // ------------------------------------------------------------------ // /** 单条 SSE 解析结果:`event` 为事件名,`data` 为已 JSON 解析的负载对象。 */ interface SSEEvent { event: string; data: Record; } /** * 以 POST 发起请求,按 SSE 规范读取响应体并 yield 事件。 * - 缓冲不完整的行,按 `\n` 切分;`event:` 与紧随其后的 `data:` 配对。 * - `data` 行需为合法 JSON;解析失败则静默跳过该行。 * @throws 非 2xx 或响应无 body 时抛出带 HTTP 状态信息的 Error */ async function* readSSE(url: string, body: object, signal: AbortSignal): AsyncGenerator { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal, }); if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buf = ''; let eventName = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { if (line.startsWith('event: ')) { eventName = line.slice(7).trim(); } else if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)) as Record; yield { event: eventName, data }; } catch { // 非 JSON 的 data 行忽略,避免半包或心跳污染中断整条流 } eventName = ''; } } } } // ------------------------------------------------------------------ // // 类型映射:后端列表项 → 前端卡片(字段名与 parent_* 与后端约定一致) // // ------------------------------------------------------------------ // /** 将后端「目的」结构化项转为 `PurposeCard[]`,缺失 id 时用占位 id。 */ function toPurposeCards(items: Record[]): PurposeCard[] { return items.map((it, idx) => ({ id: (it.id as string) || `purpose-${idx + 1}`, index: idx + 1, title: (it.title as string) || '', reason: (it.reason as string) || '', })); } /** 行为体卡片:`parent_purpose_id` 回退到第一个目的或空串,保证 UI 可挂载。 */ function toActorCards(items: Record[], purposes: PurposeCard[]): ActorCard[] { return items.map((it, idx) => ({ id: (it.id as string) || `actor-${idx + 1}`, index: idx + 1, purposeId: (it.parent_purpose_id as string) || purposes[0]?.id || '', title: (it.title as string) || '', content: (it.content as string) || '', })); } /** 预期行为与驱动因素:`parent_actor_id` 关联行为体;字段名与后端 snake_case 对应。 */ function toBehaviorCards(items: Record[], actors: ActorCard[]): BehaviorDriverCard[] { return items.map((it, idx) => ({ id: (it.id as string) || `behavior-${idx + 1}`, index: idx + 1, actorId: (it.parent_actor_id as string) || actors[0]?.id || '', expectedBehavior: (it.expected_behavior as string) || '', driver: (it.driving_factor as string) || '', })); } /** 叙事卡片:`parent_factor_id` 关联上一步的驱动因素节点。 */ function toNarrativeCards(items: Record[], behaviors: BehaviorDriverCard[]): NarrativeCard[] { return items.map((it, idx) => ({ id: (it.id as string) || `narrative-${idx + 1}`, index: idx + 1, driverId: (it.parent_factor_id as string) || behaviors[0]?.id || '', title: (it.title as string) || '', reason: (it.reason as string) || '', })); } // ------------------------------------------------------------------ // // 打字机模拟:按固定步长追加字符,与 mock 侧流式体验一致 // // ------------------------------------------------------------------ // /** * 将完整文本按小块定时写入 `write`,直到写完或 `isCancelled()` 为 true。 * 用于结构化模式下展示 thinking 与最终汇总,避免界面瞬间跳变。 */ function typeText( text: string, write: (chunk: string) => void, isCancelled: () => boolean, ): Promise { return new Promise((resolve) => { const step = 5; let pos = 0; const tick = () => { if (isCancelled()) { resolve(); return; } pos = Math.min(pos + step, text.length); write(text.slice(0, pos)); if (pos < text.length) { setTimeout(tick, 20); } else { resolve(); } }; tick(); }); } // ------------------------------------------------------------------ // // 结构化模式:根据已收集各阶段卡片拼出一份 Markdown 风格的最终摘要 // // ------------------------------------------------------------------ // /** 从 `message.stages` 抽取各阶段标题/行为描述,生成给用户看的综合结论文案。 */ function buildFinalFromStages(msg: SixStepMessage): string { const purposes = (msg.stages?.purpose?.cards ?? []).map((c) => c.title).join('、'); const actors = (msg.stages?.actor?.cards ?? []).map((c) => c.title).join('、'); const behaviors = (msg.stages?.behavior?.cards ?? []).map((c) => c.expectedBehavior).join('、'); const narratives = (msg.stages?.narrative?.cards ?? []).map((c) => c.title).join('、'); return [ '综合本次 6 步法推演结果,可以得到如下战略要点:\n', purposes ? `1. **目的**:${purposes}。` : '', actors ? `2. **行为体**:${actors}。` : '', behaviors ? `3. **预期行为与驱动因素**:${behaviors}。` : '', narratives ? `4. **叙事**:${narratives}。` : '', '\n后续可基于该推演继续生成传播节奏、渠道分工与内容资产清单。', ] .filter(Boolean) .join('\n'); } // ------------------------------------------------------------------ // // 后端步骤 key → 前端子步骤 `SubStepKey`(编排事件里 key 与 UI 对齐) // // ------------------------------------------------------------------ // /** 编排层 `behavior_factor` 在前端统一为 `behavior`。 */ const KEY_MAP: Record = { purpose: 'purpose', actor: 'actor', behavior_factor: 'behavior', narrative: 'narrative', }; // ------------------------------------------------------------------ // // 主入口:与 mockData.runSixStepDeduction 相同的控制器形态(message/stop/promise) // // ------------------------------------------------------------------ // /** `runSixStepDeduction` / `runIntentOrchestrateStream` 共用的回调选项。 */ export interface RunOptions { /** 任意阶段状态或内容变化时调用;实现方应视为不可变快照触发重渲染。 */ onUpdate: (message: SixStepMessage) => void; /** 整条流正常结束、被取消后的收尾、或出错并写入 message 后调用(具体见各分支)。 */ onComplete?: () => void; } /** * 执行六步法编排(直连 `/api/orchestrate/stream`,无意图分支)。 * - 根据 `requestBody.output_format` 区分结构化卡片流与 Markdown 整段流。 * - 根据 `selected_mcps` 过滤 `mcp_list` 中实际执行的子步骤顺序。 * - 监听 `start` / `step_start` / `delta` / `step_end` / `flow` / `done`,同步 `message` 并 `emit`。 * @returns 与 mock 一致:`stop()` 中止 fetch;`promise` 在流结束后 settle。 */ const backUrl = 'https://ch1.b.uat.4.cn/zncmback'; export function runSixStepDeduction( message: SixStepMessage, options: RunOptions, requestBody?: Record, ): SixStepMockController { // 就地更新传入的 `message`;emit 时对 `stages` 做浅拷贝,便于依赖该引用的 UI 刷新。 const abortCtrl = new AbortController(); let cancelled = false; const outputFormat = (requestBody?.output_format as string) || 'structured'; const isMarkdown = outputFormat === 'markdown'; message.outputFormat = outputFormat as 'structured' | 'markdown'; const stop = () => { cancelled = true; abortCtrl.abort(); }; const emit = () => { if (cancelled) return; options.onUpdate({ ...message, stages: { ...message.stages } }); }; const setMain = (key: MainStepKey, status: 'pending' | 'running' | 'done') => { message.mainStepStatus = { ...(message.mainStepStatus ?? { info: 'pending', sixstep: 'pending', final: 'pending' }), [key]: status, }; if (status === 'running') message.currentMainStep = key; }; const setSub = (key: SubStepKey, status: 'pending' | 'running' | 'done') => { message.subStepStatus = { ...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }), [key]: status, }; if (status === 'running') message.currentSubStep = key; }; const promise = (async () => { setMain('info', 'running'); emit(); const userInput = (requestBody?.user_input as string) || ''; const selectedMcps = (requestBody?.selected_mcps as SelectableMcpKey[] | undefined); const FIXED_ORDER: SelectableMcpKey[] = ['purpose', 'actor', 'behavior_factor', 'narrative']; const filteredMain = selectedMcps && selectedMcps.length > 0 ? FIXED_ORDER.filter((key) => selectedMcps.includes(key)) : FIXED_ORDER; const mcpList = ['info_prepare', ...filteredMain]; const body = { user_input: userInput, mcp_list: mcpList, output_format: outputFormat, max_items: 3, ...requestBody, }; let purposeCards: PurposeCard[] = []; let actorCards: ActorCard[] = []; let behaviorCards: BehaviorDriverCard[] = []; /** 当前子步骤开始时 `markdownContent` 的长度;用于判断本步是否收到过 `delta`,避免与 `step_end` 重复拼接。 */ let markdownLenAtStepStart = 0; let completed = false; try { for await (const { event, data } of readSSE(`${backUrl}/api/orchestrate/stream`, body, abortCtrl.signal)) { if (cancelled) break; // ---- start:流已建立(尚未必有步骤数据)---- if (event === 'start') { // 保持连接感知;若链中无 info_prepare,后续仍可能仅靠其它事件推进,此处仅做一次 emit。 emit(); } // ---- step_start:某 MCP 子步骤开始 ---- if (event === 'step_start') { const backendKey = data.key as string; if (backendKey === 'info_prepare') { message.stages = { ...message.stages, info: { content: '', complete: false }, }; emit(); } else { const key = KEY_MAP[backendKey]; if (key) { if (message.mainStepStatus?.info !== 'done') { setMain('info', 'done'); } if (message.mainStepStatus?.sixstep !== 'running') { setMain('sixstep', 'running'); } setSub(key, 'running'); markdownLenAtStepStart = message.markdownContent?.length ?? 0; if (!isMarkdown) { message.stages = { ...message.stages, [key]: { thinking: '', cards: [], complete: false }, }; } emit(); } } } // ---- delta:Markdown 模式下后端按片段推送正文 ---- if (event === 'delta' && isMarkdown) { const text = (data.text as string) || ''; if (text) { message.markdownContent = (message.markdownContent || '') + text; emit(); } } // ---- step_end:单步结束,携带 thinking / structured_outputs / answer 等 ---- if (event === 'step_end') { const backendKey = data.key as string; // 信息准备阶段:合并 answer 与 thinking 作为 info 区展示内容 if (backendKey === 'info_prepare') { const answer = (data.answer as string) || (data.text as string) || ''; const thinking = (data.thinking as string) || ''; const content = answer || thinking; message.stages = { ...message.stages, info: { content, complete: true }, }; if (message.mainStepStatus?.info !== 'done') { setMain('info', 'done'); } emit(); continue; } const key = KEY_MAP[backendKey]; if (!key) continue; if (isMarkdown) { // 若本步未收到 delta,则从 step_end 的 answer/text/thinking 补一段,避免空白步 const gotDeltasForStep = (message.markdownContent?.length ?? 0) > markdownLenAtStepStart; if (!gotDeltasForStep) { const answer = (data.answer as string) || (data.text as string) || (data.thinking as string) || ''; if (answer) { const sep = message.markdownContent ? '\n\n' : ''; message.markdownContent = (message.markdownContent || '') + sep + answer; } } setSub(key, 'done'); message.stages = { ...message.stages, [key]: { thinking: '', cards: [], complete: true }, }; emit(); } else { // 结构化模式:先打字机展示 thinking,再逐张追加卡片(与 mock 节奏一致) const thinking = (data.thinking as string) || ''; const rawItems = (data.structured_outputs as Record[]) || []; await typeText( thinking, (chunk) => { message.stages = { ...message.stages, [key]: { ...(message.stages?.[key] as object), thinking: chunk }, }; emit(); }, () => cancelled, ); if (cancelled) break; let cards: PurposeCard[] | ActorCard[] | BehaviorDriverCard[] | NarrativeCard[] = []; if (key === 'purpose') { cards = purposeCards = toPurposeCards(rawItems); } else if (key === 'actor') { cards = actorCards = toActorCards(rawItems, purposeCards); } else if (key === 'behavior') { cards = behaviorCards = toBehaviorCards(rawItems, actorCards); } else if (key === 'narrative') { cards = toNarrativeCards(rawItems, behaviorCards); } for (const card of cards) { if (cancelled) break; const existing = (message.stages?.[key]?.cards ?? []) as typeof cards; message.stages = { ...message.stages, [key]: { ...(message.stages?.[key] as object), cards: [...existing, card], }, }; emit(); await new Promise((r) => setTimeout(r, 240)); } if (cancelled) break; message.stages = { ...message.stages, [key]: { ...(message.stages?.[key] as object), complete: true }, }; setSub(key, 'done'); emit(); } } // ---- flow:后端在全部步骤后下发完整图;用于补全未单独收到 step_end 的阶段(如部分 MCP 被跳过)---- if (event === 'flow' && !isMarkdown) { const nodes = (data.nodes as Array>) || []; const itemsOf = (type: string) => nodes .filter((n) => n.type === type) .map((n) => (n.data as Record) || {}); const flowPurposes = toPurposeCards(itemsOf('purpose')); const flowActors = toActorCards(itemsOf('actor'), flowPurposes); const flowBehaviors = toBehaviorCards(itemsOf('behavior'), flowActors); const flowNarratives = toNarrativeCards(itemsOf('narrative'), flowBehaviors); const fillStage = ( key: K, cards: PurposeCard[] | ActorCard[] | BehaviorDriverCard[] | NarrativeCard[], ) => { const existing = (message.stages?.[key]?.cards ?? []) as typeof cards; if (existing.length === 0 && cards.length > 0) { message.stages = { ...message.stages, [key]: { thinking: '', cards, complete: true }, }; if (key === 'purpose') purposeCards = cards as PurposeCard[]; if (key === 'actor') actorCards = cards as ActorCard[]; if (key === 'behavior') behaviorCards = cards as BehaviorDriverCard[]; } }; fillStage('purpose', flowPurposes); fillStage('actor', flowActors); fillStage('behavior', flowBehaviors); fillStage('narrative', flowNarratives); emit(); } // ---- done:整条编排结束 ---- if (event === 'done') { if (cancelled) break; // 强制各子步骤为 done,避免漏事件导致进度条/沙盘卡在中间态 if (message.mainStepStatus?.info !== 'done') setMain('info', 'done'); message.subStepStatus = { purpose: 'done', actor: 'done', behavior: 'done', narrative: 'done', }; setMain('sixstep', 'done'); if (isMarkdown) { // Markdown 无单独「最终答案」主步骤,直接标记 final 完成 setMain('final', 'done'); message.currentMainStep = undefined; message.currentSubStep = undefined; emit(); completed = true; options.onComplete?.(); } else { setMain('final', 'running'); emit(); const finalText = buildFinalFromStages(message); await typeText( finalText, (chunk) => { message.finalAnswer = chunk; emit(); }, () => cancelled, ); if (!cancelled) { setMain('final', 'done'); message.currentMainStep = undefined; message.currentSubStep = undefined; emit(); completed = true; options.onComplete?.(); } } } } if (!completed && !cancelled) { options.onComplete?.(); } } catch (err) { if (!cancelled) { const errMsg = err instanceof Error ? err.message : '网络错误,请检查后端服务是否正常运行'; const networkErrItem: StreamErrorItem = { errorId: `local-${Date.now()}`, level: 'error', source: 'network', message: `接口请求失败:${errMsg}`, recoverable: false, createdAt: Date.now(), }; message.errors = [...(message.errors ?? []), networkErrItem]; if (isMarkdown) { message.markdownContent = (message.markdownContent || '') + `\n\n⚠️ 接口请求失败:${errMsg}`; } else { message.finalAnswer = `⚠️ 接口请求失败:${errMsg}`; } options.onUpdate({ ...message }); options.onComplete?.(); } } })(); return { message, stop, promise }; } // ------------------------------------------------------------------ // // 专家:通过 /invoke 同步调用单个 MCP(查找 / 创建),非 SSE // // ------------------------------------------------------------------ // /** agents 下各 MCP 的 invoke 接口(如 find_expert、create_expert)返回体的常用字段子集(与后端约定,字段可选)。 */ interface InvokeResponse { status?: string; thinking?: string; structured_outputs?: Array>; markdown?: string; references?: Array>; duration_ms?: number; } /** * 将 invoke 返回的 `structured_outputs` 与 `references` 交叉解析为 `ExpertCard[]`。 * - `item.references` 可为 id 字符串或下标,用于定位 `references` 中的详情与 `raw`(含 avatar 等)。 */ const buildExpertCards = (resp: InvokeResponse): ExpertCard[] => { const items = resp.structured_outputs ?? []; const refs = resp.references ?? []; const refById = new Map(refs.map((ref) => [ref.id as string | undefined, ref])); return items.map((item, idx) => { const itemRefs = Array.isArray(item.references) ? item.references : []; const firstRefToken = itemRefs[0]; const firstRef = typeof firstRefToken === 'string' ? refById.get(firstRefToken) : typeof firstRefToken === 'number' ? refs[firstRefToken] : refs[idx]; const raw = (firstRef?.raw as Record | undefined) ?? undefined; const rawId = raw?.id; const backendId = rawId === undefined || rawId === null ? undefined : String(rawId); const id = (item.id as string) || (firstRef?.id as string) || `expert-${Date.now()}-${idx}`; const avatarUrl = (raw?.avatar_url as string | undefined) || undefined; const title = (firstRef?.title as string | undefined) || undefined; return { id, backendId, name: (item.name as string) || title || '未命名专家', detail: (item.detail as string) || (firstRef?.snippet as string) || '', avatarUrl, title, tags: [], isCustom: false, }; }); }; /** 专家查找/创建流程的 UI 回传选项;`onComplete` 可拿到最终 `ExpertMatchData`。 */ export interface ExpertRunOptions { onUpdate: (data: ExpertMatchData) => void; onComplete?: (data: ExpertMatchData) => void; } /** 与六步法控制器类似:`stop` 中止请求,`promise` 在结束后 settle。 */ export interface ExpertRunController { stop: () => void; promise: Promise; } /** * 先打字机展示 `thinking`,再一次性写入专家列表,最后逐张「展开」专家卡片(动画节奏)。 * @param appendMode 为 true 时在已有 thinking 后拼接新一段(用于连续说明场景)。 */ const animateExpertResponse = async ( current: ExpertMatchData, resp: InvokeResponse, isCancelled: () => boolean, onUpdate: (data: ExpertMatchData) => void, appendMode = false, ): Promise => { let next: ExpertMatchData = appendMode ? current : { ...current, status: current.status, thinking: '', experts: [] }; onUpdate(next); const thinking = (resp.thinking as string) || ''; if (thinking) { await new Promise((resolve) => { const step = 6; let pos = 0; const tick = () => { if (isCancelled()) { resolve(); return; } pos = Math.min(pos + step, thinking.length); const composed = appendMode ? `${current.thinking ? current.thinking + '\n\n' : ''}${thinking.slice(0, pos)}` : thinking.slice(0, pos); next = { ...next, thinking: composed }; onUpdate(next); if (pos < thinking.length) setTimeout(tick, 18); else resolve(); }; tick(); }); } if (isCancelled()) return next; const newCards = buildExpertCards(resp); next = { ...next, experts: newCards, status: 'ready', searchedAt: Date.now() }; onUpdate(next); for (let idx = 1; idx <= newCards.length; idx += 1) { if (isCancelled()) return next; next = { ...next, experts: newCards.slice(0, idx) }; onUpdate(next); await new Promise((r) => setTimeout(r, 180)); } return next; }; /** * 调用 `find_expert` MCP:根据用户描述检索专家候选。 * - `exclude_ids` 通过 `context_list.exclude_expert_ids` 传给后端,避免重复推荐。 */ export function runExpertSearch( baseData: ExpertMatchData, body: { user_input: string; exclude_ids?: string[]; conversation_history?: unknown[] }, options: ExpertRunOptions, ): ExpertRunController { const abortCtrl = new AbortController(); let cancelled = false; const stop = () => { cancelled = true; abortCtrl.abort(); }; const promise = (async () => { let next: ExpertMatchData = { ...baseData, status: 'searching', thinking: '', experts: [], errorMessage: undefined, }; options.onUpdate(next); try { const resp = await fetch(`${backUrl}/api/agents/find_expert/invoke`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_input: body.user_input, output_format: 'structured', max_items: 3, context_list: body.exclude_ids && body.exclude_ids.length > 0 ? [{ key: 'exclude_expert_ids', value: body.exclude_ids.join(',') }] : undefined, }), signal: abortCtrl.signal, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = (await resp.json()) as InvokeResponse; if (cancelled) return; next = await animateExpertResponse(next, data, () => cancelled, options.onUpdate, false); if (cancelled) return; options.onComplete?.(next); } catch (err) { if (cancelled) return; const msg = err instanceof Error ? err.message : '查找专家失败'; next = { ...next, status: 'error', errorMessage: msg }; options.onUpdate(next); options.onComplete?.(next); } })(); return { stop, promise }; } // ------------------------------------------------------------------ // // 意图编排 SSE:在六步法事件之上扩展 intent / 专家 / 澄清 / 恢复 / 重跑等 // // ------------------------------------------------------------------ // /** * `runIntentOrchestrateStream` 请求体:与后端意图路由、断点续跑、专家上下文等字段对齐。 */ export interface IntentOrchestrateRequest { /** 用户当前轮输入(主问题或补充说明)。 */ user_input: string; /** 输出形态:结构化卡片链或整段 Markdown。 */ output_format?: 'structured' | 'markdown'; /** 各 MCP 结构化项数量上限,默认 3。 */ max_items?: number; /** 可选模型名,透传后端。 */ model?: string; /** 意图路由策略:自动、强制六步法、强制找专家。 */ intent_mode?: 'auto' | 'force_orchestrate' | 'force_find_expert'; /** 前端会话阶段(如 idle / expert-match),供后端恢复上下文。 */ current_phase?: string; /** 用户已选中的专家快照,可为 null。 */ selected_expert?: Record | null; /** 用户勾选的主线 MCP 子步骤 key 列表(与编排层一致)。 */ selected_mcps?: string[]; /** 已完成的各步原始结果,用于续跑或对比。 */ last_steps?: Record[]; /** 从某后端步骤 key 恢复执行时为非 null。 */ resume_from_step?: string | null; /** 当前轮专家候选(SSE expert_end 等回灌)。 */ expert_candidates?: Record[]; /** 多轮对话历史,格式由前后端约定。 */ conversation_history?: unknown[]; } /** SSE `expert_end` 等设备推送的简化专家项 → `ExpertCard`(无 references 交叉时的轻量映射)。 */ function buildExpertCardsFromSSE(items: Record[]): ExpertCard[] { return items.map((item, idx) => ({ id: (item.id as string) || `expert-sse-${Date.now()}-${idx}`, backendId: (item.id as string) || undefined, name: (item.name as string) || '未命名专家', detail: (item.detail as string) || '', avatarUrl: undefined, tags: [], isCustom: false, })); } /** * 带意图识别的编排流:仍连接 `/api/orchestrate/stream`,但事件集包含 `intent`、`expert_*`、`clarify`、`resume`、`rerun`、`change_steps`、`edit_apply` 等。 * - 根据 `intent.name` 设置 `message.phase`、内部 `flow` 分支,决定是否在 `done` 时走六步法收尾逻辑。 * - 六步法子逻辑与 `runSixStepDeduction` 对齐:`step_start` / `delta` / `step_end` / `flow` / `done` / `error`。 * - `follow_up` 意图下会按 Markdown 追加 `markdownContent`,并在 `done` 时直接完成不拼结构化 final。 */ export function runIntentOrchestrateStream( message: SixStepMessage, options: RunOptions, requestBody: IntentOrchestrateRequest, ): SixStepMockController { // 与 `runSixStepDeduction` 相同:就地更新 `message`,通过浅拷贝 `stages` 触发订阅方更新。 const abortCtrl = new AbortController(); let cancelled = false; const outputFormat = requestBody.output_format ?? 'structured'; const isMarkdown = outputFormat === 'markdown'; message.outputFormat = outputFormat as 'structured' | 'markdown'; const stop = () => { cancelled = true; abortCtrl.abort(); }; const emit = () => { if (cancelled) return; options.onUpdate({ ...message, stages: { ...message.stages } }); }; const setMain = (key: MainStepKey, status: 'pending' | 'running' | 'done') => { message.mainStepStatus = { ...(message.mainStepStatus ?? { info: 'pending', sixstep: 'pending', final: 'pending' }), [key]: status, }; if (status === 'running') message.currentMainStep = key; }; const setSub = (key: SubStepKey, status: 'pending' | 'running' | 'done') => { message.subStepStatus = { ...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }), [key]: status, }; if (status === 'running') message.currentSubStep = key; }; const promise = (async () => { /** 当前会话主线:影响 `done` 时是否走六步法收尾、错误写到哪里等。 */ let flow: 'unknown' | 'expert' | 'orchestrate' | 'clarify' | 'follow_up' = 'unknown'; /** 是否为 resume / rerun / change_steps / edit_apply 之后的续流,用于 `start` 时是否清空 stages。 */ let isResume = false; let purposeCards: PurposeCard[] = []; let actorCards: ActorCard[] = []; let behaviorCards: BehaviorDriverCard[] = []; /** 当前子步骤开始时 Markdown 累计长度,用于与 `runSixStepDeduction` 相同的 delta / step_end 去重逻辑。 */ let markdownLenAtStepStart = 0; let completed = false; const body: Record = { user_input: requestBody.user_input, output_format: outputFormat, max_items: requestBody.max_items ?? 3, intent_mode: requestBody.intent_mode ?? 'auto', current_phase: requestBody.current_phase ?? 'idle', selected_expert: requestBody.selected_expert ?? null, selected_mcps: requestBody.selected_mcps ?? [], last_steps: requestBody.last_steps ?? [], resume_from_step: requestBody.resume_from_step ?? null, expert_candidates: requestBody.expert_candidates ?? [], conversation_history: requestBody.conversation_history ?? [], }; if (requestBody.model) body.model = requestBody.model; try { for await (const { event, data } of readSSE(`${backUrl}/api/orchestrate/stream`, body, abortCtrl.signal)) { if (cancelled) break; // ---- intent:后端识别用户意图,驱动 phase / flow ---- if (event === 'intent') { const intentName = data.intent as string; const intentSlots = (data.slots as Record) ?? {}; message.intent = { name: intentName, confidence: data.confidence as number | undefined, reason: data.reason as string | undefined, }; if (intentName === 'find_expert' || intentName === 'reselect_expert') { flow = 'expert'; message.phase = 'expert-match'; } else if (intentName === 'follow_up') { flow = 'follow_up'; message.phase = 'follow-up'; message.outputFormat = 'markdown'; } else if (intentName === 'unknown') { flow = 'clarify'; message.phase = 'clarifying'; } else if (intentName === 'revise_input_continue') { // 修改问题后重新分析:清空已有分析内容,记录新问题 flow = 'orchestrate'; const newInput = intentSlots.new_input as string | undefined; if (newInput) message.effectiveUserInput = newInput; // 提前清空旧阶段数据,避免短暂显示旧内容 message.stages = {}; message.rawSteps = []; message.subStepStatus = { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }; message.mainStepStatus = { info: 'pending', sixstep: 'pending', final: 'pending' }; purposeCards = []; actorCards = []; behaviorCards = []; } else if (intentName === 'change_steps_continue') { // 修改步骤选择后继续:等 change_steps 事件到来时再做清理 flow = 'orchestrate'; } else if (intentName === 'edit_stage_continue') { // 修改卡片内容后继续:等 edit_apply 事件到来时再做清理 flow = 'orchestrate'; } else { flow = 'orchestrate'; } emit(); } // ---- expert_start:进入专家检索 UI(与 intent 中 find_expert 类同)---- if (event === 'expert_start') { flow = 'expert'; message.phase = 'expert-match'; message.expertMatch = { status: 'searching', thinking: '', experts: [] }; emit(); } // ---- expert_end:推送专家列表与 thinking,并做卡片逐张展开动画 ---- if (event === 'expert_end') { const rawExperts = (data.experts as Record[]) ?? []; const thinking = (data.thinking as string) ?? ''; const expertCards = buildExpertCardsFromSSE(rawExperts); message.expertMatch = { status: 'ready', thinking, experts: [] }; emit(); for (let i = 1; i <= expertCards.length; i++) { if (cancelled) break; message.expertMatch = { ...message.expertMatch!, experts: expertCards.slice(0, i) }; emit(); await new Promise((r) => setTimeout(r, 180)); } } // ---- clarify:需要用户补充说明时展示 clarifyQuestion ---- if (event === 'clarify') { message.phase = 'clarifying'; message.clarifyQuestion = (data.message as string) ?? '请补充说明你的意图。'; emit(); } // ---- resume:从某后端步骤恢复,同步内存中的 purpose/actor/behavior 卡片缓存 ---- if (event === 'resume') { isResume = true; const resumeFrom = data.resume_from as string | undefined; message.resumedFrom = resumeFrom; if (resumeFrom) { const resumeKey = KEY_MAP[resumeFrom]; if (resumeKey) message.currentSubStep = resumeKey; } // 从已保留的 stages 中恢复卡片数组,确保继续执行时 parent ID fallback 正确 if (message.stages?.purpose?.cards) purposeCards = message.stages.purpose.cards as PurposeCard[]; if (message.stages?.actor?.cards) actorCards = message.stages.actor.cards as ActorCard[]; if (message.stages?.behavior?.cards) behaviorCards = message.stages.behavior.cards as BehaviorDriverCard[]; emit(); } // ---- rerun (rerun_from_step:清空受影响阶段,保留之前阶段) ---- if (event === 'rerun') { isResume = true; const fromStep = data.from_step as string; const affectedSteps = (data.affected_steps as string[]) ?? []; const affectedKeySet = new Set(affectedSteps); // 清空受影响阶段的 stages 和 subStepStatus const updatedStages = { ...message.stages }; const updatedSubStatus = { ...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }) }; for (const backendKey of affectedSteps) { const frontendKey = KEY_MAP[backendKey]; if (frontendKey) { delete updatedStages[frontendKey]; updatedSubStatus[frontendKey] = 'pending'; } } message.stages = updatedStages; message.subStepStatus = updatedSubStatus; // 从 rawSteps 中移除受影响步骤的旧数据 if (message.rawSteps) { message.rawSteps = message.rawSteps.filter((s) => !affectedKeySet.has(s.key)); } // 重置受影响步骤之后的卡片数组 if (affectedKeySet.has('purpose')) { purposeCards = []; actorCards = []; behaviorCards = []; } else if (affectedKeySet.has('actor')) { actorCards = []; behaviorCards = []; } else if (affectedKeySet.has('behavior_factor')) { behaviorCards = []; } message.resumedFrom = fromStep; if (fromStep) { const resumeKey = KEY_MAP[fromStep]; if (resumeKey) message.currentSubStep = resumeKey; } emit(); } // ---- change_steps (change_steps_continue:清空被移除步骤,保留其余) ---- if (event === 'change_steps') { isResume = true; const removedSteps = (data.removed_steps as string[]) ?? []; const removedKeySet = new Set(removedSteps); const updatedStages = { ...message.stages }; const updatedSubStatus = { ...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }), }; for (const backendKey of removedSteps) { const frontendKey = KEY_MAP[backendKey]; if (frontendKey) { delete updatedStages[frontendKey]; updatedSubStatus[frontendKey] = 'pending'; } } message.stages = updatedStages; message.subStepStatus = updatedSubStatus; if (message.rawSteps) { message.rawSteps = message.rawSteps.filter((s) => !removedKeySet.has(s.key)); } // 重置受影响的卡片数组 if (removedKeySet.has('purpose')) { purposeCards = []; actorCards = []; behaviorCards = []; } else if (removedKeySet.has('actor')) { actorCards = []; behaviorCards = []; } else if (removedKeySet.has('behavior_factor')) { behaviorCards = []; } // 恢复保留步骤的卡片数组,确保后续步骤 parent ID fallback 正确 if (message.stages?.purpose?.cards) purposeCards = message.stages.purpose.cards as PurposeCard[]; if (message.stages?.actor?.cards) actorCards = message.stages.actor.cards as ActorCard[]; if (message.stages?.behavior?.cards) behaviorCards = message.stages.behavior.cards as BehaviorDriverCard[]; emit(); } // ---- edit_apply (edit_stage_continue:更新卡片内容,清空受影响后续步骤) ---- if (event === 'edit_apply') { isResume = true; const editedStep = data.edited_step as string; const editedIndex = data.edited_index as number; const newContent = data.new_content as string; const field = (data.field as string) ?? 'title'; const affectedSteps = (data.affected_steps as string[]) ?? []; const affectedKeySet = new Set(affectedSteps); // 更新被修改步骤中对应卡片的内容 const editedFrontendKey = KEY_MAP[editedStep]; if (editedFrontendKey && message.stages?.[editedFrontendKey]) { const stageData = message.stages[editedFrontendKey]; const cards = [...(stageData?.cards ?? [])]; if (editedIndex < cards.length) { const card = cards[editedIndex]; let updatedCard: typeof card; if (editedFrontendKey === 'behavior') { updatedCard = { ...card, expectedBehavior: newContent } as typeof card; } else { updatedCard = { ...card, title: newContent } as typeof card; } cards[editedIndex] = updatedCard; message.stages = { ...message.stages, [editedFrontendKey]: { ...stageData, cards }, }; } } // 同步更新 rawSteps 中对应步骤的 structured_outputs if (message.rawSteps) { message.rawSteps = message.rawSteps.map((s) => { if (s.key !== editedStep) return s; const outputs = [...(s.structured_outputs ?? [])]; if (editedIndex < outputs.length) { outputs[editedIndex] = { ...outputs[editedIndex], [field]: newContent }; } return { ...s, structured_outputs: outputs }; }); } // 清空受影响的后续步骤 const updatedStages = { ...message.stages }; const updatedSubStatus = { ...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }), }; for (const backendKey of affectedSteps) { const frontendKey = KEY_MAP[backendKey]; if (frontendKey) { delete updatedStages[frontendKey]; updatedSubStatus[frontendKey] = 'pending'; } } message.stages = updatedStages; message.subStepStatus = updatedSubStatus; if (message.rawSteps) { message.rawSteps = message.rawSteps.filter((s) => !affectedKeySet.has(s.key)); } // 重置受影响步骤对应的卡片数组 if (affectedKeySet.has('purpose')) { purposeCards = []; actorCards = []; behaviorCards = []; } else if (affectedKeySet.has('actor')) { actorCards = []; behaviorCards = []; } else if (affectedKeySet.has('behavior_factor')) { behaviorCards = []; } // 恢复保留步骤的卡片数组(包含刚修改的那个步骤) if (message.stages?.purpose?.cards) purposeCards = message.stages.purpose.cards as PurposeCard[]; if (message.stages?.actor?.cards) actorCards = message.stages.actor.cards as ActorCard[]; if (message.stages?.behavior?.cards) behaviorCards = message.stages.behavior.cards as BehaviorDriverCard[]; emit(); } // ---- start(编排):非续跑时重置各阶段;续跑时保留已有 stages ---- if (event === 'start') { if (!isResume) { message.stages = {}; message.subStepStatus = { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }; message.mainStepStatus = { info: 'pending', sixstep: 'pending', final: 'pending' }; } if (flow === 'orchestrate' || flow === 'unknown') setMain('info', 'running'); emit(); } // ---- step_start(编排):与纯六步法相同,含 info_prepare 与各 KEY_MAP 子步骤 ---- if (event === 'step_start') { const backendKey = data.key as string; if (backendKey === 'info_prepare') { message.stages = { ...message.stages, info: { content: '', complete: false } }; emit(); } else { const key = KEY_MAP[backendKey]; if (key) { if (message.mainStepStatus?.info !== 'done') setMain('info', 'done'); if (message.mainStepStatus?.sixstep !== 'running') setMain('sixstep', 'running'); setSub(key, 'running'); markdownLenAtStepStart = message.markdownContent?.length ?? 0; if (!isMarkdown) { message.stages = { ...message.stages, [key]: { thinking: '', cards: [], complete: false } }; } emit(); } } } // ---- delta:Markdown 模式或追问 follow_up 下拼接 markdownContent ---- if (event === 'delta' && (isMarkdown || flow === 'follow_up')) { const text = (data.text as string) ?? ''; if (text) { message.markdownContent = (message.markdownContent ?? '') + text; emit(); } } // ---- step_end(编排):写入 rawSteps;结构化/Markdown 分支与纯六步法一致 ---- if (event === 'step_end') { const backendKey = data.key as string; if (backendKey === 'info_prepare') { const answer = (data.answer as string) ?? (data.text as string) ?? ''; const thinking = (data.thinking as string) ?? ''; message.stages = { ...message.stages, info: { content: answer || thinking, complete: true } }; if (message.mainStepStatus?.info !== 'done') setMain('info', 'done'); emit(); continue; } const key = KEY_MAP[backendKey]; if (!key) continue; const rawStep: BackendStepResult = { key: backendKey, name: (data.name as string) ?? backendKey, status: (data.status as string) ?? 'completed', thinking: (data.thinking as string) ?? '', structured_outputs: (data.structured_outputs as Record[]) ?? [], markdown: (data.markdown as string) ?? '', references: (data.references as Record[]) ?? [], }; message.rawSteps = [...(message.rawSteps ?? []), rawStep]; if (isMarkdown) { const gotDeltas = (message.markdownContent?.length ?? 0) > markdownLenAtStepStart; if (!gotDeltas) { const answer = (data.answer as string) ?? (data.text as string) ?? (data.thinking as string) ?? ''; if (answer) { message.markdownContent = (message.markdownContent ?? '') + (message.markdownContent ? '\n\n' : '') + answer; } } setSub(key, 'done'); message.stages = { ...message.stages, [key]: { thinking: '', cards: [], complete: true } }; emit(); } else { const thinking = (data.thinking as string) ?? ''; const rawItems = (data.structured_outputs as Record[]) ?? []; await typeText(thinking, (chunk) => { message.stages = { ...message.stages, [key]: { ...(message.stages?.[key] as object), thinking: chunk } }; emit(); }, () => cancelled); if (cancelled) break; let cards: PurposeCard[] | ActorCard[] | BehaviorDriverCard[] | NarrativeCard[] = []; if (key === 'purpose') cards = purposeCards = toPurposeCards(rawItems); else if (key === 'actor') cards = actorCards = toActorCards(rawItems, purposeCards); else if (key === 'behavior') cards = behaviorCards = toBehaviorCards(rawItems, actorCards); else if (key === 'narrative') cards = toNarrativeCards(rawItems, behaviorCards); for (const card of cards) { if (cancelled) break; const existing = (message.stages?.[key]?.cards ?? []) as typeof cards; message.stages = { ...message.stages, [key]: { ...(message.stages?.[key] as object), cards: [...existing, card] } }; emit(); await new Promise((r) => setTimeout(r, 240)); } if (cancelled) break; message.stages = { ...message.stages, [key]: { ...(message.stages?.[key] as object), complete: true } }; setSub(key, 'done'); emit(); } } // ---- flow(编排):仅结构化;用全图节点补全空阶段 ---- if (event === 'flow' && !isMarkdown) { const nodes = (data.nodes as Array>) ?? []; const itemsOf = (type: string) => nodes.filter((n) => n.type === type).map((n) => (n.data as Record) ?? {}); const fp = toPurposeCards(itemsOf('purpose')); const fa = toActorCards(itemsOf('actor'), fp); const fb = toBehaviorCards(itemsOf('behavior'), fa); const fn = toNarrativeCards(itemsOf('narrative'), fb); const fillStage = (k: K, cards: PurposeCard[] | ActorCard[] | BehaviorDriverCard[] | NarrativeCard[]) => { if ((message.stages?.[k]?.cards ?? []).length === 0 && cards.length > 0) { message.stages = { ...message.stages, [k]: { thinking: '', cards, complete: true } }; if (k === 'purpose') purposeCards = cards as PurposeCard[]; if (k === 'actor') actorCards = cards as ActorCard[]; if (k === 'behavior') behaviorCards = cards as BehaviorDriverCard[]; } }; fillStage('purpose', fp); fillStage('actor', fa); fillStage('behavior', fb); fillStage('narrative', fn); emit(); } // ---- done:专家/澄清/追问分支提前结束;否则走六步法 final 与 phase=completed ---- if (event === 'done') { if (cancelled) break; if (flow === 'expert' || flow === 'clarify' || flow === 'follow_up') { if (flow === 'follow_up') message.phase = 'completed'; emit(); completed = true; options.onComplete?.(); break; } if (message.mainStepStatus?.info !== 'done') setMain('info', 'done'); message.subStepStatus = { purpose: 'done', actor: 'done', behavior: 'done', narrative: 'done' }; setMain('sixstep', 'done'); if (isMarkdown) { setMain('final', 'done'); message.currentMainStep = undefined; message.currentSubStep = undefined; message.phase = 'completed'; emit(); completed = true; options.onComplete?.(); } else { setMain('final', 'running'); emit(); const finalText = buildFinalFromStages(message); await typeText(finalText, (chunk) => { message.finalAnswer = chunk; emit(); }, () => cancelled); if (!cancelled) { setMain('final', 'done'); message.currentMainStep = undefined; message.currentSubStep = undefined; message.phase = 'completed'; emit(); completed = true; options.onComplete?.(); } } } // ---- error_log:可恢复错误(skill 失败、步骤失败)追加到 message.errors ---- if (event === 'error_log') { const errItem: StreamErrorItem = { errorId: (data.error_id as string) ?? `local-${Date.now()}`, taskId: data.task_id as string | undefined, level: (data.level as StreamErrorItem['level']) ?? 'error', source: (data.source as StreamErrorItem['source']) ?? 'mcp', mcpKey: data.mcp_key as string | undefined, stepName: data.step_name as string | undefined, skillKey: data.skill_key as string | undefined, skillName: data.skill_name as string | undefined, message: (data.message as string) ?? '未知错误', recoverable: data.recoverable as boolean | undefined, createdAt: (data.ts as number) ?? Date.now(), }; message.errors = [...(message.errors ?? []), errItem]; emit(); } // ---- error:致命错误;专家流写入 expertMatch;否则写入 finalAnswer ---- if (event === 'error') { const errMsg = (data.message as string) ?? '推演出错'; const errItem: StreamErrorItem = { errorId: (data.error_id as string) ?? `local-${Date.now()}`, taskId: data.task_id as string | undefined, level: 'error', source: (data.source as StreamErrorItem['source']) ?? 'orchestrator', message: errMsg, recoverable: data.recoverable as boolean | undefined, createdAt: Date.now(), }; message.errors = [...(message.errors ?? []), errItem]; if (flow === 'expert') { message.expertMatch = { ...(message.expertMatch ?? { thinking: '', experts: [] }), status: 'error', errorMessage: errMsg, }; } else if (flow === 'follow_up') { message.markdownContent = (message.markdownContent ?? '') + `\n\n⚠️ ${errMsg}`; } else { message.finalAnswer = `⚠️ ${errMsg}`; } emit(); } } if (!completed && !cancelled) options.onComplete?.(); } catch (err) { if (!cancelled) { const errMsg = err instanceof Error ? err.message : '网络错误,请检查后端服务是否正常运行'; const networkErrItem: StreamErrorItem = { errorId: `local-${Date.now()}`, level: 'error', source: 'network', message: `接口请求失败:${errMsg}`, recoverable: false, createdAt: Date.now(), }; message.errors = [...(message.errors ?? []), networkErrItem]; if (flow === 'expert') { message.expertMatch = { ...(message.expertMatch ?? { thinking: '', experts: [] }), status: 'error', errorMessage: errMsg, }; } else if (isMarkdown || flow === 'follow_up') { message.markdownContent = (message.markdownContent ?? '') + `\n\n⚠️ 接口请求失败:${errMsg}`; } else { message.finalAnswer = `⚠️ 接口请求失败:${errMsg}`; } options.onUpdate({ ...message }); options.onComplete?.(); } } })(); return { message, stop, promise }; } /** * 调用 `create_expert` MCP:根据描述生成自定义专家卡片并追加到当前列表,默认选中新创建的第一位。 * - 成功后将 `isCustom: true`;thinking 会与既有 thinking 用「— 创建专家 —」分段拼接。 */ export function runExpertCreate( baseData: ExpertMatchData, body: { user_input: string; conversation_history?: unknown[] }, options: ExpertRunOptions, ): ExpertRunController { const abortCtrl = new AbortController(); let cancelled = false; const stop = () => { cancelled = true; abortCtrl.abort(); }; const promise = (async () => { const next: ExpertMatchData = { ...baseData, status: 'creating', errorMessage: undefined, }; options.onUpdate(next); try { const resp = await fetch(`${backUrl}/api/agents/create_expert/invoke`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_input: body.user_input, output_format: 'structured', max_items: 1, }), signal: abortCtrl.signal, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = (await resp.json()) as InvokeResponse; if (cancelled) return; const newCards = buildExpertCards(data).map((c) => ({ ...c, isCustom: true })); // 在保留已有专家列表的前提下追加新卡片,避免覆盖历史检索结果。 const merged: ExpertMatchData = { ...next, thinking: data.thinking ? `${next.thinking ? next.thinking + '\n\n— 创建专家 —\n' : '— 创建专家 —\n'}${data.thinking}` : next.thinking, experts: [...next.experts, ...newCards], selectedExpertId: newCards[0]?.id ?? next.selectedExpertId, status: 'ready', }; options.onUpdate(merged); options.onComplete?.(merged); } catch (err) { if (cancelled) return; const msg = err instanceof Error ? err.message : '创建专家失败'; const failed: ExpertMatchData = { ...next, status: 'error', errorMessage: msg }; options.onUpdate(failed); options.onComplete?.(failed); } })(); return { stop, promise }; }