Files
deerflow-code/web-code/components/angentchat/api.ts
T
2026-05-28 18:29:37 +08:00

1353 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 智能体聊天(六步法 / 意图编排 / 专家匹配)的前端 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<string, unknown>;
}
/**
* 以 POST 发起请求,按 SSE 规范读取响应体并 yield 事件。
* - 缓冲不完整的行,按 `\n` 切分;`event:` 与紧随其后的 `data:` 配对。
* - `data` 行需为合法 JSON;解析失败则静默跳过该行。
* @throws 非 2xx 或响应无 body 时抛出带 HTTP 状态信息的 Error
*/
async function* readSSE(url: string, body: object, signal: AbortSignal): AsyncGenerator<SSEEvent> {
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<string, unknown>;
yield { event: eventName, data };
} catch {
// 非 JSON 的 data 行忽略,避免半包或心跳污染中断整条流
}
eventName = '';
}
}
}
}
// ------------------------------------------------------------------ //
// 类型映射:后端列表项 → 前端卡片(字段名与 parent_* 与后端约定一致) //
// ------------------------------------------------------------------ //
/** 将后端「目的」结构化项转为 `PurposeCard[]`,缺失 id 时用占位 id。 */
function toPurposeCards(items: Record<string, unknown>[]): 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<string, unknown>[], 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<string, unknown>[], 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<string, unknown>[], 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<void> {
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<string, SubStepKey> = {
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<string, unknown>,
): 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<string, unknown>[]) || [];
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<Record<string, unknown>>) || [];
const itemsOf = (type: string) =>
nodes
.filter((n) => n.type === type)
.map((n) => (n.data as Record<string, unknown>) || {});
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 = <K extends SubStepKey>(
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<Record<string, unknown>>;
markdown?: string;
references?: Array<Record<string, unknown>>;
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<string, unknown> | 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<void>;
}
/**
* 先打字机展示 `thinking`,再一次性写入专家列表,最后逐张「展开」专家卡片(动画节奏)。
* @param appendMode 为 true 时在已有 thinking 后拼接新一段(用于连续说明场景)。
*/
const animateExpertResponse = async (
current: ExpertMatchData,
resp: InvokeResponse,
isCancelled: () => boolean,
onUpdate: (data: ExpertMatchData) => void,
appendMode = false,
): Promise<ExpertMatchData> => {
let next: ExpertMatchData = appendMode ? current : { ...current, status: current.status, thinking: '', experts: [] };
onUpdate(next);
const thinking = (resp.thinking as string) || '';
if (thinking) {
await new Promise<void>((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<string, unknown> | null;
/** 用户勾选的主线 MCP 子步骤 key 列表(与编排层一致)。 */
selected_mcps?: string[];
/** 已完成的各步原始结果,用于续跑或对比。 */
last_steps?: Record<string, unknown>[];
/** 从某后端步骤 key 恢复执行时为非 null。 */
resume_from_step?: string | null;
/** 当前轮专家候选(SSE expert_end 等回灌)。 */
expert_candidates?: Record<string, unknown>[];
/** 多轮对话历史,格式由前后端约定。 */
conversation_history?: unknown[];
}
/** SSE `expert_end` 等设备推送的简化专家项 → `ExpertCard`(无 references 交叉时的轻量映射)。 */
function buildExpertCardsFromSSE(items: Record<string, unknown>[]): 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<string, unknown> = {
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<string, unknown>) ?? {};
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<string, unknown>[]) ?? [];
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<string, unknown>[]) ?? [],
markdown: (data.markdown as string) ?? '',
references: (data.references as Record<string, unknown>[]) ?? [],
};
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<string, unknown>[]) ?? [];
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<Record<string, unknown>>) ?? [];
const itemsOf = (type: string) =>
nodes.filter((n) => n.type === type).map((n) => (n.data as Record<string, unknown>) ?? {});
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 extends SubStepKey>(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 };
}