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; } /** 后端返回的单条会话记录(含 messages 数组)。 */ interface ConversationApiRecord { id: string; filter_key: string; filter_value: string; title: string; model?: string | null; meta?: Record; 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 = { info: 'pending', sixstep: 'pending', final: 'pending', }; /** 六步子步骤默认进度(purpose / actor / behavior / narrative)。 */ const DEFAULT_SUB_STATUS: Record = { 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([]); const [activeConvId, setActiveConvId] = useState(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([]); 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(null); /** 专家搜索/创建等独立流(与主控制器分离,便于分别 stop)。 */ const expertControllerRef = useRef(null); const containerRef = useRef(null); /** 流程图沙箱宽度拖拽时的指针起点与初始宽度。 */ const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); /** 避免首屏重复请求会话列表。 */ const hasLoadedRef = useRef(false); /** 与 `conversations` state 同步,供不依赖 conversations 的闭包读取最新列表。 */ const conversationsRef = useRef([]); /** * 当前「正在交互」的助手消息 id(专家卡片高亮、重搜专家等)。 * 若未显式设置,则回退为会话中最后一条 assistant(见下方 `activeAssistantId` 计算)。 */ const [activeAssistantMsgId, setActiveAssistantMsgId] = useState(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( () => 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 => { 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 => { 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 => { 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; messages?: SixStepMessage[]; }, ): Promise => { 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>>( (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[]; const isPaused = currentPhase === 'paused'; const lastSteps = isPaused && lastAssistant?.rawSteps ? (lastAssistant.rawSteps as unknown as Record[]) : []; const selectedExpertPayload = selectedExpert ? (selectedExpert as unknown as Record) : 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[]; // 清空上一轮专家列表与意图,界面进入「识别中」再收 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, 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 (

6 bf推演问答1

基于 6 步法的结构化推演与可视化生成
模型
{/* Output format toggle */}
{outputFormat === 'structured' && ( )}
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()} />
void handleSend()} onStop={handleStop} placeholder="输入你想要推演的问题或场景..." isSending={isGenerating} isDarkMode={isDark} showBorderTop showUpload onUploadFile={async () => undefined} />
{sandboxOpen ? ( setSandboxOpen(false)} onOpenFullscreen={() => setFullscreenOpen(true)} /> ) : null}
{fullscreenOpen ? ( setFullscreenOpen(false)} /> ) : null}
); }