import { useState, useRef, useEffect } from 'react'; import { motion } from 'framer-motion'; import { Send, Bot, User, Loader2, Trash2, AlertTriangle, Maximize2, Minimize2, ChevronDown, Settings } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useSettings } from '@/contexts/SettingsContext'; import { useToast } from '@/hooks/use-toast'; import GlitchText from '@/components/GlitchText'; import MessageContent from '@/components/MessageContent'; import { AI_PROVIDERS, AIProvider, getProvider, CUSTOM_API_STORAGE_KEY } from '@/lib/aiProviders'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; interface Message { id: string; role: 'user' | 'assistant' | 'system'; content: string; timestamp: Date; } interface CustomApiConfig { endpoint: string; apiKey: string; model: string; } const STORAGE_KEY = 'ai-chat-history'; const PROVIDER_KEY = 'ai-chat-provider'; const AIChat = () => { const [messages, setMessages] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { try { const parsed = JSON.parse(stored); return parsed.map((msg: Message) => ({ ...msg, timestamp: new Date(msg.timestamp), })); } catch { return []; } } return []; }); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [selectedProvider, setSelectedProvider] = useState(() => { const stored = localStorage.getItem(PROVIDER_KEY); return (stored as AIProvider) || 'pollinations'; }); const [customApiConfig, setCustomApiConfig] = useState(() => { const stored = localStorage.getItem(CUSTOM_API_STORAGE_KEY); if (stored) { try { return JSON.parse(stored); } catch { return { endpoint: '', apiKey: '', model: '' }; } } return { endpoint: '', apiKey: '', model: '' }; }); const [showCustomApiDialog, setShowCustomApiDialog] = useState(false); const [tempCustomConfig, setTempCustomConfig] = useState({ endpoint: '', apiKey: '', model: '' }); const scrollRef = useRef(null); const { playSound } = useSettings(); const { toast } = useToast(); // Persist messages to localStorage useEffect(() => { if (messages.length > 0) { localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } else { localStorage.removeItem(STORAGE_KEY); } }, [messages]); // Persist provider selection useEffect(() => { localStorage.setItem(PROVIDER_KEY, selectedProvider); }, [selectedProvider]); // Persist custom API config useEffect(() => { if (customApiConfig.endpoint || customApiConfig.apiKey) { localStorage.setItem(CUSTOM_API_STORAGE_KEY, JSON.stringify(customApiConfig)); } }, [customApiConfig]); // Exit fullscreen on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFullscreen) { setIsFullscreen(false); playSound('click'); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [isFullscreen, playSound]); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages]); const sendMessage = async () => { if (!input.trim() || isLoading) return; const userMessage: Message = { id: Date.now().toString(), role: 'user', content: input.trim(), timestamp: new Date(), }; setMessages(prev => [...prev, userMessage]); setInput(''); setIsLoading(true); playSound('click'); try { // Build chat history, filtering out empty messages, system notices, and ensuring proper alternation const validMessages = messages.filter(msg => msg.content.trim() !== '' && msg.role !== 'system'); let chatHistory: { role: 'user' | 'assistant'; content: string }[] = []; for (const msg of validMessages) { // Avoid consecutive same-role messages by merging or skipping if (chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === msg.role) { // Merge consecutive same-role messages chatHistory[chatHistory.length - 1].content += '\n' + msg.content; } else if (msg.role === 'user' || msg.role === 'assistant') { chatHistory.push({ role: msg.role, content: msg.content }); } } // Add the new user message if (chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === 'user') { chatHistory[chatHistory.length - 1].content += '\n' + userMessage.content; } else { chatHistory.push({ role: 'user', content: userMessage.content }); } // Truncate history to stay within API character limits (max ~5000 chars to be safe) const MAX_CHARS = 5000; const systemPromptLength = 100; // approximate system prompt length let totalChars = systemPromptLength; // Keep messages from the end (most recent) until we hit the limit const truncatedHistory: typeof chatHistory = []; const originalLength = chatHistory.length; for (let i = chatHistory.length - 1; i >= 0; i--) { const msgLength = chatHistory[i].content.length; if (totalChars + msgLength > MAX_CHARS && truncatedHistory.length > 0) { break; } totalChars += msgLength; truncatedHistory.unshift(chatHistory[i]); } // Check if truncation occurred and notify user const wasTruncated = truncatedHistory.length < originalLength; if (wasTruncated) { const systemNotice: Message = { id: `system-${Date.now()}`, role: 'system', content: '⚠ Memory limit reached. Earlier conversation context has been cleared. The AI may not remember previous topics.', timestamp: new Date(), }; setMessages(prev => [...prev, systemNotice]); } chatHistory = truncatedHistory; const assistantMessage: Message = { id: (Date.now() + 1).toString(), role: 'assistant', content: '', timestamp: new Date(), }; setMessages(prev => [...prev, assistantMessage]); // Helper function to make API request with retry logic const provider = getProvider(selectedProvider); // For custom provider, use user's config const apiEndpoint = selectedProvider === 'custom' ? customApiConfig.endpoint : provider.endpoint; const apiKey = selectedProvider === 'custom' ? customApiConfig.apiKey : provider.apiKey; const apiModel = selectedProvider === 'custom' ? customApiConfig.model : provider.model; if (selectedProvider === 'custom' && (!customApiConfig.endpoint || !customApiConfig.apiKey)) { toast({ title: 'Custom API not configured', description: 'Please configure your custom API settings first.', variant: 'destructive', }); setMessages(prev => prev.filter(msg => msg.id !== assistantMessage.id)); setIsLoading(false); return; } const makeRequest = async (retries = 3, delay = 1000): Promise => { for (let attempt = 1; attempt <= retries; attempt++) { try { const headers: Record = { 'Content-Type': 'application/json', }; if ((provider.requiresAuth || selectedProvider === 'custom') && apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } const response = await fetch(apiEndpoint, { method: 'POST', headers, body: JSON.stringify({ model: apiModel, messages: [ { role: 'system', content: 'You are a helpful AI assistant with a hacker/cyberpunk personality. Keep responses concise and engaging. IMPORTANT: When sharing code examples, ALWAYS wrap them in markdown code blocks with the language specified, like ```python\ncode here\n``` or ```javascript\ncode here\n```. Never show code without proper markdown code block formatting.' }, ...chatHistory, ], stream: true, }), }); if (response.ok) { return response; } // If it's a 500 error and we have retries left, wait and try again if (response.status >= 500 && attempt < retries) { console.log(`API returned ${response.status}, retrying in ${delay}ms (attempt ${attempt}/${retries})`); await new Promise(resolve => setTimeout(resolve, delay)); delay *= 2; // Exponential backoff continue; } throw new Error(`API error: ${response.status}`); } catch (error) { // Network errors (failed to fetch) - retry if we have attempts left if (attempt < retries && error instanceof TypeError) { console.log(`Network error, retrying in ${delay}ms (attempt ${attempt}/${retries})`); await new Promise(resolve => setTimeout(resolve, delay)); delay *= 2; continue; } throw error; } } throw new Error('Failed after all retries'); }; const response = await makeRequest(); const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (reader) { let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Process SSE format const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6).trim(); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); const content = parsed.choices?.[0]?.delta?.content || ''; if (content) { setMessages(prev => prev.map(msg => msg.id === assistantMessage.id ? { ...msg, content: msg.content + content } : msg ) ); } } catch { // Not valid JSON, might be plain text if (data && data !== '[DONE]') { setMessages(prev => prev.map(msg => msg.id === assistantMessage.id ? { ...msg, content: msg.content + data } : msg ) ); } } } } } // Handle any remaining buffer content if (buffer.trim()) { setMessages(prev => prev.map(msg => msg.id === assistantMessage.id ? { ...msg, content: msg.content + buffer } : msg ) ); } } playSound('click'); } catch (error) { console.error('AI Chat error:', error); toast({ title: 'Error', description: error instanceof Error ? error.message : 'Failed to get AI response', variant: 'destructive', }); // Remove the empty assistant message if error occurred setMessages(prev => prev.filter(msg => msg.content !== '')); } finally { setIsLoading(false); } }; const clearChat = () => { setMessages([]); playSound('click'); toast({ title: 'Chat Cleared', description: 'Conversation history has been erased.', }); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; const toggleFullscreen = () => { setIsFullscreen(!isFullscreen); playSound('click'); }; return (
{messages.length > 0 && ( <>
Memory: { const totalChars = messages.filter(m => m.role !== 'system').reduce((acc, m) => acc + m.content.length, 0); const percent = (totalChars / 5000) * 100; return percent > 80 ? 'text-red-400' : percent > 50 ? 'text-yellow-400' : 'text-green-400'; })() }`}> {Math.min(100, Math.round((messages.filter(m => m.role !== 'system').reduce((acc, m) => acc + m.content.length, 0) / 5000) * 100))}%
)}
{'>'} Model: {AI_PROVIDERS.map((provider) => ( { if (provider.id === 'custom') { setTempCustomConfig(customApiConfig); setShowCustomApiDialog(true); } setSelectedProvider(provider.id); playSound('click'); }} className={`font-pixel text-xs cursor-pointer ${ selectedProvider === provider.id ? 'text-primary' : 'text-foreground' }`} >
{provider.name}
{provider.description}
{provider.id === 'custom' && ( )}
))}
{selectedProvider === 'custom' && ( )}
{messages.length === 0 ? (

No messages yet.

Start a conversation with the AI.

) : (
{messages.map((message, index) => ( message.role === 'system' ? (

{message.content}

) : ( {message.role === 'assistant' && (
)}
{message.role === 'user' && (
)}
) ))} {isLoading && messages[messages.length - 1]?.role === 'user' && (
)}
)}