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