| document.addEventListener('DOMContentLoaded', function() { |
| |
| 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'); |
| |
| |
| 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'); |
| |
| |
| const avgPrice = document.getElementById('avg-price'); |
| const avgSqft = document.getElementById('avg-sqft'); |
| const totalProperties = document.getElementById('total-properties'); |
| const priceRange = document.getElementById('price-range'); |
| |
| |
| const script = document.createElement('script'); |
| script.src = 'https://cdnjs.cloudflare.com/ajax/libs/marked/4.0.2/marked.min.js'; |
| document.head.appendChild(script); |
| |
| |
| 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); |
| |
| |
| let chatHistory = []; |
| let isProcessing = false; |
| |
| |
| let recognition = null; |
| let isRecognizing = false; |
| let audioPlayer = new Audio(); |
| let ttsEnabled = true; |
| |
| |
| function initSpeechRecognition() { |
| |
| 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; |
|
|
| recognition.onstart = function() { |
| isRecognizing = true; |
| voiceButton.classList.add('recording'); |
| voiceButton.innerHTML = '<i class="fas fa-stop"></i>'; |
| updateStatus('Listening...', 'processing'); |
| |
| |
| silenceTimeout = setTimeout(() => { |
| stopRecognition(); |
| }, SILENCE_DELAY); |
| }; |
|
|
| recognition.onresult = function(event) { |
| |
| 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; |
| } |
| } |
|
|
| |
| userInput.value = finalTranscript || interimTranscript; |
| userInput.dispatchEvent(new Event('input')); |
|
|
| |
| 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 = '<i class="fas fa-microphone"></i>'; |
| voiceButton.classList.remove('recording'); |
| } else { |
| startRecognition(); |
| voiceButton.innerHTML = '<i class="fas fa-stop"></i>'; |
| voiceButton.classList.add('recording'); |
| } |
| } |
|
|
| function startRecognition() { |
| try { |
| recognition.start(); |
| } catch (e) { |
| console.error('Recognition error:', e); |
| |
| 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 = '<i class="fas fa-microphone"></i>'; |
| clearTimeout(silenceTimeout); |
| } |
| } |
| |
| |
| function toggleTTS() { |
| ttsEnabled = !ttsEnabled; |
| ttsToggle.classList.toggle('active', ttsEnabled); |
| |
| |
| 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(); |
| |
| |
| if (!audioPlayer.onplay) { |
| setupAudioEvents(); |
| } |
| } |
| |
| function setupAudioEvents() { |
| audioPlayer.onplay = updatePlayButton; |
| audioPlayer.onpause = updatePlayButton; |
| audioPlayer.onended = function() { |
| updatePlayButton(); |
| setTimeout(() => { |
| audioPlayerContainer.classList.add('hidden'); |
| }, 1000); |
| }; |
| |
| |
| audioPlayer.ontimeupdate = function() { |
| const percent = (audioPlayer.currentTime / audioPlayer.duration) * 100; |
| audioProgress.style.width = percent + '%'; |
| }; |
| } |
| |
| function updatePlayButton() { |
| if (audioPlayer.paused) { |
| playPauseBtn.innerHTML = '<i class="fas fa-play"></i>'; |
| } else { |
| playPauseBtn.innerHTML = '<i class="fas fa-pause"></i>'; |
| } |
| } |
| |
| 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'); |
| } |
| } |
| |
| |
| checkStatus(); |
| loadPropertyStats(); |
| |
| |
| sendButton.addEventListener('click', sendMessage); |
| userInput.addEventListener('keypress', function(e) { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| sendMessage(); |
| } |
| }); |
| |
| |
| voiceButton.addEventListener('click', toggleSpeechRecognition); |
| ttsToggle.addEventListener('click', toggleTTS); |
| playPauseBtn.addEventListener('click', toggleAudioPlayback); |
| |
| |
| userInput.addEventListener('input', function() { |
| this.style.height = 'auto'; |
| const maxHeight = 150; |
| this.style.height = (this.scrollHeight > maxHeight ? maxHeight : this.scrollHeight) + 'px'; |
| }); |
| |
| |
| userInput.focus(); |
| |
| clearButton.addEventListener('click', clearChat); |
| |
| |
| suggestionButtons.forEach(button => { |
| button.addEventListener('click', function() { |
| const query = this.getAttribute('data-query'); |
| userInput.value = query; |
| userInput.dispatchEvent(new Event('input')); |
| sendMessage(); |
| }); |
| }); |
| |
| |
| function sendMessage() { |
| const message = userInput.value.trim(); |
| |
| if (message === '' || isProcessing) return; |
| |
| |
| stopRecognition(); |
| |
| |
| stopAudio(); |
| |
| |
| const now = new Date(); |
| const time = now.getHours() + ':' + (now.getMinutes() < 10 ? '0' : '') + now.getMinutes(); |
| |
| |
| addMessage(message, 'user', time); |
| |
| |
| userInput.value = ''; |
| userInput.style.height = 'auto'; |
| userInput.focus(); |
| |
| |
| showTypingIndicator(); |
| |
| |
| isProcessing = true; |
| updateStatus('Processing...', 'processing'); |
| |
| |
| fetch('/ask', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json' |
| }, |
| body: JSON.stringify({ |
| query: message, |
| chat_history: chatHistory |
| }) |
| }) |
| .then(response => response.json()) |
| .then(data => { |
| |
| removeTypingIndicator(); |
| |
| |
| const responseTime = new Date(); |
| const responseTimeStr = responseTime.getHours() + ':' + |
| (responseTime.getMinutes() < 10 ? '0' : '') + responseTime.getMinutes(); |
| |
| |
| addMessage(data.answer, 'assistant', responseTimeStr); |
| |
| |
| if (ttsEnabled) { |
| |
| const plainText = stripMarkdown(data.answer); |
| playTTS(plainText); |
| } |
| |
| |
| chatHistory.push( |
| { role: 'user', content: message }, |
| { role: 'assistant', content: data.answer }, |
| { role: 'system', content: JSON.stringify(data.system_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'); |
| }); |
| } |
| |
| |
| function stripMarkdown(text) { |
| return text |
| .replace(/\*\*(.*?)\*\*/g, '$1') |
| .replace(/\*(.*?)\*/g, '$1') |
| .replace(/\[(.*?)\]\((.*?)\)/g, '$1') |
| .replace(/#{1,6}\s?(.*)/g, '$1') |
| .replace(/```[a-z]*\n([\s\S]*?)```/g, 'Code block removed.') |
| .replace(/`(.*?)`/g, '$1') |
| .replace(/^\s*[-*+]\s+/gm, '') |
| .replace(/^\s*\d+\.\s+/gm, '') |
| .replace(/\n{2,}/g, '. ') |
| .replace(/\n/g, ' ') |
| .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'); |
| |
| |
| 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); |
| |
| |
| if (role === 'assistant' && typeof marked !== 'undefined') { |
| const messageBody = document.createElement('div'); |
| messageBody.classList.add('message-body'); |
| |
| |
| 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; |
| } |
| }); |
| |
| |
| messageBody.innerHTML = marked.parse(content); |
| |
| |
| if (typeof hljs !== 'undefined') { |
| messageBody.querySelectorAll('pre code').forEach((block) => { |
| hljs.highlightElement(block); |
| }); |
| } |
| |
| messageContent.appendChild(messageBody); |
| } else { |
| |
| const paragraph = document.createElement('p'); |
| paragraph.textContent = content; |
| messageContent.appendChild(paragraph); |
| } |
| |
| messageDiv.appendChild(avatar); |
| messageDiv.appendChild(messageContent); |
| |
| |
| messageDiv.style.opacity = '0'; |
| messageDiv.style.transform = 'translateY(20px)'; |
| chatMessages.appendChild(messageDiv); |
| |
| |
| void messageDiv.offsetWidth; |
| |
| |
| messageDiv.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; |
| messageDiv.style.opacity = '1'; |
| messageDiv.style.transform = 'translateY(0)'; |
| |
| |
| smoothScrollToBottom(); |
| } |
| |
| function smoothScrollToBottom() { |
| const scrollHeight = chatContainer.scrollHeight; |
| const height = chatContainer.clientHeight; |
| const maxScrollTop = scrollHeight - height; |
| |
| |
| const startTime = performance.now(); |
| const startPos = chatContainer.scrollTop; |
| const duration = 300; |
| |
| function step(currentTime) { |
| const elapsed = currentTime - startTime; |
| |
| if (elapsed < duration) { |
| |
| 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'); |
| |
| |
| 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); |
| |
| |
| 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() { |
| |
| stopAudio(); |
| |
| |
| const messages = chatMessages.querySelectorAll('.message:not(:first-child)'); |
| let animationCount = 0; |
| const totalAnimations = messages.length; |
| |
| if (totalAnimations === 0) { |
| return; |
| } |
| |
| messages.forEach((msg, index) => { |
| |
| 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) { |
| |
| while (chatMessages.children.length > 1) { |
| chatMessages.removeChild(chatMessages.lastChild); |
| } |
| |
| |
| chatHistory = []; |
| |
| |
| updateStatus('Online', 'online'); |
| |
| |
| userInput.focus(); |
| } |
| }); |
| }, index * 50); |
| }); |
| } |
| |
| function updateStatus(text, status) { |
| statusText.textContent = text; |
| |
| |
| statusBadge.classList.remove('online', 'offline', 'error', 'processing'); |
| |
| |
| 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 => { |
| |
| 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'; |
| }); |
| } |
| |
| |
| 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); |
| |
| |
| 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); |
| } |
| |
| |
| function formatNumber(num) { |
| return new Intl.NumberFormat('en-US', { |
| maximumFractionDigits: 0 |
| }).format(Math.round(num)); |
| } |
| |
| |
| setInterval(checkStatus, 60000); |
| }); |