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

1359 lines
54 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.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlignLeft, GitBranch, History, LayoutList, Sparkles } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import ChatInputComposer from '../agent/ChatInputComposer';
import { runExpertCreate, runExpertSearch, runSixStepDeduction, runIntentOrchestrateStream, type ExpertRunController, type IntentOrchestrateRequest } from './api';
import McpSelectModal from './McpSelectModal';
import SixStepConversation from './SixStepConversation';
import SixStepChatHistory, { type SixStepConversationMeta } from './SixStepChatHistory';
import SixStepFlowFullscreen from './SixStepFlowFullscreen';
import SixStepFlowSandbox from './SixStepFlowSandbox';
import SixStepProgressHorizontal from './SixStepProgressHorizontal';
import { buildInitialAssistantMessage, type SixStepMockController } from './mockData';
import './styles.css';
import type {
ExpertCard,
ExpertMatchData,
MainStepKey,
SelectableMcpKey,
SixStepMessage,
StepStatus,
SubStepKey,
} from './types';
/**
* ===========================================================================
* SixStepChatPage —— 六步法(6bf)推演问答主界面
* ===========================================================================
*
* 职责概览:
* - 管理多会话列表与当前激活会话,并与后端 `/api/conversations` 做增删改查同步。
* - 将用户输入与「上一条助手消息」中的会话态(`phase`、`selectedExpert`、`rawSteps` 等)
* 组装为 `IntentOrchestrateRequest`,通过 `runIntentOrchestrateStream` 消费 SSE,
* 持续合并到当前助手 `SixStepMessage`(意图识别、专家匹配、六步编排、追问 Markdown 等)。
* - 专家流程:列表内操作经 `expertHandlers` 回到本页;用户在 `McpSelectModal` 选定子链后,
* 以 `intent_mode: 'force_orchestrate'` 立即启动编排,无需再次输入。
* - 停止:`handleStop` 中止流式控制器,并把生成中的助手消息标为 `paused`,
* 便于后端根据 `last_steps` / `current_phase` 做继续分析等分支。
*
* 主流程(用户发送一条消息):
* 1. `handleSend`:校验输入 → 无会话则 `createConversationOnServer`。
* 2. 追加 user 消息 + 新的 assistant 占位(`buildInitialAssistantMessage`,初始 `intent-detecting`)。
* 3. 从最后一条 assistant 推导 `current_phase`、`last_steps`(暂停续跑场景)等,构造 `reqBody`。
* 4. `runIntentOrchestrateStream`:`onUpdate` 按消息 id 合并助手状态;`onComplete` 持久化对话。
*
* 专家相关分支:
* - 后端下发 `expert_*` 事件 → 助手进入 `expert-match` / `expert-confirm`(由 `api.ts` 内处理)。
* - `handleRefetchExpert`:`intent_mode: 'force_find_expert'`,并带上已有 `expert_candidates`。
* - `handleMcpModalConfirm`:写入已选专家与子链,再 `force_orchestrate` 跑六步。
*
* 下方「模块级」函数只做类型与 API 载荷转换;组件内 `useCallback` 承载业务编排。
* ===========================================================================
*/
/** 前端内存中的单条会话:在通用 meta 上扩展创建时间与完整消息列表。 */
interface SixStepConversationEntry extends SixStepConversationMeta {
createdAt: number;
model?: string;
messages: SixStepMessage[];
}
/** 后端 `/api/conversations` 中单条消息的存储形态:`data` 承载 SixStep 扩展字段。 */
interface ConversationApiMessage {
id: string;
role: 'user' | 'assistant';
content: string;
created_at?: number;
data?: Partial<SixStepMessage>;
}
/** 后端返回的单条会话记录(含 messages 数组)。 */
interface ConversationApiRecord {
id: string;
filter_key: string;
filter_value: string;
title: string;
model?: string | null;
meta?: Record<string, unknown>;
created_at: number;
updated_at: number;
messages: ConversationApiMessage[];
}
interface ModelOption {
id: string;
name: string;
provider: string;
enabled?: boolean;
}
interface ModelsResponse {
default_model_id: string;
models: ModelOption[];
}
/** 后端对话列表筛选键:区分不同页面写入的会话数据。 */
const FILTER_KEY = 'page';
/** 与 `FILTER_KEY` 成对使用,本页固定为 sixstep 聊天场景。 */
const FILTER_VALUE = 'sixstep-chat';
/** 新建会话时的占位标题,首条用户消息后会用摘要替换。 */
const NEW_TITLE = '新对话';
/** 助手消息缺少 `mainStepStatus` 时的默认进度占位(与 `SixStepMessage` 结构对齐)。 */
const DEFAULT_MAIN_STATUS: Record<MainStepKey, StepStatus> = {
info: 'pending',
sixstep: 'pending',
final: 'pending',
};
/** 六步子步骤默认进度(purpose / actor / behavior / narrative)。 */
const DEFAULT_SUB_STATUS: Record<SubStepKey, StepStatus> = {
purpose: 'pending',
actor: 'pending',
behavior: 'pending',
narrative: 'pending',
};
/** 右侧流程图沙箱可拖拽宽度区间(像素)。 */
const MIN_SANDBOX_WIDTH = 360;
const MAX_SANDBOX_WIDTH = 900;
/** 沙箱初始宽度。 */
const DEFAULT_SANDBOX_WIDTH = 520;
// ---------------------------------------------------------------------------
// 模块级工具:会话排序、时间戳、与后端 Conversation API 的序列化/反序列化
// ---------------------------------------------------------------------------
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
/** 按 `updatedAt` 降序排列,保证侧边历史列表「最近在上」。 */
const sortConversations = (items: SixStepConversationEntry[]) =>
[...items].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
/** 将 API 或持久化里混用的日期类型统一为 `Date`,解析失败则回退为当前时间。 */
const toTimestamp = (value?: Date | number | string): Date => {
if (value instanceof Date) {
return value;
}
if (typeof value === 'number') {
return new Date(value);
}
if (typeof value === 'string' && value) {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
return date;
}
}
return new Date();
};
/** 将单条 API 消息(`data` 内嵌 SixStep 扩展字段)还原为前端 `SixStepMessage`。 */
const normalizeMessage = (item: ConversationApiMessage): SixStepMessage => {
const payload = item.data || {};
return {
...payload,
id: item.id,
role: item.role,
content: item.content ?? payload.content ?? '',
timestamp: toTimestamp(payload.timestamp ?? item.created_at),
} as SixStepMessage;
};
/** 将后端完整会话记录转为带 `messages: SixStepMessage[]` 的前端会话条目。 */
const normalizeConversation = (item: ConversationApiRecord): SixStepConversationEntry => ({
id: item.id,
title: item.title || NEW_TITLE,
createdAt: Number(item.created_at || Date.now()),
updatedAt: Number(item.updated_at || Date.now()),
model: item.model || '',
messages: (item.messages || []).map(normalizeMessage),
});
/**
* 持久化单条消息:`data` 中存放完整 SixStep 状态,便于刷新后恢复卡片/阶段/rawSteps。
* `created_at` 使用消息时间戳的毫秒值,供列表排序。
*/
const serializeMessage = (message: SixStepMessage) => ({
id: message.id,
role: message.role,
content: message.content || '',
created_at: message.timestamp instanceof Date ? message.timestamp.getTime() : Date.now(),
data: {
...message,
timestamp: message.timestamp instanceof Date ? message.timestamp.toISOString() : new Date().toISOString(),
},
});
/** 拼接带 `filter_key` / `filter_value` 查询参数的会话 REST URL。 */
const conversationRequestUrl = (conversationId?: string) => {
const search = new URLSearchParams({
filter_key: FILTER_KEY,
filter_value: FILTER_VALUE,
});
const base = conversationId ? `/api/conversations/${conversationId}` : '/api/conversations';
return `${backUrl}${base}?${search.toString()}`;
};
/**
* 六步法推演页组件:对话列表 + 消息区 + 输入区 + 可选流程图沙箱/全屏 + MCP 选择弹窗。
* 所有与后端的流式编排交互的入口集中在 `handleSend`、`handleRefetchExpert`、`handleMcpModalConfirm`。
*/
export default function SixStepChatPage() {
const { theme } = useTheme();
const isDark = theme === 'dark';
// ----- 会话与消息(核心状态)-----
const [conversations, setConversations] = useState<SixStepConversationEntry[]>([]);
const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [input, setInput] = useState('');
/** 是否有流式任务在跑(意图流、专家创建、旧版推演等共用)。 */
const [isGenerating, setIsGenerating] = useState(false);
/** 编排请求体 `output_format`:结构化卡片 vs Markdown(影响关闭流程图侧栏等行为)。 */
const [outputFormat, setOutputFormat] = useState<'structured' | 'markdown'>('structured');
/** 模型切换:模型列表、全局默认模型、当前页面选中模型。 */
const [models, setModels] = useState<ModelOption[]>([]);
const [globalDefaultModel, setGlobalDefaultModel] = useState('');
const [selectedModel, setSelectedModel] = useState('');
/** 切换输出格式;Markdown 模式下关闭流程图沙箱与全屏,避免与纯文展示冲突。 */
const handleSetOutputFormat = useCallback((fmt: 'structured' | 'markdown') => {
setOutputFormat(fmt);
if (fmt === 'markdown') {
setSandboxOpen(false);
setFullscreenOpen(false);
}
}, []);
// ----- 布局与 UI 附属状态 -----
const [sandboxOpen, setSandboxOpen] = useState(false);
const [sandboxWidth, setSandboxWidth] = useState(DEFAULT_SANDBOX_WIDTH);
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
/** 首次从服务端拉取会话列表完成前为 true,用于禁用输入等。 */
const [loading, setLoading] = useState(true);
// ----- Refs:流式控制器、布局、与异步闭包中读取的最新 conversations -----
/** 当前主编排/意图流控制器(`runIntentOrchestrateStream` 或 `runSixStepDeduction`)。 */
const controllerRef = useRef<SixStepMockController | null>(null);
/** 专家搜索/创建等独立流(与主控制器分离,便于分别 stop)。 */
const expertControllerRef = useRef<ExpertRunController | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
/** 流程图沙箱宽度拖拽时的指针起点与初始宽度。 */
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
/** 避免首屏重复请求会话列表。 */
const hasLoadedRef = useRef(false);
/** 与 `conversations` state 同步,供不依赖 conversations 的闭包读取最新列表。 */
const conversationsRef = useRef<SixStepConversationEntry[]>([]);
/**
* 当前「正在交互」的助手消息 id(专家卡片高亮、重搜专家等)。
* 若未显式设置,则回退为会话中最后一条 assistant(见下方 `activeAssistantId` 计算)。
*/
const [activeAssistantMsgId, setActiveAssistantMsgId] = useState<string | null>(null);
/** MCP 子链选择弹窗:挂在某条助手消息上,并持有待确认的专家卡片。 */
const [mcpModal, setMcpModal] = useState<{ open: boolean; messageId: string | null; expert: ExpertCard | null }>(
{ open: false, messageId: null, expert: null },
);
/** 当前选中的完整会话实体;无选中时为 null。 */
const activeConv = useMemo(
() => conversations.find((conversation) => conversation.id === activeConvId) || null,
[activeConvId, conversations],
);
/** 当前会话下的消息列表(仅渲染用,写入请通过 `setMessages` / `patchMessageById` / `upsertConversationState`)。 */
const messages = activeConv?.messages ?? [];
/** 侧边历史抽屉所需的轻量元数据(已按更新时间排序)。 */
const conversationMetas = useMemo<SixStepConversationMeta[]>(
() =>
sortConversations(conversations).map(({ id, title, updatedAt }) => ({
id,
title,
updatedAt,
})),
[conversations],
);
/**
* 顶部进度条与流程图沙箱所绑定的「当前助手消息」id:
* 优先 `activeAssistantMsgId`(若仍存在于当前会话);否则取最后一条 assistant。
*/
const activeAssistantId = (() => {
if (activeAssistantMsgId && messages.some((message) => message.id === activeAssistantMsgId)) {
return activeAssistantMsgId;
}
const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant');
return lastAssistant?.id ?? null;
})();
/** 绑定到顶部进度条、流程图沙箱/全屏的助手消息;无消息时为 undefined。 */
const activeAssistant = messages.find((message) => message.id === activeAssistantId);
/** 主步骤状态(信息准备 / 六步 / 最终回答)用于 `SixStepProgressHorizontal`。 */
const liveMainStatus = activeAssistant?.mainStepStatus ?? DEFAULT_MAIN_STATUS;
/** 子步骤(purpose / actor / behavior / narrative)进度展示。 */
const liveSubStatus = activeAssistant?.subStepStatus ?? DEFAULT_SUB_STATUS;
useEffect(() => {
conversationsRef.current = conversations;
}, [conversations]);
/** 中止所有流式任务并清理控制器引用;不修改消息内容(停止后的 UI 由 `handleStop` 负责)。 */
const stopGeneration = useCallback(() => {
controllerRef.current?.stop();
controllerRef.current = null;
expertControllerRef.current?.stop();
expertControllerRef.current = null;
setIsGenerating(false);
}, []);
/**
* 在指定会话内按消息 id 做一次不可变更新(`patch` 返回完整新消息对象)。
* 用于专家卡片局部更新等「只改一条消息」的场景。
*/
const patchMessageById = useCallback(
(conversationId: string, messageId: string, patch: (msg: SixStepMessage) => SixStepMessage) => {
setConversations((prev) =>
prev.map((conversation) => {
if (conversation.id !== conversationId) return conversation;
return {
...conversation,
messages: conversation.messages.map((m) => (m.id === messageId ? patch(m) : m)),
updatedAt: Date.now(),
};
}),
);
},
[],
);
/** GET 列表:拉取本页 `filter_key` / `filter_value` 下的全部会话并排序。 */
const fetchConversations = useCallback(async (): Promise<SixStepConversationEntry[]> => {
const response = await fetch(conversationRequestUrl());
if (!response.ok) {
throw new Error(`加载聊天记录失败:${response.status}`);
}
const payload = (await response.json()) as ConversationApiRecord[];
return sortConversations(payload.map(normalizeConversation));
}, []);
/** GET 模型列表:用于页面头部模型切换。 */
const fetchModels = useCallback(async (): Promise<ModelsResponse> => {
const response = await fetch(`${backUrl}/api/models`);
if (!response.ok) {
throw new Error(`加载模型列表失败:${response.status}`);
}
return (await response.json()) as ModelsResponse;
}, []);
/** POST 创建空会话(可带初始 messages),返回规范化后的会话条目。 */
const createConversationOnServer = useCallback(
async (
title: string,
model?: string,
messagesToSave: SixStepMessage[] = [],
): Promise<SixStepConversationEntry> => {
const response = await fetch(`${backUrl}/api/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filter_key: FILTER_KEY,
filter_value: FILTER_VALUE,
title,
model,
meta: { page: FILTER_VALUE },
messages: messagesToSave.map(serializeMessage),
}),
});
if (!response.ok) {
throw new Error(`创建对话失败:${response.status}`);
}
return normalizeConversation((await response.json()) as ConversationApiRecord);
},
[],
);
/** PUT 更新会话:标题、模型、meta 或整表 messages(由调用方决定传哪些字段)。 */
const updateConversationOnServer = useCallback(
async (
conversationId: string,
payload: {
title?: string;
model?: string;
meta?: Record<string, unknown>;
messages?: SixStepMessage[];
},
): Promise<SixStepConversationEntry> => {
const response = await fetch(conversationRequestUrl(conversationId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
model: payload.model,
meta: payload.meta,
messages: payload.messages?.map(serializeMessage),
}),
});
if (!response.ok) {
throw new Error(`更新对话失败:${response.status}`);
}
return normalizeConversation((await response.json()) as ConversationApiRecord);
},
[],
);
/** DELETE 单条会话。 */
const deleteConversationOnServer = useCallback(async (conversationId: string) => {
const response = await fetch(conversationRequestUrl(conversationId), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`删除对话失败:${response.status}`);
}
}, []);
/** DELETE 当前筛选条件下的全部会话(清空历史)。 */
const clearConversationsOnServer = useCallback(async () => {
const response = await fetch(conversationRequestUrl(), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`清空聊天记录失败:${response.status}`);
}
}, []);
/** 将一条会话合并进 state:同 id 覆盖,新会话插入队首后再按时间排序。 */
const upsertConversationState = useCallback((conversation: SixStepConversationEntry) => {
setConversations((prev) =>
sortConversations([conversation, ...prev.filter((item) => item.id !== conversation.id)]),
);
}, []);
/**
* 用 `conversationsRef` 中的最新快照整表 PUT 到服务端,再用返回结果更新本地。
* 流式 `onComplete`、停止后延迟保存等会调用,避免丢最后几条 SSE 合并结果。
*/
const persistConversation = useCallback(
async (conversationId: string) => {
const current = conversationsRef.current.find((item) => item.id === conversationId);
if (!current) return null;
const updated = await updateConversationOnServer(conversationId, {
title: current.title,
model: current.model,
meta: { page: FILTER_VALUE },
messages: current.messages,
});
upsertConversationState(updated);
return updated;
},
[updateConversationOnServer, upsertConversationState],
);
/**
* 仅更新「当前激活会话」的 messages,语义上类似 `useState` 的 setState,
* 但实现为在 `conversations` 数组里找到 `activeConvId` 对应项并替换其 `messages`。
*/
const setMessages = useCallback<React.Dispatch<React.SetStateAction<SixStepMessage[]>>>(
(value) => {
setConversations((prev) =>
prev.map((conversation) => {
if (conversation.id !== activeConvId) return conversation;
const nextMessages =
typeof value === 'function'
? (value as (messages: SixStepMessage[]) => SixStepMessage[])(conversation.messages)
: value;
return {
...conversation,
messages: nextMessages,
updatedAt: Date.now(),
};
}),
);
},
[activeConvId],
);
/** 挂载时拉取会话列表;成功则选中最新一条,失败则置空列表(组件卸载时忽略异步结果)。 */
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const [items, modelsPayload] = await Promise.all([fetchConversations(), fetchModels()]);
if (cancelled) return;
const nextModels = modelsPayload.models || [];
const defaultModelId = modelsPayload.default_model_id || '';
setConversations(items);
setModels(nextModels);
setGlobalDefaultModel(defaultModelId);
setActiveConvId(items[0]?.id || null);
setSelectedModel(items[0]?.model || defaultModelId || nextModels[0]?.id || '');
hasLoadedRef.current = true;
} catch {
if (!cancelled) {
setConversations([]);
setModels([]);
setGlobalDefaultModel('');
setSelectedModel('');
setActiveConvId(null);
hasLoadedRef.current = true;
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [fetchConversations, fetchModels]);
/** 新建会话:停流 → 服务端创建 → 切到新会话并重置专家/MCP 弹窗与输入。 */
const handleNewConversation = useCallback(async () => {
stopGeneration();
const created = await createConversationOnServer(NEW_TITLE, selectedModel || globalDefaultModel || undefined);
upsertConversationState(created);
setActiveConvId(created.id);
setSelectedModel(created.model || selectedModel || globalDefaultModel || models[0]?.id || '');
setActiveAssistantMsgId(null);
setMcpModal({ open: false, messageId: null, expert: null });
setInput('');
}, [createConversationOnServer, globalDefaultModel, models, selectedModel, stopGeneration, upsertConversationState]);
/** 切换侧边选中的会话:停流、清空专家弹窗与输入(不自动持久化,仅切本地状态)。 */
const handleSelectConversation = useCallback(
(id: string) => {
if (id === activeConvId) return;
stopGeneration();
const nextConversation = conversationsRef.current.find((item) => item.id === id) || null;
setActiveConvId(id);
setSelectedModel(nextConversation?.model || globalDefaultModel || models[0]?.id || '');
setActiveAssistantMsgId(null);
setMcpModal({ open: false, messageId: null, expert: null });
setInput('');
},
[activeConvId, globalDefaultModel, models, stopGeneration],
);
/** 选择模型后写入当前会话,后续 /api/orchestrate/stream 请求会透传该 model。 */
const handleModelChange = useCallback(
async (modelId: string) => {
setSelectedModel(modelId);
if (!activeConvId) return;
setConversations((prev) =>
prev.map((conversation) =>
conversation.id === activeConvId
? { ...conversation, model: modelId, updatedAt: Date.now() }
: conversation,
),
);
try {
const updated = await updateConversationOnServer(activeConvId, { model: modelId });
upsertConversationState(updated);
} catch {
// 本地模型仍保留,发送时继续透传 selectedModel。
}
},
[activeConvId, updateConversationOnServer, upsertConversationState],
);
/** 删除指定会话;若删的是当前会话则停流并自动选中剩余列表中的下一条(若有)。 */
const handleDeleteConversation = useCallback(
async (id: string) => {
await deleteConversationOnServer(id);
setConversations((prev) => {
const remaining = prev.filter((item) => item.id !== id);
if (id === activeConvId) {
stopGeneration();
const next = sortConversations(remaining)[0] || null;
setActiveConvId(next?.id || null);
setSelectedModel(next?.model || globalDefaultModel || models[0]?.id || '');
}
return remaining;
});
},
[activeConvId, deleteConversationOnServer, globalDefaultModel, models, stopGeneration],
);
/** 乐观更新标题后立即 PUT,再用服务端返回覆盖(避免短暂显示旧标题)。 */
const handleRenameConversation = useCallback(
async (id: string, title: string) => {
const current = conversations.find((item) => item.id === id);
if (!current) return;
const optimistic = { ...current, title, updatedAt: Date.now() };
upsertConversationState(optimistic);
const updated = await updateConversationOnServer(id, { title });
upsertConversationState(updated);
},
[conversations, updateConversationOnServer, upsertConversationState],
);
/** 清空服务端与本地的全部本会话类型记录,并取消当前选中。 */
const handleClearAllConversations = useCallback(async () => {
stopGeneration();
await clearConversationsOnServer();
setConversations([]);
setActiveConvId(null);
}, [clearConversationsOnServer, stopGeneration]);
/**
* 仅调用专家搜索 API(不经意图编排 SSE)。
* 当前主对话已统一走 `runIntentOrchestrateStream`;本函数保留为独立专家搜索能力的封装。
*/
const startExpertSearch = useCallback(
(conversationId: string, messageId: string, userInput: string, excludeIds?: string[]) => {
expertControllerRef.current?.stop();
const baseData: ExpertMatchData = {
status: 'searching',
thinking: '',
experts: [],
};
const controller = runExpertSearch(
baseData,
{ user_input: userInput, exclude_ids: excludeIds },
{
onUpdate: (data) => {
patchMessageById(conversationId, messageId, (m) => ({
...m,
expertMatch: data,
}));
},
onComplete: () => {
expertControllerRef.current = null;
setIsGenerating(false);
void persistConversation(conversationId);
},
},
);
expertControllerRef.current = controller;
void controller.promise.catch(() => {
setIsGenerating(false);
});
},
[patchMessageById, persistConversation],
);
/**
* 旧版六步推演:在已选定专家与子链后,直接 `runSixStepDeduction` 拉流。
* 与 `runIntentOrchestrateStream` + `force_orchestrate` 并存,便于迁移期或特殊入口复用。
*/
const runDeductionForMessage = useCallback(
(conversationId: string, message: SixStepMessage, userInput: string, expert: ExpertCard, selectedMcps: SelectableMcpKey[]) => {
const requestExpert = { ...expert, id: expert.backendId ?? expert.id };
setIsGenerating(true);
setActiveAssistantMsgId(message.id);
const controller = runSixStepDeduction(
{ ...message, phase: 'generate', selectedExpert: requestExpert, selectedMcps },
{
onUpdate: (updated) => {
setConversations((prev) =>
prev.map((conversation) => {
if (conversation.id !== conversationId) return conversation;
return {
...conversation,
messages: conversation.messages.map((item) =>
item.id === updated.id ? { ...item, ...updated } : item,
),
updatedAt: Date.now(),
};
}),
);
},
onComplete: () => {
setIsGenerating(false);
controllerRef.current = null;
void persistConversation(conversationId);
},
},
{
user_input: userInput,
output_format: outputFormat,
model: selectedModel || undefined,
selected_expert: requestExpert,
selected_mcps: selectedMcps,
},
);
controllerRef.current = controller;
void controller.promise.catch(() => {
setIsGenerating(false);
});
},
[outputFormat, persistConversation, selectedModel],
);
/**
* 用户点击发送后的主编排入口(意图识别 + 分支 SSE)。
*
* 流程要点:
* 1. 无当前会话则先创建服务端会话。
* 2. 从「上一条助手消息」读取 `phase`、`selectedExpert`、`expertMatch.experts`、`rawSteps` 等,
* 组装 `IntentOrchestrateRequest`,供后端 `detect_intent` 与 continue 分支使用。
* 3. 若 `phase === 'paused'`,把上一轮的 stages / rawSteps / 选中专家与子链预填到新助手占位里,
* 并把 `last_steps`、`selected_mcps` 放进请求体(仅暂停续跑场景传 `last_steps`,避免误传)。
* 4. 追加 user + assistant 后立刻 `runIntentOrchestrateStream`,用返回的 `updated` 合并同 id 助手消息。
*/
const handleSend = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || isGenerating) return;
let target = activeConv;
if (!target) {
target = await createConversationOnServer(NEW_TITLE);
upsertConversationState(target);
setActiveConvId(target.id);
}
// 意图编排上下文:来自「上一条助手消息」,与后端 OrchestrateRequest 字段对齐
const lastAssistant = [...target.messages].reverse().find((m) => m.role === 'assistant');
const currentPhase = lastAssistant?.phase ?? 'idle';
const selectedExpert = lastAssistant?.selectedExpert ?? null;
const expertCandidates = (lastAssistant?.expertMatch?.experts ?? []) as unknown as Record<string, unknown>[];
const isPaused = currentPhase === 'paused';
const lastSteps = isPaused && lastAssistant?.rawSteps
? (lastAssistant.rawSteps as unknown as Record<string, unknown>[])
: [];
const selectedExpertPayload = selectedExpert
? (selectedExpert as unknown as Record<string, unknown>)
: null;
const pausedSelectedMcps = isPaused && lastAssistant?.selectedMcps ? lastAssistant.selectedMcps : [];
// 本轮用户气泡
const userMessage: SixStepMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: trimmed,
timestamp: new Date(),
};
/**
* 新的助手占位:从「意图识别中」开始。
* 若从 paused 恢复,则克隆上一轮卡片/步骤/rawSteps/已选专家子链,便于 UI 连续与后端续跑。
*/
const assistantMessage: SixStepMessage = {
...buildInitialAssistantMessage(),
outputFormat,
phase: 'intent-detecting',
...(isPaused && lastAssistant ? {
stages: { ...lastAssistant.stages },
rawSteps: lastAssistant.rawSteps ? [...lastAssistant.rawSteps] : undefined,
subStepStatus: lastAssistant.subStepStatus ? { ...lastAssistant.subStepStatus } : undefined,
mainStepStatus: lastAssistant.mainStepStatus ? { ...lastAssistant.mainStepStatus } : undefined,
selectedExpert: lastAssistant.selectedExpert,
selectedMcps: lastAssistant.selectedMcps,
} : {}),
};
// 首条真实问题时,用摘要覆盖默认标题「新对话」
const nextTitle = !target.title || target.title === NEW_TITLE ? trimmed.slice(0, 24) : target.title;
const nextConversation: SixStepConversationEntry = {
...target,
title: nextTitle,
messages: [...target.messages, userMessage, assistantMessage],
updatedAt: Date.now(),
};
upsertConversationState(nextConversation);
setActiveConvId(nextConversation.id);
setInput('');
setIsGenerating(true);
setActiveAssistantMsgId(assistantMessage.id);
setHistoryOpen(false);
const convId = nextConversation.id;
// intent_mode 默认 auto,由 api 层与后端协商;此处只传会话态供意图识别与 continue 使用
const reqBody: IntentOrchestrateRequest = {
user_input: trimmed,
output_format: outputFormat,
model: selectedModel || target.model || globalDefaultModel || undefined,
current_phase: currentPhase,
selected_expert: selectedExpertPayload,
selected_mcps: pausedSelectedMcps,
expert_candidates: expertCandidates,
last_steps: lastSteps,
};
const controller = runIntentOrchestrateStream(
assistantMessage,
{
onUpdate: (updated) => {
setConversations((prev) =>
prev.map((conv) => {
if (conv.id !== convId) return conv;
return {
...conv,
messages: conv.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
updatedAt: Date.now(),
};
}),
);
},
onComplete: () => {
setIsGenerating(false);
controllerRef.current = null;
void persistConversation(convId);
},
},
reqBody,
);
controllerRef.current = controller;
void controller.promise.catch(() => { setIsGenerating(false); });
}, [
activeConv,
createConversationOnServer,
globalDefaultModel,
input,
isGenerating,
outputFormat,
persistConversation,
selectedModel,
upsertConversationState,
]);
// ---------------------------------------------------------------------------
// 专家匹配与 MCP 子链:由 SixStepConversation 经 expertHandlers 回调到本页
// 典型顺序:选专家卡片 → 确认 → McpSelectModal 选子链 → handleMcpModalConfirm 开跑编排
// ---------------------------------------------------------------------------
/** 在 `conversationsRef` 全集中按消息 id 查找所属会话与消息(避免闭包拿到过期列表)。 */
const findMessage = useCallback(
(messageId: string): { conv: SixStepConversationEntry | null; msg: SixStepMessage | null } => {
for (const conv of conversationsRef.current) {
const m = conv.messages.find((x) => x.id === messageId);
if (m) return { conv, msg: m };
}
return { conv: null, msg: null };
},
[],
);
/** 用户在专家卡片上点选某张专家:只写入 `expertMatch.selectedExpertId`(尚未开始推演)。 */
const handleSelectExpert = useCallback(
(messageId: string, expertId: string) => {
const { conv } = findMessage(messageId);
if (!conv) return;
patchMessageById(conv.id, messageId, (m) => ({
...m,
expertMatch: m.expertMatch ? { ...m.expertMatch, selectedExpertId: expertId } : m.expertMatch,
}));
},
[findMessage, patchMessageById],
);
/**
* 「换一批 / 重新找专家」:用触发该助手气泡前的用户原句 + `force_find_expert`,
* 并传入已有 `expert_candidates` 供后端去重。流控制器挂在 `expertControllerRef` 以便 stop。
*/
const handleRefetchExpert = useCallback(
(messageId: string) => {
const { conv, msg } = findMessage(messageId);
if (!conv || !msg) return;
const idx = conv.messages.findIndex((m) => m.id === messageId);
const userMsg = idx > 0 ? [...conv.messages.slice(0, idx)].reverse().find((m) => m.role === 'user') : null;
const userInput = userMsg?.content?.trim() ?? '';
if (!userInput) return;
const existingCandidates = (msg.expertMatch?.experts ?? []) as unknown as Record<string, unknown>[];
// 清空上一轮专家列表与意图,界面进入「识别中」再收 SSE
patchMessageById(conv.id, messageId, (m) => ({
...m,
phase: 'intent-detecting' as const,
expertMatch: { status: 'searching' as const, thinking: '', experts: [] },
intent: undefined,
}));
setIsGenerating(true);
setActiveAssistantMsgId(messageId);
const convId = conv.id;
const refetchMsg: SixStepMessage = {
...msg,
phase: 'intent-detecting',
expertMatch: { status: 'searching', thinking: '', experts: [] },
intent: undefined,
};
const controller = runIntentOrchestrateStream(
refetchMsg,
{
onUpdate: (updated) => {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== convId) return c;
return {
...c,
messages: c.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
updatedAt: Date.now(),
};
}),
);
},
onComplete: () => {
setIsGenerating(false);
controllerRef.current = null;
void persistConversation(convId);
},
},
{
user_input: userInput,
intent_mode: 'force_find_expert',
model: selectedModel || conv.model || globalDefaultModel || undefined,
expert_candidates: existingCandidates,
},
);
expertControllerRef.current = { stop: controller.stop, promise: controller.promise };
void controller.promise.catch(() => { setIsGenerating(false); });
},
[findMessage, globalDefaultModel, patchMessageById, persistConversation, selectedModel],
);
/** 调用创建专家接口,进度与结果写回同一条消息的 `expertMatch`。 */
const handleCreateExpert = useCallback(
(messageId: string) => {
const { conv, msg } = findMessage(messageId);
if (!conv || !msg) return;
const idx = conv.messages.findIndex((m) => m.id === messageId);
const userMsg = idx > 0 ? [...conv.messages.slice(0, idx)].reverse().find((m) => m.role === 'user') : null;
const userInput = userMsg?.content?.trim() || '';
if (!userInput) return;
expertControllerRef.current?.stop();
setIsGenerating(true);
setActiveAssistantMsgId(messageId);
const baseData = msg.expertMatch ?? { status: 'idle', thinking: '', experts: [] };
const controller = runExpertCreate(
baseData,
{ user_input: userInput },
{
onUpdate: (data) => {
patchMessageById(conv.id, messageId, (m) => ({ ...m, expertMatch: data }));
},
onComplete: () => {
expertControllerRef.current = null;
setIsGenerating(false);
void persistConversation(conv.id);
},
},
);
expertControllerRef.current = controller;
void controller.promise.catch(() => {
setIsGenerating(false);
});
},
[findMessage, patchMessageById, persistConversation],
);
/**
* 用户确认当前选中的专家:打开 MCP 子链选择弹窗(不立即请求编排)。
* 真正开跑在 `handleMcpModalConfirm`。
*/
const handleConfirmExpertSelect = useCallback(
(messageId: string) => {
const { msg } = findMessage(messageId);
if (!msg || !msg.expertMatch) return;
const expert = msg.expertMatch.experts.find((e) => e.id === msg.expertMatch?.selectedExpertId) || null;
if (!expert) return;
setMcpModal({ open: true, messageId, expert });
},
[findMessage],
);
/** 关闭 MCP 弹窗且不提交子链选择。 */
const handleMcpModalCancel = useCallback(() => {
setMcpModal({ open: false, messageId: null, expert: null });
}, []);
/**
* MCP 弹窗确认:把专家 + 勾选子链写入消息,并以「原始用户问题」为 `user_input`、
* `intent_mode: 'force_orchestrate'` 立即启动六步编排 SSE(不再依赖本轮意图识别)。
*
* 注意:会清空 `stages` / 最终回答等,相当于从专家确认点重新开始一条生成链路。
*/
const handleMcpModalConfirm = useCallback(
(selectedMcps: SelectableMcpKey[]) => {
const messageId = mcpModal.messageId;
const expert = mcpModal.expert;
if (!messageId || !expert) {
setMcpModal({ open: false, messageId: null, expert: null });
return;
}
const { conv, msg } = findMessage(messageId);
if (!conv || !msg) return;
// 回溯该助手消息之前的最近一条用户消息,作为编排 user_input(与首次 handleSend 的句子一致)
const msgIdx = conv.messages.findIndex((m) => m.id === messageId);
const userMsg = msgIdx > 0
? [...conv.messages.slice(0, msgIdx)].reverse().find((m) => m.role === 'user')
: null;
const originalInput = userMsg?.content?.trim() ?? '';
const requestExpert = { ...expert, id: expert.backendId ?? expert.id } as unknown as ExpertCard;
// 重置为「即将跑六步」的干净状态,避免残留上一次的 stages / 流式片段
const resetMsg: SixStepMessage = {
...msg,
phase: 'generate',
outputFormat,
selectedExpert: requestExpert,
selectedMcps,
expertMatch: msg.expertMatch
? { ...msg.expertMatch, status: 'confirmed', selectedExpertId: expert.id, selectedMcps }
: msg.expertMatch,
intent: undefined,
stages: {},
finalAnswer: '',
markdownContent: '',
mainStepStatus: { info: 'pending', sixstep: 'pending', final: 'pending' },
subStepStatus: { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' },
currentMainStep: undefined,
currentSubStep: undefined,
};
// 先同步到 state,再发起流式请求,避免首包事件到达时仍显示旧 UI
patchMessageById(conv.id, messageId, () => resetMsg);
setMcpModal({ open: false, messageId: null, expert: null });
setActiveAssistantMsgId(messageId);
setIsGenerating(true);
const convId = conv.id;
const controller = runIntentOrchestrateStream(
resetMsg,
{
onUpdate: (updated) => {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== convId) return c;
return {
...c,
messages: c.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
updatedAt: Date.now(),
};
}),
);
},
onComplete: () => {
setIsGenerating(false);
controllerRef.current = null;
void persistConversation(convId);
},
},
{
user_input: originalInput,
output_format: outputFormat,
model: selectedModel || conv.model || globalDefaultModel || undefined,
intent_mode: 'force_orchestrate',
selected_expert: requestExpert as unknown as Record<string, unknown>,
selected_mcps: selectedMcps,
},
);
controllerRef.current = controller;
void controller.promise.catch(() => { setIsGenerating(false); });
// 与 onComplete 双保险:确保刚写入 resetMsg 的本地状态也被持久化(避免竞态丢保存)
window.setTimeout(() => { void persistConversation(convId); }, 0);
},
[findMessage, globalDefaultModel, mcpModal.expert, mcpModal.messageId, outputFormat, patchMessageById, persistConversation, selectedModel],
);
/**
* 用户中止生成:停止所有流式控制器,并把「未完成的助手消息」标为可续跑状态。
* - 将 `running` 的主/子步骤重置为 `pending`,清除当前指针。
* - 若正处于 `generate` 或 `intent-detecting`,将 `phase` 置为 `paused`,配合下次 `handleSend` 传 `last_steps`。
* - 异步触发一次 `persistConversation`,把 paused 态写入服务端。
*/
const handleStop = useCallback(() => {
const targetId = activeConvId;
controllerRef.current?.stop();
controllerRef.current = null;
expertControllerRef.current?.stop();
expertControllerRef.current = null;
setIsGenerating(false);
setMessages((prev) =>
prev.map((message) => {
if (message.role !== 'assistant' || message.mainStepStatus?.final === 'done') return message;
const main = { ...(message.mainStepStatus ?? DEFAULT_MAIN_STATUS) };
const sub = { ...(message.subStepStatus ?? DEFAULT_SUB_STATUS) };
(Object.keys(main) as MainStepKey[]).forEach((key) => {
if (main[key] === 'running') main[key] = 'pending';
});
(Object.keys(sub) as SubStepKey[]).forEach((key) => {
if (sub[key] === 'running') sub[key] = 'pending';
});
// 暂停态:后端 continue_analysis 依赖 current_phase 与 last_steps
const isInGenerate = message.phase === 'generate' || message.phase === 'intent-detecting';
return {
...message,
phase: isInGenerate ? ('paused' as const) : message.phase,
mainStepStatus: main,
subStepStatus: sub,
currentMainStep: undefined,
currentSubStep: undefined,
};
}),
);
if (targetId) {
window.setTimeout(() => { void persistConversation(targetId); }, 0);
}
}, [activeConvId, persistConversation, setMessages]);
/** 拖拽调整流程图沙箱宽度:在最小/最大值与主内容区剩余宽度之间取交集。 */
const handleResizeMove = useCallback((event: MouseEvent) => {
const resizeData = resizeRef.current;
const container = containerRef.current;
if (!resizeData || !container) return;
const containerRect = container.getBoundingClientRect();
const delta = resizeData.startX - event.clientX;
let next = resizeData.startWidth + delta;
next = Math.max(MIN_SANDBOX_WIDTH, Math.min(MAX_SANDBOX_WIDTH, next));
next = Math.min(next, containerRect.width - 480);
setSandboxWidth(next);
}, []);
/** 结束拖拽:恢复光标与文本选择,移除全局鼠标监听。 */
const handleResizeEnd = useCallback(() => {
resizeRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', handleResizeMove);
window.removeEventListener('mouseup', handleResizeEnd);
}, [handleResizeMove]);
/** 开始拖拽沙箱左边界:记录初始宽度并注册 `mousemove` / `mouseup`。 */
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
resizeRef.current = { startX: event.clientX, startWidth: sandboxWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', handleResizeMove);
window.addEventListener('mouseup', handleResizeEnd);
},
[handleResizeEnd, handleResizeMove, sandboxWidth],
);
/** 组件卸载时:中止主编排流并清理拖拽监听,防止泄漏与幽灵请求。 */
useEffect(() => {
return () => {
controllerRef.current?.stop();
window.removeEventListener('mousemove', handleResizeMove);
window.removeEventListener('mouseup', handleResizeEnd);
};
}, [handleResizeEnd, handleResizeMove]);
// ---------------------------------------------------------------------------
// 布局:顶栏(标题 + 进度 + 格式/历史/沙箱开关)| 侧栏历史 | 主列对话+输入 | 可选沙箱/全屏
// ---------------------------------------------------------------------------
return (
<div
ref={containerRef}
className={`flex h-[calc(100vh-120px)] flex-col rounded-xl border theme-bg ${
isDark ? 'border-slate-700/50 bg-slate-800/50' : 'border-red-100 bg-white'
}`}
>
<div
className={`flex items-center gap-4 border-b px-6 py-4 ${
isDark ? 'border-slate-700/50' : 'border-red-100'
}`}
>
<div className="min-w-0 shrink-0">
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}
>
<Sparkles className="h-5 w-5" />
</div>
<div className="min-w-0">
<h1 className={`truncate text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
6 bf推演问答1
</h1>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
基于 6 步法的结构化推演与可视化生成
</div>
</div>
</div>
</div>
<div className="flex min-w-0 flex-1 items-center justify-center">
<SixStepProgressHorizontal mainStatus={liveMainStatus} subStatus={liveSubStatus} isDark={isDark} />
</div>
<div className="shrink-0">
<div className="flex items-center gap-2">
<div className={`flex items-center rounded-lg border overflow-hidden ${
isDark ? 'border-slate-600/40' : 'border-gray-200'
}`}>
<div className={`px-2 py-2 text-xs ${
isDark ? 'bg-slate-700/40 text-slate-400' : 'bg-gray-50 text-gray-500'
}`}>
模型
</div>
<select
value={selectedModel || globalDefaultModel}
onChange={(e) => { void handleModelChange(e.target.value); }}
className={`h-[34px] min-w-[170px] border-0 px-2 text-xs outline-none ${
isDark ? 'bg-slate-700/40 text-slate-100' : 'bg-white text-gray-700'
}`}
title="切换当前对话使用模型"
>
{models.length === 0 ? (
<option value="">默认模型</option>
) : null}
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>
{/* Output format toggle */}
<div className={`flex items-center rounded-lg border overflow-hidden ${
isDark ? 'border-slate-600/40' : 'border-gray-200'
}`}>
<button
type="button"
onClick={() => handleSetOutputFormat('structured')}
title="结构化卡片输出"
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all ${
outputFormat === 'structured'
? isDark
? 'bg-cyan-500/20 text-cyan-200'
: 'bg-red-50 text-red-600'
: isDark
? 'bg-slate-700/40 text-slate-400 hover:text-slate-200'
: 'bg-white text-gray-500 hover:text-gray-700'
}`}
>
<LayoutList className="h-3.5 w-3.5" />
<span>结构化</span>
</button>
<div className={`w-px self-stretch ${isDark ? 'bg-slate-600/40' : 'bg-gray-200'}`} />
<button
type="button"
onClick={() => handleSetOutputFormat('markdown')}
title="Markdown 文本输出"
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all ${
outputFormat === 'markdown'
? isDark
? 'bg-cyan-500/20 text-cyan-200'
: 'bg-red-50 text-red-600'
: isDark
? 'bg-slate-700/40 text-slate-400 hover:text-slate-200'
: 'bg-white text-gray-500 hover:text-gray-700'
}`}
>
<AlignLeft className="h-3.5 w-3.5" />
<span>Markdown</span>
</button>
</div>
<button
type="button"
onClick={() => setHistoryOpen((value) => !value)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
historyOpen
? isDark
? 'border-cyan-400/40 bg-cyan-500/15 text-cyan-200'
: 'border-red-200 bg-red-50 text-red-600'
: isDark
? 'border-slate-600/40 bg-slate-700/40 text-slate-300 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<History className="h-4 w-4" />
<span>{historyOpen ? '关闭记录' : '聊天记录'}</span>
</button>
{outputFormat === 'structured' && (
<button
type="button"
onClick={() => setSandboxOpen((value) => !value)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
sandboxOpen
? isDark
? 'border-cyan-400/40 bg-cyan-500/15 text-cyan-200'
: 'border-red-200 bg-red-50 text-red-600'
: isDark
? 'border-slate-600/40 bg-slate-700/40 text-slate-300 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<GitBranch className="h-4 w-4" />
<span>{sandboxOpen ? '关闭流程图' : '流程图沙箱'}</span>
</button>
)}
</div>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
<SixStepChatHistory
isOpen={historyOpen}
isDark={isDark}
conversations={conversationMetas}
activeId={activeConvId}
onClose={() => setHistoryOpen(false)}
onOpen={() => setHistoryOpen(true)}
onSelect={handleSelectConversation}
onNew={() => void handleNewConversation()}
onDelete={(id) => void handleDeleteConversation(id)}
onRename={(id, title) => void handleRenameConversation(id, title)}
onClearAll={() => void handleClearAllConversations()}
/>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-hidden">
<SixStepConversation
messages={messages}
isGenerating={isGenerating || loading}
activeMessageId={activeAssistantMsgId}
expertHandlers={{
onSelectExpert: handleSelectExpert,
onRefetchExpert: handleRefetchExpert,
onCreateExpert: handleCreateExpert,
onConfirmExpert: handleConfirmExpertSelect,
}}
/>
</div>
<ChatInputComposer
value={input}
onChange={setInput}
onSend={() => void handleSend()}
onStop={handleStop}
placeholder="输入你想要推演的问题或场景..."
isSending={isGenerating}
isDarkMode={isDark}
showBorderTop
showUpload
onUploadFile={async () => undefined}
/>
</div>
{sandboxOpen ? (
<SixStepFlowSandbox
message={activeAssistant}
width={sandboxWidth}
onResizeStart={handleResizeStart}
onClose={() => setSandboxOpen(false)}
onOpenFullscreen={() => setFullscreenOpen(true)}
/>
) : null}
</div>
{fullscreenOpen ? (
<SixStepFlowFullscreen message={activeAssistant} onClose={() => setFullscreenOpen(false)} />
) : null}
<McpSelectModal
open={mcpModal.open}
expert={mcpModal.expert}
onCancel={handleMcpModalCancel}
onConfirm={handleMcpModalConfirm}
/>
</div>
);
}