/** * useSSAChat — Phase II 统一对话 Hook * * 基于 POST /api/v1/ssa/sessions/:id/chat SSE 端点, * 提供流式对话能力,替换原有 parseIntent -> generateWorkflowPlan 流程。 * * 能力: * - SSE 流式消息接收(OpenAI Compatible 格式) * - 意图分类元数据解析(intent_classified 事件) * - H3:isGenerating 输入锁 * - 对话历史加载(GET /sessions/:id/chat/history) * - 深度思考内容(reasoning_content) * - 中断请求 */ import { useState, useCallback, useRef } from 'react'; import { getAccessToken, isTokenExpired, refreshAccessToken } from '@/framework/auth/api'; import { useSSAStore } from '../stores/ssaStore'; import type { AskUserEventData, AskUserResponseData } from '../components/AskUserCard'; import type { WorkflowPlan, AgentStepResult } from '../types'; // ──────────────────────────────────────────── // Types // ──────────────────────────────────────────── export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback' | 'system'; export interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; thinking?: string; intent?: ChatIntentType; status?: 'complete' | 'generating' | 'error'; createdAt: string; /** Agent 执行 ID(用于在对话中渲染可点击的结果卡片) */ executionId?: string; /** Agent 执行的查询摘要 */ executionQuery?: string; } export interface IntentMeta { intent: ChatIntentType; confidence: number; source: 'rules' | 'llm' | 'default' | 'guard'; guardTriggered: boolean; guardMessage?: string; } interface OpenAIChunk { id?: string; choices?: Array<{ delta: { content?: string; reasoning_content?: string; }; finish_reason: string | null; }>; } export type AgentActionType = 'confirm_plan' | 'confirm_code' | 'cancel'; export interface UseSSAChatReturn { chatMessages: ChatMessage[]; isGenerating: boolean; currentIntent: ChatIntentType | null; intentMeta: IntentMeta | null; thinkingContent: string; streamingContent: string; error: string | null; pendingQuestion: AskUserEventData | null; pendingPlanConfirm: { workflowId: string } | null; sendChatMessage: (sessionId: string, content: string, metadata?: Record) => Promise; executeAgentAction: (sessionId: string, action: AgentActionType) => Promise; respondToQuestion: (sessionId: string, response: AskUserResponseData) => Promise; skipQuestion: (sessionId: string, questionId: string) => Promise; loadHistory: (sessionId: string) => Promise; abort: () => void; clearMessages: () => void; retryLastMessage: () => Promise; } // ──────────────────────────────────────────── // Helpers // ──────────────────────────────────────────── const MAX_AUTO_RETRY = 2; function retryDelay(attempt: number): number { return Math.min(1000 * (2 ** attempt), 5000); } function toFriendlyError(err: any): string { const msg = (err?.message || '').toLowerCase(); if (msg.includes('failed to fetch') || msg.includes('networkerror') || msg.includes('network')) return '网络连接不稳定,请稍后重试。'; if (msg.includes('http2') || msg.includes('protocol')) return '网络链路出现瞬时波动,请重新发送消息。'; if (msg.includes('timeout') || msg.includes('timed out')) return '请求超时,服务器响应较慢,请稍后重试。'; if (msg.includes('502') || msg.includes('503') || msg.includes('504')) return '服务暂时不可用,可能正在更新中,请稍后重试。'; if (msg.includes('401') || msg.includes('登录')) return '登录已过期,请刷新页面重新登录。'; return err?.message || '请求失败,请重试。'; } // ──────────────────────────────────────────── // Hook // ──────────────────────────────────────────── export function useSSAChat(): UseSSAChatReturn { const [chatMessages, setChatMessages] = useState([]); const [isGenerating, setIsGenerating] = useState(false); const [currentIntent, setCurrentIntent] = useState(null); const [pendingPlanConfirm, setPendingPlanConfirm] = useState<{ workflowId: string } | null>(null); const [intentMeta, setIntentMeta] = useState(null); const [thinkingContent, setThinkingContent] = useState(''); const [streamingContent, setStreamingContent] = useState(''); const [error, setError] = useState(null); const [pendingQuestion, setPendingQuestion] = useState(null); const abortRef = useRef(null); const lastRequestRef = useRef<{ sessionId: string; content: string; metadata?: Record } | null>(null); const retryCountRef = useRef(0); const ensureFreshToken = useCallback(async (): Promise => { if (isTokenExpired()) { try { await refreshAccessToken(); } catch { throw new Error('登录已过期,请重新登录'); } } return getAccessToken() || ''; }, []); const abort = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } setIsGenerating(false); }, []); const clearMessages = useCallback(() => { setChatMessages([]); setCurrentIntent(null); setIntentMeta(null); setThinkingContent(''); setStreamingContent(''); setError(null); setPendingQuestion(null); setPendingPlanConfirm(null); }, []); /** * 加载对话历史 */ const loadHistory = useCallback(async (sessionId: string) => { try { const token = await ensureFreshToken(); const resp = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat/history`, { headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) return; const data = await resp.json(); const loaded: ChatMessage[] = []; if (data.messages?.length > 0) { loaded.push( ...data.messages .filter((m: any) => m.status !== 'generating') .map((m: any) => ({ id: m.id, role: m.role as 'user' | 'assistant', content: m.content || '', thinking: m.thinkingContent, intent: m.intent, status: (m.status || 'complete') as 'complete' | 'generating' | 'error', createdAt: m.createdAt, executionId: m.executionId, executionQuery: m.executionQuery, })), ); } // 从 store 中的 agentExecutionHistory 注入结果卡片 const { agentExecutionHistory } = useSSAStore.getState(); for (const exec of agentExecutionHistory) { if (exec.status === 'completed') { loaded.push({ id: `exec-card-${exec.id}`, role: 'assistant', content: `✅ 分析完成:${exec.query}`, status: 'complete', intent: 'system', executionId: exec.id, executionQuery: exec.query, createdAt: exec.createdAt || new Date().toISOString(), }); } } loaded.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); setChatMessages(loaded); } catch (err) { console.warn('[useSSAChat] Failed to load history:', err); } }, []); /** * 发送消息并接收 SSE 流式响应 */ const sendChatMessage = useCallback(async (sessionId: string, content: string, metadata?: Record) => { lastRequestRef.current = { sessionId, content, metadata }; setError(null); setIsGenerating(true); setThinkingContent(''); setStreamingContent(''); setCurrentIntent(null); setIntentMeta(null); setPendingQuestion(null); const { executionMode, agentExecution } = useSSAStore.getState(); const isAgentInlineInstruction = executionMode === 'agent' && !!agentExecution && (agentExecution.status === 'plan_pending' || agentExecution.status === 'code_pending') && !metadata?.agentAction && !metadata?.askUserResponse; const finalMetadata = isAgentInlineInstruction ? { ...(metadata || {}), agentInlineInstruction: true } : metadata; const isAgentAction = !!finalMetadata?.agentAction || isAgentInlineInstruction; const assistantMsgId = crypto.randomUUID(); const assistantPlaceholder: ChatMessage = { id: assistantMsgId, role: 'assistant', content: '', status: 'generating', createdAt: new Date().toISOString(), }; if (isAgentAction) { // 右侧工作区操作:不添加用户气泡,只添加 AI 占位 setChatMessages(prev => [...prev, assistantPlaceholder]); } else { const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content, status: 'complete', createdAt: new Date().toISOString(), }; setChatMessages(prev => [...prev, userMsg, assistantPlaceholder]); } const controller = new AbortController(); abortRef.current = controller; let fullContent = ''; let fullThinking = ''; try { const token = await ensureFreshToken(); const response = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ content, ...(finalMetadata ? { metadata: finalMetadata } : {}) }), signal: controller.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const reader = response.body?.getReader(); if (!reader) throw new Error('无法获取响应流'); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim() || line.startsWith(': ')) continue; if (!line.startsWith('data: ')) continue; const data = line.slice(6).trim(); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); // 意图分类元数据 if (parsed.type === 'intent_classified') { const meta: IntentMeta = { intent: parsed.intent, confidence: parsed.confidence, source: parsed.source, guardTriggered: parsed.guardTriggered || false, guardMessage: parsed.guardMessage, }; setCurrentIntent(meta.intent); setIntentMeta(meta); setChatMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, intent: meta.intent } : m )); continue; } // ask_user 事件(Phase III) if (parsed.type === 'ask_user') { setPendingQuestion(parsed as AskUserEventData); continue; } // analysis_plan 事件(Phase IV: 对话驱动分析) // 仅创建记录,不打开工作区 — 等用户确认方案后再打开 if (parsed.type === 'analysis_plan' && parsed.plan) { const plan = parsed.plan as WorkflowPlan; const { addRecord } = useSSAStore.getState(); addRecord(content, plan); continue; } // plan_confirmed 事件(Phase IV: 用户确认方案后打开工作区) // 不自动触发 executeWorkflow — 由用户在工作区手动点击「开始执行分析」 if (parsed.type === 'plan_confirmed') { const { setActivePane, setWorkspaceOpen } = useSSAStore.getState(); setActivePane('sap'); setWorkspaceOpen(true); setPendingQuestion(null); continue; } // ── Agent 通道 SSE 事件 ── if (parsed.type === 'agent_planning') { const { pushAgentExecution, setWorkspaceOpen, setActivePane } = useSSAStore.getState(); pushAgentExecution({ id: parsed.executionId || crypto.randomUUID(), sessionId: sessionId, query: content, retryCount: 0, status: 'planning', }); setActivePane('execution'); setWorkspaceOpen(true); continue; } if (parsed.type === 'agent_plan_ready') { const { updateAgentExecution } = useSSAStore.getState(); const initialStepResults: AgentStepResult[] = Array.isArray(parsed.plan?.steps) ? parsed.plan.steps.map((s: any) => ({ stepOrder: s.order || 0, method: s.method || '', status: 'pending', retryCount: 0, })) : []; updateAgentExecution({ planText: parsed.planText, planSteps: parsed.plan?.steps, status: 'plan_pending', stepResults: initialStepResults.length > 0 ? initialStepResults : undefined, currentStep: initialStepResults.length > 0 ? initialStepResults[0].stepOrder : undefined, }); continue; } const patchStepResult = (stepOrder: number, patch: Partial) => { const { agentExecution, updateAgentExecution } = useSSAStore.getState(); const existing = agentExecution?.stepResults || []; const idx = existing.findIndex(s => s.stepOrder === stepOrder); let next: AgentStepResult[]; if (idx >= 0) { next = existing.map((s, i) => (i === idx ? { ...s, ...patch } : s)); } else { next = [ ...existing, { stepOrder, method: '', status: 'pending', retryCount: 0, ...patch, } as AgentStepResult, ].sort((a, b) => a.stepOrder - b.stepOrder); } updateAgentExecution({ stepResults: next, currentStep: stepOrder }); }; if (parsed.type === 'step_coding') { patchStepResult(parsed.stepOrder || 0, { status: 'coding', partialCode: parsed.partialCode, retryCount: parsed.retryCount || 0, }); const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ partialCode: parsed.partialCode, status: 'coding', }); continue; } if (parsed.type === 'step_code_ready') { patchStepResult(parsed.stepOrder || 0, { status: 'coding', code: parsed.code, partialCode: undefined, }); const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ generatedCode: parsed.code, partialCode: undefined, status: 'coding', }); continue; } if (parsed.type === 'step_executing') { patchStepResult(parsed.stepOrder || 0, { status: 'executing' }); const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ status: 'executing' }); continue; } if (parsed.type === 'step_result') { patchStepResult(parsed.stepOrder || 0, { status: 'completed', reportBlocks: parsed.reportBlocks, durationMs: parsed.durationMs, errorMessage: undefined, }); continue; } if (parsed.type === 'step_error') { patchStepResult(parsed.stepOrder || 0, { status: parsed.willRetry ? 'coding' : 'error', errorMessage: parsed.message, retryCount: parsed.retryCount || 0, }); continue; } if (parsed.type === 'step_skipped') { patchStepResult(parsed.stepOrder || 0, { status: 'skipped', errorMessage: parsed.reason, }); continue; } if (parsed.type === 'pipeline_aborted') { const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ status: 'error', errorMessage: parsed.error || '执行已终止', currentStep: parsed.stepOrder, }); continue; } if (parsed.type === 'code_generating') { const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ partialCode: parsed.partialCode, status: 'coding', }); continue; } if (parsed.type === 'code_generated') { const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ generatedCode: parsed.code || undefined, partialCode: undefined, status: 'code_pending', }); continue; } if (parsed.type === 'code_executing') { const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ status: 'executing' }); continue; } if (parsed.type === 'code_result') { const { updateAgentExecution, agentExecution: curExec } = useSSAStore.getState(); updateAgentExecution({ reportBlocks: parsed.reportBlocks, generatedCode: parsed.code || curExec?.generatedCode, status: 'completed', durationMs: parsed.durationMs, stepResults: parsed.stepResults || curExec?.stepResults, }); // 在对话中插入可点击的结果卡片 const execId = curExec?.id; const execQuery = curExec?.query || content; setChatMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'assistant' as const, content: `✅ 分析完成:${execQuery}`, status: 'complete' as const, intent: 'system', executionId: execId, executionQuery: execQuery, createdAt: new Date().toISOString(), }]); continue; } if (parsed.type === 'code_error') { const { updateAgentExecution } = useSSAStore.getState(); if (parsed.willRetry) { updateAgentExecution({ errorMessage: parsed.message, }); } else { updateAgentExecution({ status: 'error', errorMessage: parsed.message, }); } continue; } if (parsed.type === 'code_retry') { const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ status: 'coding', retryCount: parsed.retryCount || 0, partialCode: undefined, }); continue; } // 错误事件 if (parsed.type === 'error') { const errMsg = parsed.message || '处理消息时发生错误'; setError(errMsg); setChatMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: errMsg, status: 'error' } : m )); continue; } // OpenAI Compatible 流式 chunk const chunk = parsed as OpenAIChunk; const delta = chunk.choices?.[0]?.delta; if (delta) { if (delta.reasoning_content) { fullThinking += delta.reasoning_content; setThinkingContent(fullThinking); } if (delta.content) { fullContent += delta.content; setStreamingContent(fullContent); setChatMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: fullContent, thinking: fullThinking || undefined } : m )); } } } catch { // 解析失败,跳过 } } } // 流结束,标记完成 setChatMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: fullContent, thinking: fullThinking || undefined, status: 'complete' } : m )); } catch (err: any) { if (err.name === 'AbortError') { setChatMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: fullContent || '(已中断)', status: 'complete' } : m )); retryCountRef.current = 0; } else { const isNetworkError = /failed to fetch|networkerror|network|http2|protocol/i.test(err.message || ''); // 瞬时网络错误自动重试一次 if (isNetworkError && retryCountRef.current < MAX_AUTO_RETRY) { retryCountRef.current += 1; const delay = retryDelay(retryCountRef.current); setChatMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: `⏳ 网络波动,${(delay / 1000).toFixed(1)}s 后自动重试(第 ${retryCountRef.current}/${MAX_AUTO_RETRY} 次)...`, status: 'generating' } : m )); setIsGenerating(false); if (abortRef.current === controller) { abortRef.current = null; } await new Promise(r => setTimeout(r, delay)); return sendChatMessage(sessionId, content, metadata); } retryCountRef.current = 0; const friendlyMsg = toFriendlyError(err); setError(friendlyMsg); setChatMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: `⚠️ ${friendlyMsg}`, status: 'error' } : m )); } } finally { setIsGenerating(false); setStreamingContent(''); setThinkingContent(''); if (abortRef.current === controller) { abortRef.current = null; } } }, []); /** * 右侧工作区按钮触发 Agent 操作(方案 B) * 1. 在左侧对话追加审计轨迹消息(系统风格) * 2. 调用后端 API 触发下一步 * 3. 后端返回 SSE 事件更新右侧工作区 + 左侧简短 LLM 回复 */ const executeAgentAction = useCallback(async (sessionId: string, action: AgentActionType) => { const AUDIT_MESSAGES: Record = { confirm_plan: '✅ 方案已确认,已进入执行确认(执行时将分步生成代码)...', confirm_code: '✅ 代码已确认,R 引擎正在执行...', cancel: '❌ 已取消当前分析', }; const auditContent = AUDIT_MESSAGES[action]; // 1. 追加审计轨迹消息(系统风格,不是用户消息) const auditMsgId = crypto.randomUUID(); setChatMessages(prev => [...prev, { id: auditMsgId, role: 'assistant' as const, content: auditContent, status: 'complete' as const, intent: 'system', createdAt: new Date().toISOString(), }]); // 2. 通过 sendChatMessage 触发后端(带 agentAction metadata) await sendChatMessage(sessionId, auditContent, { agentAction: action }); }, [sendChatMessage]); /** * 响应 ask_user 卡片(Phase III) * 将 value 解析为中文 label 用于显示 */ const respondToQuestion = useCallback(async (sessionId: string, response: AskUserResponseData) => { const question = pendingQuestion; setPendingQuestion(null); let displayText: string; if (response.action === 'select' && response.selectedValues) { const labels = response.selectedValues.map(val => { const opt = question?.options?.find(o => o.value === val); return opt?.label || val; }); displayText = `选择了 ${labels.join('、')}`; } else if (response.action === 'free_text') { displayText = response.freeText || '(已回复)'; } else { displayText = '(已回复)'; } await sendChatMessage(sessionId, displayText, { askUserResponse: response }); }, [sendChatMessage, pendingQuestion]); /** * H1: 跳过 ask_user 卡片 */ const skipQuestion = useCallback(async (sessionId: string, questionId: string) => { setPendingQuestion(null); const skipResponse: AskUserResponseData = { questionId, action: 'skip', }; await sendChatMessage(sessionId, '已跳过此问题', { askUserResponse: skipResponse }); }, [sendChatMessage]); const retryLastMessage = useCallback(async () => { const last = lastRequestRef.current; if (!last) return; retryCountRef.current = 0; setChatMessages(prev => { let lastErrIdx = -1; for (let i = prev.length - 1; i >= 0; i--) { if (prev[i].status === 'error') { lastErrIdx = i; break; } } return lastErrIdx >= 0 ? prev.filter((_: ChatMessage, i: number) => i !== lastErrIdx) : prev; }); await sendChatMessage(last.sessionId, last.content, last.metadata); }, [sendChatMessage]); return { chatMessages, isGenerating, currentIntent, intentMeta, thinkingContent, streamingContent, error, pendingQuestion, pendingPlanConfirm, sendChatMessage, executeAgentAction, respondToQuestion, skipQuestion, loadHistory, abort, clearMessages, retryLastMessage, }; } export default useSSAChat;