import { useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { motion } from 'framer-motion' import { ChevronDown, ChevronUp, BookOpen, Hash, LoaderCircle, Volume2, VolumeX } from 'lucide-react' import { Stethoscope, User } from 'lucide-react' import clsx from 'clsx' import toast from 'react-hot-toast' import { audioApi, getApiErrorMessage } from '../../api/client' function TypingDots() { return (
) } function SourceCard({ source, index }) { const [open, setOpen] = useState(false) return (
{open && (
chunk {source.chunk_index} {source.page_number ? page {source.page_number} : null} {source.doc_id.slice(0, 8)}
{source.text}
)}
) } export default function MessageBubble({ message, isStreaming = false }) { const [showSources, setShowSources] = useState(false) const [isSpeaking, setIsSpeaking] = useState(false) const isUser = message.role === 'user' const sources = message.sources || [] const audioRef = useRef(null) const handleSpeak = async () => { if (!message.content?.trim()) return if (audioRef.current && !audioRef.current.paused) { audioRef.current.pause() audioRef.current.currentTime = 0 setIsSpeaking(false) return } setIsSpeaking(true) try { const blob = await audioApi.speak({ text: message.content }) const url = URL.createObjectURL(blob) const audio = new Audio(url) audioRef.current = audio audio.onended = () => { URL.revokeObjectURL(url) setIsSpeaking(false) } audio.onerror = () => { URL.revokeObjectURL(url) setIsSpeaking(false) } await audio.play() } catch (err) { setIsSpeaking(false) toast.error(getApiErrorMessage(err, 'Failed to generate speech')) } } return (
{isUser ? : }
{isUser ? (
{message.content}
) : (
{isStreaming ? ( ) : (
{message.content}
)}
{!isStreaming && sources.length > 0 && (
{showSources && ( {sources.map((s, i) => ( ))} )}
)}
)}
) }