import { useState, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import { Maximize2, Download, Code, X } from 'lucide-react'; import './MessageBubble.css'; export type MessageRole = 'user' | 'assistant' | 'system'; export interface MediaItem { type: 'plot' | 'video'; base64: string; path?: string; code?: string; mimetype?: string; } export interface ChatMessage { id: string; role: MessageRole; content: string; plots?: string[]; // base64 PNG strings (legacy) media?: MediaItem[]; // full media items (plot + video) toolLabel?: string; // e.g. "Fetching ERA5..." statusText?: string; // e.g. "🔍 Analyzing..." arraylakeSnippets?: string[]; isStreaming?: boolean; } /* ── Image Modal ── */ function ImageModal({ src, onClose }: { src: string; onClose: () => void }) { return (
e.stopPropagation()}> Enlarged plot
); } /* ── Plot Figure ── */ function PlotFigure({ item, onEnlarge }: { item: MediaItem; onEnlarge: (src: string) => void }) { const [showCode, setShowCode] = useState(false); const src = item.base64.startsWith('data:') ? item.base64 : `data:image/png;base64,${item.base64}`; return (
Generated plot onEnlarge(src)} style={{ cursor: 'pointer' }} />
{item.code && item.code.trim() && ( )}
{showCode && item.code && (
{item.code}
)}
); } /* ── Video Figure ── */ function VideoFigure({ item, onEnlarge }: { item: MediaItem; onEnlarge: (src: string) => void }) { const isGif = item.mimetype === 'image/gif'; const src = item.base64.startsWith('data:') ? item.base64 : `data:${item.mimetype || 'video/mp4'};base64,${item.base64}`; const handleDownload = () => { const a = document.createElement('a'); a.href = src; const ext = isGif ? 'gif' : item.mimetype?.includes('webm') ? 'webm' : 'mp4'; a.download = item.path ? item.path.split('/').pop()! : `eurus_animation.${ext}`; a.click(); }; if (isGif) { return (
Animation onEnlarge(src)} style={{ cursor: 'pointer' }} />
); } return (
); } /* ── Arraylake Snippet ── */ function ArraylakeSnippet({ code }: { code: string }) { const [open, setOpen] = useState(false); // Strip markdown fences const clean = code .replace(/^\n?📦[^\n]*\n/, '') .replace(/^```python\n?/, '') .replace(/\n?```$/, '') .trim(); return (
{open && (
{clean}
)}
); } /* ── Legacy plot ── */ function LegacyPlotImage({ base64, onEnlarge }: { base64: string; onEnlarge: (src: string) => void }) { const src = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`; return (
Generated plot onEnlarge(src)} style={{ cursor: 'pointer' }} />
); } /* ── Main Bubble ── */ export default function MessageBubble({ msg }: { msg: ChatMessage }) { const isUser = msg.role === 'user'; const [modalSrc, setModalSrc] = useState(null); const handleEnlarge = useCallback((src: string) => setModalSrc(src), []); return ( <>
{msg.toolLabel && (
⚙ {msg.toolLabel}
)} {msg.statusText && (
{msg.statusText}
)} {msg.content} {/* Legacy plots */} {msg.plots?.map((b64, i) => )} {/* Rich media (plots + videos) */} {msg.media?.map((item, i) => item.type === 'video' ? : )} {/* Arraylake snippets */} {msg.arraylakeSnippets?.map((s, i) => )} {msg.isStreaming && !msg.media?.length && !msg.arraylakeSnippets?.length && }
{modalSrc && setModalSrc(null)} />} ); }