import { useCallback, useEffect, useRef, useState, ReactNode } from 'react'; import { Send, Wifi, WifiOff, Loader2, Trash2 } from 'lucide-react'; import ThemeToggle from './ThemeToggle'; import ModelSelector from './ModelSelector'; import { useWebSocket, WSEvent } from '../hooks/useWebSocket'; import MessageBubble, { ChatMessage, MediaItem } from './MessageBubble'; import ApiKeysPanel from './ApiKeysPanel'; import './ChatPanel.css'; interface ChatPanelProps { cacheToggle?: ReactNode; } let msgCounter = 0; const uid = () => `msg-${++msgCounter}-${Date.now()}`; export default function ChatPanel({ cacheToggle }: ChatPanelProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isThinking, setIsThinking] = useState(false); const [statusMsg, setStatusMsg] = useState(''); const [needKeys, setNeedKeys] = useState(null); // null = don't know yet const [keysConfigured, setKeysConfigured] = useState(false); const bottomRef = useRef(null); const streamBuf = useRef(''); const streamMedia = useRef([]); const streamSnippets = useRef([]); const streamId = useRef(null); const textareaRef = useRef(null); /* ── event handler ── */ const handleEvent = useCallback((ev: WSEvent) => { switch (ev.type) { case 'thinking': setIsThinking(true); setStatusMsg(''); streamBuf.current = ''; streamMedia.current = []; streamSnippets.current = []; streamId.current = uid(); break; case 'status': setStatusMsg(ev.content ?? ''); break; case 'tool_start': setMessages(prev => { const id = streamId.current ?? uid(); streamId.current = id; const exists = prev.find(m => m.id === id); if (exists) { return prev.map(m => m.id === id ? { ...m, toolLabel: ev.content ?? '' } : m ); } return [...prev, { id, role: 'assistant', content: '', toolLabel: ev.content ?? '', isStreaming: true }]; }); break; case 'stream': { setIsThinking(false); setStatusMsg(''); const chunk = ev.content ?? ''; streamBuf.current += chunk; const id = streamId.current ?? uid(); streamId.current = id; setMessages(prev => { const exists = prev.find(m => m.id === id); if (exists) { return prev.map(m => m.id === id ? { ...m, content: streamBuf.current, isStreaming: true } : m ); } return [...prev, { id, role: 'assistant', content: streamBuf.current, isStreaming: true }]; }); break; } case 'plot': { const id = streamId.current ?? uid(); streamId.current = id; if (ev.data) { streamMedia.current.push({ type: 'plot', base64: ev.data as string, path: ev.path as string | undefined, code: ev.code as string | undefined, }); } setMessages(prev => { const exists = prev.find(m => m.id === id); if (exists) { return prev.map(m => m.id === id ? { ...m, media: [...streamMedia.current] } : m ); } return [...prev, { id, role: 'assistant', content: streamBuf.current, media: [...streamMedia.current], isStreaming: true }]; }); break; } case 'video': { const id = streamId.current ?? uid(); streamId.current = id; if (ev.data) { streamMedia.current.push({ type: 'video', base64: ev.data as string, path: ev.path as string | undefined, mimetype: ev.mimetype as string | undefined, }); } setMessages(prev => { const exists = prev.find(m => m.id === id); if (exists) { return prev.map(m => m.id === id ? { ...m, media: [...streamMedia.current] } : m ); } return [...prev, { id, role: 'assistant', content: streamBuf.current, media: [...streamMedia.current], isStreaming: true }]; }); break; } case 'arraylake_snippet': { const id = streamId.current; if (ev.content && id) { streamSnippets.current.push(ev.content); setMessages(prev => prev.map(m => m.id === id ? { ...m, arraylakeSnippets: [...streamSnippets.current] } : m ) ); } break; } case 'complete': setIsThinking(false); setStatusMsg(''); // Only finalize the existing streaming message — never create a new one. // Snapshot refs into locals BEFORE the state setter runs. if (streamId.current) { const capturedId = streamId.current; const capturedContent = ev.content ?? streamBuf.current; const capturedMedia = [...streamMedia.current]; const capturedSnippets = [...streamSnippets.current]; setMessages(prev => prev.map(m => { if (m.id !== capturedId) return m; return { ...m, content: capturedContent || m.content, // Preserve media/snippets already on the message if our refs are empty media: capturedMedia.length > 0 ? capturedMedia : (m.media || []), arraylakeSnippets: capturedSnippets.length > 0 ? capturedSnippets : (m.arraylakeSnippets || []), isStreaming: false, toolLabel: undefined, statusText: undefined, }; }) ); } streamBuf.current = ''; streamMedia.current = []; streamSnippets.current = []; streamId.current = null; break; case 'error': setIsThinking(false); setStatusMsg(''); setMessages(prev => [...prev, { id: uid(), role: 'system', content: `⚠ ${ev.content ?? 'Unknown error'}` }]); streamId.current = null; break; case 'keys_configured': if (ev.ready) { setNeedKeys(false); setKeysConfigured(true); } break; case 'request_keys': // Server lost keys — resend from sessionStorage setNeedKeys(true); break; case 'clear': setMessages([]); streamBuf.current = ''; streamMedia.current = []; streamSnippets.current = []; streamId.current = null; break; default: break; } }, []); const { status, send, sendMessage, configureKeys } = useWebSocket(handleEvent); /* ── check if server has keys ── */ useEffect(() => { if (status !== 'connected') return; // only check when connected fetch('/api/keys-status') .then(r => r.json()) .then(data => { setNeedKeys(!data.openai); }) .catch(() => setNeedKeys(true)); // no server keys — show panel }, [status]); /* ── auto-scroll ── */ useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, isThinking, statusMsg]); /* ── send ── */ const handleSend = () => { const text = input.trim(); if (!text || status !== 'connected') return; setMessages(prev => [...prev, { id: uid(), role: 'user', content: text }]); sendMessage(text); setInput(''); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }; /* ── clear conversation ── */ const handleClear = async () => { if (!confirm('Clear conversation history?')) return; try { await fetch('/api/conversation', { method: 'DELETE' }); setMessages([]); } catch { /* ignore */ } }; /* ── auto-resize textarea ── */ const handleInputChange = (e: React.ChangeEvent) => { setInput(e.target.value); const ta = e.target; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; }; /* ── keys handler ── */ const handleSaveKeys = (keys: { openai_api_key: string; arraylake_api_key: string }) => { configureKeys(keys); }; const statusColor = status === 'connected' ? '#34d399' : status === 'connecting' ? '#fbbf24' : '#f87171'; const StatusIcon = status === 'connected' ? Wifi : WifiOff; const statusClass = `status-badge ${status === 'disconnected' ? 'disconnected' : ''}`; const canSend = status === 'connected' && needKeys !== true; return (
{/* header */}
🌊

Eurus Climate Agent

{status}
{cacheToggle}
{/* API keys panel */} {/* messages */}
{messages.length === 0 && (
🌍

Welcome to Eurus

Ask about ERA5 climate data — SST, wind, precipitation, temperature and more.

⚠️ Experimental — research prototype. Avoid very large datasets. Use 📦 Arraylake Code for heavy workloads.

)} {messages.map((m) => )} {(isThinking || statusMsg) && (
{statusMsg || 'Analyzing...'}
)}
{/* input */}