Download frontend/src/components/ChatPanel.tsx from dmpantiu/Eurus: direct link, hf CLI and curl.
- Browser
- Download file 14.1 kB
-
https://huggingface.co/spaces/dmpantiu/Eurus/resolve/main/frontend/src/components/ChatPanel.tsx
- Command line
-
hf download hf://spaces/dmpantiu/Eurus/frontend/src/components/ChatPanel.tsx
-
curl -L -o ChatPanel.tsx https://huggingface.co/spaces/dmpantiu/Eurus/resolve/main/frontend/src/components/ChatPanel.tsx
14.1 kB
| 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<ChatMessage[]>([]); | |
| const [input, setInput] = useState(''); | |
| const [isThinking, setIsThinking] = useState(false); | |
| const [statusMsg, setStatusMsg] = useState(''); | |
| const [needKeys, setNeedKeys] = useState<boolean | null>(null); // null = don't know yet | |
| const [keysConfigured, setKeysConfigured] = useState(false); | |
| const bottomRef = useRef<HTMLDivElement>(null); | |
| const streamBuf = useRef(''); | |
| const streamMedia = useRef<MediaItem[]>([]); | |
| const streamSnippets = useRef<string[]>([]); | |
| const streamId = useRef<string | null>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => { | |
| 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 ( | |
| <div className="chat-panel"> | |
| {/* header */} | |
| <header className="chat-header"> | |
| <div className="chat-title"> | |
| <div className="chat-logo">π</div> | |
| <h1>Eurus Climate Agent</h1> | |
| </div> | |
| <div className="chat-header-actions"> | |
| <div className={statusClass} style={{ color: statusColor }}> | |
| <StatusIcon size={12} /> | |
| <span>{status}</span> | |
| </div> | |
| {cacheToggle} | |
| <ModelSelector send={send} /> | |
| <ThemeToggle /> | |
| <button className="icon-btn danger-btn" onClick={handleClear} title="Clear conversation"> | |
| <Trash2 size={16} /> | |
| </button> | |
| </div> | |
| </header> | |
| {/* API keys panel */} | |
| <ApiKeysPanel visible={needKeys === true} onSave={handleSaveKeys} configured={keysConfigured} /> | |
| {/* messages */} | |
| <div className="messages-container"> | |
| {messages.length === 0 && ( | |
| <div className="empty-state"> | |
| <div className="empty-icon">π</div> | |
| <h2>Welcome to Eurus</h2> | |
| <p>Ask about ERA5 climate data β SST, wind, precipitation, temperature and more.</p> | |
| <p className="empty-warning"> | |
| β οΈ <strong>Experimental</strong> β research prototype. Avoid very large datasets. Use π¦ Arraylake Code for heavy workloads. | |
| </p> | |
| <div className="example-queries"> | |
| <button onClick={() => { setInput('Show SST map for the North Atlantic, Jan 2024'); }}> | |
| π‘ SST β North Atlantic | |
| </button> | |
| <button onClick={() => { setInput('Compare 2m temperature Berlin vs Tokyo, March 2023'); }}> | |
| π¨ Temperature β Berlin vs Tokyo | |
| </button> | |
| <button onClick={() => { setInput('Precipitation anomalies over Amazon, 2023'); }}> | |
| π§ Rain β Amazon basin | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {messages.map((m) => <MessageBubble key={m.id} msg={m} />)} | |
| {(isThinking || statusMsg) && ( | |
| <div className="thinking-indicator"> | |
| <Loader2 className="spin" size={16} /> | |
| <span>{statusMsg || 'Analyzing...'}</span> | |
| </div> | |
| )} | |
| <div ref={bottomRef} /> | |
| </div> | |
| {/* input */} | |
| <div className="input-bar"> | |
| <textarea | |
| ref={textareaRef} | |
| value={input} | |
| onChange={handleInputChange} | |
| onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }} | |
| placeholder={canSend ? 'Ask about climate dataβ¦' : needKeys ? 'Enter API keys aboveβ¦' : 'Connectingβ¦'} | |
| disabled={!canSend} | |
| rows={1} | |
| /> | |
| <button | |
| className="send-btn" | |
| onClick={handleSend} | |
| disabled={!input.trim() || !canSend} | |
| > | |
| <Send size={18} /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |