MedRAG / frontend /src /components /chat /MessageBubble.jsx
hetsheta's picture
Initial commit
ea50fb7
Raw
History Blame Contribute Delete
6.87 kB
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 (
<div className="flex items-center gap-1 py-1">
<span className="typing-dot" />
<span className="typing-dot" />
<span className="typing-dot" />
</div>
)
}
function SourceCard({ source, index }) {
const [open, setOpen] = useState(false)
return (
<div className="border border-white/8 rounded-lg overflow-hidden text-xs">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 bg-surface-3 hover:bg-surface-4 transition-colors text-left"
>
<span className="w-5 h-5 rounded-full bg-accent/20 text-accent flex items-center justify-center font-bold text-[10px] shrink-0">{index}</span>
<div className="flex-1 min-w-0">
<p className="text-white/80 font-medium truncate">{source.title}</p>
<p className="text-gray-500 truncate">
{source.filename}
{source.page_number ? ` | page ${source.page_number}` : ''}
{source.retrieval_method ? ` | ${source.retrieval_method}` : ''}
</p>
</div>
<span className="text-gray-500 shrink-0 font-mono">{(source.score * 100).toFixed(0)}%</span>
{open ? <ChevronUp size={12} className="text-gray-500 shrink-0" /> : <ChevronDown size={12} className="text-gray-500 shrink-0" />}
</button>
{open && (
<div className="px-3 py-2 bg-surface-2 border-t border-white/5 text-gray-400 leading-relaxed text-[11px] max-h-40 overflow-y-auto">
<div className="flex items-center gap-3 text-[10px] text-gray-500 mb-2">
<span className="flex items-center gap-1"><Hash size={10} /> chunk {source.chunk_index}</span>
{source.page_number ? <span>page {source.page_number}</span> : null}
<span className="font-mono">{source.doc_id.slice(0, 8)}</span>
</div>
{source.text}
</div>
)}
</div>
)
}
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 (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={clsx('flex gap-3 group', isUser && 'flex-row-reverse')}
>
<div className={clsx(
'w-8 h-8 rounded-xl flex items-center justify-center shrink-0 mt-0.5',
isUser ? 'bg-accent/20 border border-accent/30' : 'bg-med-teal/10 border border-med-teal/20'
)}>
{isUser
? <User size={14} className="text-accent" />
: <Stethoscope size={14} className="text-med-teal" />
}
</div>
<div className={clsx('flex-1 min-w-0 max-w-2xl', isUser && 'flex flex-col items-end')}>
{isUser ? (
<div className="bg-accent/10 border border-accent/20 rounded-2xl rounded-tr-sm px-4 py-3 text-sm text-white leading-relaxed">
{message.content}
</div>
) : (
<div className="space-y-3">
<div className="bg-surface-2 border border-white/5 rounded-2xl rounded-tl-sm px-4 py-3">
{isStreaming ? (
<TypingDots />
) : (
<div className="space-y-3">
<div className="prose-dark text-sm leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</div>
<div className="flex items-center justify-end">
<button
onClick={handleSpeak}
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-white transition-colors"
title={isSpeaking ? 'Stop audio' : 'Play answer audio'}
>
{isSpeaking
? <LoaderCircle size={12} className="animate-spin" />
: audioRef.current && !audioRef.current.paused
? <VolumeX size={12} />
: <Volume2 size={12} />
}
{isSpeaking ? 'Speaking...' : 'Speak'}
</button>
</div>
</div>
)}
</div>
{!isStreaming && sources.length > 0 && (
<div className="space-y-2">
<button
onClick={() => setShowSources(s => !s)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
<BookOpen size={11} />
{sources.length} source{sources.length !== 1 ? 's' : ''}
{showSources ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
</button>
{showSources && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-1.5"
>
{sources.map((s, i) => (
<SourceCard key={i} source={s} index={i + 1} />
))}
</motion.div>
)}
</div>
)}
</div>
)}
</div>
</motion.div>
)
}