Spaces:
Running
Running
feat: implement real-time text-to-speech highlighting and document management within chat interface
28674f1 | import { useState, useRef, useEffect } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| const logo = "https://huggingface.co/spaces/JenishMakwana/RAG/resolve/main/frontend-react/public/logo.png"; | |
| import remarkGfm from 'remark-gfm'; | |
| import rehypeRaw from 'rehype-raw'; | |
| import { | |
| Send, | |
| Paperclip, | |
| Mic, | |
| MicOff, | |
| PanelLeftClose, | |
| PanelLeftOpen, | |
| Sparkles, | |
| Search, | |
| Users, | |
| FileText, | |
| Volume2, | |
| VolumeX, | |
| ChevronRight, | |
| CheckCircle, | |
| MessageSquare, | |
| BrainCircuit, | |
| ShieldCheck, | |
| Scale, | |
| AlertCircle | |
| } from 'lucide-react'; | |
| import { | |
| chatQuery, | |
| fetchChatHistory, | |
| uploadDocument, | |
| transcribeVoice, | |
| getTtsAudio | |
| } from '../api'; | |
| // ... (existing functions) | |
| export default function ChatWindow({ | |
| token, | |
| messages, | |
| setMessages, | |
| sessionId, | |
| onFirstMessage, | |
| setIsUploading, | |
| onUploadSuccess, | |
| isSidebarCollapsed, | |
| setIsSidebarCollapsed, | |
| sessionDocuments, | |
| setSessionDocuments, | |
| onLogout | |
| }) { | |
| const [input, setInput] = useState(''); | |
| const [loading, setLoading] = useState(false); | |
| const [historyLoading, setHistoryLoading] = useState(true); | |
| const [uploading, setUploading] = useState(false); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [isProcessingVoice, setIsProcessingVoice] = useState(false); | |
| const [speakingIdx, setSpeakingIdx] = useState(null); | |
| const messagesEndRef = useRef(null); | |
| const audioRef = useRef(null); | |
| const mediaRecorderRef = useRef(null); | |
| const audioChunksRef = useRef([]); | |
| const [selectedFiles, setSelectedFiles] = useState([]); | |
| const [pendingSelectionQuery, setPendingSelectionQuery] = useState(null); | |
| // TTS Highlighting State | |
| const [highlightedInfo, setHighlightedInfo] = useState({ | |
| msgIdx: null, | |
| sentence: null, | |
| wordIdx: -1 | |
| }); | |
| const highlightTimerRef = useRef(null); | |
| /** | |
| * Injects <mark> tags into raw markdown while preserving formatting. | |
| * This handles the core logic of 'Seamless Highlighting' | |
| */ | |
| const injectHighlighting = (rawText, msgIdx) => { | |
| if (highlightedInfo.msgIdx !== msgIdx || !highlightedInfo.sentence) { | |
| return rawText; | |
| } | |
| const { sentence } = highlightedInfo; | |
| // Find the exact sentence block. | |
| const sentencePos = rawText.indexOf(sentence); | |
| if (sentencePos === -1) return rawText; | |
| const highlightedSentence = `<mark class="highlight-sentence">${sentence}</mark>`; | |
| const before = rawText.substring(0, sentencePos); | |
| const after = rawText.substring(sentencePos + sentence.length); | |
| return before + highlightedSentence + after; | |
| }; | |
| const toggleFileSelection = (filename) => { | |
| setSelectedFiles(prev => { | |
| const exists = prev.includes(filename); | |
| if (exists) return prev.filter(f => f !== filename); | |
| return [...prev, filename]; | |
| }); | |
| }; | |
| const renderMessageContent = (text, msgIdx) => { | |
| if (!text || typeof text !== 'string') return text; | |
| const sourceMarker = '[Source:'; | |
| let content = text; | |
| let source = ''; | |
| if (text.includes(sourceMarker)) { | |
| const parts = text.split(sourceMarker); | |
| content = parts[0]; | |
| source = sourceMarker + parts.slice(1).join(sourceMarker); | |
| } | |
| const processedContent = injectHighlighting(content, msgIdx); | |
| return ( | |
| <> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| rehypePlugins={[rehypeRaw]} | |
| > | |
| {processedContent} | |
| </ReactMarkdown> | |
| {source && ( | |
| <div className="message-citation"> | |
| {source} | |
| </div> | |
| )} | |
| </> | |
| ); | |
| }; | |
| const renderErrorMessage = (text) => { | |
| if (!text || typeof text !== 'string') return text; | |
| const lines = text.split('\n'); | |
| const headerText = lines[0]; | |
| const detailText = lines.slice(1).join('\n'); | |
| return ( | |
| <div className="error-body"> | |
| <div className="error-header"> | |
| <AlertCircle size={16} className="error-header-icon" /> | |
| <span className="error-header-title">{headerText}</span> | |
| </div> | |
| {detailText && ( | |
| <div className="error-details"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}> | |
| {detailText} | |
| </ReactMarkdown> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const handleUpload = async (e) => { | |
| const files = Array.from(e.target.files); | |
| if (files.length === 0) return; | |
| setUploading(true); | |
| setIsUploading(true); | |
| try { | |
| const results = await Promise.allSettled( | |
| files.map(async (file) => { | |
| try { | |
| const result = await uploadDocument(token, file, sessionId); | |
| return { file, result, success: true }; | |
| } catch (err) { | |
| return { file, error: err.message, status: err.status, success: false }; | |
| } | |
| }) | |
| ); | |
| const successful = results.filter(r => r.value && r.value.success); | |
| const failed = results.filter(r => (r.value && !r.value.success) || r.status === 'rejected'); | |
| const isUnauthorized = failed.some(r => r.value?.status === 401); | |
| if (isUnauthorized && onLogout) { | |
| onLogout(); | |
| return; | |
| } | |
| if (successful.length > 0) { | |
| if (onUploadSuccess) onUploadSuccess(); | |
| const newDocs = successful.map(r => ({ | |
| filename: r.value.file.name, | |
| chunks: r.value.result.chunks | |
| })); | |
| setSessionDocuments(prev => [...prev, ...newDocs]); | |
| const successMsg = successful.length === 1 | |
| ? `✅ Attached document: **${successful[0].value.file.name}**` | |
| : `✅ Attached **${successful.length}** documents successfully.`; | |
| setMessages(prev => [...prev, { role: 'assistant', text: successMsg }]); | |
| } | |
| if (failed.length > 0) { | |
| const errorMsg = failed.map(r => { | |
| const name = r.value?.file?.name || "Unknown file"; | |
| const error = r.value?.error || "Upload failed"; | |
| return `- ${name}: ${error}`; | |
| }).join('\n'); | |
| setMessages(prev => [...prev, { role: 'error', text: `Failed to upload some documents:\n${errorMsg}` }]); | |
| } | |
| } catch (err) { | |
| alert("An unexpected error occurred during upload."); | |
| } finally { | |
| setUploading(false); | |
| setIsUploading(false); | |
| e.target.value = null; | |
| } | |
| }; | |
| useEffect(() => { | |
| const loadHistory = async () => { | |
| setHistoryLoading(true); | |
| try { | |
| const data = await fetchChatHistory(token, sessionId); | |
| setMessages(data.history); | |
| setSessionDocuments(data.documents || []); | |
| setSelectedFiles([]); // Reset selection when switching chats | |
| } catch (err) { | |
| setMessages([]); | |
| setSessionDocuments([]); | |
| if (err.status === 401 && onLogout) { | |
| onLogout(); | |
| } | |
| } finally { | |
| setHistoryLoading(false); | |
| } | |
| }; | |
| if (token && sessionId) loadHistory(); | |
| }, [token, sessionId, setMessages, setSessionDocuments, onLogout]); | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages]); | |
| const handleStartChat = () => { | |
| setMessages([ | |
| { | |
| role: 'assistant', | |
| text: 'Hello! I am your Legal Case Assistant. How can I help you with your case analysis or document research today?' | |
| } | |
| ]); | |
| }; | |
| const submitQuery = async (queryText, force = false) => { | |
| if (!queryText.trim() || loading) return; | |
| // Check if we need to "ask" for document focus | |
| if (!force && sessionDocuments.length > 1 && selectedFiles.length === 0) { | |
| setPendingSelectionQuery(queryText); | |
| return; | |
| } | |
| const isFirstMessage = !messages.some(m => m.role === 'user'); | |
| setMessages(prev => [...prev, { role: 'user', text: queryText }]); | |
| setLoading(true); | |
| try { | |
| // Send the list of selected filenames (if any) | |
| const filenames = selectedFiles.length > 0 ? selectedFiles : null; | |
| const response = await chatQuery(token, queryText, sessionId, null, filenames); | |
| const aiMessage = { role: 'assistant', text: '' }; | |
| setMessages(prev => [...prev, aiMessage]); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let fullText = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| fullText += decoder.decode(value, { stream: true }); | |
| setMessages(prev => { | |
| const newMessages = [...prev]; | |
| newMessages[newMessages.length - 1] = { ...newMessages[newMessages.length - 1], text: fullText }; | |
| return newMessages; | |
| }); | |
| } | |
| if (isFirstMessage) onFirstMessage(); | |
| } catch (err) { | |
| setMessages(prev => [...prev, { role: 'error', text: err.message }]); | |
| if (err.status === 401 && onLogout) { | |
| onLogout(); | |
| } | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleSubmit = (e) => { | |
| e.preventDefault(); | |
| if (uploading) return; | |
| const queryText = input; | |
| if (!queryText.trim()) return; | |
| setInput(''); | |
| submitQuery(queryText); | |
| }; | |
| const handleConfirmSelection = () => { | |
| if (pendingSelectionQuery) { | |
| const queryToSubmit = pendingSelectionQuery; | |
| setPendingSelectionQuery(null); | |
| submitQuery(queryToSubmit, true); | |
| } | |
| }; | |
| const toggleVoice = async () => { | |
| if (isRecording) { | |
| if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { | |
| mediaRecorderRef.current.stop(); | |
| } | |
| } else { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const mediaRecorder = new MediaRecorder(stream); | |
| mediaRecorderRef.current = mediaRecorder; | |
| audioChunksRef.current = []; | |
| mediaRecorder.ondataavailable = (event) => { | |
| if (event.data.size > 0) { | |
| audioChunksRef.current.push(event.data); | |
| } | |
| }; | |
| mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); | |
| setIsRecording(false); | |
| setIsProcessingVoice(true); | |
| // Stop all audio tracks to release the microphone | |
| stream.getTracks().forEach(track => track.stop()); | |
| try { | |
| const result = await transcribeVoice(token, audioBlob); | |
| if (result.query) setInput(result.query); | |
| } catch (err) { | |
| alert('Voice processing failed: ' + err.message); | |
| } finally { | |
| setIsProcessingVoice(false); | |
| } | |
| }; | |
| mediaRecorder.start(); | |
| setIsRecording(true); | |
| } catch (err) { | |
| setIsRecording(false); | |
| alert('Microphone error: ' + (err.name || 'Unknown') + ' - ' + (err.message || err)); | |
| } | |
| } | |
| }; | |
| const audioQueueRef = useRef([]); | |
| const abortControllerRef = useRef(null); | |
| const [isPlaying, setIsPlaying] = useState(false); | |
| // Play next item in queue | |
| const playNextInQueue = () => { | |
| if (highlightTimerRef.current) clearInterval(highlightTimerRef.current); | |
| if (audioQueueRef.current.length === 0) { | |
| setIsPlaying(false); | |
| setSpeakingIdx(null); | |
| setHighlightedInfo({ msgIdx: null, sentence: null, wordIdx: -1 }); | |
| return; | |
| } | |
| const { url, text: sentenceText } = audioQueueRef.current.shift(); | |
| setIsPlaying(true); | |
| // Update the single audio element's source (iOS/Mobile compatible) | |
| audioRef.current.src = url; | |
| audioRef.current.load(); | |
| audioRef.current.onplay = () => { | |
| setHighlightedInfo(prev => ({ | |
| ...prev, | |
| sentence: sentenceText, | |
| wordIdx: 0 | |
| })); | |
| }; | |
| audioRef.current.onended = () => { | |
| URL.revokeObjectURL(url); | |
| playNextInQueue(); | |
| }; | |
| audioRef.current.play().catch(err => { | |
| console.error("Playback error", err); | |
| playNextInQueue(); | |
| }); | |
| }; | |
| const handleSpeak = async (text, index) => { | |
| // If clicking same button while playing, STOP everything | |
| if (speakingIdx === index) { | |
| if (audioRef.current) { | |
| audioRef.current.onplay = null; | |
| audioRef.current.onended = null; | |
| audioRef.current.pause(); | |
| audioRef.current.src = ""; | |
| audioRef.current.load(); | |
| } | |
| if (abortControllerRef.current) { | |
| abortControllerRef.current.abort(); | |
| } | |
| if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); | |
| audioQueueRef.current.forEach(item => URL.revokeObjectURL(item.url)); | |
| audioQueueRef.current = []; | |
| setSpeakingIdx(null); | |
| setIsPlaying(false); | |
| setHighlightedInfo({ msgIdx: null, sentence: null, wordIdx: -1 }); | |
| return; | |
| } | |
| // Stop current if any | |
| if (audioRef.current) { | |
| audioRef.current.onplay = null; | |
| audioRef.current.onended = null; | |
| audioRef.current.pause(); | |
| audioRef.current.src = ""; | |
| audioRef.current.load(); | |
| } | |
| if (abortControllerRef.current) abortControllerRef.current.abort(); | |
| if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); | |
| audioQueueRef.current.forEach(item => URL.revokeObjectURL(item.url)); | |
| audioQueueRef.current = []; | |
| // Create & unlock the single Audio element synchronously in the user gesture context | |
| if (!audioRef.current) { | |
| audioRef.current = new Audio(); | |
| } | |
| // Mobile browsers require a synchronous play/pause on a user gesture to lift restriction | |
| audioRef.current.play().then(() => { | |
| audioRef.current.pause(); | |
| }).catch(() => { | |
| // Ignore initial play error on empty source | |
| }); | |
| setSpeakingIdx(index); | |
| setHighlightedInfo({ msgIdx: index, sentence: null, wordIdx: -1 }); | |
| const controller = new AbortController(); | |
| abortControllerRef.current = controller; | |
| try { | |
| const response = await getTtsAudio(token, text, controller.signal); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let startedPlaying = false; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop(); // Keep last partial line | |
| for (const line of lines) { | |
| if (!line.trim()) continue; | |
| try { | |
| const { audio: b64, text: sentenceText } = JSON.parse(line); | |
| const byteCharacters = atob(b64); | |
| const byteNumbers = new Array(byteCharacters.length); | |
| for (let i = 0; i < byteCharacters.length; i++) { | |
| byteNumbers[i] = byteCharacters.charCodeAt(i); | |
| } | |
| const byteArray = new Uint8Array(byteNumbers); | |
| const blob = new Blob([byteArray], { type: 'audio/wav' }); | |
| const url = URL.createObjectURL(blob); | |
| // Queue only the url and text (avoiding creating new Audio elements asynchronously) | |
| audioQueueRef.current.push({ url, text: sentenceText }); | |
| // If we haven't started playing, start now | |
| if (!startedPlaying) { | |
| startedPlaying = true; | |
| playNextInQueue(); | |
| } | |
| } catch (e) { | |
| console.error("Error parsing audio chunk JSON:", e); | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| if (err.name !== 'AbortError') { | |
| console.error("TTS Stream error:", err); | |
| setSpeakingIdx(null); | |
| } | |
| } | |
| }; | |
| return ( | |
| <div className="chat-main"> | |
| <header className="chat-header"> | |
| <div className="header-left"> | |
| <button | |
| className="sidebar-toggle" | |
| onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} | |
| title={isSidebarCollapsed ? "Show sidebar" : "Hide sidebar"} | |
| > | |
| {isSidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />} | |
| </button> | |
| <h1>Legal Case Analysis</h1> | |
| </div> | |
| </header> | |
| <div className="chat-messages"> | |
| {historyLoading ? ( | |
| <div className="welcome-screen"> | |
| <div className="loader-ring" style={{ width: '40px', height: '40px', margin: '0 auto 1rem' }}></div> | |
| <p>Restoring conversation...</p> | |
| </div> | |
| ) : ( | |
| <> | |
| {messages.length === 0 && ( | |
| <div className="welcome-screen"> | |
| <div className="welcome-logo-container" style={{ | |
| width: '64px', | |
| height: '64px', | |
| margin: '0 auto 1.5rem', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| background: '#000000', | |
| border: '1.5px solid rgba(168, 85, 247, 0.45)', | |
| boxShadow: '0 12px 32px rgba(168, 85, 247, 0.25), inset 0 0 16px rgba(168, 85, 247, 0.2)', | |
| padding: '4px', | |
| borderRadius: '16px' | |
| }}> | |
| <img src={logo} alt="Logo" style={{ width: '100%', height: '100%', objectFit: 'contain', borderRadius: '12px' }} /> | |
| </div> | |
| <h2>Legal Case Assistant</h2> | |
| <p className="welcome-subtitle"> | |
| Analyze complex case files, retrieve precise legal citations, and verify precedents securely. | |
| </p> | |
| <div className="system-pills-row"> | |
| <div className="system-pill"> | |
| <BrainCircuit size={16} className="pill-icon" /> | |
| <span>Always Online</span> | |
| </div> | |
| <div className="system-pill"> | |
| <Scale size={16} className="pill-icon" /> | |
| <span>Smart Search</span> | |
| </div> | |
| <div className="system-pill"> | |
| <ShieldCheck size={16} className="pill-icon" /> | |
| <span>Ironclad Privacy</span> | |
| </div> | |
| </div> | |
| <button | |
| className="start-chat-btn" | |
| onClick={handleStartChat} | |
| > | |
| <MessageSquare size={16} style={{ marginRight: '8px' }} /> | |
| <span>Start Chat</span> | |
| </button> | |
| </div> | |
| )} | |
| {messages.map((m, i) => ( | |
| <div key={i} className={`message-bubble ${m.role}`}> | |
| {m.role === 'error' ? ( | |
| renderErrorMessage(m.text) | |
| ) : ( | |
| <div className="message-content"> | |
| {m.role === 'assistant' ? ( | |
| renderMessageContent(m.text, i) | |
| ) : ( | |
| m.text | |
| )} | |
| </div> | |
| )} | |
| {m.role === 'assistant' && !m.text.startsWith('✅') && !m.text.includes('No documents found') && ( | |
| <button | |
| className={`speak-btn ${speakingIdx === i ? 'speaking' : ''}`} | |
| onClick={() => handleSpeak(m.text, i)} | |
| title="Read Aloud" | |
| > | |
| {speakingIdx === i ? <VolumeX size={16} /> : <Volume2 size={16} />} | |
| </button> | |
| )} | |
| </div> | |
| ))} | |
| {loading && ( | |
| <div className="message-bubble assistant"> | |
| <div className="loading-dots">Analyzing</div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </> | |
| )} | |
| </div> | |
| {messages.length > 0 && ( | |
| <div className="chat-input-area"> | |
| {sessionDocuments.length > 0 && pendingSelectionQuery && ( | |
| <div className="selector-panel prompt"> | |
| <div className="selector-header"> | |
| <h3>Select focus for your question</h3> | |
| <div className="selector-pagination"> | |
| <span>{selectedFiles.length === 0 ? 'All Documents' : `${selectedFiles.length} selected`}</span> | |
| </div> | |
| </div> | |
| <div className="selector-list"> | |
| <div | |
| className={`selector-item ${selectedFiles.length === 0 ? 'active' : ''}`} | |
| onClick={() => setSelectedFiles([])} | |
| > | |
| <div className="item-number"> | |
| {selectedFiles.length === 0 ? <CheckCircle size={16} /> : 1} | |
| </div> | |
| <div className="item-info"> | |
| <span className="item-name">All Documents</span> | |
| </div> | |
| <ChevronRight className="item-arrow" size={18} /> | |
| </div> | |
| {sessionDocuments.map((doc, idx) => { | |
| const isActive = selectedFiles.includes(doc.filename); | |
| return ( | |
| <div | |
| key={idx} | |
| className={`selector-item ${isActive ? 'active' : ''}`} | |
| onClick={() => toggleFileSelection(doc.filename)} | |
| > | |
| <div className="item-number"> | |
| {isActive ? <CheckCircle size={16} /> : idx + 2} | |
| </div> | |
| <div className="item-info"> | |
| <span className="item-name">{doc.filename}</span> | |
| </div> | |
| <ChevronRight className="item-arrow" size={18} /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="selector-footer"> | |
| <button className="selector-confirm-btn" onClick={handleConfirmSelection}> | |
| Analyze selected documents | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {uploading && ( | |
| <div className="embedding-status"> | |
| <div className="mini-spinner"></div> | |
| <span>Processing and embedding documents... Please wait.</span> | |
| </div> | |
| )} | |
| <form onSubmit={handleSubmit} className="chat-input-wrapper"> | |
| <label className="icon-btn" title="Attach Document"> | |
| <input type="file" accept=".pdf" multiple style={{display: 'none'}} onChange={handleUpload} disabled={loading || uploading} /> | |
| <Paperclip size={20} /> | |
| </label> | |
| <button | |
| type="button" | |
| className={`icon-btn voice-btn ${isRecording ? 'recording' : ''}`} | |
| onClick={toggleVoice} | |
| disabled={loading || uploading || isProcessingVoice} | |
| > | |
| {isRecording ? <MicOff size={20} /> : <Mic size={20} />} | |
| </button> | |
| <input | |
| type="text" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| placeholder={ | |
| isProcessingVoice ? "Processing voice..." : | |
| selectedFiles.length > 0 ? `Searching ${selectedFiles.length} doc${selectedFiles.length > 1 ? 's' : ''}...` : | |
| sessionDocuments.length > 0 ? "Search documents..." : | |
| "Ask a question..." | |
| } | |
| disabled={loading || isProcessingVoice} | |
| /> | |
| <button type="submit" className="send-btn" disabled={loading || uploading || !input.trim()}> | |
| <Send size={18} /> | |
| </button> | |
| </form> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |