1353 lines
56 KiB
TypeScript
1353 lines
56 KiB
TypeScript
/**
|
||
* 智能体聊天(六步法 / 意图编排 / 专家匹配)的前端 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 };
|
||
} |