Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { useDropzone } from 'react-dropzone'; | |
| import { useNavigate, useLocation } from 'react-router-dom'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { | |
| Send, | |
| FileText, | |
| TrendingUp, | |
| Network, | |
| Mic, | |
| Paperclip, | |
| X, | |
| Plus, | |
| Trash2, | |
| MessageSquare, | |
| Pencil, | |
| Check, | |
| Copy, | |
| CheckCheck, | |
| Sparkles, | |
| ChevronDown, | |
| ArrowLeft, | |
| Settings, | |
| Menu, | |
| Database, | |
| Eye, | |
| Layers, | |
| RefreshCw, | |
| Square, | |
| Zap, | |
| Bot, | |
| } from 'lucide-react'; | |
| import apiService, { api as axiosApi } from '@/services/api'; | |
| import { useUserStore } from '@/store/userStore'; | |
| import { useConfirmModal } from '@/components/ui/ConfirmModal'; | |
| import { useToast } from '@/contexts/ToastContext'; | |
| import { getUserIdSync, getAuthHeadersSync } from '@/utils/userId'; | |
| // Lazy load PlotlyChart for performance | |
| const PlotlyChart = React.lazy(() => import('@/components/PlotlyChart')); | |
| // ============================================================================ | |
| // 🎯 DIRECT ANSWER BADGE - ChatGPT/Claude Style Highlighted Answer | |
| // Shows the key answer prominently (percentage, value, etc.) before full response | |
| // ============================================================================ | |
| interface DirectAnswer { | |
| value: string; | |
| type: 'percentage' | 'number' | 'currency' | 'text'; | |
| label?: string; | |
| trend?: 'up' | 'down' | 'stable'; | |
| } | |
| const DirectAnswerBadge: React.FC<{ answer: DirectAnswer }> = ({ answer }) => { | |
| const getTrendIcon = () => { | |
| if (answer.trend === 'up') return '📈'; | |
| if (answer.trend === 'down') return '📉'; | |
| return ''; | |
| }; | |
| const getTrendColor = () => { | |
| if (answer.trend === 'up') return 'text-green-400'; | |
| if (answer.trend === 'down') return 'text-red-400'; | |
| return 'text-blue-400'; | |
| }; | |
| return ( | |
| <div className="mb-4 p-4 rounded-xl bg-gradient-to-r from-green-500/20 to-emerald-500/10 border border-green-500/30 animate-fadeIn"> | |
| <div className="flex items-center gap-3"> | |
| {answer.label && ( | |
| <span className="text-sm text-gray-400">{answer.label}</span> | |
| )} | |
| <span className={`text-3xl font-bold ${getTrendColor()}`}> | |
| {answer.value} | |
| </span> | |
| {answer.trend && <span className="text-2xl">{getTrendIcon()}</span>} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // ============================================================================ | |
| // 🔍 EXTRACT DIRECT ANSWER - Finds key values to highlight | |
| // ============================================================================ | |
| const extractDirectAnswer = (content: string, query: string): DirectAnswer | null => { | |
| const queryLower = query.toLowerCase(); | |
| // Check if user is asking for a specific metric | |
| const percentageKeywords = ['percent', '%', 'rate', 'growth', 'increase', 'decrease', 'ratio', 'proportion', 'share']; | |
| const currencyKeywords = ['revenue', 'sales', 'profit', 'cost', 'price', 'total', 'amount', 'value', '$', '₹', '€']; | |
| const countKeywords = ['count', 'number', 'how many', 'total', 'quantity']; | |
| const isPercentageQuery = percentageKeywords.some(kw => queryLower.includes(kw)); | |
| const isCurrencyQuery = currencyKeywords.some(kw => queryLower.includes(kw)); | |
| const isCountQuery = countKeywords.some(kw => queryLower.includes(kw)); | |
| // Extract percentage values (e.g., "45.2%", "45%", "45.2 percent") | |
| const percentMatch = content.match(/(\d+\.?\d*)\s*%/); | |
| if (percentMatch && isPercentageQuery) { | |
| const value = parseFloat(percentMatch[1]); | |
| return { | |
| value: `${value}%`, | |
| type: 'percentage', | |
| trend: content.toLowerCase().includes('increase') || content.toLowerCase().includes('growth') || content.toLowerCase().includes('up') | |
| ? 'up' | |
| : content.toLowerCase().includes('decrease') || content.toLowerCase().includes('decline') || content.toLowerCase().includes('down') | |
| ? 'down' | |
| : undefined | |
| }; | |
| } | |
| // Extract currency values (e.g., "$1,234.56", "₹45,000", "€1.2M") | |
| const currencyMatch = content.match(/[\$€₹£¥][\d,]+\.?\d*\s*[KMBkmb]?/); | |
| if (currencyMatch && isCurrencyQuery) { | |
| return { | |
| value: currencyMatch[0], | |
| type: 'currency', | |
| trend: content.toLowerCase().includes('increase') || content.toLowerCase().includes('growth') ? 'up' : | |
| content.toLowerCase().includes('decrease') || content.toLowerCase().includes('decline') ? 'down' : undefined | |
| }; | |
| } | |
| // Extract large numbers with formatting (e.g., "1,234", "45K", "2.5M") | |
| const numberMatch = content.match(/\b(\d{1,3}(?:,\d{3})*(?:\.\d+)?)\s*([KMBkmb])?\b/); | |
| if (numberMatch && isCountQuery) { | |
| let value = numberMatch[0]; | |
| return { | |
| value: value, | |
| type: 'number' | |
| }; | |
| } | |
| return null; | |
| }; | |
| // ============================================================================ | |
| // 🛡️ USER-FRIENDLY ERROR MESSAGE FORMATTER | |
| // Shows nice messages like Claude/ChatGPT instead of raw API errors | |
| // ============================================================================ | |
| const formatUserFriendlyError = (error: string | any): string => { | |
| const errorStr = typeof error === 'string' ? error.toLowerCase() : | |
| (error?.message || error?.response?.data?.detail || String(error)).toLowerCase(); | |
| // Rate limit / Service busy errors | |
| if (errorStr.includes('rate') || errorStr.includes('limit') || errorStr.includes('429') || | |
| errorStr.includes('quota') || errorStr.includes('too many requests')) { | |
| return `⏳ **Service Temporarily Busy** | |
| Our AI service is experiencing high demand. Please wait a moment and try again. | |
| 💡 **Tips:** | |
| • Wait 30 seconds before retrying | |
| • Try asking a simpler question | |
| • Your data is safe and ready`; | |
| } | |
| // API Key / Auth errors | |
| if (errorStr.includes('api_key') || errorStr.includes('unauthorized') || | |
| errorStr.includes('authentication') || errorStr.includes('401') || errorStr.includes('invalid key')) { | |
| return `🔑 **Configuration Required** | |
| The AI service needs to be configured. Please contact your administrator. | |
| _This is a server-side configuration issue._`; | |
| } | |
| // Connection / Timeout errors | |
| if (errorStr.includes('timeout') || errorStr.includes('connection') || | |
| errorStr.includes('network') || errorStr.includes('fetch') || errorStr.includes('econnrefused')) { | |
| return `🌐 **Connection Issue** | |
| Unable to reach the AI service. Please check your internet connection and try again. | |
| 💡 **Tips:** | |
| • Check your internet connection | |
| • Refresh the page | |
| • Try again in a few moments`; | |
| } | |
| // Context too large | |
| if (errorStr.includes('context') || errorStr.includes('too large') || | |
| errorStr.includes('token') || errorStr.includes('length') || errorStr.includes('maximum')) { | |
| return `📏 **Request Too Large** | |
| Your question or data exceeds the model's capacity. | |
| 💡 **Tips:** | |
| • Try asking a more specific question | |
| • Break down complex queries into smaller parts | |
| • Focus on specific columns or time periods`; | |
| } | |
| // Server errors | |
| if (errorStr.includes('500') || errorStr.includes('502') || errorStr.includes('503') || | |
| errorStr.includes('504') || errorStr.includes('internal server')) { | |
| return `🔧 **Service Maintenance** | |
| Our AI service is temporarily unavailable. We're working on it! | |
| Please try again in a few minutes.`; | |
| } | |
| // Model not found | |
| if (errorStr.includes('model') && (errorStr.includes('not found') || errorStr.includes('unavailable'))) { | |
| return `🤖 **AI Model Unavailable** | |
| The requested AI model is temporarily unavailable. Trying alternative models... | |
| Please try again in a moment.`; | |
| } | |
| // Generic fallback - don't show raw technical errors | |
| if (errorStr.includes('error') || errorStr.includes('exception') || errorStr.includes('failed')) { | |
| return `⚠️ **Something Went Wrong** | |
| I encountered an issue processing your request. Please try again. | |
| 💡 **Tips:** | |
| • Rephrase your question | |
| • Try a simpler query | |
| • If the problem persists, refresh the page`; | |
| } | |
| // If nothing matches, return a clean generic message | |
| return `⚠️ **Unable to Process Request** | |
| Please try again in a moment. If the issue persists, try refreshing the page.`; | |
| }; | |
| // Helper function to format inline text (bold, italic, code) | |
| const formatInlineText = (text: string): string => { | |
| return text | |
| .replace(/\*\*(.+?)\*\*/g, '<strong class="font-semibold">$1</strong>') | |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| .replace(/`([^`]+)`/g, '<code class="inline-code-highlight">$1</code>'); | |
| }; | |
| // Component to render formatted text with inline styles | |
| const InlineFormattedText: React.FC<{ text: string; className?: string }> = ({ text, className = '' }) => { | |
| const formatted = formatInlineText(text); | |
| return <span className={className} dangerouslySetInnerHTML={{ __html: formatted }} />; | |
| }; | |
| // Image with download/copy buttons | |
| const ChartImage: React.FC<{ src: string; alt: string }> = ({ src, alt }) => { | |
| const [copied, setCopied] = React.useState(false); | |
| const handleDownload = () => { | |
| const link = document.createElement('a'); | |
| link.href = src; | |
| link.download = `chart_${Date.now()}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| const handleCopy = async () => { | |
| try { | |
| if (src.startsWith('data:image')) { | |
| const response = await fetch(src); | |
| const blob = await response.blob(); | |
| await navigator.clipboard.write([ | |
| new ClipboardItem({ [blob.type]: blob }) | |
| ]); | |
| } | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| } catch (err) { | |
| console.error('Failed to copy image:', err); | |
| } | |
| }; | |
| return ( | |
| <div className="my-4 relative group chart-fade-in"> | |
| <img | |
| src={src} | |
| alt={alt} | |
| className="max-w-full rounded-xl border border-[var(--border-color)] shadow-lg transition-transform duration-300 group-hover:scale-[1.02]" | |
| style={{ maxHeight: '450px' }} | |
| /> | |
| <div className="absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button | |
| onClick={handleDownload} | |
| className="p-2 bg-[var(--bg-card)]/90 hover:bg-green-600 rounded-lg text-white text-xs flex items-center gap-1 backdrop-blur-sm border border-[var(--border-color)]" | |
| title="Download Chart" | |
| > | |
| <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> | |
| </svg> | |
| Download | |
| </button> | |
| <button | |
| onClick={handleCopy} | |
| className="p-2 bg-[var(--bg-card)]/90 hover:bg-green-600 rounded-lg text-white text-xs flex items-center gap-1 backdrop-blur-sm border border-[var(--border-color)]" | |
| title="Copy Chart" | |
| > | |
| {copied ? ( | |
| <> | |
| <svg className="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> | |
| </svg> | |
| Copied! | |
| </> | |
| ) : ( | |
| <> | |
| <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> | |
| </svg> | |
| Copy | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| {alt && <p className="text-xs text-gray-500 text-center mt-2">{alt}</p>} | |
| </div> | |
| ); | |
| }; | |
| // ============================================================================= | |
| // 🖥️ V5: INTERACTIVE CODE BLOCK — Inline Python Execution (AI Chat v2) | |
| // ============================================================================= | |
| const InteractiveCodeBlock: React.FC<{ | |
| code: string; | |
| language: string; | |
| isPython: boolean; | |
| }> = ({ code, language, isPython }) => { | |
| const [isRunning, setIsRunning] = React.useState(false); | |
| const [result, setResult] = React.useState<{ | |
| success?: boolean; | |
| stdout?: string; | |
| stderr?: string; | |
| figures?: { base64: string; format: string }[]; | |
| tables?: { columns: string[]; data: any[][]; shape?: number[] }[]; | |
| error?: string; | |
| execution_time_ms?: number; | |
| } | null>(null); | |
| const [copied, setCopied] = React.useState(false); | |
| const handleRun = async () => { | |
| setIsRunning(true); | |
| setResult(null); | |
| try { | |
| const userId = getUserIdSync(); | |
| const headers = getAuthHeadersSync(); | |
| const baseUrl = axiosApi.defaults.baseURL || ''; | |
| const res = await fetch(`${baseUrl}/api/v1/chat/execute-code`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', ...headers }, | |
| body: JSON.stringify({ code, user_id: userId }), | |
| }); | |
| const data = await res.json(); | |
| setResult(data); | |
| } catch (err: any) { | |
| setResult({ success: false, error: err.message, stdout: '', stderr: err.message, figures: [], tables: [] }); | |
| } finally { | |
| setIsRunning(false); | |
| } | |
| }; | |
| const handleCopy = () => { | |
| navigator.clipboard.writeText(code); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| return ( | |
| <div className="my-3 rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-color)', background: 'var(--bg-card)' }}> | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-3 py-2 border-b" style={{ borderColor: 'var(--border-color)', background: isPython ? 'rgba(59, 130, 246, 0.05)' : 'transparent' }}> | |
| <span className="text-xs font-mono font-bold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}> | |
| {language || 'code'} | |
| </span> | |
| <div className="flex items-center gap-2"> | |
| <button onClick={handleCopy} className="text-xs px-2 py-1 rounded-md hover:bg-white/10 transition-colors flex items-center gap-1" style={{ color: 'var(--text-muted)' }}> | |
| {copied ? <><CheckCheck className="w-3 h-3 text-green-400" /> Copied</> : <><Copy className="w-3 h-3" /> Copy</>} | |
| </button> | |
| {isPython && ( | |
| <button | |
| onClick={handleRun} | |
| disabled={isRunning} | |
| className={`text-xs px-3 py-1 rounded-md font-semibold flex items-center gap-1.5 transition-all ${ | |
| isRunning ? 'bg-blue-500/20 text-blue-300 cursor-wait' : 'bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 hover:text-emerald-300' | |
| }`} | |
| > | |
| {isRunning ? ( | |
| <><span className="w-3 h-3 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin inline-block" /> Running...</> | |
| ) : ( | |
| <><Zap className="w-3 h-3" /> Run</> | |
| )} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Code */} | |
| <pre className="p-4 overflow-x-auto"> | |
| <code className="text-sm font-mono" style={{ color: 'var(--text-secondary)' }}>{code}</code> | |
| </pre> | |
| {/* Execution Result */} | |
| {result && ( | |
| <div className="border-t" style={{ borderColor: 'var(--border-color)' }}> | |
| <div className="px-3 py-1.5 flex items-center justify-between" style={{ background: result.success ? 'rgba(16,185,129,0.05)' : 'rgba(239,68,68,0.05)' }}> | |
| <span className={`text-xs font-semibold ${result.success ? 'text-emerald-400' : 'text-red-400'}`}> | |
| {result.success ? '✓ Output' : '✗ Error'} | |
| </span> | |
| {result.execution_time_ms != null && ( | |
| <span className="text-xs" style={{ color: 'var(--text-muted)' }}>{result.execution_time_ms}ms</span> | |
| )} | |
| </div> | |
| {result.stdout && ( | |
| <pre className="px-4 py-3 text-sm font-mono overflow-x-auto whitespace-pre-wrap" style={{ color: 'var(--text-primary)', maxHeight: '300px', overflowY: 'auto' }}> | |
| {result.stdout} | |
| </pre> | |
| )} | |
| {(result.stderr || result.error) && ( | |
| <pre className="px-4 py-3 text-sm font-mono text-red-400 overflow-x-auto whitespace-pre-wrap" style={{ maxHeight: '200px', overflowY: 'auto' }}> | |
| {result.stderr || result.error} | |
| </pre> | |
| )} | |
| {result.figures && result.figures.length > 0 && ( | |
| <div className="px-4 py-3 space-y-3"> | |
| {result.figures.map((fig, i) => ( | |
| <img key={i} src={`data:image/png;base64,${fig.base64}`} alt={`Figure ${i+1}`} | |
| className="max-w-full rounded-lg border shadow-lg" style={{ borderColor: 'var(--border-color)', maxHeight: '400px' }} /> | |
| ))} | |
| </div> | |
| )} | |
| {result.tables && result.tables.length > 0 && ( | |
| <div className="px-4 py-3 overflow-x-auto"> | |
| {result.tables.map((table, i) => ( | |
| <div key={i}> | |
| {table.shape && <p className="text-xs mb-1" style={{ color: 'var(--text-muted)' }}>DataFrame: {table.shape[0]} rows × {table.shape[1]} cols</p>} | |
| <table className="w-full text-xs border-collapse"> | |
| <thead><tr>{table.columns.map((col, ci) => ( | |
| <th key={ci} className="text-left px-2 py-1.5 border-b font-semibold" style={{ borderColor: 'var(--border-color)', color: 'var(--text-primary)' }}>{col}</th> | |
| ))}</tr></thead> | |
| <tbody>{table.data.slice(0,20).map((row, ri) => ( | |
| <tr key={ri} className="hover:bg-white/[0.03]">{row.map((cell, ci) => ( | |
| <td key={ci} className="px-2 py-1 border-b" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>{cell != null ? String(cell) : '—'}</td> | |
| ))}</tr> | |
| ))}</tbody> | |
| </table> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Lazy-loaded ForecastChartBlock for rendering prediction charts | |
| const ForecastChartBlock: React.FC<{ payload: Record<string, unknown> }> = ({ payload }) => { | |
| const PredictionChart = React.lazy(() => | |
| import('@/components/PredictionChartRenderer').then(m => ({ default: m.PredictionChartRenderer })) | |
| ); | |
| return ( | |
| <React.Suspense fallback={ | |
| <div className="animate-pulse bg-[var(--bg-card)] rounded-xl h-64 flex items-center justify-center"> | |
| <span className="text-gray-500">Loading chart...</span> | |
| </div> | |
| }> | |
| <PredictionChart payload={payload as unknown as string} /> | |
| </React.Suspense> | |
| ); | |
| }; | |
| // PlotlyChartBlock for rendering interactive Plotly charts - MEMOIZED for performance | |
| const PlotlyChartBlock: React.FC<{ data: any[]; layout: any }> = React.memo(({ data, layout }) => { | |
| // PERFORMANCE FIX: Memoize chart data to prevent unnecessary re-renders | |
| const chartData = React.useMemo(() => data, [JSON.stringify(data)]); | |
| const chartLayout = React.useMemo(() => ({ | |
| ...layout, | |
| autosize: true, | |
| // Disable animations for better performance | |
| transition: { duration: 0 }, | |
| }), [JSON.stringify(layout)]); | |
| return ( | |
| <React.Suspense fallback={ | |
| <div className="animate-pulse bg-[var(--bg-card)] rounded-xl h-64 flex items-center justify-center"> | |
| <span className="text-gray-500">Loading interactive chart...</span> | |
| </div> | |
| }> | |
| <div className="my-4 p-4 glass-card rounded-xl border border-[var(--border-color)]"> | |
| <PlotlyChart data={chartData} layout={chartLayout} /> | |
| </div> | |
| </React.Suspense> | |
| ); | |
| }, (prevProps, nextProps) => { | |
| // Custom comparison - only re-render if data/layout actually changed | |
| return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data) && | |
| JSON.stringify(prevProps.layout) === JSON.stringify(nextProps.layout); | |
| }); | |
| // MLChartBlock - Renders matplotlib/seaborn charts from Predict mode (base64 PNG) | |
| const MLChartBlock: React.FC<{ | |
| chart: { type: string; image: string; title?: string } | |
| }> = ({ chart }) => { | |
| const typeIcons: Record<string, string> = { | |
| 'ml_forecast': '📈', | |
| 'ml_importance': '🔑', | |
| 'ml_correlation': '🔗', | |
| 'ml_distribution': '📊', | |
| 'ml_residual': '📉', | |
| 'ml_summary': '🤖' | |
| }; | |
| const icon = typeIcons[chart.type] || '📊'; | |
| if (!chart.image) return null; | |
| return ( | |
| <div className="my-4 p-4 glass-card rounded-xl border border-[var(--border-color)] shadow-lg"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <span className="text-lg">{icon}</span> | |
| <span className="text-sm font-medium text-green-400"> | |
| {chart.title || 'ML Visualization'} | |
| </span> | |
| <span className="text-xs text-gray-500 ml-auto"> | |
| Powered by scikit-learn + matplotlib | |
| </span> | |
| </div> | |
| <ChartImage src={chart.image} alt={chart.title || 'ML Chart'} /> | |
| </div> | |
| ); | |
| }; | |
| // MLChartsContainer - Renders multiple ML charts from response | |
| const MLChartsContainer: React.FC<{ | |
| charts: Array<{ type: string; image: string; title?: string }> | |
| }> = ({ charts }) => { | |
| if (!charts || charts.length === 0) return null; | |
| return ( | |
| <div className="space-y-4 mt-4"> | |
| <div className="flex items-center gap-2 text-sm text-green-400"> | |
| <Zap className="w-4 h-4" /> | |
| <span>ML Visualizations • Real scikit-learn analysis</span> | |
| </div> | |
| {charts.map((chart, index) => ( | |
| <MLChartBlock key={index} chart={chart} /> | |
| ))} | |
| </div> | |
| ); | |
| }; | |
| // Typewriter Animation Component - ChatGPT-like word-by-word reveal - Memoized for Performance | |
| const TypewriterText: React.FC<{ | |
| content: string; | |
| isNew?: boolean; | |
| onComplete?: () => void; | |
| }> = React.memo(({ content, isNew = false, onComplete }) => { | |
| const [displayedContent, setDisplayedContent] = useState(isNew ? '' : content); | |
| const [isComplete, setIsComplete] = useState(!isNew); | |
| const hasAnimatedRef = useRef(false); | |
| const intervalRef = useRef<NodeJS.Timeout | null>(null); | |
| const onCompleteRef = useRef(onComplete); | |
| // Keep onComplete ref updated without causing re-renders | |
| useEffect(() => { | |
| onCompleteRef.current = onComplete; | |
| }, [onComplete]); | |
| useEffect(() => { | |
| // Clear any existing interval | |
| if (intervalRef.current) { | |
| clearInterval(intervalRef.current); | |
| intervalRef.current = null; | |
| } | |
| // If not a new message OR already animated, show full content immediately | |
| if (!isNew || hasAnimatedRef.current) { | |
| setDisplayedContent(content); | |
| setIsComplete(true); | |
| return; | |
| } | |
| // PERFORMANCE FIX: Skip animation for content with charts/visualizations | |
| // Charts cause heavy re-renders during typewriter animation | |
| const hasCharts = content.includes('plotly_chart') || | |
| content.includes('forecast_chart') || | |
| content.includes('"data"') && content.includes('"layout"') || | |
| content.includes('```') && content.length > 2000; | |
| if (hasCharts) { | |
| // Show immediately without animation for chart content | |
| setDisplayedContent(content); | |
| setIsComplete(true); | |
| hasAnimatedRef.current = true; | |
| onCompleteRef.current?.(); | |
| return; | |
| } | |
| // Mark as animating | |
| hasAnimatedRef.current = true; | |
| // Reset for new content | |
| setDisplayedContent(''); | |
| setIsComplete(false); | |
| // Split content into words for word-by-word animation | |
| const words = content.split(/(\s+)/); | |
| let currentIndex = 0; | |
| // PERFORMANCE FIX: Use larger chunks (8 words) and longer interval (35ms) | |
| intervalRef.current = setInterval(() => { | |
| if (currentIndex < words.length) { | |
| // Add 8 words at a time for faster, less CPU-intensive animation | |
| const nextIndex = Math.min(currentIndex + 8, words.length); | |
| const wordsToAdd = words.slice(currentIndex, nextIndex).join(''); | |
| setDisplayedContent(prev => prev + wordsToAdd); | |
| currentIndex = nextIndex; | |
| } else { | |
| if (intervalRef.current) { | |
| clearInterval(intervalRef.current); | |
| intervalRef.current = null; | |
| } | |
| setIsComplete(true); | |
| onCompleteRef.current?.(); | |
| } | |
| }, 35); // 35ms per chunk - less CPU intensive | |
| return () => { | |
| if (intervalRef.current) { | |
| clearInterval(intervalRef.current); | |
| intervalRef.current = null; | |
| } | |
| }; | |
| }, [content, isNew]); // Removed onComplete from deps - using ref instead | |
| return ( | |
| <> | |
| <FormattedMessage content={displayedContent} /> | |
| {!isComplete && <span className="typing-cursor"></span>} | |
| </> | |
| ); | |
| }); | |
| // Component to format markdown-like responses - Memoized for Performance | |
| const FormattedMessage: React.FC<{ content: string }> = React.memo(({ content }) => { | |
| const formatContent = (text: string) => { | |
| let normalizedText = text.replace(/\r\n/g, '\n'); | |
| // PRE-PROCESS: Compact any pretty-printed JSON in chart blocks | |
| // This fixes the issue where JSON gets split across paragraphs | |
| normalizedText = normalizedText.replace( | |
| /```plotly_chart\s*([\s\S]*?)```/gi, | |
| (match, jsonContent) => { | |
| try { | |
| // Try to parse and re-stringify as compact JSON | |
| const parsed = JSON.parse(jsonContent.trim()); | |
| return '```plotly_chart\n' + JSON.stringify(parsed) + '\n```'; | |
| } catch (e) { | |
| // If parsing fails, try to compact manually (remove newlines/spaces in JSON) | |
| const compacted = jsonContent.replace(/\n\s*/g, '').replace(/\s{2,}/g, ' ').trim(); | |
| return '```plotly_chart\n' + compacted + '\n```'; | |
| } | |
| } | |
| ); | |
| // Also handle JSON blocks that might have "type":"plotly" | |
| normalizedText = normalizedText.replace( | |
| /(\{[^{}]*"type"\s*:\s*"plotly"[^{}]*"data"[\s\S]*?"layout"[\s\S]*?\}(?:\s*\})*)/gi, | |
| (match) => { | |
| try { | |
| // Find complete JSON by brace matching | |
| let braceCount = 0; | |
| let startIdx = match.indexOf('{'); | |
| let endIdx = startIdx; | |
| for (let i = startIdx; i < match.length; i++) { | |
| if (match[i] === '{') braceCount++; | |
| if (match[i] === '}') braceCount--; | |
| if (braceCount === 0) { | |
| endIdx = i + 1; | |
| break; | |
| } | |
| } | |
| const jsonStr = match.substring(startIdx, endIdx); | |
| const parsed = JSON.parse(jsonStr); | |
| return '```plotly_chart\n' + JSON.stringify(parsed) + '\n```'; | |
| } catch (e) { | |
| return match; | |
| } | |
| } | |
| ); | |
| const blocks: string[] = []; | |
| let currentBlock = ''; | |
| let insideCodeBlock = false; | |
| const lines = normalizedText.split('\n'); | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| const isEmptyLine = line.trim() === ''; | |
| // Track if we're inside a code block (``` markers) | |
| if (line.trim().startsWith('```')) { | |
| insideCodeBlock = !insideCodeBlock; | |
| } | |
| // Don't split inside code blocks - keep them together | |
| if (insideCodeBlock) { | |
| currentBlock += (currentBlock ? '\n' : '') + line; | |
| } else if (isEmptyLine && currentBlock.trim()) { | |
| blocks.push(currentBlock.trim()); | |
| currentBlock = ''; | |
| } else { | |
| currentBlock += (currentBlock ? '\n' : '') + line; | |
| } | |
| } | |
| if (currentBlock.trim()) { | |
| blocks.push(currentBlock.trim()); | |
| } | |
| return blocks.map((para, idx) => { | |
| if (!para.trim()) return null; | |
| // Headers | |
| if (para.startsWith('###')) { | |
| const headerText = para.replace(/^###\s*/, ''); | |
| return <h3 key={idx} className="text-base font-semibold text-white mt-4 mb-2"><InlineFormattedText text={headerText} /></h3>; | |
| } | |
| if (para.startsWith('##')) { | |
| const headerText = para.replace(/^##\s*/, ''); | |
| return <h2 key={idx} className="text-lg font-semibold text-white mt-5 mb-2"><InlineFormattedText text={headerText} /></h2>; | |
| } | |
| if (para.startsWith('#')) { | |
| const headerText = para.replace(/^#\s*/, ''); | |
| return <h1 key={idx} className="text-xl font-bold text-white mt-5 mb-3"><InlineFormattedText text={headerText} /></h1>; | |
| } | |
| // Code blocks - special handling for forecast_chart | |
| // Handle BOTH proper (```) and malformed (``) backticks | |
| const isForecastChartBlock = /^`{2,3}\s*forecast_chart/i.test(para); | |
| const hasChartTypeJson = para.includes('"chart_type"') && para.includes('"forecast'); | |
| if (para.startsWith('```') || para.startsWith('``') || isForecastChartBlock || hasChartTypeJson) { | |
| // Check if it's a forecast chart - handle variations with whitespace and malformed backticks | |
| const isForecastChart = /^`{2,3}\s*forecast_chart/i.test(para) || hasChartTypeJson; | |
| if (isForecastChart) { | |
| // Extract JSON with improved regex that handles both ``` and `` backticks | |
| let chartJson = ''; | |
| // Try triple backticks first | |
| let match = para.match(/^`{2,3}\s*forecast_chart\s*([\s\S]*?)`{2,3}\s*$/); | |
| if (match) { | |
| chartJson = match[1].trim(); | |
| } else { | |
| // Fallback: try to find JSON object directly | |
| const jsonMatch = para.match(/\{[\s\S]*"chart_type"[\s\S]*\}/); | |
| if (jsonMatch) { | |
| chartJson = jsonMatch[0].trim(); | |
| } | |
| } | |
| // Skip if empty | |
| if (!chartJson) { | |
| // Check if the paragraph IS the JSON (no backticks at all) | |
| if (para.trim().startsWith('{') && para.includes('"chart_type"')) { | |
| chartJson = para.trim(); | |
| } | |
| } | |
| if (chartJson) { | |
| try { | |
| const chartPayload = JSON.parse(chartJson); | |
| // Validate it has required chart properties | |
| if (chartPayload && chartPayload.chart_type) { | |
| return ( | |
| <div key={idx} className="my-4 animate-fade-in"> | |
| <ForecastChartBlock payload={chartPayload} /> | |
| </div> | |
| ); | |
| } | |
| } catch (e) { | |
| console.error('Chart JSON parse error:', e, 'JSON:', chartJson.substring(0, 200)); | |
| // Show user-friendly error instead of raw JSON | |
| return ( | |
| <div key={idx} className="my-3 p-4 bg-red-900/20 border border-red-500/50 rounded-xl"> | |
| <p className="text-red-400 text-sm">⚠️ Chart rendering failed. The data is available but couldn't be visualized.</p> | |
| </div> | |
| ); | |
| } | |
| } | |
| } | |
| // Check if it's a Plotly chart - improved detection | |
| const isPlotlyChart = /^`{2,3}\s*plotly_chart/i.test(para) || para.includes('"type":"plotly"') || (para.includes('"data"') && para.includes('"layout"')); | |
| if (isPlotlyChart) { | |
| console.log('[CHART DEBUG] Detected plotly_chart block:', para.substring(0, 100)); | |
| // Try multiple patterns to extract JSON - BRACE COUNTING IS MOST RELIABLE | |
| let chartJson = ''; | |
| // PATTERN 1 (PRIMARY): Use brace-counting for complete JSON extraction | |
| // This is the most reliable method for nested JSON | |
| if (para.includes('{') && (para.includes('"data"') || para.includes('"type"'))) { | |
| const startIdx = para.indexOf('{'); | |
| if (startIdx !== -1) { | |
| let braceCount = 0; | |
| let endIdx = para.length; | |
| for (let i = startIdx; i < para.length; i++) { | |
| if (para[i] === '{') braceCount++; | |
| if (para[i] === '}') braceCount--; | |
| if (braceCount === 0) { | |
| endIdx = i + 1; | |
| break; | |
| } | |
| } | |
| chartJson = para.substring(startIdx, endIdx); | |
| console.log('[CHART] Brace-counting extracted', chartJson.length, 'chars'); | |
| } | |
| } | |
| // PATTERN 2 (FALLBACK): Regex for plotly_chart code block | |
| if (!chartJson) { | |
| const match = para.match(/`{2,3}\s*plotly_chart\s*\n?([\s\S]+?)\n?`{2,3}/); | |
| if (match) { | |
| chartJson = match[1].trim(); | |
| console.log('[CHART] Regex extracted', chartJson.length, 'chars'); | |
| } | |
| } | |
| console.log('[CHART DEBUG] Extracted JSON length:', chartJson.length, 'First 100 chars:', chartJson.substring(0, 100)); | |
| if (chartJson) { | |
| try { | |
| const plotlyData = JSON.parse(chartJson); | |
| console.log('[CHART DEBUG] Parsed plotly data:', { | |
| hasData: !!plotlyData.data, | |
| hasLayout: !!plotlyData.layout, | |
| chartType: plotlyData.chart_type, | |
| dataLength: Array.isArray(plotlyData.data) ? plotlyData.data.length : 0 | |
| }); | |
| if (plotlyData && plotlyData.data && plotlyData.layout) { | |
| return ( | |
| <div key={idx} className="my-4 animate-fade-in"> | |
| <PlotlyChartBlock data={plotlyData.data} layout={plotlyData.layout} /> | |
| </div> | |
| ); | |
| } else { | |
| console.error('[CHART DEBUG] Missing data or layout:', { | |
| hasData: !!plotlyData.data, | |
| hasLayout: !!plotlyData.layout, | |
| keys: Object.keys(plotlyData) | |
| }); | |
| } | |
| } catch (e) { | |
| console.error('Plotly chart parse error:', e, 'JSON sample:', chartJson.substring(0, 200)); | |
| return ( | |
| <div key={idx} className="my-3 p-4 bg-red-500/10 border border-red-500/50 rounded-xl"> | |
| <p className="text-red-400 text-sm">⚠️ Interactive chart rendering failed. Check console for details.</p> | |
| </div> | |
| ); | |
| } | |
| } | |
| } | |
| // Regular code block — with V5 inline execution for Python | |
| const langMatch = para.match(/^```(\w+)/); | |
| const lang = langMatch ? langMatch[1].toLowerCase() : ''; | |
| const codeContent = para.replace(/^```\w*\n?/, '').replace(/```$/, ''); | |
| const isPython = lang === 'python' || lang === 'py' || (!lang && (codeContent.includes('import pandas') || codeContent.includes('import numpy') || codeContent.includes('print(') || codeContent.includes('df.'))); | |
| return ( | |
| <InteractiveCodeBlock key={idx} code={codeContent} language={lang || (isPython ? 'python' : 'text')} isPython={isPython} /> | |
| ); | |
| } | |
| // Images (base64 charts) | |
| const imageMatch = para.match(/!\[([^\]]*)\]\(([^)]+)\)/); | |
| if (imageMatch) { | |
| return <ChartImage key={idx} alt={imageMatch[1]} src={imageMatch[2]} />; | |
| } | |
| // Tables | |
| const tableLines = para.split('\n').filter(l => l.trim().startsWith('|') || l.includes('|')); | |
| if (tableLines.length >= 2) { | |
| const isSeparatorLine = (line: string) => /^[\s|:\-]+$/.test(line.replace(/\|/g, '')); | |
| const hasSeparator = tableLines.length > 1 && isSeparatorLine(tableLines[1]); | |
| const headers = tableLines[0].split('|').map(h => h.trim()).filter(h => h); | |
| const dataStartIndex = hasSeparator ? 2 : 1; | |
| const rows = tableLines.slice(dataStartIndex) | |
| .filter(row => !isSeparatorLine(row)) | |
| .map(row => row.split('|').map(cell => cell.trim()).filter(cell => cell)) | |
| .filter(row => row.length > 0); | |
| if (headers.length > 0 && rows.length > 0) { | |
| return ( | |
| <div key={idx} className="my-4 overflow-x-auto rounded-xl border border-dark-border animate-fade-in"> | |
| <table className="min-w-full"> | |
| <thead className="bg-[var(--bg-secondary)]/50"> | |
| <tr> | |
| {headers.map((header, i) => ( | |
| <th key={i} className="px-4 py-3 text-left text-sm font-semibold border-b border-[var(--border-color)]" style={{ color: 'var(--text-primary)' }}> | |
| <InlineFormattedText text={header} /> | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-[var(--border-color)]"> | |
| {rows.map((row, i) => ( | |
| <tr key={i} className="hover:bg-[var(--bg-hover)]/50 transition-colors"> | |
| {row.map((cell, j) => ( | |
| <td key={j} className="px-4 py-3 text-sm" style={{ color: 'var(--text-secondary)' }}> | |
| <InlineFormattedText text={cell} /> | |
| </td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ); | |
| } | |
| } | |
| // Lists | |
| const paraLines = para.split('\n'); | |
| const bulletLines = paraLines.filter(line => line.trim().match(/^[-•*]\s/) || line.trim().match(/^[•●]\s?/)); | |
| const numberedLines = paraLines.filter(line => line.trim().match(/^\d+[\.\\)]\s/)); | |
| if (bulletLines.length > 0 && bulletLines.length >= paraLines.length * 0.4) { | |
| return ( | |
| <ul key={idx} className="space-y-2 my-3"> | |
| {paraLines.map((line, i) => { | |
| const cleanLine = line.trim(); | |
| if (cleanLine.match(/^[-•*●]\s?/) || cleanLine.match(/^[•●]/)) { | |
| const text = cleanLine.replace(/^[-•*●]\s*/, ''); | |
| return ( | |
| <li key={i} className="flex items-start gap-2 text-gray-300"> | |
| <span className="text-green-400 mt-0.5">•</span> | |
| <span><InlineFormattedText text={text} /></span> | |
| </li> | |
| ); | |
| } | |
| return cleanLine ? <p key={i} className="text-gray-300 ml-5"><InlineFormattedText text={cleanLine} /></p> : null; | |
| })} | |
| </ul> | |
| ); | |
| } | |
| if (numberedLines.length > 0 && numberedLines.length >= paraLines.length * 0.4) { | |
| return ( | |
| <ol key={idx} className="space-y-2 my-3"> | |
| {paraLines.map((line, i) => { | |
| const cleanLine = line.trim(); | |
| const match = cleanLine.match(/^(\d+)[\.\\)]\s*(.*)$/); | |
| if (match) { | |
| return ( | |
| <li key={i} className="flex items-start gap-3 text-gray-300"> | |
| <span className="text-green-400 font-semibold min-w-[1.5rem]">{match[1]}.</span> | |
| <span><InlineFormattedText text={match[2]} /></span> | |
| </li> | |
| ); | |
| } | |
| return cleanLine ? <p key={i} className="text-gray-300 ml-7"><InlineFormattedText text={cleanLine} /></p> : null; | |
| })} | |
| </ol> | |
| ); | |
| } | |
| // Regular paragraph | |
| const formattedHtml = formatInlineText(para).replace(/\n/g, '<br/>'); | |
| return ( | |
| <p key={idx} className="mb-3 text-gray-300 leading-relaxed" dangerouslySetInnerHTML={{ __html: formattedHtml }} /> | |
| ); | |
| }); | |
| }; | |
| return <div className="space-y-1">{formatContent(content)}</div>; | |
| }); | |
| // Animated Logo for AI Assistant - Uses actual DataVision logo with subtle animation | |
| // Adapts to light/dark mode automatically | |
| const AnimatedBotIcon: React.FC<{ size?: number; isDark?: boolean }> = ({ size = 28, isDark = true }) => { | |
| // Determine the logo to use based on theme | |
| const logoSrc = isDark ? '/datavision-logo-dark.jpg' : '/datavision-logo-light.jpg'; | |
| return ( | |
| <div | |
| className="relative flex items-center justify-center flex-shrink-0 rounded-lg overflow-hidden" | |
| style={{ | |
| width: size, | |
| height: size, | |
| minWidth: size, | |
| minHeight: size, | |
| }} | |
| > | |
| <img | |
| src={logoSrc} | |
| alt="DataVision" | |
| className="w-full h-full object-cover" | |
| style={{ | |
| animation: 'pulse-glow 2s ease-in-out infinite', | |
| }} | |
| onError={(e) => { (e.target as HTMLImageElement).src = '/logo.svg'; }} | |
| /> | |
| <style>{` | |
| @keyframes pulse-glow { | |
| 0%, 100% { | |
| filter: drop-shadow(0 0 2px rgba(20, 184, 166, 0.4)); | |
| opacity: 1; | |
| } | |
| 50% { | |
| filter: drop-shadow(0 0 6px rgba(20, 184, 166, 0.5)); | |
| opacity: 0.9; | |
| } | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| }; | |
| // Mode-specific thinking messages (Clean & Simple) | |
| const getModeThinkingText = (modeId: string): string => { | |
| const thinkingTexts: Record<string, string> = { | |
| 'analyst': 'Analyzing data...', | |
| 'deep': 'Thinking deeply...', | |
| 'vision': 'Analyzing image...', | |
| 'predict': 'Running prediction...', | |
| 'agent': 'Taking action...', | |
| }; | |
| return thinkingTexts[modeId] || 'Processing...'; | |
| }; | |
| // Sleek, minimal Thinking Animation Indicator | |
| const TypingIndicator: React.FC<{ mode?: string; isDark?: boolean }> = ({ mode = 'rag', isDark = true }) => { | |
| const thinkingText = getModeThinkingText(mode); | |
| return ( | |
| <div className="flex items-center gap-3 py-2 px-3 rounded-xl bg-white/5 border border-white/10 w-fit backdrop-blur-sm animate-pulse"> | |
| <AnimatedBotIcon size={22} isDark={isDark} /> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs font-semibold text-[#16A34A]">{thinkingText}</span> | |
| <div className="flex items-center gap-1"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-[#16A34A] animate-ping" /> | |
| <span className="w-1.5 h-1.5 rounded-full bg-[#16A34A] animate-ping delay-150" /> | |
| <span className="w-1.5 h-1.5 rounded-full bg-[#16A34A] animate-ping delay-300" /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // ChatGPT-Style Welcome Screen with Centered Input (includes Mode & MCP controls) | |
| interface WelcomeScreenProps { | |
| onSuggestionClick: (text: string) => void; | |
| input: string; | |
| setInput: (val: string) => void; | |
| onSend: () => void; | |
| isLoading: boolean; | |
| onFileClick: () => void; | |
| onPaste: (event: React.ClipboardEvent) => void; // 📋 Added paste handler for new chat | |
| hasAttachment: boolean; // 📎 Enable send button when file attached | |
| imagePreview: string | null; // 🖼️ Show image preview | |
| onRemoveImage: () => void; // ❌ Remove image | |
| currentMode: { id: string; label: string; icon: any }; | |
| onModeClick: () => void; | |
| onMcpClick: () => void; | |
| mcpCount: { active: number; total: number }; | |
| llm: string; | |
| setLlm: (val: string) => void; | |
| } | |
| const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ | |
| onSuggestionClick, | |
| input, | |
| setInput, | |
| onSend, | |
| isLoading, | |
| onFileClick, | |
| onPaste, // 📋 Added paste handler | |
| hasAttachment, // 📎 Track if file attached | |
| imagePreview, // 🖼️ Image preview URL | |
| onRemoveImage, // ❌ Remove image handler | |
| currentMode, | |
| onModeClick, | |
| onMcpClick, | |
| mcpCount, | |
| llm, | |
| setLlm | |
| }) => { | |
| const suggestions = [ | |
| { icon: TrendingUp, text: "Analyze trends in my data" }, | |
| { icon: FileText, text: "Summarize my uploaded data" }, | |
| { icon: Sparkles, text: "What insights can you find?" }, | |
| { icon: Network, text: "Find patterns and correlations" }, | |
| ]; | |
| // Use state for logo to ensure reactivity | |
| const [logoSrc, setLogoSrc] = React.useState('/datavision-logo-dark.jpg'); | |
| React.useEffect(() => { | |
| const updateLogo = () => { | |
| const isDarkTheme = !document.documentElement.classList.contains('light-theme'); | |
| setLogoSrc(isDarkTheme ? '/datavision-logo-dark.jpg' : '/datavision-logo-light.jpg'); | |
| }; | |
| updateLogo(); | |
| // Listen for theme changes | |
| const observer = new MutationObserver(updateLogo); | |
| observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); | |
| return () => observer.disconnect(); | |
| }, []); | |
| return ( | |
| <div className="flex flex-col items-center justify-center h-full px-4"> | |
| {/* Logo - Theme Aware */} | |
| <div className="w-16 h-16 mb-4 rounded-xl overflow-hidden shadow-lg"> | |
| <img src={logoSrc} alt="DataVision" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).src = '/logo.svg'; }} /> | |
| </div> | |
| {/* Title - ChatGPT style */} | |
| <h1 className="text-2xl md:text-3xl font-semibold text-white mb-8 text-center"> | |
| How can I help you today? | |
| </h1> | |
| {/* 🖼️ Image Preview - Show attached image above input */} | |
| {imagePreview && ( | |
| <div className="w-full max-w-3xl mb-4"> | |
| <div className="relative inline-block"> | |
| <img | |
| src={imagePreview} | |
| alt="Attached" | |
| className="max-h-32 rounded-xl border border-green-500/50 shadow-lg" | |
| /> | |
| <button | |
| onClick={onRemoveImage} | |
| className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white shadow-md transition-colors" | |
| title="Remove image" | |
| > | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| <p className="text-xs text-green-400 mt-1">📷 Image ready - ask a question about it!</p> | |
| </div> | |
| )} | |
| {/* Centered Search Box - ChatGPT Style with Mode & MCP */} | |
| <div className="w-full max-w-3xl mb-10"> | |
| <div className="relative flex items-center glass-input rounded-2xl transition-colors"> | |
| {/* MCP Button */} | |
| <button | |
| onClick={onMcpClick} | |
| className="p-3 text-gray-400 hover:text-gray-200 transition-colors relative" | |
| title="MCP Servers" | |
| > | |
| <Database className="w-5 h-5" /> | |
| <span className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ${mcpCount.active === mcpCount.total ? 'bg-green-500' : 'bg-yellow-500'}`} /> | |
| </button> | |
| {/* File Attach Button */} | |
| <button | |
| onClick={onFileClick} | |
| className="p-3 text-gray-400 hover:text-gray-200 transition-colors" | |
| title="Attach file" | |
| > | |
| <Paperclip className="w-5 h-5" /> | |
| </button> | |
| {/* Input Field */} | |
| <input | |
| type="text" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); } }} | |
| onPaste={onPaste} | |
| placeholder="Ask anything about your data..." | |
| className="flex-1 bg-transparent text-white placeholder-gray-500 py-4 px-2 outline-none text-base" | |
| /> | |
| {/* Mode Selector Button */} | |
| <button | |
| onClick={onModeClick} | |
| className="flex items-center gap-1.5 px-3 py-2 bg-[var(--bg-hover)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors text-sm font-medium whitespace-nowrap mr-1" | |
| style={{ color: 'var(--text-secondary)' }} | |
| > | |
| {currentMode.label} | |
| <ChevronDown className="w-4 h-4" /> | |
| </button> | |
| {/* Send Button */} | |
| <button | |
| onClick={onSend} | |
| disabled={(!input.trim() && !hasAttachment) || isLoading} | |
| className={`m-2 p-3 rounded-xl transition-all ${(input.trim() || hasAttachment) && !isLoading | |
| ? 'bg-green-500 hover:bg-green-600 text-white' | |
| : 'bg-[var(--bg-hover)] text-gray-400 cursor-not-allowed' | |
| }`} | |
| > | |
| <Send className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Suggestion Cards - 2x2 Grid */} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl w-full"> | |
| {suggestions.map((suggestion, i) => ( | |
| <button | |
| key={i} | |
| onClick={() => onSuggestionClick(suggestion.text)} | |
| className="flex items-center gap-3 p-4 rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)]/30 hover:bg-[var(--bg-hover)] hover:border-gray-500 transition-all duration-200 text-left group" | |
| > | |
| <div className="p-2 rounded-lg bg-green-500/10 group-hover:bg-green-500/20 transition-colors"> | |
| <suggestion.icon className="w-5 h-5 text-green-400" /> | |
| </div> | |
| <span className="text-sm transition-colors group-hover:text-[var(--text-primary)]" style={{ color: 'var(--text-secondary)' }}> | |
| {suggestion.text} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const AnalystChat: React.FC = () => { | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const { | |
| conversations, | |
| currentConversationId, | |
| createConversation, | |
| deleteConversation, | |
| selectConversation, | |
| addMessageToConversation, | |
| updateConversationMessages, | |
| getCurrentConversation, | |
| clearAllChats, | |
| isDark, | |
| } = useUserStore(); | |
| const { confirm, ConfirmModal } = useConfirmModal(); | |
| const toast = useToast(); | |
| const [input, setInput] = useState(''); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [mode, setMode] = useState<string>('analyst'); | |
| const [llm, setLlm] = useState<string>('llama'); | |
| const [isFullscreen, setIsFullscreen] = useState(false); | |
| // Advanced Progressive Loading States | |
| const [loadingMessage, setLoadingMessage] = useState("Initializing DataVision intelligence..."); | |
| const loadingMessages = [ | |
| "Initializing DataVision intelligence...", | |
| "Scanning dataset structure...", | |
| "Identifying patterns and correlations...", | |
| "Applying predictive models...", | |
| "Synthesizing insights...", | |
| "Finalizing response...", | |
| "Almost there..." | |
| ]; | |
| useEffect(() => { | |
| let interval: NodeJS.Timeout; | |
| if (isLoading) { | |
| let currentIndex = 0; | |
| setLoadingMessage(loadingMessages[0]); | |
| interval = setInterval(() => { | |
| currentIndex = (currentIndex + 1) % loadingMessages.length; | |
| // Stop at the last message to avoid looping back to "Initializing..." after a long time | |
| if (currentIndex === loadingMessages.length - 1) { | |
| clearInterval(interval); | |
| } | |
| setLoadingMessage(loadingMessages[currentIndex]); | |
| }, 2500); | |
| } | |
| return () => clearInterval(interval); | |
| }, [isLoading]); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [selectedImage, setSelectedImage] = useState<File | null>(null); | |
| const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null); | |
| const [attachedFiles, setAttachedFiles] = useState<File[]>([]); | |
| const [editingMessageId, setEditingMessageId] = useState<string | null>(null); | |
| const [editingContent, setEditingContent] = useState(''); | |
| const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null); | |
| const [messageImages, setMessageImages] = useState<Record<string, string>>({}); | |
| const [modeDropdownOpen, setModeDropdownOpen] = useState(false); | |
| const [animatingMessageId, setAnimatingMessageId] = useState<string | null>(null); // Track which message is animating | |
| const [sidebarOpen, setSidebarOpen] = useState(true); | |
| const [mcpDropdownOpen, setMcpDropdownOpen] = useState(false); | |
| const [plusMenuOpen, setPlusMenuOpen] = useState(false); // Plus menu state | |
| // 🏆 8 Powerful MCPs - all enabled by default | |
| const [enabledMcps, setEnabledMcps] = useState<Record<string, boolean>>({ | |
| 'data_analyzer': true, | |
| 'pattern_finder': true, | |
| 'forecaster': true, // NEW: ML predictions | |
| 'anomaly_detector': true, // NEW: Outlier detection | |
| 'chart_executor': true, // NEW: Auto-visualization | |
| 'report_generator': true, | |
| 'insight_extractor': true, | |
| 'comparison_engine': true, | |
| }); | |
| // MCP execution state for Claude-style animations | |
| const [mcpExecutionStatus, setMcpExecutionStatus] = useState<{ | |
| isRunning: boolean; | |
| currentTools: Array<{ name: string; icon: string; status: 'pending' | 'running' | 'success' }>; | |
| startTime: number | null; | |
| }>({ | |
| isRunning: false, | |
| currentTools: [], | |
| startTime: null, | |
| }); | |
| // MCP Permission Dialog state (Claude-style Allow/Deny) | |
| const [mcpPermissionDialog, setMcpPermissionDialog] = useState<{ | |
| isOpen: boolean; | |
| pendingTools: Array<{ name: string; icon: string; description: string }>; | |
| pendingMessage: string; | |
| onAllow: (() => void) | null; | |
| onDeny: (() => void) | null; | |
| }>({ | |
| isOpen: false, | |
| pendingTools: [], | |
| pendingMessage: '', | |
| onAllow: null, | |
| onDeny: null, | |
| }); | |
| // Streaming state for ChatGPT-like word-by-word responses | |
| const [useStreaming, setUseStreaming] = useState(false); // Disabled - use reliable non-streaming mode | |
| const [streamingContent, setStreamingContent] = useState(''); | |
| const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const chatContainerRef = useRef<HTMLDivElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const abortControllerRef = useRef<AbortController | null>(null); | |
| // Feedback state for thumbs up/down | |
| const [messageFeedback, setMessageFeedback] = useState<Record<string, 'up' | 'down' | null>>({}); | |
| // Handle stop generation (ChatGPT-style stop button in input area) | |
| const handleStopGeneration = () => { | |
| if (abortControllerRef.current) { | |
| abortControllerRef.current.abort(); | |
| abortControllerRef.current = null; | |
| } | |
| setIsLoading(false); | |
| }; | |
| // Handle thumbs up/down feedback | |
| const handleFeedback = (messageId: string, type: 'up' | 'down') => { | |
| setMessageFeedback(prev => ({ | |
| ...prev, | |
| [messageId]: prev[messageId] === type ? null : type | |
| })); | |
| // You could also send this to the backend for analytics | |
| console.log(`Feedback ${type} for message ${messageId}`); | |
| }; | |
| // 🏆 5 POWERFUL MCPs - Competition-Winning Tools | |
| // Each MCP is a unique powerhouse that works autonomously | |
| const mcpServers = [ | |
| { | |
| id: 'data_analyzer', | |
| name: '📊 Data Analyzer', | |
| icon: '📊', | |
| description: 'Deep data exploration • Schema analysis • Quality checks', | |
| power: 'Explores any dataset structure autonomously' | |
| }, | |
| { | |
| id: 'pattern_finder', | |
| name: '🔎 Pattern Finder', | |
| icon: '🔎', | |
| description: 'Anomaly detection • Trends • Correlations', | |
| power: 'Discovers hidden patterns you never noticed' | |
| }, | |
| { | |
| id: 'forecaster', | |
| name: '🔮 Forecaster', | |
| icon: '🔮', | |
| description: 'ML predictions • Confidence intervals • Trend projection', | |
| power: 'Predicts future with scikit-learn algorithms' | |
| }, | |
| { | |
| id: 'anomaly_detector', | |
| name: '⚠️ Anomaly Detector', | |
| icon: '⚠️', | |
| description: 'Outlier detection • Z-score analysis • Risk alerts', | |
| power: 'Finds statistical anomalies automatically' | |
| }, | |
| { | |
| id: 'chart_executor', | |
| name: '📈 Chart Generator', | |
| icon: '📈', | |
| description: 'Auto-visualization • Intent detection • Dynamic colors', | |
| power: 'Creates perfect charts from natural language' | |
| }, | |
| { | |
| id: 'report_generator', | |
| name: '📄 Report Generator', | |
| icon: '📄', | |
| description: 'Executive summaries • Key metrics • Recommendations', | |
| power: 'Creates professional reports in seconds' | |
| }, | |
| { | |
| id: 'insight_extractor', | |
| name: '💡 Insight Extractor', | |
| icon: '💡', | |
| description: 'Business opportunities • Pareto analysis • Growth potential', | |
| power: 'Mines the most valuable insights from your data' | |
| }, | |
| { | |
| id: 'comparison_engine', | |
| name: '⚖️ Comparison Engine', | |
| icon: '⚖️', | |
| description: 'Category comparison • Segment analysis • Benchmarks', | |
| power: 'Compares data across any dimension' | |
| }, | |
| ]; | |
| // Mode definitions - 5 CORE MODES (Clean & Professional) | |
| // Each mode has a specific purpose | |
| const modes = [ | |
| // 📊 ANALYST - Default mode, smart data analysis | |
| { | |
| id: 'analyst', | |
| label: '📊 Analyst', | |
| icon: FileText, | |
| description: 'Smart data analysis with charts & insights', | |
| fullDescription: 'Upload data and ask questions - get instant analysis with visualizations', | |
| features: ['Data Analysis', 'Auto Charts', 'Insights', 'Summaries'], | |
| badge: null, | |
| isAI: false, | |
| category: 'core', | |
| supportsText: true, | |
| supportsCharts: true, | |
| color: 'from-blue-500 to-cyan-500' | |
| }, | |
| // 🧠 DEEP THINK - Complex reasoning | |
| { | |
| id: 'deep', | |
| label: '🧠 Deep Think', | |
| icon: Sparkles, | |
| description: 'Multi-step reasoning for complex questions', | |
| fullDescription: 'Think deeper - breaks down complex problems step by step', | |
| features: ['Deep Research', 'Multi-Step', 'Reasoning', 'Analysis'], | |
| badge: 'Pro', | |
| isAI: true, | |
| category: 'core', | |
| supportsText: true, | |
| supportsCharts: true, | |
| color: 'from-purple-500 to-pink-500' | |
| }, | |
| // 👁️ VISION - Image analysis | |
| { | |
| id: 'vision', | |
| label: '👁️ Vision', | |
| icon: Eye, | |
| description: 'Analyze images, charts, documents & screenshots', | |
| fullDescription: 'Upload any image and ask questions about it', | |
| features: ['Image Analysis', 'Chart Reading', 'OCR', 'Document Scan'], | |
| badge: null, | |
| isAI: false, | |
| category: 'core', | |
| supportsText: true, | |
| supportsCharts: true, | |
| supportsImages: true, | |
| color: 'from-green-500 to-emerald-500' | |
| }, | |
| // 📈 PREDICT - ML forecasting | |
| { | |
| id: 'predict', | |
| label: '📈 Predict', | |
| icon: TrendingUp, | |
| description: 'ML predictions, forecasts & trend analysis', | |
| fullDescription: 'Predict future trends using machine learning', | |
| features: ['ML Forecasts', 'Trends', 'Predictions', 'Confidence'], | |
| badge: 'ML', | |
| isAI: true, | |
| category: 'core', | |
| supportsText: true, | |
| supportsCharts: true, | |
| color: 'from-amber-500 to-orange-500' | |
| }, | |
| // 🤖 AGENT - Autonomous AI | |
| { | |
| id: 'agent', | |
| label: '🤖 Agent', | |
| icon: Bot, | |
| description: 'Autonomous AI that takes actions on your data', | |
| fullDescription: 'Let AI plan and execute multi-step data tasks', | |
| features: ['Auto Actions', 'Multi-Step', 'Tools', 'Planning'], | |
| badge: 'Pro', | |
| isAI: true, | |
| category: 'core', | |
| supportsText: true, | |
| supportsCharts: true, | |
| color: 'from-red-500 to-rose-500' | |
| }, | |
| ]; | |
| const currentConversation = getCurrentConversation(); | |
| const messages = currentConversation?.messages.map(m => ({ | |
| ...m, | |
| timestamp: new Date(m.timestamp) | |
| })) || []; | |
| // Auto-send prefilled message from other pages (like Anomaly Monitor) | |
| useEffect(() => { | |
| if (location.state?.prefillMessage) { | |
| const msg = location.state.prefillMessage; | |
| // Clear the state so it doesn't fire again if the user refreshes the page | |
| navigate(location.pathname, { replace: true, state: {} }); | |
| // Small delay to ensure state and component are fully mounted | |
| setTimeout(() => { | |
| handleSend(msg); | |
| }, 500); | |
| } | |
| }, [location.state, navigate]); | |
| useEffect(() => { | |
| if (!currentConversationId && conversations.length > 0) { | |
| selectConversation(conversations[0].id); | |
| } | |
| }, [currentConversationId, conversations]); | |
| useEffect(() => { | |
| if (currentConversation) { | |
| setMode(currentConversation.mode); | |
| // Restore images from stored imageData in messages | |
| const restoredImages: Record<string, string> = {}; | |
| currentConversation.messages.forEach((msg: any) => { | |
| if (msg.imageData) { | |
| restoredImages[msg.id] = msg.imageData; | |
| } | |
| }); | |
| if (Object.keys(restoredImages).length > 0) { | |
| setMessageImages(prev => ({ ...prev, ...restoredImages })); | |
| } | |
| } | |
| }, [currentConversation?.id]); | |
| // Listen for file updates from DataHub - notify user when new files are available | |
| useEffect(() => { | |
| const handleFilesUpdated = () => { | |
| console.log('📁 Files updated in chat context'); | |
| // Add a system notification message to let user know data has been refreshed | |
| if (currentConversationId) { | |
| const systemMessage = { | |
| id: `system_${Date.now()}`, | |
| role: 'assistant' as const, | |
| content: '📁 **Data Updated!** New files have been uploaded. Your data context has been refreshed. Feel free to ask questions about your updated dataset.', | |
| timestamp: new Date().toISOString(), | |
| }; | |
| addMessageToConversation(currentConversationId, systemMessage); | |
| } | |
| }; | |
| window.addEventListener('filesUpdated', handleFilesUpdated); | |
| return () => window.removeEventListener('filesUpdated', handleFilesUpdated); | |
| }, [currentConversationId, addMessageToConversation]); | |
| // ========================================================================= | |
| // CHATGPT-STYLE SCROLL LOGIC - OPTIMIZED TO PREVENT JITTER | |
| // ========================================================================= | |
| // Track previous message count to detect actual new messages | |
| const prevMessageCountRef = useRef(messages.length); | |
| const prevLoadingRef = useRef(isLoading); | |
| const userScrolledUpRef = useRef(false); | |
| // Check if user is near the bottom of the chat (within 200px) | |
| const isNearBottom = (): boolean => { | |
| const container = chatContainerRef.current; | |
| if (!container) return true; | |
| return container.scrollHeight - container.scrollTop - container.clientHeight < 200; | |
| }; | |
| // Scroll to bottom - instant for new messages, smooth for loading | |
| const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { | |
| if (messagesEndRef.current) { | |
| messagesEndRef.current.scrollIntoView({ behavior, block: 'end' }); | |
| } | |
| }; | |
| // Track user scroll position to avoid interrupting reading | |
| useEffect(() => { | |
| const container = chatContainerRef.current; | |
| if (!container) return; | |
| const handleScroll = () => { | |
| // Mark that user has scrolled up if not near bottom | |
| userScrolledUpRef.current = !isNearBottom(); | |
| }; | |
| container.addEventListener('scroll', handleScroll, { passive: true }); | |
| return () => container.removeEventListener('scroll', handleScroll); | |
| }, []); | |
| // Auto-scroll ONLY when: | |
| // 1. A NEW message is added (not re-renders) | |
| // 2. Loading state changes to true (user sent a message) | |
| // 3. User hasn't scrolled up to read | |
| useEffect(() => { | |
| const messageCountChanged = messages.length !== prevMessageCountRef.current; | |
| const loadingStarted = isLoading && !prevLoadingRef.current; | |
| // Update refs | |
| prevMessageCountRef.current = messages.length; | |
| prevLoadingRef.current = isLoading; | |
| // Only scroll if there's an actual new message or user started loading | |
| if (messageCountChanged || loadingStarted) { | |
| // Reset user scrolled flag when they send a new message | |
| if (loadingStarted) { | |
| userScrolledUpRef.current = false; | |
| } | |
| // Don't scroll if user has scrolled up to read earlier messages | |
| if (!userScrolledUpRef.current) { | |
| // Use requestAnimationFrame for smooth scroll without jitter | |
| requestAnimationFrame(() => { | |
| scrollToBottom(loadingStarted ? 'smooth' : 'instant'); | |
| }); | |
| } | |
| } | |
| }, [messages.length, isLoading]); | |
| useEffect(() => { | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = 'auto'; | |
| textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; | |
| } | |
| }, [input]); | |
| const { getRootProps, getInputProps, isDragActive } = useDropzone({ | |
| onDrop: (acceptedFiles: File[]) => { | |
| // Separate images from other files | |
| const images = acceptedFiles.filter(f => f.type.startsWith('image/')); | |
| const otherFiles = acceptedFiles.filter(f => !f.type.startsWith('image/')); | |
| // If there's an image, set it as selected (for vision) | |
| if (images.length > 0) { | |
| const imageFile = images[0]; | |
| setSelectedImage(imageFile); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| setImagePreviewUrl(e.target?.result as string); | |
| }; | |
| reader.readAsDataURL(imageFile); | |
| toast.success(`🖼️ Image "${imageFile.name}" ready for analysis!`); | |
| } | |
| // Add other files as attachments | |
| if (otherFiles.length > 0) { | |
| setAttachedFiles(prev => [...prev, ...otherFiles]); | |
| toast.success(`📎 ${otherFiles.length} file(s) attached!`); | |
| } | |
| }, | |
| noClick: true, | |
| accept: { | |
| 'application/pdf': ['.pdf'], | |
| 'application/vnd.ms-excel': ['.xls'], | |
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], | |
| 'text/csv': ['.csv'], | |
| 'application/json': ['.json'], | |
| 'text/plain': ['.txt'], | |
| 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'], | |
| 'application/vnd.ms-powerpoint': ['.ppt'], | |
| 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], | |
| 'application/msword': ['.doc'], | |
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], | |
| }, | |
| }); | |
| const removeAttachedFile = (index: number) => { | |
| setAttachedFiles(prev => prev.filter((_, i) => i !== index)); | |
| }; | |
| const handleVoiceInput = async () => { | |
| if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) { | |
| toast.warning('Speech recognition is not supported in your browser.'); | |
| return; | |
| } | |
| const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; | |
| const recognition = new SpeechRecognition(); | |
| recognition.lang = 'en-US'; | |
| recognition.continuous = false; | |
| recognition.onstart = () => setIsRecording(true); | |
| recognition.onend = () => setIsRecording(false); | |
| recognition.onresult = (event: any) => { | |
| const transcript = event.results[0][0].transcript; | |
| setInput(prev => prev + ' ' + transcript); | |
| }; | |
| recognition.onerror = (event: any) => { | |
| console.error('Speech recognition error:', event.error); | |
| setIsRecording(false); | |
| }; | |
| recognition.start(); | |
| }; | |
| const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = event.target.files; | |
| if (!files || files.length === 0) return; | |
| const fileArray = Array.from(files); | |
| let hasImage = false; | |
| const otherFiles: File[] = []; | |
| for (const file of fileArray) { | |
| if (file.type.startsWith('image/')) { | |
| // Set first image as the main image for vision | |
| if (!hasImage) { | |
| setSelectedImage(file); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| setImagePreviewUrl(e.target?.result as string); | |
| }; | |
| reader.readAsDataURL(file); | |
| hasImage = true; | |
| } | |
| } else { | |
| // Non-image files go to attachedFiles | |
| otherFiles.push(file); | |
| } | |
| } | |
| // Add non-image files as attachments | |
| if (otherFiles.length > 0) { | |
| setAttachedFiles(prev => [...prev, ...otherFiles]); | |
| toast.success(`📎 ${otherFiles.length} file(s) attached!`); | |
| } | |
| // Set default prompt based on what was uploaded | |
| if (hasImage && !input.trim()) { | |
| setInput('What can you see in this image?'); | |
| } else if (otherFiles.length > 0 && !input.trim()) { | |
| setInput('Analyze this data'); | |
| } | |
| // Reset file input | |
| event.target.value = ''; | |
| }; | |
| const handlePaste = async (event: React.ClipboardEvent) => { | |
| const items = event.clipboardData?.items; | |
| const files = event.clipboardData?.files; | |
| if (!items && !files) return; | |
| // Check for pasted files | |
| if (files && files.length > 0) { | |
| event.preventDefault(); | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| if (file.type.startsWith('image/')) { | |
| // Handle image paste | |
| setSelectedImage(file); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| setImagePreviewUrl(e.target?.result as string); | |
| }; | |
| reader.readAsDataURL(file); | |
| setInput(prev => prev || 'What can you see in this image?'); | |
| } else { | |
| // Handle other file types (PDF, Excel, CSV, etc.) | |
| const supportedTypes = [ | |
| 'application/pdf', | |
| 'application/vnd.ms-excel', | |
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
| 'text/csv', | |
| 'application/json', | |
| 'text/plain' | |
| ]; | |
| if (supportedTypes.includes(file.type) || file.name.match(/\.(pdf|xlsx|xls|csv|json|txt)$/i)) { | |
| setAttachedFiles(prev => [...prev, file]); | |
| toast.success(`📎 File "${file.name}" attached!`); | |
| } | |
| } | |
| } | |
| return; | |
| } | |
| // Check clipboard items for images | |
| for (let i = 0; i < (items?.length || 0); i++) { | |
| const item = items![i]; | |
| if (item.type.startsWith('image/')) { | |
| event.preventDefault(); | |
| const blob = item.getAsFile(); | |
| if (blob) { | |
| const file = new File([blob], `pasted_image_${Date.now()}.png`, { type: blob.type }); | |
| setSelectedImage(file); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| setImagePreviewUrl(e.target?.result as string); | |
| }; | |
| reader.readAsDataURL(file); | |
| setInput(prev => prev || 'What can you see in this image?'); | |
| } | |
| break; | |
| } | |
| } | |
| }; | |
| // Reusable function to process user message (MCPs, API, Animation) | |
| const processUserMessage = async ( | |
| messageText: string, | |
| convId: string, | |
| visionAttachments: any[] = [], | |
| filesToCompare?: string[] | |
| ) => { | |
| setIsLoading(true); | |
| try { | |
| // Check if we should use streaming (for RAG modes without images) | |
| const canStream = useStreaming && !visionAttachments.length && | |
| ['rag', 'hybrid'].includes(mode); | |
| if (canStream) { | |
| // STREAMING MODE - Word by word like ChatGPT | |
| const assistantMsgId = (Date.now() + 1).toString(); | |
| // Create placeholder message that will be updated | |
| const streamingAssistantMessage = { | |
| id: assistantMsgId, | |
| role: 'assistant' as const, | |
| content: '', | |
| timestamp: new Date().toISOString(), | |
| sources: [] as string[], | |
| }; | |
| addMessageToConversation(convId, streamingAssistantMessage); | |
| setStreamingMessageId(assistantMsgId); | |
| setStreamingContent(''); | |
| let fullContent = ''; | |
| // Get model based on selected LLM | |
| const model = llm; | |
| await apiService.streamMessage( | |
| messageText, | |
| model, | |
| 'rag', | |
| // onChunk - update message word by word | |
| (chunk: string) => { | |
| fullContent += chunk; | |
| setStreamingContent(fullContent); | |
| // Update the message in conversation | |
| const currentConv = getCurrentConversation(); | |
| if (currentConv) { | |
| const updatedMessages = currentConv.messages.map(m => | |
| m.id === assistantMsgId | |
| ? { ...m, content: fullContent } | |
| : m | |
| ); | |
| updateConversationMessages(convId, updatedMessages); | |
| } | |
| }, | |
| // onDone | |
| () => { | |
| setStreamingMessageId(null); | |
| setStreamingContent(''); | |
| setIsLoading(false); | |
| }, | |
| // onError | |
| (error: string) => { | |
| console.error('Streaming error:', error); | |
| const currentConv = getCurrentConversation(); | |
| if (currentConv) { | |
| // Format error as user-friendly message | |
| const friendlyError = formatUserFriendlyError(error); | |
| const updatedMessages = currentConv.messages.map(m => | |
| m.id === assistantMsgId | |
| ? { ...m, content: fullContent || friendlyError } | |
| : m | |
| ); | |
| updateConversationMessages(convId, updatedMessages); | |
| } | |
| setStreamingMessageId(null); | |
| setIsLoading(false); | |
| } | |
| ); | |
| } else { | |
| // NON-STREAMING MODE - Regular API call with MCP animation | |
| let mcpsToUse = enabledMcps; | |
| // Detect which MCPs might be triggered based on query (EXPANDED keyword matching) | |
| const queryLower = messageText.toLowerCase(); | |
| const potentialTools: Array<{ name: string; icon: string; status: 'pending' | 'running' | 'success'; description: string }> = []; | |
| // Helper function for partial keyword matching | |
| const matchesAny = (keywords: string[]) => keywords.some(kw => queryLower.includes(kw)); | |
| // 🏆 DETECT 5 POWERFUL MCPs based on query | |
| if (enabledMcps.data_analyzer && matchesAny(['analyz', 'explore', 'schema', 'structure', 'columns', 'data type', 'overview', 'describe', 'what data', 'statistics', 'stats'])) { | |
| potentialTools.push({ name: '🔍 Data Analyzer', icon: '🔍', status: 'pending', description: 'Deep data exploration & schema analysis' }); | |
| } | |
| if (enabledMcps.pattern_finder && matchesAny(['pattern', 'anomal', 'outlier', 'trend', 'unusual', 'spike', 'drop', 'correlation', 'detect', 'discover', 'hidden'])) { | |
| potentialTools.push({ name: '🔎 Pattern Finder', icon: '🔎', status: 'pending', description: 'Finds patterns, anomalies & correlations' }); | |
| } | |
| if (enabledMcps.report_generator && matchesAny(['report', 'summary', 'executive', 'document', 'presentation', 'findings', 'conclusion', 'recommend'])) { | |
| potentialTools.push({ name: '📄 Report Generator', icon: '📄', status: 'pending', description: 'Creates professional reports' }); | |
| } | |
| if (enabledMcps.insight_extractor && matchesAny(['insight', 'key', 'important', 'valuable', 'business', 'opportunit', 'risk', 'growth', 'potential', 'learn'])) { | |
| potentialTools.push({ name: '💡 Insight Extractor', icon: '💡', status: 'pending', description: 'Mines valuable insights from data' }); | |
| } | |
| if (enabledMcps.comparison_engine && matchesAny(['compare', 'versus', 'vs', 'difference', 'between', 'segment', 'category', 'breakdown', 'by', 'top', 'bottom', 'best', 'worst'])) { | |
| potentialTools.push({ name: '⚖️ Comparison Engine', icon: '⚖️', status: 'pending', description: 'Compares across dimensions' }); | |
| } | |
| // 🚀 SPEED OPTIMIZATION: Skip MCP permission dialog for faster responses | |
| // MCPs are auto-allowed silently (like Claude after first authorization) | |
| // This removes the 300-400ms delay per tool and permission dialog wait time | |
| if (potentialTools.length > 0) { | |
| // Show quick MCP indicator (non-blocking) | |
| setMcpExecutionStatus({ | |
| isRunning: true, | |
| currentTools: potentialTools.slice(0, 2).map((t, idx) => ({ | |
| name: t.name, | |
| icon: t.icon, | |
| status: idx === 0 ? 'running' : 'pending' | |
| })), | |
| startTime: Date.now(), | |
| }); | |
| } | |
| // 🧠 BUILD CONVERSATION HISTORY FOR PERSISTENT MEMORY | |
| // Get last 10 messages for context (like ChatGPT/Claude) | |
| const currentConv = getCurrentConversation(); | |
| const recentHistory = currentConv?.messages | |
| .slice(-10) | |
| .map(m => ({ | |
| role: m.role, | |
| content: m.content.substring(0, 500) // Limit content length | |
| })) || []; | |
| const response = await apiService.sendMessage( | |
| messageText, | |
| llm, | |
| mode, | |
| convId, | |
| filesToCompare && filesToCompare.length > 0 ? filesToCompare : undefined, | |
| visionAttachments.length > 0 ? visionAttachments : undefined, | |
| mcpsToUse, | |
| recentHistory // Pass conversation history for memory | |
| ); | |
| // Clear MCP animation | |
| setMcpExecutionStatus({ isRunning: false, currentTools: [], startTime: null }); | |
| const responseContent = response.data?.message || response.data?.answer || 'No response received'; | |
| const responseSources = response.data?.sources || []; | |
| const responseSuggestions = response.data?.suggestions || []; | |
| const responseConfidence = response.data?.confidence; | |
| const assistantMessage = { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant' as const, | |
| content: String(responseContent), | |
| timestamp: new Date().toISOString(), | |
| sources: Array.isArray(responseSources) ? responseSources.map(s => String(s)) : [], | |
| // 🏆 Competition-winning features | |
| suggestions: responseSuggestions, | |
| confidence: responseConfidence, | |
| mode: mode, | |
| }; | |
| addMessageToConversation(convId, assistantMessage); | |
| setAnimatingMessageId(assistantMessage.id); // Trigger typewriter animation | |
| setIsLoading(false); | |
| } | |
| } catch (error: any) { | |
| console.error('Error processing message:', error); | |
| // Check for rate limit error first (added by our interceptor) | |
| let friendlyError: string; | |
| if (error.isRateLimited) { | |
| friendlyError = error.rateLimitMessage; | |
| } else if (error.serverErrorMessage) { | |
| friendlyError = error.serverErrorMessage; | |
| } else { | |
| // Format error as user-friendly message | |
| const rawError = error.response?.data?.detail || error.message || 'Unknown error'; | |
| friendlyError = formatUserFriendlyError(rawError); | |
| } | |
| const errorMessage = { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant' as const, | |
| content: friendlyError, | |
| timestamp: new Date().toISOString(), | |
| }; | |
| addMessageToConversation(convId, errorMessage); | |
| setIsLoading(false); | |
| setMcpExecutionStatus({ isRunning: false, currentTools: [], startTime: null }); | |
| } | |
| }; | |
| const handleSend = async (eOrText?: React.MouseEvent | string) => { | |
| let textOverride: string | undefined; | |
| if (typeof eOrText === 'string') { | |
| textOverride = eOrText; | |
| } | |
| const textToProcess = typeof textOverride === 'string' ? textOverride : input; | |
| const hasContent = textToProcess.trim().length > 0 || selectedImage || attachedFiles.length > 0; | |
| if (!hasContent || isLoading) return; | |
| let convId = currentConversationId; | |
| if (!convId) { | |
| convId = createConversation(mode); | |
| } | |
| const userMessage: any = { | |
| id: Date.now().toString(), | |
| role: 'user' as const, | |
| content: textToProcess.trim(), | |
| timestamp: new Date().toISOString(), | |
| imageData: null, | |
| }; | |
| // If image selected, read as base64 BEFORE adding to conversation | |
| if (selectedImage) { | |
| const base64Content = await new Promise<string>((resolve) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result as string); | |
| reader.readAsDataURL(selectedImage); | |
| }); | |
| // Store base64 in message for persistence | |
| userMessage.imageData = base64Content; | |
| // Also set in messageImages state for immediate display | |
| setMessageImages(prev => ({ ...prev, [userMessage.id]: base64Content })); | |
| } | |
| addMessageToConversation(convId, userMessage); | |
| // Scroll to bottom immediately after sending message (ChatGPT behavior) | |
| setTimeout(() => scrollToBottom('smooth'), 100); | |
| const messageToSend = textToProcess.trim(); | |
| const filesToCompare = attachedFiles.map(f => f.name); | |
| // Create vision attachments | |
| let visionAttachments: any[] = []; | |
| // Use the already-stored imageData from userMessage | |
| if (userMessage.imageData) { | |
| visionAttachments.push({ | |
| name: 'uploaded_image', | |
| type: 'image/png', | |
| size: 0, | |
| content: userMessage.imageData | |
| }); | |
| } | |
| // Upload files | |
| if (attachedFiles.length > 0) { | |
| await apiService.uploadFiles(attachedFiles); | |
| window.dispatchEvent(new CustomEvent('filesUpdated')); | |
| } | |
| // Reset Input if we were using the text area | |
| if (typeof textOverride !== 'string') { | |
| setInput(''); | |
| } | |
| setSelectedImage(null); | |
| setImagePreviewUrl(null); | |
| setAttachedFiles([]); | |
| // Process Message | |
| await processUserMessage(messageToSend, convId, visionAttachments, filesToCompare); | |
| }; | |
| const handleEditMessage = (messageId: string, content: string) => { | |
| setEditingMessageId(messageId); | |
| setEditingContent(content); | |
| }; | |
| const handleCancelEdit = () => { | |
| setEditingMessageId(null); | |
| setEditingContent(''); | |
| }; | |
| const handleSaveAndResend = async (messageId: string) => { | |
| if (!editingContent.trim() || isLoading) return; | |
| const messageIndex = messages.findIndex(m => m.id === messageId); | |
| if (messageIndex === -1) return; | |
| let convId = currentConversationId || createConversation(mode); | |
| const messagesToKeep = messages.slice(0, messageIndex).map(m => ({ | |
| ...m, | |
| timestamp: m.timestamp instanceof Date ? m.timestamp.toISOString() : m.timestamp | |
| })); | |
| updateConversationMessages(convId, messagesToKeep); | |
| const messageToSend = editingContent.trim(); | |
| setEditingMessageId(null); | |
| setEditingContent(''); | |
| const userMessage = { | |
| id: Date.now().toString(), | |
| role: 'user' as const, | |
| content: messageToSend, | |
| timestamp: new Date().toISOString(), | |
| }; | |
| addMessageToConversation(convId, userMessage); | |
| await processUserMessage(messageToSend, convId); | |
| }; | |
| const handleCopyMessage = async (messageId: string, content: string) => { | |
| try { | |
| await navigator.clipboard.writeText(content); | |
| setCopiedMessageId(messageId); | |
| setTimeout(() => setCopiedMessageId(null), 2000); | |
| } catch (error) { | |
| console.error('Failed to copy:', error); | |
| } | |
| }; | |
| // Handle export message to PDF/PPTX/Email | |
| const handleExportMessage = async (content: string, format: 'pdf' | 'pptx' | 'email') => { | |
| try { | |
| const response = await fetch('/api/v1/exports/generate', { | |
| method: 'POST', | |
| headers: getAuthHeadersSync(), | |
| body: JSON.stringify({ | |
| title: 'Business Analyst Report', | |
| content: content, | |
| format: format, | |
| workspace_id: getUserIdSync() | |
| }) | |
| }); | |
| if (!response.ok) throw new Error('Export failed'); | |
| const data = await response.json(); | |
| // Decode base64 and download | |
| const binaryString = atob(data.content_base64); | |
| const bytes = new Uint8Array(binaryString.length); | |
| for (let i = 0; i < binaryString.length; i++) { | |
| bytes[i] = binaryString.charCodeAt(i); | |
| } | |
| const blob = new Blob([bytes], { type: data.content_type }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = data.filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } catch (error: any) { | |
| console.error('Export failed:', error); | |
| toast.error('Export failed: ' + (error.message || 'Unknown error')); | |
| } | |
| }; | |
| // Handle regenerate response - find the previous user message and resend it | |
| const handleRegenerate = async (messageId: string) => { | |
| const currentConv = getCurrentConversation(); | |
| if (!currentConv) return; | |
| // Find the assistant message index | |
| const msgIndex = currentConv.messages.findIndex(m => m.id === messageId); | |
| if (msgIndex <= 0) return; | |
| // Find the previous user message | |
| let userMsgIndex = msgIndex - 1; | |
| while (userMsgIndex >= 0 && currentConv.messages[userMsgIndex].role !== 'user') { | |
| userMsgIndex--; | |
| } | |
| if (userMsgIndex < 0) return; | |
| const userMessage = currentConv.messages[userMsgIndex] as any; | |
| // Remove the assistant message we're regenerating | |
| const updatedMessages = currentConv.messages.filter((_, i) => i !== msgIndex); | |
| updateConversationMessages(currentConv.id, updatedMessages); | |
| // Prepare vision attachments if image existed | |
| let visionAttachments: any[] = []; | |
| if (userMessage.imageData) { | |
| visionAttachments.push({ | |
| name: 'uploaded_image', | |
| type: 'image/png', | |
| size: 0, | |
| content: userMessage.imageData | |
| }); | |
| } | |
| // Call processUserMessage | |
| await processUserMessage(userMessage.content, currentConv.id, visionAttachments); | |
| }; | |
| const handleNewChat = () => { | |
| // Reset all file states for new chat to enable immediate upload | |
| setSelectedImage(null); | |
| setImagePreviewUrl(null); | |
| setAttachedFiles([]); | |
| setInput(''); | |
| setIsLoading(false); | |
| setEditingMessageId(null); | |
| setEditingContent(''); | |
| // Create new conversation | |
| createConversation(mode); | |
| }; | |
| const handleDeleteConversation = async (id: string, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| const conv = conversations.find(c => c.id === id); | |
| const confirmed = await confirm({ | |
| title: 'Delete Conversation', | |
| message: `Delete "${conv?.title || 'this conversation'}"? This cannot be undone.`, | |
| confirmText: 'Delete', | |
| cancelText: 'Keep', | |
| variant: 'danger', | |
| }); | |
| if (confirmed) { | |
| deleteConversation(id); | |
| toast.deleted('Conversation Deleted'); | |
| } | |
| }; | |
| const handleSuggestionClick = (text: string) => { | |
| setInput(text); | |
| textareaRef.current?.focus(); | |
| }; | |
| const formatDate = (dateStr: string) => { | |
| const date = new Date(dateStr); | |
| const now = new Date(); | |
| const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); | |
| if (diffDays === 0) return 'Today'; | |
| if (diffDays === 1) return 'Yesterday'; | |
| if (diffDays < 7) return `${diffDays} days ago`; | |
| return date.toLocaleDateString(); | |
| }; | |
| const currentMode = modes.find(m => m.id === mode) || modes[0]; // Safe fallback to analyst | |
| // Refs for click-outside detection | |
| const mcpMenuRef = useRef<HTMLDivElement>(null); | |
| const modeMenuRef = useRef<HTMLDivElement>(null); | |
| // Close dropdowns when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent | TouchEvent) => { | |
| if (mcpDropdownOpen && mcpMenuRef.current && !mcpMenuRef.current.contains(event.target as Node)) { | |
| // Check if the click was on the toggle button - if so, let the button handle it | |
| const target = event.target as Element; | |
| if (!target.closest('button[title="MCP Servers"]')) { | |
| setMcpDropdownOpen(false); | |
| } | |
| } | |
| if (modeDropdownOpen && modeMenuRef.current && !modeMenuRef.current.contains(event.target as Node)) { | |
| // Check if the click was on the toggle button | |
| const target = event.target as Element; | |
| if ( | |
| !target.closest('button[title="Analysis Modes"]') && | |
| !target.closest('.mode-selector-btn') // Add class to mode buttons to be safe | |
| ) { | |
| setModeDropdownOpen(false); | |
| } | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| document.addEventListener('touchstart', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| document.removeEventListener('touchstart', handleClickOutside); | |
| }; | |
| }, [mcpDropdownOpen, modeDropdownOpen]); | |
| return ( | |
| <div className="flex h-screen sm:h-[100dvh] overflow-hidden bg-[var(--bg-primary)] w-full max-w-[100vw]" style={{ overflowX: 'hidden' }} {...getRootProps()}> | |
| <input {...getInputProps()} /> | |
| {/* 📎 DRAG OVERLAY - Visual feedback when dragging files */} | |
| {isDragActive && ( | |
| <div className="fixed inset-0 z-[100] bg-black/70 backdrop-blur-sm flex items-center justify-center"> | |
| <div className="p-8 rounded-2xl border-2 border-dashed border-green-500 bg-green-500/10 text-center animate-pulse"> | |
| <div className="text-6xl mb-4">📎</div> | |
| <h3 className="text-2xl font-bold text-white mb-2">Drop your files here</h3> | |
| <p className="text-gray-300"> | |
| Supports: Images, PDF, Excel, CSV, JSON, Word, PowerPoint | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Mobile Sidebar Overlay - CSS transitions instead of Framer Motion for performance */} | |
| {sidebarOpen && ( | |
| <div | |
| onClick={() => setSidebarOpen(false)} | |
| className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm transition-opacity duration-200" | |
| /> | |
| )} | |
| {/* Sidebar - Desktop Fixed / Mobile Drawer */} | |
| <aside className={` | |
| fixed md:static inset-y-0 left-0 z-50 w-[260px] bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col transition-transform duration-300 transform | |
| ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0 md:w-0 md:border-none md:overflow-hidden'} | |
| `}> | |
| {/* Sidebar Header */} | |
| <div className="p-4 border-b border-[var(--border-color)] flex items-center justify-between min-w-[256px]"> | |
| <h2 className="text-lg font-semibold text-white">Chat History</h2> | |
| {/* Mobile close button */} | |
| <button | |
| onClick={() => setSidebarOpen(false)} | |
| className="lg:hidden p-1 hover:bg-[var(--bg-hover)] rounded-lg transition-colors" | |
| title="Close" | |
| > | |
| <X className="w-5 h-5 text-gray-400" /> | |
| </button> | |
| </div> | |
| {/* New Chat Button */} | |
| <div className="p-3"> | |
| <button | |
| onClick={handleNewChat} | |
| className="w-full flex items-center justify-center gap-2 py-3 px-4 btn-new-chat rounded-xl text-sm font-medium shadow-sm transition-all" | |
| > | |
| <Plus className="w-4 h-4" /> | |
| New Chat | |
| </button> | |
| </div> | |
| {/* Chat List */} | |
| <div className="flex-1 overflow-y-auto p-2"> | |
| <div className="flex items-center justify-between px-2 py-2 mb-2"> | |
| <span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Recent</span> | |
| {conversations.length > 1 && ( | |
| <button | |
| onClick={async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const confirmed = await confirm({ | |
| title: 'Clear All Chats', | |
| message: 'Are you sure you want to clear all chat history? This action cannot be undone.', | |
| confirmText: 'Clear All', | |
| variant: 'danger', | |
| icon: <Trash2 className="w-6 h-6" /> | |
| }); | |
| if (confirmed) { | |
| clearAllChats(); | |
| toast.deleted('All chat history cleared'); | |
| } | |
| }} | |
| className="text-xs text-gray-500 hover:text-red-400 transition-colors" | |
| > | |
| Clear all | |
| </button> | |
| )} | |
| </div> | |
| <div className="space-y-1"> | |
| {conversations.map((conv) => ( | |
| <div | |
| key={conv.id} | |
| onClick={() => selectConversation(conv.id)} | |
| className={`group flex items-center gap-2 p-3 rounded-lg cursor-pointer chat-item ${currentConversationId === conv.id ? 'active' : ''}`} | |
| > | |
| <MessageSquare className={`w-4 h-4 flex-shrink-0 ${currentConversationId === conv.id ? 'text-green-400' : 'text-gray-500'}`} /> | |
| <div className="flex-1 min-w-0"> | |
| <p className={`text-sm truncate ${currentConversationId === conv.id ? 'text-gray-100' : 'text-gray-300'}`}> | |
| {conv.title} | |
| </p> | |
| <p className="text-xs text-gray-500">{formatDate(conv.updatedAt)}</p> | |
| </div> | |
| <button | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleDeleteConversation(conv.id, e); | |
| }} | |
| className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-500/20 rounded-lg transition-all" | |
| title="Delete" | |
| > | |
| <Trash2 className="w-3.5 h-3.5 text-gray-400 hover:text-red-400" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Sidebar Footer */} | |
| <div className="p-3 border-t border-[var(--border-color)] space-y-1"> | |
| <button | |
| onClick={() => navigate('/settings')} | |
| className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-[var(--bg-hover)] text-gray-400 hover:text-gray-200 transition-colors text-sm" | |
| > | |
| <Settings className="w-4 h-4" /> | |
| Settings | |
| </button> | |
| <button | |
| onClick={() => navigate('/datahub')} | |
| className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-[var(--bg-hover)] text-gray-400 hover:text-gray-200 transition-colors text-sm" | |
| > | |
| <ArrowLeft className="w-4 h-4" /> | |
| Back to DataHub | |
| </button> | |
| </div> | |
| </aside> | |
| {/* Main Content Area */} | |
| <div className="flex-1 flex flex-col relative min-w-0 bg-[var(--bg-primary)]"> | |
| {/* Top Navigation Bar - Now visible on Desktop too */} | |
| <div className="h-16 border-b border-[var(--border-color)] flex items-center justify-between px-4 bg-[var(--bg-card)]/50 backdrop-blur-sm sticky top-0 z-20"> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={() => setSidebarOpen(!sidebarOpen)} | |
| className="p-2 hover:bg-[var(--bg-hover)] rounded-lg transition-colors text-gray-400 hover:text-white" | |
| title={sidebarOpen ? "Close sidebar" : "Open sidebar"} | |
| > | |
| <Menu className="w-5 h-5" /> | |
| </button> | |
| {/* Only show title when there are messages */} | |
| {messages.length > 0 && ( | |
| <div className="flex flex-col"> | |
| <h1 className="text-base font-semibold text-white leading-tight">Analyst Chat</h1> | |
| <p className="text-xs text-gray-500"> | |
| {currentMode.label} Mode • {Object.values(enabledMcps).filter(Boolean).length} Active Tools | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Top Right Actions */} | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={async () => { | |
| if (messages.length === 0) { | |
| toast.warning("No chat history to export."); | |
| return; | |
| } | |
| const content = messages.map(m => `[${m.role.toUpperCase()}]\n${m.content}`).join('\n\n'); | |
| await handleExportMessage(content, 'pdf'); | |
| }} | |
| className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-[var(--bg-hover)] text-gray-400 hover:text-white" | |
| title="Export Chat to PDF" | |
| > | |
| <FileText className="w-3 h-3" /> | |
| Export PDF | |
| </button> | |
| <button | |
| onClick={() => setUseStreaming(!useStreaming)} | |
| className={`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${useStreaming ? 'bg-green-500/10 text-green-400' : 'bg-[var(--bg-hover)] text-gray-500' | |
| }`} | |
| title="Toggle Streaming" | |
| > | |
| <Zap className={`w-3 h-3 ${useStreaming ? 'fill-current' : ''}`} /> | |
| {useStreaming ? 'Fast' : 'Standard'} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Messages Container - Scrollable with ChatGPT-like experience */} | |
| <div ref={chatContainerRef} className="flex-1 overflow-y-auto custom-scrollbar"> | |
| <div className={`max-w-3xl mx-auto px-4 ${messages.length === 0 ? 'h-full flex flex-col justify-center py-8' : 'py-6 pb-40'}`}> | |
| {messages.length === 0 ? ( | |
| <WelcomeScreen | |
| onSuggestionClick={handleSuggestionClick} | |
| input={input} | |
| setInput={setInput} | |
| onSend={handleSend} | |
| isLoading={isLoading} | |
| onFileClick={() => fileInputRef.current?.click()} | |
| onPaste={handlePaste} | |
| hasAttachment={!!(selectedImage || attachedFiles.length > 0)} | |
| imagePreview={imagePreviewUrl} | |
| onRemoveImage={() => { setSelectedImage(null); setImagePreviewUrl(null); }} | |
| currentMode={currentMode} | |
| onModeClick={() => setModeDropdownOpen(true)} | |
| onMcpClick={() => setMcpDropdownOpen(true)} | |
| mcpCount={{ active: Object.values(enabledMcps).filter(Boolean).length, total: mcpServers.length }} | |
| llm={llm} | |
| setLlm={setLlm} | |
| /> | |
| ) : ( | |
| <div className="space-y-4"> | |
| {messages.filter(msg => msg && msg.id && msg.content).map((message) => ( | |
| <div | |
| key={message.id} | |
| className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} group`} | |
| > | |
| {message.role === 'user' ? ( | |
| // User Message - Right side, contained bubble | |
| <div className="flex flex-col items-end max-w-[85%]"> | |
| {editingMessageId === message.id ? ( | |
| <div className="w-full"> | |
| <textarea | |
| value={editingContent} | |
| onChange={(e) => setEditingContent(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (editingContent.trim()) { | |
| handleSaveAndResend(message.id); | |
| } | |
| } | |
| if (e.key === 'Escape') { | |
| handleCancelEdit(); | |
| } | |
| }} | |
| className="w-full p-4 bg-[var(--bg-card)] border border-accent-primary rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-accent-primary text-gray-100" | |
| rows={3} | |
| autoFocus | |
| /> | |
| <div className="flex justify-end gap-2 mt-2"> | |
| <button onClick={handleCancelEdit} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white"> | |
| Cancel | |
| </button> | |
| <button | |
| onClick={() => handleSaveAndResend(message.id)} | |
| disabled={!editingContent.trim()} | |
| className="px-4 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg disabled:opacity-50" | |
| > | |
| Save & Resend | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="px-4 py-3 chat-bubble-user rounded-2xl rounded-br-md shadow-lg"> | |
| <p className="whitespace-pre-wrap">{message.content}</p> | |
| {messageImages[message.id] && ( | |
| <img src={messageImages[message.id]} alt="Uploaded" className="mt-3 max-w-full rounded-lg" /> | |
| )} | |
| </div> | |
| <div className="flex gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button onClick={() => handleCopyMessage(message.id, message.content)} className="p-1 hover:bg-[var(--bg-hover)] rounded text-gray-500 hover:text-gray-300"> | |
| {copiedMessageId === message.id ? <CheckCheck className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />} | |
| </button> | |
| <button onClick={() => handleEditMessage(message.id, message.content)} className="p-1 hover:bg-[var(--bg-hover)] rounded text-gray-500 hover:text-gray-300"> | |
| <Pencil className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| ) : ( | |
| // Bot Message - Left side with logo, full width response | |
| <div className="flex items-start gap-3 w-full max-w-full bot-message-animate"> | |
| {/* DataVision Logo with animation */} | |
| <div className="w-8 h-8 flex-shrink-0 mt-1 flex items-center justify-center"> | |
| <AnimatedBotIcon size={24} isDark={isDark} /> | |
| </div> | |
| <div className="flex-1"> | |
| {/* 🎯 DIRECT ANSWER BADGE - Show key value prominently first */} | |
| {(() => { | |
| // Find the user message that preceded this response | |
| const msgIndex = messages.findIndex(m => m.id === message.id); | |
| const userMsg = msgIndex > 0 ? messages.slice(0, msgIndex).reverse().find(m => m.role === 'user') : null; | |
| const directAnswer = userMsg ? extractDirectAnswer(message.content, userMsg.content) : null; | |
| return directAnswer && <DirectAnswerBadge answer={directAnswer} />; | |
| })()} | |
| <div className="prose prose-invert max-w-none"> | |
| <TypewriterText | |
| content={message.content || 'No content'} | |
| isNew={message.id === animatingMessageId} | |
| onComplete={() => setAnimatingMessageId(null)} | |
| /> | |
| {message.sources && Array.isArray(message.sources) && message.sources.length > 0 && ( | |
| <div className="mt-4 pt-4 border-t border-[var(--border-color)]"> | |
| <p className="text-xs font-semibold text-gray-500 mb-2 flex items-center gap-1"> | |
| <FileText className="w-3 h-3" /> Sources | |
| </p> | |
| <div className="flex flex-wrap gap-2"> | |
| {message.sources.map((source, i) => ( | |
| <span key={i} className="px-2 py-1 bg-green-500/20 text-green-300 rounded-lg text-xs"> | |
| {String(source)} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex gap-1 mt-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button onClick={() => handleCopyMessage(message.id, message.content)} className="p-1.5 hover:bg-[var(--bg-hover)] rounded-lg text-gray-500 hover:text-gray-300 transition-colors" title="Copy"> | |
| {copiedMessageId === message.id ? <CheckCheck className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />} | |
| </button> | |
| <button | |
| onClick={() => handleFeedback(message.id, 'up')} | |
| className={`p-1.5 hover:bg-[var(--bg-hover)] rounded-lg transition-colors ${messageFeedback[message.id] === 'up' | |
| ? 'text-green-400 bg-green-400/10' | |
| : 'text-gray-500 hover:text-green-400' | |
| }`} | |
| title="Good response" | |
| > | |
| <svg className="w-4 h-4" fill={messageFeedback[message.id] === 'up' ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-1.53a2 2 0 01-1.271-.453L11 19.5a3 3 0 01-1-2.236V13a3 3 0 011-2.236L14 8.5V10z" /> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 10h-.5a2 2 0 00-2 2v6a2 2 0 002 2h.5" /> | |
| </svg> | |
| </button> | |
| <button | |
| onClick={() => handleFeedback(message.id, 'down')} | |
| className={`p-1.5 hover:bg-[var(--bg-hover)] rounded-lg transition-colors ${messageFeedback[message.id] === 'down' | |
| ? 'text-red-400 bg-red-400/10' | |
| : 'text-gray-500 hover:text-red-400' | |
| }`} | |
| title="Bad response" | |
| > | |
| <svg className="w-4 h-4" fill={messageFeedback[message.id] === 'down' ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.737 3h1.53a2 2 0 011.271.453L13 4.5a3 3 0 011 2.236V11a3 3 0 01-1 2.236L10 15.5V14z" /> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 14h.5a2 2 0 002-2V6a2 2 0 00-2-2h-.5" /> | |
| </svg> | |
| </button> | |
| <button | |
| onClick={() => handleRegenerate(message.id)} | |
| className="p-1.5 hover:bg-[var(--bg-hover)] rounded-lg text-gray-500 hover:text-green-400 transition-colors" | |
| title="Regenerate response" | |
| > | |
| <RefreshCw className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| {/* 🏆 Dynamic Follow-up Suggestions - Competition Winner Feature */} | |
| {message.suggestions && message.suggestions.length > 0 && ( | |
| <div className="mt-4 pt-3 border-t border-[var(--border-color)]/50 animate-fadeIn"> | |
| <p className="text-xs text-gray-500 mb-2 flex items-center gap-1"> | |
| <Sparkles className="w-3 h-3 text-green-400" /> Follow-up questions | |
| </p> | |
| <div className="flex flex-wrap gap-2"> | |
| {message.suggestions.map((suggestion: string, i: number) => ( | |
| <button | |
| key={i} | |
| onClick={() => { | |
| setInput(suggestion); | |
| const inputEl = document.querySelector('textarea'); | |
| inputEl?.focus(); | |
| }} | |
| className="px-3 py-1.5 bg-green-50 dark:bg-green-500/10 hover:bg-green-100 dark:hover:bg-green-500/20 border border-green-200 dark:border-green-500/30 rounded-full text-xs text-green-700 dark:text-green-300 transition-colors duration-200" | |
| > | |
| {suggestion} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* 🏆 Confidence Indicator - Shows data grounding quality */} | |
| {message.confidence !== undefined && ( | |
| <div className="mt-3 flex items-center gap-2 text-xs"> | |
| <span className="text-gray-500">Data Confidence:</span> | |
| <div className="flex items-center gap-1"> | |
| <div className={`w-2 h-2 rounded-full ${message.confidence > 0.8 ? 'bg-green-500' : | |
| message.confidence > 0.5 ? 'bg-yellow-500' : 'bg-red-500' | |
| }`} /> | |
| <span className={`${message.confidence > 0.8 ? 'text-green-400' : | |
| message.confidence > 0.5 ? 'text-yellow-400' : 'text-red-400' | |
| }`}> | |
| {message.confidence > 0.8 ? 'High - grounded in data' : | |
| message.confidence > 0.5 ? 'Medium' : 'Low - limited data'} | |
| </span> | |
| </div> | |
| </div> | |
| )} | |
| {/* 🤖 ML Charts - Matplotlib/Seaborn Visualizations from Predict Mode */} | |
| {(message as any).mlCharts && Array.isArray((message as any).mlCharts) && (message as any).mlCharts.length > 0 && ( | |
| <div className="mt-4 space-y-4"> | |
| <div className="flex items-center gap-2 text-sm text-green-400 border-t border-[var(--border-color)] pt-3"> | |
| <Zap className="w-4 h-4" /> | |
| <span>ML Visualizations • Real scikit-learn analysis</span> | |
| </div> | |
| {(message as any).mlCharts.map((chart: { type: string; image: string; title?: string }, i: number) => ( | |
| chart.image && ( | |
| <div key={i} className="p-4 bg-gradient-to-br from-[var(--bg-secondary)] to-[var(--bg-primary)] rounded-xl border border-[var(--border-color)] shadow-lg"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <span className="text-lg"> | |
| {chart.type === 'ml_forecast' ? '📈' : | |
| chart.type === 'ml_importance' ? '🔑' : | |
| chart.type === 'ml_correlation' ? '🔗' : | |
| chart.type === 'ml_distribution' ? '📊' : | |
| chart.type === 'ml_summary' ? '🤖' : '📊'} | |
| </span> | |
| <span className="text-sm font-medium text-green-400"> | |
| {chart.title || 'ML Visualization'} | |
| </span> | |
| <span className="text-xs text-gray-500 ml-auto"> | |
| scikit-learn + matplotlib | |
| </span> | |
| </div> | |
| <ChartImage src={chart.image} alt={chart.title || 'ML Chart'} /> | |
| </div> | |
| ) | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| {/* MCP Permission Dialog - Fixed position to prevent layout shift */} | |
| {mcpPermissionDialog.isOpen && ( | |
| <div className="sticky bottom-4 z-30 mx-auto max-w-2xl"> | |
| <div className="bg-[var(--bg-card)] border border-amber-500/50 rounded-xl p-4 shadow-2xl backdrop-blur-sm"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <span className="text-lg">🔧</span> | |
| <div className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></div> | |
| <span className="text-sm text-amber-500 font-medium">Permission Required</span> | |
| </div> | |
| <p className="text-gray-300 text-sm mb-3"> | |
| DataVision wants to use the following tools: | |
| </p> | |
| <div className="space-y-2 mb-4 max-h-40 overflow-y-auto"> | |
| {mcpPermissionDialog.pendingTools.map((tool) => ( | |
| <div | |
| key={tool.name} | |
| className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-hover)]/50 border border-[var(--border-color)] rounded-lg" | |
| > | |
| <span className="text-lg">{tool.icon}</span> | |
| <div className="flex-1 min-w-0"> | |
| <span className="text-sm font-medium text-gray-200 truncate block">{tool.name}</span> | |
| <p className="text-xs text-gray-500 truncate">{tool.description}</p> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="flex gap-3 justify-end"> | |
| <button | |
| onClick={() => mcpPermissionDialog.onDeny?.()} | |
| className="px-5 py-2.5 text-sm font-medium text-gray-300 hover:text-white bg-[var(--bg-hover)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors border border-[var(--border-color)]" | |
| > | |
| Deny | |
| </button> | |
| <button | |
| onClick={() => mcpPermissionDialog.onAllow?.()} | |
| className="px-5 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors shadow-lg" | |
| > | |
| Allow | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {isLoading && ( | |
| <div className="space-y-3"> | |
| {/* MCP Tool Execution Animation - Claude Style */} | |
| {mcpExecutionStatus.isRunning && mcpExecutionStatus.currentTools.length > 0 && ( | |
| <div className="flex items-start gap-3 w-full max-w-full animate-fadeIn"> | |
| <div className="w-8 h-8 flex-shrink-0 mt-1 flex items-center justify-center"> | |
| <div className="animate-spin text-xl">⚙️</div> | |
| </div> | |
| <div className="flex-1 bg-[var(--bg-card)]/80 backdrop-blur-sm border border-accent-primary/30 rounded-xl p-4"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <div className="w-2 h-2 bg-accent-primary rounded-full animate-pulse"></div> | |
| <span className="text-sm text-accent-primary font-medium">Running MCP Tools</span> | |
| </div> | |
| <div className="space-y-2"> | |
| {mcpExecutionStatus.currentTools.map((tool) => ( | |
| <div | |
| key={tool.name} | |
| className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-300 ${tool.status === 'running' | |
| ? 'bg-accent-primary/20 border border-accent-primary/40' | |
| : tool.status === 'success' | |
| ? 'bg-green-500/20 border border-green-500/40' | |
| : 'bg-[var(--bg-hover)]/50 border border-[var(--border-color)]' | |
| }`} | |
| > | |
| <span className={`text-lg ${tool.status === 'running' ? 'animate-bounce' : ''}`}> | |
| {tool.status === 'success' ? '✓' : tool.icon} | |
| </span> | |
| <span className={`text-sm ${tool.status === 'running' ? 'text-accent-primary' : | |
| tool.status === 'success' ? 'text-green-400' : 'text-gray-400' | |
| }`}> | |
| {tool.name} | |
| </span> | |
| {tool.status === 'running' && ( | |
| <div className="ml-auto flex items-center gap-1"> | |
| <div className="w-1 h-1 bg-accent-primary rounded-full animate-ping"></div> | |
| <span className="text-xs text-accent-primary">Running...</span> | |
| </div> | |
| )} | |
| {tool.status === 'success' && ( | |
| <span className="ml-auto text-xs text-green-400">Done</span> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Regular Typing Indicator with mode-specific text */} | |
| <TypingIndicator mode={mode} isDark={isDark} /> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| </div> | |
| {/* ========== GLOBAL DROPDOWNS - Both appear CENTERED below search box ========== */} | |
| {/* Global MCP Dropdown - CSS transitions for performance */} | |
| {mcpDropdownOpen && ( | |
| <div | |
| ref={mcpMenuRef} | |
| className={`fixed z-50 left-4 md:left-[calc(50%-384px)] w-80 max-w-[calc(100vw-2rem)] bg-[var(--bg-card)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden transition-all duration-200 ${messages.length === 0 ? 'top-1/2 mt-16' : 'bottom-28'}`} | |
| > | |
| <div className="p-3 border-b border-[var(--border-color)] flex items-center justify-between bg-[var(--bg-secondary)]/50"> | |
| <p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">MCP Servers</p> | |
| <span className="text-xs text-green-400 font-medium">{Object.values(enabledMcps).filter(Boolean).length}/{mcpServers.length} active</span> | |
| </div> | |
| <div className="max-h-64 overflow-y-auto"> | |
| {mcpServers.map((mcp) => ( | |
| <button | |
| key={mcp.id} | |
| onClick={() => setEnabledMcps(prev => ({ ...prev, [mcp.id]: !prev[mcp.id] }))} | |
| className="flex items-center gap-3 w-full p-3 hover:bg-[var(--bg-hover)] transition-colors" | |
| > | |
| <span className="text-lg">{mcp.icon}</span> | |
| <div className="flex-1 text-left"> | |
| <p className={`text-sm font-medium ${enabledMcps[mcp.id] ? 'text-gray-200' : 'text-gray-500'}`}>{mcp.name}</p> | |
| <p className="text-xs text-gray-500 line-clamp-1">{mcp.description}</p> | |
| </div> | |
| <div className={`w-9 h-5 rounded-full transition-all duration-200 ${enabledMcps[mcp.id] ? 'bg-green-500' : 'bg-gray-700'} flex items-center`}> | |
| <div className={`w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${enabledMcps[mcp.id] ? 'translate-x-4' : 'translate-x-0.5'}`} /> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Global Mode Dropdown - CSS transitions for performance */} | |
| {modeDropdownOpen && ( | |
| <div | |
| ref={modeMenuRef} | |
| className={`fixed z-50 right-4 md:right-[calc(50%-384px)] w-80 max-w-[calc(100vw-2rem)] bg-[var(--bg-card)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden max-h-[60vh] overflow-y-auto transition-all duration-200 ${messages.length === 0 ? 'top-1/2 mt-16' : 'bottom-28'}`} | |
| > | |
| {/* RAG Modes */} | |
| <div className="p-2 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/50"> | |
| <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider px-2">📊 Data Modes</span> | |
| </div> | |
| {modes.filter(m => !m.isAI).map((m) => ( | |
| <button | |
| key={m.id} | |
| onClick={() => { setMode(m.id); setModeDropdownOpen(false); }} | |
| className={`flex items-center gap-3 w-full p-3 hover:bg-[var(--bg-hover)] transition-colors ${mode === m.id ? 'bg-[var(--bg-hover)]' : ''}`} | |
| > | |
| <div className="flex-1 text-left"> | |
| <div className="flex items-center gap-2"> | |
| <p className={`text-sm font-medium ${mode === m.id ? 'text-white' : 'text-gray-200'}`}>{m.label}</p> | |
| {m.badge && ( | |
| <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/20 text-green-400 rounded"> | |
| {m.badge} | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-xs text-gray-500">{m.description}</p> | |
| </div> | |
| {mode === m.id && <Check className="w-4 h-4 text-green-400" />} | |
| </button> | |
| ))} | |
| {/* AI Models Divider */} | |
| <div className="p-2 border-t border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/50"> | |
| <span className="text-xs font-semibold text-purple-400 uppercase tracking-wider px-2">🤖 AI Models</span> | |
| </div> | |
| {modes.filter(m => m.isAI).map((m: any) => ( | |
| <button | |
| key={m.id} | |
| onClick={() => { setMode(m.id); setModeDropdownOpen(false); }} | |
| className={`flex items-center gap-3 w-full p-3 hover:bg-[var(--bg-hover)] transition-colors ${mode === m.id ? 'bg-[var(--bg-hover)]' : ''}`} | |
| > | |
| {m.logo && ( | |
| <img | |
| src={m.logo} | |
| alt={m.label} | |
| className="w-6 h-6 rounded object-contain bg-white/10 p-0.5" | |
| onError={(e) => { e.currentTarget.style.display = 'none'; }} | |
| /> | |
| )} | |
| <div className="flex-1 text-left"> | |
| <div className="flex items-center gap-2"> | |
| <p className={`text-sm font-medium ${mode === m.id ? 'text-white' : 'text-gray-200'}`}>{m.label}</p> | |
| {m.badge && ( | |
| <span className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${m.badge === 'New' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'}`}> | |
| {m.badge} | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-xs text-gray-500">{m.description}</p> | |
| </div> | |
| {mode === m.id && <Check className="w-4 h-4 text-green-400" />} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Global Hidden File Input - Always in DOM for WelcomeScreen and bottom input */} | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*,.pdf,.xlsx,.xls,.csv,.json,.txt,.doc,.docx,.ppt,.pptx" | |
| onChange={handleImageSelect} | |
| className="hidden" | |
| multiple | |
| /> | |
| {/* Claude-style Input Bar - Only show when there are messages */} | |
| {/* Floating Input Area - Fixed at Bottom Center (hidden on empty state) */} | |
| { | |
| messages.length > 0 && ( | |
| <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-dark-bg via-dark-bg to-transparent pt-10 pb-6 px-4 z-20"> | |
| <div className="max-w-3xl mx-auto"> | |
| {/* Attached Files Preview */} | |
| {attachedFiles.length > 0 && ( | |
| <div className="mb-3 flex flex-wrap gap-2"> | |
| {attachedFiles.map((file, index) => ( | |
| <div key={index} className="flex items-center gap-2 px-3 py-2 bg-dark-card border border-dark-border rounded-lg text-sm"> | |
| <FileText className="w-4 h-4 text-green-400" /> | |
| <span className="text-gray-300 truncate max-w-[200px]">{file.name}</span> | |
| <button | |
| onClick={() => removeAttachedFile(index)} className="text-gray-500 hover:text-red-400"> | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Image Preview - Shows separately from attached files */} | |
| {selectedImage && imagePreviewUrl && ( | |
| <div className="mb-3 flex flex-wrap gap-2"> | |
| <div className="relative group inline-block"> | |
| {/* ChatGPT-style Image Preview Thumbnail */} | |
| <div className="relative w-32 h-24 rounded-xl overflow-hidden border-2 border-indigo-500/50 shadow-lg bg-dark-card transition-all duration-200 hover:border-indigo-400"> | |
| <img | |
| src={imagePreviewUrl} | |
| alt="Preview" | |
| className="w-full h-full object-cover" | |
| /> | |
| {/* Overlay with actions */} | |
| <div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-all duration-200 flex items-center justify-center"> | |
| {/* Edit/View button - top left */} | |
| <button | |
| className="absolute top-1 left-1 p-1 bg-black/50 hover:bg-black/70 rounded-md opacity-0 group-hover:opacity-100 transition-opacity" | |
| title="View image" | |
| > | |
| <Pencil className="w-3 h-3 text-white" /> | |
| </button> | |
| </div> | |
| {/* Dismiss button - top right */} | |
| <button | |
| onClick={() => { | |
| setSelectedImage(null); | |
| setImagePreviewUrl(null); | |
| }} | |
| className="absolute top-1 right-1 p-1 bg-gray-800/80 hover:bg-red-600 rounded-md text-white transition-colors" | |
| title="Remove image" | |
| > | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Input Bar Container - Premium ChatGPT Style */} | |
| <div className="glass-input w-full flex items-end gap-2 sm:gap-3 p-3 sm:p-4 rounded-2xl transition-all"> | |
| {/* Left side buttons - Hidden on mobile, shown on desktop */} | |
| <div className="hidden sm:flex items-center gap-2"> | |
| {/* MCP Servers Toggle - Uses unified dropdown below */} | |
| <button | |
| onClick={() => setMcpDropdownOpen(!mcpDropdownOpen)} | |
| className="p-2 hover:bg-dark-hover rounded-lg transition-colors text-gray-400 hover:text-gray-200 relative" | |
| title="MCP Servers" | |
| > | |
| <Database className="w-5 h-5" /> | |
| <span className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ${Object.values(enabledMcps).every(Boolean) ? 'bg-green-500' : 'bg-yellow-500'}`} /> | |
| </button> | |
| {/* File Upload */} | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| className="p-2 hover:bg-dark-hover rounded-lg transition-colors text-gray-400 hover:text-gray-200" | |
| title="Attach File" | |
| > | |
| <Paperclip className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| {/* Mobile: Plus button with menu */} | |
| <div className="relative sm:hidden"> | |
| {plusMenuOpen && ( | |
| <> | |
| <div className="fixed inset-0 z-40" onClick={() => setPlusMenuOpen(false)} /> | |
| <div className="absolute bottom-full mb-2 left-0 w-64 bg-dark-card border border-dark-border rounded-xl shadow-xl overflow-hidden z-50"> | |
| {/* Upload Files */} | |
| <button | |
| onClick={() => { | |
| fileInputRef.current?.click(); | |
| setPlusMenuOpen(false); | |
| }} | |
| className="flex items-center gap-3 w-full p-4 hover:bg-dark-hover transition-colors text-left" | |
| > | |
| <div className="p-2 bg-blue-500/10 rounded-lg"> | |
| <Paperclip className="w-5 h-5 text-blue-400" /> | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-sm font-medium text-gray-200">Add photos & files</p> | |
| <p className="text-xs text-gray-500">Upload documents or images</p> | |
| </div> | |
| </button> | |
| {/* MCPs Submenu */} | |
| <button | |
| onClick={() => { | |
| setMcpDropdownOpen(true); | |
| setPlusMenuOpen(false); | |
| }} | |
| className="flex items-center gap-3 w-full p-4 hover:bg-dark-hover transition-colors text-left border-t border-dark-border" | |
| > | |
| <div className="p-2 bg-green-500/10 rounded-lg"> | |
| <Database className="w-5 h-5 text-green-400" /> | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-sm font-medium text-gray-200">MCP Servers</p> | |
| <p className="text-xs text-gray-500">{Object.values(enabledMcps).filter(Boolean).length}/{mcpServers.length} active</p> | |
| </div> | |
| <ChevronDown className="w-4 h-4 text-gray-500 -rotate-90" /> | |
| </button> | |
| {/* Modes Submenu */} | |
| <button | |
| onClick={() => { | |
| setModeDropdownOpen(true); | |
| setPlusMenuOpen(false); | |
| }} | |
| className="flex items-center gap-3 w-full p-4 hover:bg-dark-hover transition-colors text-left border-t border-dark-border" | |
| > | |
| <div className="p-2 bg-purple-500/10 rounded-lg"> | |
| <Sparkles className="w-5 h-5 text-purple-400" /> | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-sm font-medium text-gray-200">AI Modes</p> | |
| <p className="text-xs text-gray-500">Current: {currentMode.label}</p> | |
| </div> | |
| <ChevronDown className="w-4 h-4 text-gray-500 -rotate-90" /> | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| <button | |
| onClick={() => setPlusMenuOpen(!plusMenuOpen)} | |
| className="p-3 hover:bg-dark-hover rounded-xl transition-colors text-gray-400 hover:text-gray-200" | |
| title="More options" | |
| > | |
| <Plus className="w-6 h-6" /> | |
| </button> | |
| </div> | |
| {/* Text Input - Large and prominent */} | |
| <textarea | |
| ref={textareaRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }} | |
| onPaste={handlePaste} | |
| placeholder="Ask anything..." | |
| rows={1} | |
| className="flex-1 min-w-0 bg-transparent border-none outline-none resize-none text-base text-gray-100 placeholder-gray-400 py-3 px-3 sm:py-2 sm:px-2 max-h-32 min-h-[44px]" | |
| style={{ lineHeight: '1.5' }} | |
| /> | |
| {/* Hidden file input */} | |
| {/* Right side: Voice + Mode + Send */} | |
| <div className="flex items-center gap-2"> | |
| {/* Voice Input */} | |
| <button | |
| onClick={handleVoiceInput} | |
| className={`p-3 sm:p-2 rounded-xl sm:rounded-lg transition-colors ${isRecording ? 'bg-red-500 text-white animate-pulse' : 'hover:bg-dark-hover text-gray-400 hover:text-gray-200'}`} | |
| title="Voice Input" | |
| > | |
| <Mic className="w-6 h-6 sm:w-5 sm:h-5" /> | |
| </button> | |
| {/* Mode Selector - Works on mobile and desktop */} | |
| <div className="flex items-center gap-1.5 ml-2"> | |
| <button | |
| onClick={() => setModeDropdownOpen(true)} | |
| className="flex items-center gap-1.5 px-3 py-2 bg-[var(--bg-secondary)] hover:bg-dark-hover rounded-lg transition-colors text-sm font-medium whitespace-nowrap" | |
| style={{ color: 'var(--text-primary)' }} | |
| > | |
| {currentMode.label} | |
| <ChevronDown className="w-4 h-4 text-gray-500" /> | |
| </button> | |
| </div> | |
| {/* Send/Stop Button - Transforms like ChatGPT */} | |
| {isLoading ? ( | |
| <button | |
| onClick={handleStopGeneration} | |
| className="p-3 sm:p-2.5 bg-gray-600 hover:bg-red-600 text-white rounded-xl transition-all duration-200" | |
| title="Stop generating" | |
| > | |
| <Square className="w-5 h-5 sm:w-4 sm:h-4 fill-current" /> | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={handleSend} | |
| disabled={!input.trim() && !selectedImage && attachedFiles.length === 0} | |
| className="chat-send-btn touch-target" | |
| > | |
| <Send className="w-5 h-5 text-gray-900" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Disclaimer */} | |
| <p className="text-center text-xs text-gray-500 mt-3"> | |
| DataVision can make mistakes. Please double-check responses. | |
| </p> | |
| </div> | |
| </div > | |
| ) | |
| } | |
| </div > | |
| {/* Confirm Modal for Delete Operations */} | |
| <ConfirmModal /> | |
| </div > | |
| ); | |
| }; | |
| export default AnalystChat; | |