/** * SSAChatPane - V11 对话区(Phase II 升级) * * Phase II 改造: * - handleSend 走统一 /chat SSE API(替换 parseIntent → generateWorkflowPlan) * - 流式消息渲染(替换 TypeWriter 逐字效果) * - ThinkingBlock 深度思考折叠 * - 意图标签(intent badge) * - H3:streaming 期间锁定输入 * * 保留不变:Header、文件上传、DataProfileCard、SAP/Result 卡片 */ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { Bot, User, Paperclip, ArrowUp, FileSpreadsheet, X, ArrowLeft, FileSignature, ArrowRight, Loader2, AlertCircle, CheckCircle, BarChart2, Square, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useSSAStore } from '../stores/ssaStore'; import type { AnalysisRecord } from '../stores/ssaStore'; import { useAnalysis } from '../hooks/useAnalysis'; import { useWorkflow } from '../hooks/useWorkflow'; import { useSSAChat } from '../hooks/useSSAChat'; import type { ChatMessage, ChatIntentType } from '../hooks/useSSAChat'; import type { SSAMessage } from '../types'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { TypeWriter } from './TypeWriter'; import { DataProfileCard } from './DataProfileCard'; import { ClarificationCard } from './ClarificationCard'; import { AskUserCard } from './AskUserCard'; import type { AskUserResponseData } from './AskUserCard'; import { ThinkingBlock } from '@/shared/components/Chat'; import { ModeToggle } from './ModeToggle'; import type { ClarificationCardData, IntentResult } from '../types'; export const SSAChatPane: React.FC = () => { const navigate = useNavigate(); const { currentSession, messages, mountedFile, setMountedFile, setCurrentSession, setWorkspaceOpen, isLoading, isExecuting, error, setError, addToast, addMessage, selectRecord, analysisHistory, dataProfile, dataProfileLoading, } = useSSAStore(); const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis(); const { generateDataProfile, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow(); const { chatMessages, isGenerating, currentIntent, pendingQuestion, sendChatMessage, respondToQuestion, skipQuestion, loadHistory, abort: abortChat, clearMessages, } = useSSAChat(); const [inputValue, setInputValue] = useState(''); const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle'); const [pendingClarification, setPendingClarification] = useState<{ cards: ClarificationCardData[]; originalQuery: string; intent: IntentResult; } | null>(null); const fileInputRef = useRef(null); const chatEndRef = useRef(null); const messagesContainerRef = useRef(null); // Phase II: session 切换时加载对话历史 useEffect(() => { if (currentSession?.id) { loadHistory(currentSession.id); } else { clearMessages(); } }, [currentSession?.id, loadHistory, clearMessages]); // 自动滚动到底部,确保最新内容可见 const scrollToBottom = useCallback(() => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTo({ top: messagesContainerRef.current.scrollHeight, behavior: 'smooth' }); } }, []); useEffect(() => { const timer = setTimeout(scrollToBottom, 100); return () => clearTimeout(timer); }, [messages, chatMessages, isGenerating, scrollToBottom]); const handleBack = () => { navigate(-1); }; const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setUploadStatus('uploading'); setError(null); try { setUploadStatus('parsing'); const result = await uploadData(file); setCurrentSession({ id: result.sessionId, title: file.name.replace(/\.(csv|xlsx|xls)$/i, ''), mode: 'analysis', status: 'active', dataSchema: { columns: result.schema.columns.map((c: any) => ({ name: c.name, type: c.type as 'numeric' | 'categorical' | 'datetime' | 'text', uniqueValues: c.uniqueValues, nullCount: c.nullCount, })), rowCount: result.schema.rowCount, preview: [], }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); setMountedFile({ name: file.name, size: file.size, rowCount: result.schema.rowCount, }); setUploadStatus('success'); addToast('数据读取成功,正在进行质量核查...', 'success'); // Phase 2A: 自动触发数据质量核查 try { await generateDataProfile(result.sessionId); } catch (profileErr) { console.warn('数据画像生成失败,继续使用基础模式:', profileErr); } } catch (err: any) { setUploadStatus('error'); const errorMsg = err?.message || '上传失败,请检查文件格式'; setError(errorMsg); addToast(errorMsg, 'error'); } }; const handleRemoveFile = () => { setMountedFile(null); setWorkspaceOpen(false); setUploadStatus('idle'); setError(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleSend = async () => { if (!inputValue.trim()) return; if (isGenerating) return; const query = inputValue.trim(); setInputValue(''); if (currentSession?.id) { // Phase II: 统一走 /chat SSE API // useSSAChat 内部处理 user message 添加 + assistant placeholder + 流式接收 try { await sendChatMessage(currentSession.id, query); } catch (err: any) { addToast(err?.message || '对话失败', 'error'); } } else { // 没有 session(未上传数据):走老的 generatePlan 流程 addMessage({ id: crypto.randomUUID(), role: 'user', content: query, createdAt: new Date().toISOString(), }); try { await generatePlan(query); } catch (err: any) { addToast(err?.message || '生成计划失败', 'error'); } } }; const handleClarificationSelect = async (selections: Record) => { if (!currentSession?.id || !pendingClarification) return; setPendingClarification(null); const selectedLabel = Object.values(selections).join(', '); addMessage({ id: crypto.randomUUID(), role: 'user', content: selectedLabel, createdAt: new Date().toISOString(), }); try { const resp = await handleClarify( currentSession.id, pendingClarification.originalQuery, selections ); if (resp.needsClarification && resp.clarificationCards?.length > 0) { addMessage({ id: crypto.randomUUID(), role: 'assistant', content: '还需要确认一下:', createdAt: new Date().toISOString(), }); setPendingClarification({ cards: resp.clarificationCards, originalQuery: pendingClarification.originalQuery, intent: resp.intent, }); } } catch (err: any) { addToast(err?.message || '处理追问失败', 'error'); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleOpenWorkspace = useCallback((recordId?: string) => { if (recordId) { selectRecord(recordId); } else { setWorkspaceOpen(true); } }, [selectRecord, setWorkspaceOpen]); const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; }; return (
{/* Chat Header */}
{currentSession?.title || '新的统计分析'}
{currentSession && }
{/* Chat Messages */}
{/* 欢迎语 */}
你好!我是 SSA-Pro 智能统计助手。
你可以直接描述研究目标,我将为你生成分析方案;或者点击下方 📎 上传数据文件,我们将直接开始分析。
{/* Phase 2A: 数据质量核查报告卡片 - 在欢迎语之后、用户消息之前显示 */} {dataProfile && (
)} {/* 旧消息(store 中的 SSAMessage,保留向后兼容) */} {messages.map((msg: SSAMessage, idx: number) => { const isLastAiMessage = msg.role === 'assistant' && idx === messages.length - 1; const showTypewriter = isLastAiMessage && !msg.artifactType; return (
{msg.role === 'user' ? : }
{showTypewriter ? ( ) : ( msg.content )} {(msg.artifactType === 'sap' || msg.artifactType === 'result') && msg.recordId && (() => { const rec = analysisHistory.find((r: AnalysisRecord) => r.id === msg.recordId); const isCompleted = rec?.status === 'completed'; const isSap = msg.artifactType === 'sap'; return ( ); })()}
); })} {/* Phase II: 流式对话消息(来自 useSSAChat) */} {chatMessages.map((msg: ChatMessage) => (
{msg.role === 'user' ? : }
{/* 意图标签 */} {msg.role === 'assistant' && msg.intent && ( )} {/* 深度思考折叠 */} {msg.role === 'assistant' && msg.thinking && ( )} {/* 消息内容 */} {msg.status === 'generating' && !msg.content ? (
) : msg.status === 'error' ? (
{msg.content}
) : (
{msg.content}
)}
))} {/* 数据画像生成中指示器 */} {dataProfileLoading && (
正在进行数据质量核查...
)} {/* AI 正在思考指示器(仅老流程使用,Phase II 由 chatMessages 中的 generating 状态处理) */} {(isLoading || isPlanLoading) && !isGenerating && (
)} {/* Phase Q: 追问卡片(保留向后兼容) */} {pendingClarification && (
)} {/* Phase III/IV: AskUser 交互卡片(H3 统一模型) */} {pendingQuestion && currentSession?.id && (
respondToQuestion(currentSession.id, response)} onSkip={(questionId: string) => skipQuestion(currentSession.id, questionId)} disabled={isGenerating || isExecuting} />
)} {/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
{/* Chat Input */}
{/* 上传进度条 */} {uploadStatus === 'uploading' && (
正在上传...
)} {/* 解析中状态 */} {uploadStatus === 'parsing' && (
正在解析数据结构...
)} {/* 上传错误 */} {uploadStatus === 'error' && error && (
{error}
)} {/* 数据挂载区 */} {mountedFile && uploadStatus !== 'error' && (
{mountedFile.name} {mountedFile.rowCount} rows • {formatFileSize(mountedFile.size)}
)} {/* 输入行 */}