Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { Send, Plus, Search, Settings, MoreHorizontal, User, Bot, ArrowLeft, Paperclip, Sparkles, Trash2, X, Upload, Package, FileText, BarChart3, ChevronRight } from 'lucide-react'; | |
| import { cn } from '../lib/utils'; | |
| import { Logo } from './Logo'; | |
| import ReactMarkdown from 'react-markdown'; | |
| interface Message { | |
| id: string; | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| timestamp: Date; | |
| file?: { | |
| name: string; | |
| size: number; | |
| }; | |
| reports?: Array<{ | |
| name: string; | |
| path: string; | |
| }>; | |
| plots?: Array<{ | |
| title: string; | |
| url: string; | |
| type?: 'image' | 'html'; | |
| }>; | |
| } | |
| interface ChatSession { | |
| id: string; | |
| title: string; | |
| messages: Message[]; | |
| updatedAt: Date; | |
| } | |
| export const ChatInterface: React.FC<{ onBack: () => void }> = ({ onBack }) => { | |
| const [sessions, setSessions] = useState<ChatSession[]>([ | |
| { | |
| id: '1', | |
| title: 'ML Model Analysis', | |
| messages: [], | |
| updatedAt: new Date(), | |
| } | |
| ]); | |
| const [activeSessionId, setActiveSessionId] = useState<string>('1'); // Start with default session, update to UUID after first API call | |
| const [input, setInput] = useState(''); | |
| const [isTyping, setIsTyping] = useState(false); | |
| const [currentStep, setCurrentStep] = useState<string>(''); | |
| const [uploadedFile, setUploadedFile] = useState<File | null>(null); | |
| const [reportModalUrl, setReportModalUrl] = useState<string | null>(null); | |
| const [reportModalTitle, setReportModalTitle] = useState<string>('Visualization'); | |
| const [showAssets, setShowAssets] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const eventSourceRef = useRef<EventSource | null>(null); | |
| const processedAnalysisRef = useRef<Set<string>>(new Set()); // Track processed analysis_complete events | |
| const activeSession = sessions.find(s => s.id === activeSessionId) || sessions[0]; | |
| useEffect(() => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| } | |
| }, [activeSession.messages, isTyping]); | |
| // Clear uploaded file when switching sessions | |
| useEffect(() => { | |
| setUploadedFile(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }, [activeSessionId]); | |
| // Connect to SSE when we receive a valid backend UUID | |
| useEffect(() => { | |
| // Only connect if we have a backend UUID (contains hyphens) | |
| if (!activeSessionId || !activeSessionId.includes('-')) { | |
| return; | |
| } | |
| // Check if we need a new connection for this session | |
| // Close old connection if it exists and belongs to a different session or is closed | |
| if (eventSourceRef.current) { | |
| const currentSource = eventSourceRef.current; | |
| // If readyState is CLOSED (2), we need a new connection | |
| // If it's CONNECTING (0) or OPEN (1) for the same session, reuse it | |
| if (currentSource.readyState === 2) { | |
| console.log('🔄 Existing connection closed, creating new one'); | |
| currentSource.close(); | |
| eventSourceRef.current = null; | |
| } else { | |
| console.log('♻️ Reusing existing SSE connection'); | |
| return; | |
| } | |
| } | |
| // Connect to SSE stream - will receive history + any new events | |
| const API_URL = window.location.origin; | |
| console.log(`🔌 Connecting SSE to session: ${activeSessionId}`); | |
| const eventSource = new EventSource(`${API_URL}/api/progress/stream/${activeSessionId}`); | |
| eventSource.onopen = () => { | |
| console.log('✅ SSE connection established'); | |
| }; | |
| // Handle all incoming messages | |
| eventSource.onmessage = (e) => { | |
| console.log('📨 SSE received:', e.data); | |
| try { | |
| const data = JSON.parse(e.data); | |
| // Handle different event types | |
| if (data.type === 'connected') { | |
| console.log('🔗 Connected to progress stream'); | |
| } else if (data.type === 'tool_executing') { | |
| setCurrentStep(data.message || `🔧 Executing: ${data.tool}`); | |
| } else if (data.type === 'tool_completed') { | |
| setCurrentStep(data.message || `✓ Completed: ${data.tool}`); | |
| } else if (data.type === 'tool_failed') { | |
| setCurrentStep(data.message || `❌ Failed: ${data.tool}`); | |
| } else if (data.type === 'token_update') { | |
| // Optional: Display token budget updates | |
| console.log('💰 Token update:', data.message); | |
| } else if (data.type === 'analysis_complete') { | |
| console.log('✅ Analysis completed', data.result); | |
| setIsTyping(false); | |
| // Create a unique key based on actual workflow content to prevent duplicates | |
| // Use the last tool executed + summary hash for uniqueness | |
| const lastTool = data.result?.workflow_history?.[data.result.workflow_history.length - 1]?.tool || 'unknown'; | |
| const summarySnippet = (data.result?.summary || '').substring(0, 50); | |
| const resultKey = `${activeSessionId}-${lastTool}-${summarySnippet}`; | |
| // Only process if we haven't seen this exact result before | |
| if (!processedAnalysisRef.current.has(resultKey)) { | |
| console.log('🆕 New analysis result, processing...', resultKey); | |
| processedAnalysisRef.current.add(resultKey); | |
| // Process the final result with the current session ID | |
| if (data.result) { | |
| processAnalysisResult(data.result, activeSessionId); | |
| } | |
| } else { | |
| console.log('⏭️ Skipping duplicate analysis result', resultKey); | |
| } | |
| } | |
| } catch (err) { | |
| console.error('❌ Error parsing SSE event:', err, e.data); | |
| } | |
| }; | |
| // Handle errors - DON'T immediately close, just log | |
| eventSource.onerror = (err) => { | |
| console.error('❌ SSE connection error/closed:', err); | |
| // Don't close here - let it reconnect naturally on next request | |
| // The readyState check above will handle creating a new connection if needed | |
| }; | |
| eventSourceRef.current = eventSource; | |
| // Cleanup on unmount or session change | |
| return () => { | |
| if (eventSourceRef.current) { | |
| console.log('🧹 Cleaning up SSE connection'); | |
| eventSourceRef.current.close(); | |
| eventSourceRef.current = null; | |
| } | |
| }; | |
| }, [activeSessionId]); | |
| const processAnalysisResult = (result: any, sessionId: string) => { | |
| // Extract and display the analysis result from SSE | |
| let assistantContent = '✅ Analysis Complete!\n\n'; | |
| let reports: Array<{name: string, path: string}> = []; | |
| let plots: Array<{title: string, url: string, type?: 'image' | 'html'}> = []; | |
| // PRIORITY 1: Extract plots from main result.plots array (backend enhanced summary) | |
| if (result.plots && Array.isArray(result.plots)) { | |
| result.plots.forEach((plot: any) => { | |
| plots.push({ | |
| title: plot.title || 'Visualization', | |
| url: plot.url || plot.path, | |
| type: plot.type || (plot.url?.endsWith('.html') ? 'html' : 'image') | |
| }); | |
| }); | |
| } | |
| // PRIORITY 2: Extract plots and reports from workflow_history (for backward compatibility) | |
| if (result.workflow_history) { | |
| const reportTools = ['generate_ydata_profiling_report', 'generate_plotly_dashboard', 'generate_all_plots']; | |
| const plotTools = [ | |
| 'generate_interactive_correlation_heatmap', | |
| 'generate_interactive_scatter', | |
| 'generate_interactive_histogram', | |
| 'generate_interactive_box_plots', | |
| 'generate_interactive_time_series', | |
| 'generate_eda_plots', | |
| 'generate_data_quality_plots', | |
| 'analyze_correlations' | |
| ]; | |
| result.workflow_history.forEach((step: any) => { | |
| if (reportTools.includes(step.tool)) { | |
| const reportPath = step.result?.output_path || step.result?.report_path || step.arguments?.output_path; | |
| if (reportPath && (step.result?.success !== false)) { | |
| reports.push({ | |
| name: step.tool.replace('generate_', '').replace(/_/g, ' ').trim(), | |
| path: reportPath | |
| }); | |
| } | |
| } | |
| // Only extract from workflow if not already in result.plots | |
| if (plotTools.includes(step.tool) && step.result?.result?.output_path && plots.length === 0) { | |
| const outputPath = step.result.result.output_path; | |
| plots.push({ | |
| title: step.tool.replace('generate_', '').replace('interactive_', '').replace(/_/g, ' ').trim(), | |
| url: outputPath.startsWith('/') ? outputPath : `/outputs/${outputPath.replace('./outputs/', '')}`, | |
| type: outputPath.endsWith('.html') ? 'html' : 'image' | |
| }); | |
| } | |
| }); | |
| } | |
| if (reports.length > 0) { | |
| assistantContent += '📊 **Generated Reports:**\n'; | |
| reports.forEach(r => assistantContent += `- ${r.name}\n`); | |
| assistantContent += '\n'; | |
| } | |
| if (plots.length > 0) { | |
| assistantContent += `📈 **Generated ${plots.length} Visualizations**\n\n`; | |
| } | |
| // Extract summary from backend (field changed from final_answer to summary) | |
| const summaryText = result.summary || result.final_answer || 'Analysis complete. Check the generated artifacts.'; | |
| assistantContent += summaryText; | |
| // Add assistant message with result | |
| const assistantMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'assistant', | |
| content: assistantContent, | |
| timestamp: new Date(), | |
| reports, | |
| plots | |
| }; | |
| // Get current session and add message | |
| setSessions(prev => prev.map(s => { | |
| if (s.id === sessionId) { | |
| return { ...s, messages: [...s.messages, assistantMessage], updatedAt: new Date() }; | |
| } | |
| return s; | |
| })); | |
| }; | |
| const handleSend = async () => { | |
| if ((!input.trim() && !uploadedFile) || isTyping) return; | |
| const userMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'user', | |
| content: input || (uploadedFile ? `Uploaded: ${uploadedFile.name}` : ''), | |
| timestamp: new Date(), | |
| file: uploadedFile ? { name: uploadedFile.name, size: uploadedFile.size } : undefined, | |
| }; | |
| const newMessages = [...activeSession.messages, userMessage]; | |
| updateSession(activeSessionId, newMessages); | |
| setInput(''); | |
| // Show loading indicator immediately (for UI feedback) | |
| setIsTyping(true); | |
| try { | |
| // Use the current origin if running on same server, otherwise use env variable | |
| const API_URL = window.location.origin; | |
| console.log('API URL:', API_URL); | |
| let response; | |
| const sessionKey = activeSessionId || 'default'; | |
| // Check if there's a recent file analysis in the conversation | |
| const recentFileMessage = newMessages.slice(-5).find(m => m.file || m.content.includes('Uploaded:')); | |
| const hasRecentFile = recentFileMessage && !uploadedFile; | |
| if (uploadedFile || hasRecentFile) { | |
| // Use /run endpoint for file analysis or follow-up questions about uploaded data | |
| const formData = new FormData(); | |
| if (uploadedFile) { | |
| formData.append('file', uploadedFile); | |
| formData.append('task_description', input || 'Analyze this dataset and provide insights'); | |
| formData.append('session_id', sessionKey); // Add session_id for progress tracking | |
| } else if (hasRecentFile) { | |
| // For follow-up questions, extract the filename from recent context | |
| const fileNameMatch = recentFileMessage?.content.match(/Uploaded: (.+)/); | |
| const fileName = fileNameMatch ? fileNameMatch[1] : 'dataset.csv'; | |
| // Send follow-up request as a new task description | |
| formData.append('task_description', input); | |
| formData.append('session_id', sessionKey); // Use same session key | |
| // Note: Backend needs to support session-based file context | |
| // For now, just send the task which should work with session memory | |
| } | |
| formData.append('use_cache', 'true'); | |
| formData.append('max_iterations', '20'); | |
| response = await fetch(`${API_URL}/run-async`, { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| setUploadedFile(null); | |
| } else { | |
| response = await fetch(`${API_URL}/chat`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| messages: newMessages.map(m => ({ | |
| role: m.role, | |
| content: m.content | |
| })), | |
| stream: false | |
| }) | |
| }); | |
| } | |
| if (!response.ok) { | |
| throw new Error(`API error: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| // Store UUID from backend to trigger SSE connection | |
| if (data.session_id) { | |
| console.log(`🔑 Session UUID from backend: ${data.session_id}`); | |
| const newSessionId = data.session_id; | |
| // CRITICAL: Update sessions first, then activeSessionId | |
| // React 18 batches these updates automatically, preventing flicker | |
| setSessions(prev => prev.map(s => | |
| s.id === activeSessionId ? { ...s, id: newSessionId } : s | |
| )); | |
| setActiveSessionId(newSessionId); | |
| } | |
| // For async endpoint, result comes via SSE analysis_complete event | |
| // For now, just wait for SSE to deliver the result | |
| if (data.status === 'started') { | |
| console.log('🚀 Analysis started, waiting for SSE events...'); | |
| return; // Don't process result here, will come via SSE | |
| } | |
| // Legacy sync endpoint handling (if data.result exists) | |
| let assistantContent = ''; | |
| let reports: Array<{name: string, path: string}> = []; | |
| let plots: Array<{title: string, url: string, type?: 'image' | 'html'}> = []; | |
| // Check for reports in any /run endpoint response (not just when file is uploaded) | |
| if (data.result) { | |
| const result = data.result; | |
| assistantContent = `✅ Analysis Complete!\n\n`; | |
| // Extract plots from workflow_history (PRIMARY SOURCE) | |
| if (result.workflow_history) { | |
| const reportTools = ['generate_ydata_profiling_report', 'generate_plotly_dashboard', 'generate_all_plots']; | |
| const plotTools = [ | |
| 'generate_interactive_correlation_heatmap', | |
| 'generate_interactive_scatter', | |
| 'generate_interactive_histogram', | |
| 'generate_interactive_box_plots', | |
| 'generate_interactive_time_series', | |
| 'generate_eda_plots', | |
| 'generate_data_quality_plots', | |
| 'analyze_correlations' | |
| ]; | |
| result.workflow_history.forEach((step: any) => { | |
| // Extract reports | |
| if (reportTools.includes(step.tool)) { | |
| const reportPath = step.result?.output_path || step.result?.report_path || step.arguments?.output_path; | |
| if (reportPath && (step.result?.success !== false)) { | |
| reports.push({ | |
| name: step.tool.replace('generate_', '').replace(/_/g, ' ').replace('report', '').trim(), | |
| path: reportPath | |
| }); | |
| } | |
| } | |
| // Extract plots | |
| if (plotTools.includes(step.tool)) { | |
| const plotPath = step.result?.output_path || step.arguments?.output_path; | |
| if (plotPath && (step.result?.success !== false)) { | |
| const plotTitle = step.tool | |
| .replace('generate_', '') | |
| .replace('interactive_', '') | |
| .replace(/_/g, ' ') | |
| .replace('plots', 'plot') | |
| .trim(); | |
| plots.push({ | |
| title: plotTitle.charAt(0).toUpperCase() + plotTitle.slice(1), | |
| url: plotPath.replace('./outputs/', '/outputs/'), | |
| type: plotPath.endsWith('.html') ? 'html' : 'image' | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| // Also check for report paths mentioned in the summary text | |
| if (result.summary && !reports.length) { | |
| const reportPathMatch = result.summary.match(/\.(\/outputs\/reports\/[^\s]+\.html)/); | |
| if (reportPathMatch) { | |
| reports.push({ | |
| name: 'ydata profiling', | |
| path: reportPathMatch[1] | |
| }); | |
| } | |
| } | |
| if (result.summary) { | |
| assistantContent += `**Summary:**\n${result.summary}\n\n`; | |
| } | |
| if (result.workflow_history && result.workflow_history.length > 0) { | |
| assistantContent += `**Tools Used:** ${result.workflow_history.length} steps\n\n`; | |
| assistantContent += `**Final Result:**\n${result.final_result || 'Analysis completed successfully'}`; | |
| } | |
| } else if (data.success && data.message) { | |
| assistantContent = data.message; | |
| } else { | |
| throw new Error('Invalid response from API'); | |
| } | |
| // Aggressive text cleaning to remove malformed content | |
| assistantContent = assistantContent | |
| // Remove broken markdown tables (lines with just | symbols) | |
| .replace(/^\s*\|\s*\|\s*$/gm, '') | |
| // Remove confusing phrases | |
| .replace(/Printed in logs \(see above\)/gi, '') | |
| .replace(/\(see above\)/gi, '') | |
| .replace(/see above/gi, '') | |
| // Remove broken table rows (just dashes and pipes) | |
| .replace(/^\s*[-|]+\s*$/gm, '') | |
| // Remove code block markers without content | |
| .replace(/```\s*```/g, '') | |
| // Remove empty markdown sections | |
| .replace(/\n{3,}/g, '\n\n') | |
| // Clean up broken table syntax | |
| .replace(/\|\s*\n\s*\|/g, '') | |
| .trim(); | |
| updateSession(activeSessionId, [...newMessages, { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant', | |
| content: assistantContent, | |
| timestamp: new Date(), | |
| reports: reports.length > 0 ? reports : undefined, | |
| plots: plots.length > 0 ? plots : undefined | |
| }]); | |
| } catch (error: any) { | |
| console.error("Chat Error:", error); | |
| let errorMessage = "I'm sorry, I encountered an error processing your request."; | |
| if (error.message) { | |
| errorMessage += `\n\n**Error:** ${error.message}`; | |
| } | |
| // Try to parse response error | |
| try { | |
| const errorText = await error.text?.(); | |
| if (errorText) { | |
| const errorData = JSON.parse(errorText); | |
| if (errorData.detail) { | |
| errorMessage = `**Error:** ${typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)}`; | |
| } | |
| } | |
| } catch (e) { | |
| // Ignore parsing errors | |
| } | |
| updateSession(activeSessionId, [...newMessages, { | |
| id: 'err-' + Date.now(), | |
| role: 'assistant', | |
| content: errorMessage, | |
| timestamp: new Date() | |
| }]); | |
| // On error, stop loading indicator | |
| setIsTyping(false); | |
| } | |
| // NOTE: No finally block - isTyping is set to false by SSE analysis_complete event | |
| }; | |
| const updateSession = (id: string, messages: Message[]) => { | |
| setSessions(prev => prev.map(s => { | |
| if (s.id === id) { | |
| return { ...s, messages, updatedAt: new Date() }; | |
| } | |
| return s; | |
| })); | |
| }; | |
| const createNewChat = () => { | |
| const newId = Date.now().toString(); | |
| const newSession: ChatSession = { | |
| id: newId, | |
| title: 'New Chat', | |
| messages: [], | |
| updatedAt: new Date() | |
| }; | |
| setSessions([newSession, ...sessions]); | |
| setActiveSessionId(newId); | |
| // Clear file upload state for new chat | |
| setUploadedFile(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }; | |
| const deleteSession = (e: React.MouseEvent, id: string) => { | |
| e.stopPropagation(); | |
| if (sessions.length === 1) return; | |
| setSessions(prev => prev.filter(s => s.id !== id)); | |
| if (activeSessionId === id) { | |
| setActiveSessionId(sessions.find(s => s.id !== id)?.id || ''); | |
| } | |
| }; | |
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (file) { | |
| const validTypes = ['.csv', '.parquet']; | |
| const fileExt = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); | |
| if (validTypes.includes(fileExt)) { | |
| setUploadedFile(file); | |
| } else { | |
| alert('Please upload a CSV or Parquet file'); | |
| } | |
| } | |
| }; | |
| const removeFile = () => { | |
| setUploadedFile(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }; | |
| return ( | |
| <div className="flex h-screen w-full bg-[#050505] overflow-hidden text-white/90"> | |
| {/* Sidebar */} | |
| <aside className="w-[280px] hidden md:flex flex-col border-r border-white/5 bg-[#0a0a0a]/50 backdrop-blur-xl"> | |
| <div className="p-4 flex flex-col h-full"> | |
| <div className="flex items-center gap-3 mb-8 px-2"> | |
| <Logo className="w-8 h-8" /> | |
| <span className="font-bold tracking-tight text-sm uppercase">Console</span> | |
| </div> | |
| <button | |
| onClick={createNewChat} | |
| className="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all text-sm font-medium mb-6 group" | |
| > | |
| <Plus className="w-4 h-4 group-hover:scale-110 transition-transform" /> | |
| New Conversation | |
| </button> | |
| <div className="flex-1 overflow-y-auto space-y-2 custom-scrollbar"> | |
| <p className="px-3 text-[10px] uppercase tracking-widest text-white/30 font-bold mb-2">History</p> | |
| {sessions.map(session => ( | |
| <div | |
| key={session.id} | |
| onClick={() => setActiveSessionId(session.id)} | |
| className={cn( | |
| "group flex items-center justify-between px-4 py-3 rounded-xl cursor-pointer transition-all text-sm", | |
| activeSessionId === session.id | |
| ? "bg-white/10 text-white border border-white/10 shadow-lg" | |
| : "text-white/40 hover:text-white/70 hover:bg-white/5" | |
| )} | |
| > | |
| <span className="truncate flex-1 pr-2">{session.title}</span> | |
| <Trash2 | |
| onClick={(e) => deleteSession(e, session.id)} | |
| className="w-4 h-4 opacity-0 group-hover:opacity-100 hover:text-rose-400 transition-all" | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="mt-auto pt-4 border-t border-white/5 flex items-center justify-between px-2"> | |
| <button onClick={onBack} className="p-2 hover:bg-white/5 rounded-lg transition-colors text-white/40 hover:text-white"> | |
| <ArrowLeft className="w-5 h-5" /> | |
| </button> | |
| <div className="flex gap-2"> | |
| <button className="p-2 hover:bg-white/5 rounded-lg transition-colors text-white/40 hover:text-white"> | |
| <Settings className="w-5 h-5" /> | |
| </button> | |
| <button className="p-2 hover:bg-white/5 rounded-lg transition-colors text-white/40 hover:text-white"> | |
| <User className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| {/* Main Chat Area */} | |
| <main className="flex-1 flex flex-col relative bg-gradient-to-b from-[#080808] to-[#050505]"> | |
| {/* Top Header */} | |
| <header className="h-16 flex items-center justify-between px-6 border-b border-white/5 backdrop-blur-md bg-black/20 sticky top-0 z-10"> | |
| <div className="flex items-center gap-4"> | |
| <button onClick={onBack} className="md:hidden p-2 hover:bg-white/5 rounded-lg"> | |
| <ArrowLeft className="w-5 h-5" /> | |
| </button> | |
| <div> | |
| <h2 className="text-sm font-bold text-white tracking-tight">{activeSession.title}</h2> | |
| <p className="text-[10px] text-white/30 font-medium">{activeSession.messages.length} messages in session</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={() => setShowAssets(!showAssets)} | |
| className={cn( | |
| "p-2 transition-colors rounded-lg", | |
| showAssets ? "text-emerald-400 bg-emerald-500/10" : "text-white/40 hover:text-white" | |
| )} | |
| > | |
| <Package className="w-5 h-5" /> | |
| </button> | |
| <button className="p-2 text-white/40 hover:text-white transition-colors"> | |
| <Search className="w-5 h-5" /> | |
| </button> | |
| <button className="p-2 text-white/40 hover:text-white transition-colors"> | |
| <MoreHorizontal className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </header> | |
| {/* Message List */} | |
| <div | |
| ref={scrollRef} | |
| className="flex-1 overflow-y-auto p-4 md:p-8 space-y-8 scroll-smooth" | |
| > | |
| {activeSession.messages.length === 0 ? ( | |
| <div className="h-full flex flex-col items-center justify-center text-center px-4"> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.9 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| className="w-16 h-16 bg-gradient-to-br from-indigo-500/20 to-rose-500/20 rounded-2xl flex items-center justify-center mb-6 border border-white/10" | |
| > | |
| <Sparkles className="w-8 h-8 text-indigo-400" /> | |
| </motion.div> | |
| <h1 className="text-2xl font-extrabold text-white mb-3">Welcome, Data Scientist</h1> | |
| <p className="text-white/40 max-w-sm leading-relaxed text-sm"> | |
| I'm your autonomous agent ready to profile data, train models, or build dashboards. | |
| Try uploading a dataset or describing your ML objective. | |
| </p> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-8 w-full max-w-lg"> | |
| {[ | |
| "Profile my sales.csv", | |
| "Train a XGBoost classifier", | |
| "Generate a correlation heatmap", | |
| "Explain feature importance" | |
| ].map(prompt => ( | |
| <button | |
| key={prompt} | |
| onClick={() => setInput(prompt)} | |
| className="text-left px-4 py-3 rounded-xl bg-white/[0.03] border border-white/5 hover:bg-white/5 transition-all text-xs text-white/60 hover:text-white" | |
| > | |
| "{prompt}" | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| activeSession.messages.map((msg) => ( | |
| <motion.div | |
| key={msg.id} | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className={cn( | |
| "flex w-full gap-4", | |
| msg.role === 'user' ? "flex-row-reverse" : "flex-row" | |
| )} | |
| > | |
| <div className={cn( | |
| "w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-white/10", | |
| msg.role === 'user' ? "bg-indigo-500/20" : "bg-white/5" | |
| )}> | |
| {msg.role === 'user' ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4 text-indigo-400" />} | |
| </div> | |
| <div className={cn( | |
| "max-w-[80%] md:max-w-[70%] p-4 rounded-2xl text-sm leading-relaxed", | |
| msg.role === 'user' | |
| ? "bg-indigo-600/20 text-indigo-50 border border-indigo-500/20" | |
| : "bg-white/[0.03] text-white/80 border border-white/5" | |
| )}> | |
| {msg.file && ( | |
| <div className="mb-2 flex items-center gap-2 text-xs bg-white/5 rounded-lg px-3 py-2 border border-white/10"> | |
| <Paperclip className="w-3 h-3" /> | |
| <span className="font-medium">{msg.file.name}</span> | |
| <span className="text-white/40">({(msg.file.size / 1024).toFixed(1)} KB)</span> | |
| </div> | |
| )} | |
| {msg.role === 'assistant' ? ( | |
| <ReactMarkdown | |
| className="prose prose-invert prose-sm max-w-none prose-p:leading-relaxed prose-pre:bg-black/40 prose-pre:border prose-pre:border-white/10 prose-headings:text-white prose-strong:text-white prose-li:text-white/80" | |
| components={{ | |
| p: ({node, ...props}) => <p className="mb-3 last:mb-0" {...props} />, | |
| ul: ({node, ...props}) => <ul className="mb-3 space-y-1" {...props} />, | |
| ol: ({node, ...props}) => <ol className="mb-3 space-y-1" {...props} />, | |
| li: ({node, ...props}) => <li className="ml-4" {...props} />, | |
| strong: ({node, ...props}) => <strong className="font-semibold text-white" {...props} />, | |
| code: ({node, inline, ...props}: any) => | |
| inline ? | |
| <code className="px-1.5 py-0.5 rounded bg-white/10 text-indigo-300 text-xs font-mono" {...props} /> : | |
| <code className="block p-3 rounded-lg bg-black/40 border border-white/10 text-xs font-mono overflow-x-auto" {...props} /> | |
| }} | |
| > | |
| {msg.content || ''} | |
| </ReactMarkdown> | |
| ) : ( | |
| msg.content | |
| )} | |
| {msg.reports && msg.reports.length > 0 && ( | |
| <div className="mt-4 flex flex-wrap gap-2"> | |
| {msg.reports.map((report, idx) => { | |
| // Normalize the report path: remove leading ./ and ensure it starts with / | |
| const normalizedPath = report.path.replace(/^\.\//, '/'); | |
| return ( | |
| <button | |
| key={idx} | |
| onClick={() => { setReportModalUrl(`${window.location.origin}${normalizedPath}`); setReportModalTitle(report.name || 'Report'); }} | |
| className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-500/20 hover:bg-indigo-500/30 border border-indigo-500/30 text-indigo-200 text-xs font-medium transition-all group" | |
| > | |
| <Sparkles className="w-3.5 h-3.5 group-hover:scale-110 transition-transform" /> | |
| View {report.name} Report | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {msg.plots && msg.plots.length > 0 && ( | |
| <> | |
| <div className="mt-4 space-y-3"> | |
| <div className="text-xs font-semibold text-white/60 mb-2"> | |
| 📊 Generated Visualizations ({msg.plots.length}) | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| {msg.plots.map((plot, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => { setReportModalUrl(`${window.location.origin}${plot.url}`); setReportModalTitle(plot.title || 'Visualization'); }} | |
| className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 border border-emerald-500/30 text-emerald-200 text-xs font-medium transition-all group" | |
| > | |
| <svg className="w-3.5 h-3.5 group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /> | |
| </svg> | |
| View {plot.title} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| <div className="mt-2 text-[10px] opacity-20 font-mono"> | |
| {msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} | |
| </div> | |
| </div> | |
| </motion.div> | |
| )) | |
| )} | |
| {isTyping && ( | |
| <div className="flex gap-4"> | |
| <div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-white/5 border border-white/10"> | |
| <Bot className="w-4 h-4 text-indigo-400" /> | |
| </div> | |
| <div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5"> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex gap-1"> | |
| <span className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.3s]"></span> | |
| <span className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.15s]"></span> | |
| <span className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-bounce"></span> | |
| </div> | |
| <span className="text-sm text-white/60"> | |
| {currentStep || '🔧 Starting analysis...'} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Input Bar */} | |
| <div className="p-4 md:p-8 pt-0"> | |
| <div className="max-w-4xl mx-auto relative"> | |
| <div className="absolute -top-10 left-4 flex gap-2"> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".csv,.parquet" | |
| onChange={handleFileSelect} | |
| className="hidden" | |
| id="file-upload" | |
| /> | |
| <label | |
| htmlFor="file-upload" | |
| className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/[0.03] border border-white/5 text-[10px] text-white/40 hover:text-white hover:bg-white/5 transition-all cursor-pointer" | |
| > | |
| <Upload className="w-3 h-3" /> Upload Dataset | |
| </label> | |
| {uploadedFile && ( | |
| <div className="flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-500/20 border border-indigo-500/30 text-[10px] text-indigo-200"> | |
| <Paperclip className="w-3 h-3" /> | |
| <span className="max-w-[150px] truncate">{uploadedFile.name}</span> | |
| <button onClick={removeFile} className="hover:text-white transition-colors"> | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="relative group"> | |
| <textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }} | |
| placeholder={uploadedFile ? "Describe what you want to do with this dataset..." : "Ask your agent anything or upload a dataset..."} | |
| className="w-full bg-[#0d0d0d] border border-white/10 rounded-2xl p-4 pr-16 text-sm min-h-[56px] max-h-48 resize-none focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20 transition-all text-white/90 placeholder:text-white/20 shadow-2xl" | |
| /> | |
| <button | |
| onClick={handleSend} | |
| disabled={(!input.trim() && !uploadedFile) || isTyping} | |
| className={cn( | |
| "absolute right-3 bottom-3 p-2.5 rounded-xl transition-all", | |
| (input.trim() || uploadedFile) && !isTyping | |
| ? "bg-white text-black hover:scale-105 active:scale-95" | |
| : "bg-white/5 text-white/20 cursor-not-allowed" | |
| )} | |
| > | |
| <Send className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| <p className="text-center mt-3 text-[10px] text-white/20 font-medium"> | |
| Enterprise Data Agent v3.1 | Secured with end-to-end encryption | |
| </p> | |
| </div> | |
| </div> | |
| </main> | |
| {/* Assets Sidebar */} | |
| <AnimatePresence> | |
| {showAssets && ( | |
| <motion.aside | |
| initial={{ x: 320, opacity: 0 }} | |
| animate={{ x: 0, opacity: 1 }} | |
| exit={{ x: 320, opacity: 0 }} | |
| transition={{ type: "spring", damping: 25, stiffness: 200 }} | |
| className="w-[320px] border-l border-white/5 bg-[#0a0a0a]/95 backdrop-blur-xl flex flex-col" | |
| > | |
| <div className="p-4 border-b border-white/5 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <Package className="w-5 h-5 text-emerald-400" /> | |
| <h3 className="font-bold text-sm">Assets</h3> | |
| </div> | |
| <button | |
| onClick={() => setShowAssets(false)} | |
| className="p-1.5 rounded-lg hover:bg-white/5 transition-colors" | |
| > | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar"> | |
| {(() => { | |
| const allPlots: Array<{title: string, url: string, type?: string}> = []; | |
| const allReports: Array<{name: string, path: string}> = []; | |
| const allDataFiles: string[] = []; | |
| const baselineModels = ['xgboost', 'random_forest', 'catboost', 'lightgbm', 'ridge', 'lasso']; | |
| const foundModels = new Set<string>(); | |
| activeSession.messages.forEach(msg => { | |
| if (msg.plots) allPlots.push(...msg.plots); | |
| if (msg.reports) allReports.push(...msg.reports); | |
| baselineModels.forEach(model => { | |
| if (msg.content.toLowerCase().includes(model)) { | |
| const displayName = model.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); | |
| foundModels.add(displayName); | |
| } | |
| }); | |
| if (msg.content.includes('Cleaned') || msg.content.includes('encoded')) { | |
| allDataFiles.push('Cleaned & Encoded Dataset'); | |
| } | |
| }); | |
| const uniqueDataFiles = [...new Set(allDataFiles)]; | |
| const uniqueModels = Array.from(foundModels); | |
| return ( | |
| <> | |
| {/* Plots Section FIRST */} | |
| {allPlots.length > 0 && ( | |
| <div> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <BarChart3 className="w-4 h-4 text-emerald-400" /> | |
| <h4 className="text-xs font-bold uppercase tracking-wider text-white/60">Visualizations ({allPlots.length})</h4> | |
| </div> | |
| <div className="space-y-2"> | |
| {allPlots.map((plot, idx) => { | |
| // Ensure URL is properly formatted | |
| let plotUrl = plot.url; | |
| if (plotUrl && plotUrl.startsWith('./outputs/')) { | |
| plotUrl = plotUrl.replace('./outputs/', '/outputs/'); | |
| } else if (plotUrl && !plotUrl.startsWith('/outputs/')) { | |
| plotUrl = `/outputs/${plotUrl.replace(/^outputs\//, '')}`; | |
| } | |
| return ( | |
| <button | |
| key={idx} | |
| onClick={() => { setReportModalUrl(plotUrl || plot.url); setReportModalTitle(plot.title || 'Visualization'); }} | |
| className="w-full p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-emerald-500/10 hover:border-emerald-500/30 transition-all text-left group" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-white/80 truncate flex-1">{plot.title}</span> | |
| <ChevronRight className="w-4 h-4 text-white/40 group-hover:text-emerald-400 transition-all" /> | |
| </div> | |
| <span className="text-xs text-white/40 mt-1 block">{plot.type || 'interactive'}</span> | |
| </button> | |
| ); | |
| })}\n </div> | |
| </div> | |
| )} | |
| {/* Data Files Section */} | |
| {uniqueDataFiles.length > 0 && ( | |
| <div> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <FileText className="w-4 h-4 text-blue-400" /> | |
| <h4 className="text-xs font-bold uppercase tracking-wider text-white/60">Data Files ({uniqueDataFiles.length})</h4> | |
| </div> | |
| <div className="space-y-2"> | |
| {uniqueDataFiles.map((file, idx) => { | |
| // Extract filename from path | |
| const fileName = file.split('/').pop() || file; | |
| // Create proper download URL | |
| let downloadUrl = file; | |
| if (downloadUrl.startsWith('./outputs/')) { | |
| downloadUrl = downloadUrl.replace('./outputs/', '/outputs/'); | |
| } else if (!downloadUrl.startsWith('/outputs/')) { | |
| downloadUrl = `/outputs/${file.replace(/^outputs\//, '')}`; | |
| } | |
| return ( | |
| <a | |
| key={idx} | |
| href={downloadUrl} | |
| download={fileName} | |
| className="block w-full p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-blue-500/10 hover:border-blue-500/30 transition-all group" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-white/80 truncate flex-1">{fileName}</span> | |
| <ChevronRight className="w-4 h-4 text-white/40 group-hover:text-blue-400 transition-all" /> | |
| </div> | |
| <span className="text-xs text-white/40 mt-1 block">Click to download</span> | |
| </a> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Models Section */} | |
| {uniqueModels.length > 0 && ( | |
| <div> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <FileText className="w-4 h-4 text-purple-400" /> | |
| <h4 className="text-xs font-bold uppercase tracking-wider text-white/60">Models ({uniqueModels.length})</h4> | |
| </div> | |
| <div className="space-y-2"> | |
| {uniqueModels.map((model, idx) => { | |
| // Find the model file path from workflow history | |
| let modelPath = ''; | |
| activeSession.messages.forEach(msg => { | |
| if (msg.role === 'assistant' && msg.content.includes(model)) { | |
| // Try to extract model path (typically in ./outputs/models/) | |
| const match = msg.content.match(/\.\/outputs\/models\/[^\s)]+\.pkl/); | |
| if (match) modelPath = match[0].replace('./', '/'); | |
| } | |
| }); | |
| // Fallback: construct typical path | |
| if (!modelPath) { | |
| modelPath = `/outputs/models/${model.toLowerCase().replace(/\s+/g, '_')}_model.pkl`; | |
| } | |
| return ( | |
| <button | |
| key={idx} | |
| onClick={() => { | |
| // Trigger download | |
| const link = document.createElement('a'); | |
| link.href = modelPath; | |
| link.download = `${model.toLowerCase().replace(/\s+/g, '_')}_model.pkl`; | |
| link.click(); | |
| }} | |
| className="w-full p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-purple-500/10 hover:border-purple-500/30 transition-all text-left group" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-white/80 truncate flex-1">{model}</span> | |
| <ChevronRight className="w-4 h-4 text-white/40 group-hover:text-purple-400 transition-all" /> | |
| </div> | |
| <span className="text-xs text-white/40 mt-1 block">Click to download</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Reports Section */} | |
| {allReports.length > 0 && ( | |
| <div> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <FileText className="w-4 h-4 text-purple-400" /> | |
| <h4 className="text-xs font-bold uppercase tracking-wider text-white/60">Reports ({allReports.length})</h4> | |
| </div> | |
| <div className="space-y-2"> | |
| {allReports.map((report, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => { setReportModalUrl(report.path); setReportModalTitle(report.name || 'Report'); }} | |
| className="w-full p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-purple-500/10 hover:border-purple-500/30 transition-all text-left group" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-white/80 truncate flex-1">{report.name}</span> | |
| <ChevronRight className="w-4 h-4 text-white/40 group-hover:text-purple-400 transition-all" /> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Empty State */} | |
| {allPlots.length === 0 && allReports.length === 0 && uniqueModels.length === 0 && ( | |
| <div className="flex flex-col items-center justify-center h-full text-center p-8"> | |
| <Package className="w-12 h-12 text-white/10 mb-3" /> | |
| <p className="text-sm text-white/40 mb-1">No assets yet</p> | |
| <p className="text-xs text-white/30">Upload a dataset to generate visualizations and models</p> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| })()} | |
| </div> | |
| </motion.aside> | |
| )} | |
| </AnimatePresence> | |
| {/* Report Modal */} | |
| <AnimatePresence> | |
| {reportModalUrl && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" | |
| onClick={() => { setReportModalUrl(null); setReportModalTitle('Visualization'); }} | |
| > | |
| <motion.div | |
| initial={{ scale: 0.95, opacity: 0 }} | |
| animate={{ scale: 1, opacity: 1 }} | |
| exit={{ scale: 0.95, opacity: 0 }} | |
| className="bg-[#0a0a0a] border border-white/10 rounded-2xl w-full max-w-7xl h-[90vh] flex flex-col overflow-hidden shadow-2xl" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <div className="flex items-center justify-between p-4 border-b border-white/5"> | |
| <h3 className="text-lg font-semibold text-white">{reportModalTitle}</h3> | |
| <button | |
| onClick={() => { setReportModalUrl(null); setReportModalTitle('Visualization'); }} | |
| className="p-2 rounded-lg hover:bg-white/5 transition-colors" | |
| > | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <iframe | |
| src={reportModalUrl} | |
| className="flex-1 w-full bg-white" | |
| title="Report Viewer" | |
| /> | |
| </motion.div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| <style>{` | |
| .custom-scrollbar::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 10px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { | |
| background: rgba(255, 255, 255, 0.1); | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| }; | |