import React, { useState, useRef, useEffect, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Reply, Copy, Check, Maximize2, FileText, Hash, Target, Volume2, VolumeX, Search, X, Loader2 } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import { useAppConfig } from '../contexts/AppConfigContext'; import { useTheme } from '../contexts/ThemeContext'; const stripMarkdown = (md) => { if (!md) return ''; return md .replace(/```[\s\S]*?```/g, '') .replace(/`([^`]+)`/g, '$1') .replace(/#{1,6}\s?/g, '') .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') .replace(/[-*+]\s/g, '') .replace(/\n{2,}/g, '. ') .replace(/\n/g, ' ') .trim(); }; const MessageBubble = ({ message, onReply, onCopy, onExpand, onSearchReferences, showReplyButton = false, inlineAvatar = false, userAvatarId, userAvatarOptions }) => { const { isDark } = useTheme(); const { allPersonas: advisors, getAllPersonaColors: getAdvisorColors } = useAppConfig(); const [showTooltip, setShowTooltip] = useState(null); const [copiedStates, setCopiedStates] = useState({}); const [isSpeaking, setIsSpeaking] = useState(false); const [isLoadingTTS, setIsLoadingTTS] = useState(false); const [searchPopover, setSearchPopover] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchLoading, setSearchLoading] = useState(false); const [promptCopied, setPromptCopied] = useState(false); const overlayRef = useRef(null); const tooltipTimer = useRef(null); const audioRef = useRef(null); const handleSpeak = useCallback(async (content) => { if (isSpeaking || isLoadingTTS) { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } setIsSpeaking(false); setIsLoadingTTS(false); return; } const text = (content || '').trim(); if (!text) return; setIsLoadingTTS(true); try { const token = localStorage.getItem('authToken'); const resp = await fetch(`${process.env.REACT_APP_API_URL}/voice/tts`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); if (!resp.ok) throw new Error('TTS failed'); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const audio = new Audio(url); audioRef.current = audio; setIsLoadingTTS(false); setIsSpeaking(true); audio.onended = () => { setIsSpeaking(false); URL.revokeObjectURL(url); audioRef.current = null; }; audio.onerror = () => { setIsSpeaking(false); URL.revokeObjectURL(url); audioRef.current = null; }; audio.play(); } catch (e) { console.error('TTS error:', e); setIsLoadingTTS(false); setIsSpeaking(false); } }, [isSpeaking, isLoadingTTS]); useEffect(() => { return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } }; }, []); const handleCopy = async (messageId, content) => { try { await navigator.clipboard.writeText(content || ''); setCopiedStates(prev => ({ ...prev, [messageId]: true })); if (onCopy) onCopy(messageId, content || ''); setTimeout(() => { setCopiedStates(prev => ({ ...prev, [messageId]: false })); }, 2000); } catch (err) { console.error('Failed to copy text: ', err); } }; const handleExpand = (messageId, persona_id) => { if (onExpand) onExpand(messageId, persona_id); }; const handleSearch = async () => { setSearchPopover(true); setSearchLoading(true); const content = message?.compact_markdown || message?.content || ''; try { const token = localStorage.getItem('authToken'); const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/search-references`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ statement: content.substring(0, 500) }), }); if (resp.ok) { const data = await resp.json(); setSearchQuery(data.search_query || content.substring(0, 100)); } else { setSearchQuery(content.substring(0, 100)); } } catch { setSearchQuery(content.substring(0, 100)); } finally { setSearchLoading(false); } }; const showTooltipWithDelay = (tooltipType) => { clearTimeout(tooltipTimer.current); tooltipTimer.current = setTimeout(() => setShowTooltip(tooltipType), 500); }; const hideTooltip = () => { clearTimeout(tooltipTimer.current); setShowTooltip(null); }; // Minimal, safe preprocessing (keep Markdown structure intact) const preprocessMarkdown = (content) => { const input = (content || '').toString(); // 1) Strip trailing sentinel let processed = input.replace(/\s*<\/END>\s*$/i, ''); // 2) Normalize EOL and trim right spaces (preserve newlines) processed = processed.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); processed = processed.split('\n').map(ln => ln.replace(/\s+$/, '')).join('\n'); // 3) Unicode bullets -> '-' (so GFM parses lists) processed = processed.replace(/^\s*[•●▪◦]\s+/gm, '- '); // 4) Merge orphan numbered items: "1.\nText" => "1. Text" processed = processed.replace(/(^\s*(\d+)\.\s*$)\n^\s*(\S.*)$/gm, (_m, _a, num, next) => `${num}. ${next}`); // 5) Collapse 3+ blank lines to 2 processed = processed.replace(/\n{3,}/g, '\n\n'); return processed.trim(); }; // ENHANCED MARKDOWN COMPONENTS WITH BETTER STYLING const markdownComponents = { // Keep INLINE to avoid breaking paragraphs/lists strong: ({ children }) => ( {children} ), // Italic text styling em: ({ children }) => ( {children} ), // Paragraph styling with proper spacing p: ({ children }) => (

{children}

), // Unordered list styling ul: ({ children }) => ( ), // Ordered list styling with better spacing ol: ({ children }) => (
    {children}
), // List item styling with proper spacing li: ({ children }) => (
  • {children}
  • ), // Inline code styling code: ({ inline, children }) => ( inline ? ( {children} ) : (
              
                {children}
              
            
    ) ) }; // USER MESSAGE if (message.type === 'user') { const uAvatar = userAvatarOptions?.find(a => a.id === userAvatarId); const UserIcon = uAvatar ? (LucideIcons[uAvatar.icon] || LucideIcons.User) : null; return (
    {message.replyTo && (
    to {message.replyTo.advisorName}
    )}

    {message.content}

    {UserIcon && (
    )}
    ); } // ADVISOR MESSAGE if (message.type === 'advisor') { const personaId = message?.persona_id || message?.personaId || message?.advisor_id || message?.advisorId || (typeof message?.advisor === 'string' ? message.advisor : undefined) || 'methodologist'; const advisor = advisors[personaId] || advisors[message.persona_id] || {}; const Icon = advisor.icon; const colors = getAdvisorColors(personaId, isDark); const isCopied = copiedStates[message.id]; const avatarElement = (size = 44) => { const iconSize = Math.round(size * 0.52); return (
    {advisor.avatarUrl ? ( {advisor.name ) : Icon ? ( ) : ( {advisor.name ? advisor.name.charAt(0) : 'A'} )}
    ); }; return (
    {!inlineAvatar && (
    )}
    {inlineAvatar && avatarElement(44)}

    {advisor.name || message.advisorName || 'Advisor'} {message.isReply && ↳ Reply} {message.isExpansion && ⤴ Expanded}

    {message.timestamp?.toLocaleTimeString ? message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
    {/* Enhanced markdown rendering with preprocessing */}
    {preprocessMarkdown(message?.compact_markdown || message?.content || message?.text)}
    {showReplyButton && (
    {showTooltip === 'reply' && (
    Reply to this message
    )}
    {showTooltip === 'copy' && (
    {isCopied ? 'Copied!' : 'Copy to clipboard'}
    )}
    {showTooltip === 'expand' && (
    More
    )}
    {showTooltip === 'speak' && (
    {isLoadingTTS ? 'Loading audio...' : isSpeaking ? 'Stop speaking' : 'Speak it'}
    )}
    {showTooltip === 'search' && (
    Search for references
    )}
    )} {searchPopover && (
    Search for References
    {searchLoading ? (
    Generating search query...
    ) : ( <>
    {searchQuery}
    )}
    )}
    ); } // ERROR MESSAGE if (message.type === 'error') { return (

    {message.content}

    ); } return null; }; export default MessageBubble; /** RAG Info overlay kept as-is from your original file */ const RagInfoOverlay = ({ ragMetadata, colors }) => { const overlayRef = useRef(null); const [documentChunks, setDocumentChunks] = useState([]); useEffect(() => { if (ragMetadata?.documentChunks) { setDocumentChunks(ragMetadata.documentChunks); } }, [ragMetadata]); const hasDocuments = documentChunks.length > 0; return (
    Response Details
    Model {ragMetadata?.model || 'unknown'}
    Tokens {ragMetadata?.tokens ?? '—'}
    {hasDocuments && documentChunks.length > 0 && (
    Referenced Sources
    {documentChunks.map((chunk, index) => (
    {chunk.metadata?.filename || 'Unknown file'} {Math.round((chunk.relevance_score || 0) * 100)}%
    {chunk.text && (
    {chunk.text.substring(0, 120)} {chunk.text.length > 120 && '...'}
    )}
    ))}
    )}
    ); };