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) => (
))}
)}
)}
)}
)
}