document.addEventListener('DOMContentLoaded', function() { // DOM Elements const chatMessages = document.getElementById('chat-messages'); const userInput = document.getElementById('user-input'); const sendButton = document.getElementById('send-btn'); const clearButton = document.getElementById('clear-chat'); const statusText = document.getElementById('status-text'); const statusBadge = document.getElementById('status-badge'); const suggestionButtons = document.querySelectorAll('.suggestion-btn'); const chatContainer = document.querySelector('.chat-container'); // Voice input/output elements const voiceButton = document.getElementById('voice-btn'); const ttsToggle = document.getElementById('tts-toggle'); const audioPlayerContainer = document.getElementById('audio-player-container'); const playPauseBtn = document.getElementById('play-pause-btn'); const audioProgress = document.getElementById('audio-progress'); // Stats elements const avgPrice = document.getElementById('avg-price'); const avgSqft = document.getElementById('avg-sqft'); const totalProperties = document.getElementById('total-properties'); const priceRange = document.getElementById('price-range'); // Add marked.js library for Markdown support const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/marked/4.0.2/marked.min.js'; document.head.appendChild(script); // Add highlight.js for code syntax highlighting const highlightCSS = document.createElement('link'); highlightCSS.rel = 'stylesheet'; highlightCSS.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/styles/github.min.css'; document.head.appendChild(highlightCSS); const highlightJS = document.createElement('script'); highlightJS.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js'; document.head.appendChild(highlightJS); // Chat history let chatHistory = []; let isProcessing = false; // Voice recognition variables let recognition = null; let isRecognizing = false; let audioPlayer = new Audio(); let ttsEnabled = true; // Initialize speech recognition if available function initSpeechRecognition() { // Check if browser supports speech recognition if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; recognition = new SpeechRecognition(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = 'en-US'; let silenceTimeout; const SILENCE_DELAY = 3000; // 3 seconds recognition.onstart = function() { isRecognizing = true; voiceButton.classList.add('recording'); voiceButton.innerHTML = ''; updateStatus('Listening...', 'processing'); // Start silence timer silenceTimeout = setTimeout(() => { stopRecognition(); }, SILENCE_DELAY); }; recognition.onresult = function(event) { // Clear existing silence timer clearTimeout(silenceTimeout); let interimTranscript = ''; let finalTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { finalTranscript += transcript; } else { interimTranscript += transcript; } } // Update input field with transcription userInput.value = finalTranscript || interimTranscript; userInput.dispatchEvent(new Event('input')); // Restart silence timer silenceTimeout = setTimeout(() => { stopRecognition(); }, SILENCE_DELAY); }; recognition.onerror = function(event) { console.error('Speech recognition error:', event.error); stopRecognition(); updateStatus('Online', 'online'); }; recognition.onend = function() { clearTimeout(silenceTimeout); stopRecognition(); updateStatus('Online', 'online'); sendMessage(); }; return true; } return false; } function toggleSpeechRecognition() { if (!recognition) { const initialized = initSpeechRecognition(); if (!initialized) { alert('Speech recognition is not supported in this browser.'); return; } } if (isRecognizing) { stopRecognition(); voiceButton.innerHTML = ''; voiceButton.classList.remove('recording'); } else { startRecognition(); voiceButton.innerHTML = ''; voiceButton.classList.add('recording'); } } function startRecognition() { try { recognition.start(); } catch (e) { console.error('Recognition error:', e); // Try to reinitialize recognition = null; initSpeechRecognition(); try { recognition.start(); } catch (e) { console.error('Failed to restart recognition:', e); alert('Failed to start speech recognition. Please try again.'); } } } function stopRecognition() { if (recognition && isRecognizing) { recognition.stop(); isRecognizing = false; voiceButton.classList.remove('recording'); voiceButton.innerHTML = ''; clearTimeout(silenceTimeout); } } // TTS functions function toggleTTS() { ttsEnabled = !ttsEnabled; ttsToggle.classList.toggle('active', ttsEnabled); // Stop any current playback when TTS is turned off if (!ttsEnabled) { stopAudio(); } } function playTTS(text) { if (!ttsEnabled) return; fetch('/audio/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(response => response.json()) .then(data => { if (data.audio) { const audioUrl = `data:audio/mp3;base64,${data.audio}`; audioPlayer.src = audioUrl; audioPlayer.load(); audioPlayer.currentTime = 0; showAudioPlayer(); audioPlayer.play().catch(e => console.error('Audio playback error:', e)); } }) .catch(error => { console.error('TTS error:', error); }); } function showAudioPlayer() { audioPlayerContainer.classList.remove('hidden'); updatePlayButton(); // Set up audio events if not already done if (!audioPlayer.onplay) { setupAudioEvents(); } } function setupAudioEvents() { audioPlayer.onplay = updatePlayButton; audioPlayer.onpause = updatePlayButton; audioPlayer.onended = function() { updatePlayButton(); setTimeout(() => { audioPlayerContainer.classList.add('hidden'); }, 1000); }; // Update progress bar during playback audioPlayer.ontimeupdate = function() { const percent = (audioPlayer.currentTime / audioPlayer.duration) * 100; audioProgress.style.width = percent + '%'; }; } function updatePlayButton() { if (audioPlayer.paused) { playPauseBtn.innerHTML = ''; } else { playPauseBtn.innerHTML = ''; } } function toggleAudioPlayback() { if (audioPlayer.paused) { audioPlayer.play() .catch(e => console.error('Audio playback error:', e)); } else { audioPlayer.pause(); } updatePlayButton(); } function stopAudio() { if (audioPlayer.src) { audioPlayer.pause(); audioPlayer.currentTime = 0; updatePlayButton(); audioPlayerContainer.classList.add('hidden'); } } // Check server status and load property stats on page load checkStatus(); loadPropertyStats(); // Event Listeners sendButton.addEventListener('click', sendMessage); userInput.addEventListener('keypress', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // Voice events voiceButton.addEventListener('click', toggleSpeechRecognition); ttsToggle.addEventListener('click', toggleTTS); playPauseBtn.addEventListener('click', toggleAudioPlayback); // Add input resize functionality userInput.addEventListener('input', function() { this.style.height = 'auto'; const maxHeight = 150; // Maximum height before scrolling this.style.height = (this.scrollHeight > maxHeight ? maxHeight : this.scrollHeight) + 'px'; }); // Focus input on page load userInput.focus(); clearButton.addEventListener('click', clearChat); // Suggestion buttons suggestionButtons.forEach(button => { button.addEventListener('click', function() { const query = this.getAttribute('data-query'); userInput.value = query; userInput.dispatchEvent(new Event('input')); // Trigger height adjustment sendMessage(); }); }); // Functions function sendMessage() { const message = userInput.value.trim(); if (message === '' || isProcessing) return; // Stop any current voice recording stopRecognition(); // Stop any current audio playback stopAudio(); // Get current time for message timestamp const now = new Date(); const time = now.getHours() + ':' + (now.getMinutes() < 10 ? '0' : '') + now.getMinutes(); // Add user message to chat addMessage(message, 'user', time); // Clear input and reset height userInput.value = ''; userInput.style.height = 'auto'; userInput.focus(); // Show typing indicator showTypingIndicator(); // Set processing state isProcessing = true; updateStatus('Processing...', 'processing'); // Send request to backend fetch('/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: message, chat_history: chatHistory }) }) .then(response => response.json()) .then(data => { // Remove typing indicator removeTypingIndicator(); // Get current time for response timestamp const responseTime = new Date(); const responseTimeStr = responseTime.getHours() + ':' + (responseTime.getMinutes() < 10 ? '0' : '') + responseTime.getMinutes(); // Add assistant response addMessage(data.answer, 'assistant', responseTimeStr); // Play TTS response if enabled if (ttsEnabled) { // Extract plain text from markdown for better TTS const plainText = stripMarkdown(data.answer); playTTS(plainText); } // Update chat history with both messages chatHistory.push( { role: 'user', content: message }, { role: 'assistant', content: data.answer }, { role: 'system', content: JSON.stringify(data.system_state) } ); // Reset processing state isProcessing = false; updateStatus('Online', 'online'); }) .catch(error => { console.error('Error:', error); removeTypingIndicator(); const errorTime = new Date(); const errorTimeStr = errorTime.getHours() + ':' + (errorTime.getMinutes() < 10 ? '0' : '') + errorTime.getMinutes(); addMessage('Sorry, there was an error processing your request. Please try again.', 'assistant', errorTimeStr); isProcessing = false; updateStatus('Error', 'error'); }); } // Helper function to strip markdown for better TTS function stripMarkdown(text) { return text .replace(/\*\*(.*?)\*\*/g, '$1') // Bold .replace(/\*(.*?)\*/g, '$1') // Italic .replace(/\[(.*?)\]\((.*?)\)/g, '$1') // Links .replace(/#{1,6}\s?(.*)/g, '$1') // Headers .replace(/```[a-z]*\n([\s\S]*?)```/g, 'Code block removed.') // Code blocks .replace(/`(.*?)`/g, '$1') // Inline code .replace(/^\s*[-*+]\s+/gm, '') // Lists .replace(/^\s*\d+\.\s+/gm, '') // Numbered lists .replace(/\n{2,}/g, '. ') // Multiple newlines with period for pause .replace(/\n/g, ' ') // Single newlines .trim(); } function addMessage(content, role, time) { const messageDiv = document.createElement('div'); messageDiv.classList.add('message', role); const avatar = document.createElement('div'); avatar.classList.add('message-avatar'); const icon = document.createElement('i'); icon.classList.add('fas', role === 'user' ? 'fa-user' : 'fa-robot'); avatar.appendChild(icon); const messageContent = document.createElement('div'); messageContent.classList.add('message-content'); // Create message header const messageHeader = document.createElement('div'); messageHeader.classList.add('message-header'); const nameSpan = document.createElement('span'); nameSpan.classList.add('bot-name'); nameSpan.textContent = role === 'user' ? 'You' : 'HomeHelper'; const timeSpan = document.createElement('span'); timeSpan.classList.add('message-time'); timeSpan.textContent = time || 'Just now'; messageHeader.appendChild(nameSpan); messageHeader.appendChild(timeSpan); messageContent.appendChild(messageHeader); // Parse markdown for assistant messages only if (role === 'assistant' && typeof marked !== 'undefined') { const messageBody = document.createElement('div'); messageBody.classList.add('message-body'); // Configure marked options marked.setOptions({ gfm: true, breaks: true, highlight: function(code, language) { if (typeof hljs !== 'undefined' && language && hljs.getLanguage(language)) { try { return hljs.highlight(code, { language }).value; } catch (error) { console.error('Highlight error:', error); } } return code; } }); // Render markdown messageBody.innerHTML = marked.parse(content); // Apply syntax highlighting to code blocks if (typeof hljs !== 'undefined') { messageBody.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); }); } messageContent.appendChild(messageBody); } else { // Regular text for user messages const paragraph = document.createElement('p'); paragraph.textContent = content; messageContent.appendChild(paragraph); } messageDiv.appendChild(avatar); messageDiv.appendChild(messageContent); // Add message with animation messageDiv.style.opacity = '0'; messageDiv.style.transform = 'translateY(20px)'; chatMessages.appendChild(messageDiv); // Trigger reflow void messageDiv.offsetWidth; // Apply animation messageDiv.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; messageDiv.style.opacity = '1'; messageDiv.style.transform = 'translateY(0)'; // Smooth scroll to bottom with animation smoothScrollToBottom(); } function smoothScrollToBottom() { const scrollHeight = chatContainer.scrollHeight; const height = chatContainer.clientHeight; const maxScrollTop = scrollHeight - height; // Use requestAnimationFrame for smooth animation const startTime = performance.now(); const startPos = chatContainer.scrollTop; const duration = 300; // milliseconds function step(currentTime) { const elapsed = currentTime - startTime; if (elapsed < duration) { // Easing function: easeInOutQuad const t = elapsed / duration; const factor = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; chatContainer.scrollTop = startPos + factor * (maxScrollTop - startPos); requestAnimationFrame(step); } else { chatContainer.scrollTop = maxScrollTop; } } requestAnimationFrame(step); } function showTypingIndicator() { const now = new Date(); const time = now.getHours() + ':' + (now.getMinutes() < 10 ? '0' : '') + now.getMinutes(); const typingDiv = document.createElement('div'); typingDiv.classList.add('message', 'assistant'); typingDiv.id = 'typing-indicator'; const avatar = document.createElement('div'); avatar.classList.add('message-avatar'); const icon = document.createElement('i'); icon.classList.add('fas', 'fa-robot'); avatar.appendChild(icon); const messageContent = document.createElement('div'); messageContent.classList.add('message-content'); // Create message header const messageHeader = document.createElement('div'); messageHeader.classList.add('message-header'); const nameSpan = document.createElement('span'); nameSpan.classList.add('bot-name'); nameSpan.textContent = 'HomeHelper'; const timeSpan = document.createElement('span'); timeSpan.classList.add('message-time'); timeSpan.textContent = time; messageHeader.appendChild(nameSpan); messageHeader.appendChild(timeSpan); // Create typing indicator dots const typingContainer = document.createElement('div'); typingContainer.classList.add('typing-indicator'); for (let i = 0; i < 3; i++) { const dot = document.createElement('div'); dot.classList.add('typing-dot'); typingContainer.appendChild(dot); } messageContent.appendChild(messageHeader); messageContent.appendChild(typingContainer); typingDiv.appendChild(avatar); typingDiv.appendChild(messageContent); chatMessages.appendChild(typingDiv); smoothScrollToBottom(); } function removeTypingIndicator() { const typingIndicator = document.getElementById('typing-indicator'); if (typingIndicator) { typingIndicator.remove(); } } function clearChat() { // Stop any current audio playback stopAudio(); // Add fade-out animation to messages const messages = chatMessages.querySelectorAll('.message:not(:first-child)'); let animationCount = 0; const totalAnimations = messages.length; if (totalAnimations === 0) { return; // No messages to clear } messages.forEach((msg, index) => { // Stagger the animations setTimeout(() => { msg.style.transition = 'opacity 0.2s ease-out, transform 0.2s ease-out'; msg.style.opacity = '0'; msg.style.transform = 'translateY(10px)'; msg.addEventListener('transitionend', function handler() { msg.removeEventListener('transitionend', handler); animationCount++; if (animationCount === totalAnimations) { // All animations complete, now remove messages while (chatMessages.children.length > 1) { chatMessages.removeChild(chatMessages.lastChild); } // Reset chat history chatHistory = []; // Reset status updateStatus('Online', 'online'); // Focus on input userInput.focus(); } }); }, index * 50); // Stagger by 50ms per message }); } function updateStatus(text, status) { statusText.textContent = text; // Remove all status classes statusBadge.classList.remove('online', 'offline', 'error', 'processing'); // Add appropriate class statusBadge.classList.add(status); } function checkStatus() { fetch('/status') .then(response => response.json()) .then(data => { if (data.status === 'ok') { updateStatus('Online', 'online'); } else { updateStatus('System Issue', 'error'); } }) .catch(error => { console.error('Status check error:', error); updateStatus('Offline', 'offline'); }); } function loadPropertyStats() { fetch('/property_summary') .then(response => response.json()) .then(data => { // Update stats in the sidebar with animation if (data.avg_price) { animateValue(avgPrice, 0, data.avg_price, 1500, val => `$${formatNumber(val)}`); } if (data.avg_sqft) { animateValue(avgSqft, 0, data.avg_sqft, 1500, val => `${formatNumber(val)} sq ft`); } if (data.total_properties) { animateValue(totalProperties, 0, data.total_properties, 1500, formatNumber); } if (data.price_range) { priceRange.textContent = `$${formatNumber(data.price_range.min)} - $${formatNumber(data.price_range.max)}`; } }) .catch(error => { console.error('Error loading property stats:', error); avgPrice.textContent = 'Unavailable'; avgSqft.textContent = 'Unavailable'; totalProperties.textContent = 'Unavailable'; priceRange.textContent = 'Unavailable'; }); } // Helper function to animate number values function animateValue(element, start, end, duration, formatter) { const startTime = performance.now(); function updateValue(timestamp) { const elapsed = timestamp - startTime; const progress = Math.min(elapsed / duration, 1); // Easing function: easeOutQuad const easing = 1 - (1 - progress) * (1 - progress); const current = Math.floor(start + (end - start) * easing); element.textContent = formatter(current); if (progress < 1) { requestAnimationFrame(updateValue); } } requestAnimationFrame(updateValue); } // Helper function to format numbers with commas function formatNumber(num) { return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(Math.round(num)); } // Set up polling for status checks setInterval(checkStatus, 60000); // Check status every minute });