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 (
{answer.label && (
{answer.label}
)}
{answer.value}
{answer.trend && {getTrendIcon()} }
);
};
// ============================================================================
// ๐ 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, '$1 ')
.replace(/\*(.+?)\*/g, '$1 ')
.replace(/`([^`]+)`/g, '$1');
};
// Component to render formatted text with inline styles
const InlineFormattedText: React.FC<{ text: string; className?: string }> = ({ text, className = '' }) => {
const formatted = formatInlineText(text);
return ;
};
// 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 (
Download
{copied ? (
<>
Copied!
>
) : (
<>
Copy
>
)}
{alt &&
{alt}
}
);
};
// =============================================================================
// ๐ฅ๏ธ 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 (
{/* Header */}
{language || 'code'}
{copied ? <> Copied> : <> Copy>}
{isPython && (
{isRunning ? (
<> Running...>
) : (
<> Run>
)}
)}
{/* Code */}
{code}
{/* Execution Result */}
{result && (
{result.success ? 'โ Output' : 'โ Error'}
{result.execution_time_ms != null && (
{result.execution_time_ms}ms
)}
{result.stdout && (
{result.stdout}
)}
{(result.stderr || result.error) && (
{result.stderr || result.error}
)}
{result.figures && result.figures.length > 0 && (
{result.figures.map((fig, i) => (
))}
)}
{result.tables && result.tables.length > 0 && (
{result.tables.map((table, i) => (
{table.shape &&
DataFrame: {table.shape[0]} rows ร {table.shape[1]} cols
}
{table.columns.map((col, ci) => (
{col}
))}
{table.data.slice(0,20).map((row, ri) => (
{row.map((cell, ci) => (
{cell != null ? String(cell) : 'โ'}
))}
))}
))}
)}
)}
);
};
// Lazy-loaded ForecastChartBlock for rendering prediction charts
const ForecastChartBlock: React.FC<{ payload: Record }> = ({ payload }) => {
const PredictionChart = React.lazy(() =>
import('@/components/PredictionChartRenderer').then(m => ({ default: m.PredictionChartRenderer }))
);
return (
Loading chart...
}>
);
};
// 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 (
Loading interactive chart...
}>
);
}, (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 = {
'ml_forecast': '๐',
'ml_importance': '๐',
'ml_correlation': '๐',
'ml_distribution': '๐',
'ml_residual': '๐',
'ml_summary': '๐ค'
};
const icon = typeIcons[chart.type] || '๐';
if (!chart.image) return null;
return (
{icon}
{chart.title || 'ML Visualization'}
Powered by scikit-learn + matplotlib
);
};
// 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 (
ML Visualizations โข Real scikit-learn analysis
{charts.map((chart, index) => (
))}
);
};
// 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(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 (
<>
{!isComplete && }
>
);
});
// 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 ;
}
if (para.startsWith('##')) {
const headerText = para.replace(/^##\s*/, '');
return ;
}
if (para.startsWith('#')) {
const headerText = para.replace(/^#\s*/, '');
return ;
}
// 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 (
);
}
} catch (e) {
console.error('Chart JSON parse error:', e, 'JSON:', chartJson.substring(0, 200));
// Show user-friendly error instead of raw JSON
return (
โ ๏ธ Chart rendering failed. The data is available but couldn't be visualized.
);
}
}
}
// 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 (
);
} 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 (
โ ๏ธ Interactive chart rendering failed. Check console for details.
);
}
}
}
// 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 (
);
}
// Images (base64 charts)
const imageMatch = para.match(/!\[([^\]]*)\]\(([^)]+)\)/);
if (imageMatch) {
return ;
}
// 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 (
{headers.map((header, i) => (
))}
{rows.map((row, i) => (
{row.map((cell, j) => (
))}
))}
);
}
}
// 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 (
{paraLines.map((line, i) => {
const cleanLine = line.trim();
if (cleanLine.match(/^[-โข*โ]\s?/) || cleanLine.match(/^[โขโ]/)) {
const text = cleanLine.replace(/^[-โข*โ]\s*/, '');
return (
โข
);
}
return cleanLine ?
: null;
})}
);
}
if (numberedLines.length > 0 && numberedLines.length >= paraLines.length * 0.4) {
return (
{paraLines.map((line, i) => {
const cleanLine = line.trim();
const match = cleanLine.match(/^(\d+)[\.\\)]\s*(.*)$/);
if (match) {
return (
{match[1]}.
);
}
return cleanLine ?
: null;
})}
);
}
// Regular paragraph
const formattedHtml = formatInlineText(para).replace(/\n/g, ' ');
return (
);
});
};
return {formatContent(content)}
;
});
// 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 (
{ (e.target as HTMLImageElement).src = '/logo.svg'; }}
/>
);
};
// Mode-specific thinking messages (Clean & Simple)
const getModeThinkingText = (modeId: string): string => {
const thinkingTexts: Record = {
'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 (
);
};
// 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 = ({
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 (
{/* Logo - Theme Aware */}
{ (e.target as HTMLImageElement).src = '/logo.svg'; }} />
{/* Title - ChatGPT style */}
How can I help you today?
{/* ๐ผ๏ธ Image Preview - Show attached image above input */}
{imagePreview && (
๐ท Image ready - ask a question about it!
)}
{/* Centered Search Box - ChatGPT Style with Mode & MCP */}
{/* MCP Button */}
{/* File Attach Button */}
{/* Input Field */}
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 */}
{currentMode.label}
{/* Send Button */}
{/* Suggestion Cards - 2x2 Grid */}
{suggestions.map((suggestion, i) => (
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"
>
{suggestion.text}
))}
);
};
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('analyst');
const [llm, setLlm] = useState('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(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [attachedFiles, setAttachedFiles] = useState([]);
const [editingMessageId, setEditingMessageId] = useState(null);
const [editingContent, setEditingContent] = useState('');
const [copiedMessageId, setCopiedMessageId] = useState(null);
const [messageImages, setMessageImages] = useState>({});
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
const [animatingMessageId, setAnimatingMessageId] = useState(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>({
'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(null);
const messagesEndRef = useRef(null);
const chatContainerRef = useRef(null);
const fileInputRef = useRef(null);
const textareaRef = useRef(null);
const abortControllerRef = useRef(null);
// Feedback state for thumbs up/down
const [messageFeedback, setMessageFeedback] = useState>({});
// 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 = {};
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) => {
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((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(null);
const modeMenuRef = useRef(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 (
{/* ๐ DRAG OVERLAY - Visual feedback when dragging files */}
{isDragActive && (
๐
Drop your files here
Supports: Images, PDF, Excel, CSV, JSON, Word, PowerPoint
)}
{/* Mobile Sidebar Overlay - CSS transitions instead of Framer Motion for performance */}
{sidebarOpen && (
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 */}
{/* Sidebar Header */}
Chat History
{/* Mobile close button */}
setSidebarOpen(false)}
className="lg:hidden p-1 hover:bg-[var(--bg-hover)] rounded-lg transition-colors"
title="Close"
>
{/* New Chat Button */}
{/* Chat List */}
Recent
{conversations.length > 1 && (
{
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:
});
if (confirmed) {
clearAllChats();
toast.deleted('All chat history cleared');
}
}}
className="text-xs text-gray-500 hover:text-red-400 transition-colors"
>
Clear all
)}
{conversations.map((conv) => (
selectConversation(conv.id)}
className={`group flex items-center gap-2 p-3 rounded-lg cursor-pointer chat-item ${currentConversationId === conv.id ? 'active' : ''}`}
>
{conv.title}
{formatDate(conv.updatedAt)}
{
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"
>
))}
{/* Sidebar Footer */}
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
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"
>
Back to DataHub
{/* Main Content Area */}
{/* Top Navigation Bar - Now visible on Desktop too */}
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"}
>
{/* Only show title when there are messages */}
{messages.length > 0 && (
Analyst Chat
{currentMode.label} Mode โข {Object.values(enabledMcps).filter(Boolean).length} Active Tools
)}
{/* Top Right Actions */}
{
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"
>
Export PDF
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"
>
{useStreaming ? 'Fast' : 'Standard'}
{/* Messages Container - Scrollable with ChatGPT-like experience */}
{messages.length === 0 ? (
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}
/>
) : (
{messages.filter(msg => msg && msg.id && msg.content).map((message) => (
{message.role === 'user' ? (
// User Message - Right side, contained bubble
{editingMessageId === message.id ? (
) : (
<>
{message.content}
{messageImages[message.id] && (
)}
handleCopyMessage(message.id, message.content)} className="p-1 hover:bg-[var(--bg-hover)] rounded text-gray-500 hover:text-gray-300">
{copiedMessageId === message.id ? : }
handleEditMessage(message.id, message.content)} className="p-1 hover:bg-[var(--bg-hover)] rounded text-gray-500 hover:text-gray-300">
>
)}
) : (
// Bot Message - Left side with logo, full width response
{/* DataVision Logo with animation */}
{/* ๐ฏ 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 &&
;
})()}
setAnimatingMessageId(null)}
/>
{message.sources && Array.isArray(message.sources) && message.sources.length > 0 && (
Sources
{message.sources.map((source, i) => (
{String(source)}
))}
)}
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 ? : }
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"
>
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"
>
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"
>
{/* ๐ Dynamic Follow-up Suggestions - Competition Winner Feature */}
{message.suggestions && message.suggestions.length > 0 && (
Follow-up questions
{message.suggestions.map((suggestion: string, i: number) => (
{
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}
))}
)}
{/* ๐ Confidence Indicator - Shows data grounding quality */}
{message.confidence !== undefined && (
Data Confidence:
0.8 ? 'bg-green-500' :
message.confidence > 0.5 ? 'bg-yellow-500' : 'bg-red-500'
}`} />
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'}
)}
{/* ๐ค ML Charts - Matplotlib/Seaborn Visualizations from Predict Mode */}
{(message as any).mlCharts && Array.isArray((message as any).mlCharts) && (message as any).mlCharts.length > 0 && (
ML Visualizations โข Real scikit-learn analysis
{(message as any).mlCharts.map((chart: { type: string; image: string; title?: string }, i: number) => (
chart.image && (
{chart.type === 'ml_forecast' ? '๐' :
chart.type === 'ml_importance' ? '๐' :
chart.type === 'ml_correlation' ? '๐' :
chart.type === 'ml_distribution' ? '๐' :
chart.type === 'ml_summary' ? '๐ค' : '๐'}
{chart.title || 'ML Visualization'}
scikit-learn + matplotlib
)
))}
)}
)}
))}
{/* MCP Permission Dialog - Fixed position to prevent layout shift */}
{mcpPermissionDialog.isOpen && (
DataVision wants to use the following tools:
{mcpPermissionDialog.pendingTools.map((tool) => (
{tool.icon}
{tool.name}
{tool.description}
))}
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
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
)}
{isLoading && (
{/* MCP Tool Execution Animation - Claude Style */}
{mcpExecutionStatus.isRunning && mcpExecutionStatus.currentTools.length > 0 && (
{mcpExecutionStatus.currentTools.map((tool) => (
{tool.status === 'success' ? 'โ' : tool.icon}
{tool.name}
{tool.status === 'running' && (
)}
{tool.status === 'success' && (
Done
)}
))}
)}
{/* Regular Typing Indicator with mode-specific text */}
)}
)}
{/* ========== GLOBAL DROPDOWNS - Both appear CENTERED below search box ========== */}
{/* Global MCP Dropdown - CSS transitions for performance */}
{mcpDropdownOpen && (
MCP Servers
{Object.values(enabledMcps).filter(Boolean).length}/{mcpServers.length} active
{mcpServers.map((mcp) => (
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"
>
{mcp.icon}
{mcp.name}
{mcp.description}
))}
)}
{/* Global Mode Dropdown - CSS transitions for performance */}
{modeDropdownOpen && (
{/* RAG Modes */}
๐ Data Modes
{modes.filter(m => !m.isAI).map((m) => (
{ 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.label}
{m.badge && (
{m.badge}
)}
{m.description}
{mode === m.id && }
))}
{/* AI Models Divider */}
๐ค AI Models
{modes.filter(m => m.isAI).map((m: any) => (
{ 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 && (
{ e.currentTarget.style.display = 'none'; }}
/>
)}
{m.label}
{m.badge && (
{m.badge}
)}
{m.description}
{mode === m.id && }
))}
)}
{/* Global Hidden File Input - Always in DOM for WelcomeScreen and bottom input */}
{/* Claude-style Input Bar - Only show when there are messages */}
{/* Floating Input Area - Fixed at Bottom Center (hidden on empty state) */}
{
messages.length > 0 && (
{/* Attached Files Preview */}
{attachedFiles.length > 0 && (
{attachedFiles.map((file, index) => (
{file.name}
removeAttachedFile(index)} className="text-gray-500 hover:text-red-400">
))}
)}
{/* Image Preview - Shows separately from attached files */}
{selectedImage && imagePreviewUrl && (
{/* ChatGPT-style Image Preview Thumbnail */}
{/* Overlay with actions */}
{/* Edit/View button - top left */}
{/* Dismiss button - top right */}
{
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"
>
)}
{/* Input Bar Container - Premium ChatGPT Style */}
{/* Left side buttons - Hidden on mobile, shown on desktop */}
{/* MCP Servers Toggle - Uses unified dropdown below */}
setMcpDropdownOpen(!mcpDropdownOpen)}
className="p-2 hover:bg-dark-hover rounded-lg transition-colors text-gray-400 hover:text-gray-200 relative"
title="MCP Servers"
>
{/* File Upload */}
fileInputRef.current?.click()}
className="p-2 hover:bg-dark-hover rounded-lg transition-colors text-gray-400 hover:text-gray-200"
title="Attach File"
>
{/* Mobile: Plus button with menu */}
{plusMenuOpen && (
<>
setPlusMenuOpen(false)} />
{/* Upload Files */}
{
fileInputRef.current?.click();
setPlusMenuOpen(false);
}}
className="flex items-center gap-3 w-full p-4 hover:bg-dark-hover transition-colors text-left"
>
Add photos & files
Upload documents or images
{/* MCPs Submenu */}
{
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"
>
MCP Servers
{Object.values(enabledMcps).filter(Boolean).length}/{mcpServers.length} active
{/* Modes Submenu */}
{
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"
>
AI Modes
Current: {currentMode.label}
>
)}
setPlusMenuOpen(!plusMenuOpen)}
className="p-3 hover:bg-dark-hover rounded-xl transition-colors text-gray-400 hover:text-gray-200"
title="More options"
>
{/* Text Input - Large and prominent */}
{/* Disclaimer */}
DataVision can make mistakes. Please double-check responses.
)
}
{/* Confirm Modal for Delete Operations */}
);
};
export default AnalystChat;