RAG / frontend-react /src /components /ChatWindow.jsx
JenishMakwana's picture
feat: implement real-time text-to-speech highlighting and document management within chat interface
28674f1
Raw
History Blame Contribute Delete
23.7 kB
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>
);
}