Spaces:
Sleeping
Sleeping
| """Chat widget HTML interface for ChatCal.ai.""" | |
| from fastapi import APIRouter, Request | |
| from fastapi.responses import HTMLResponse | |
| router = APIRouter() | |
| async def chat_widget(request: Request): | |
| """Embeddable chat widget.""" | |
| base_url = f"{request.url.scheme}://{request.url.netloc}" | |
| return f""" | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ChatCal.ai - Calendar Assistant</title> | |
| <style> | |
| * {{ | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| }} | |
| body {{ | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| }} | |
| .chat-container {{ | |
| background: white; | |
| border-radius: 20px; | |
| box-shadow: 0 20px 40px rgba(0,0,0,0.1); | |
| width: 100%; | |
| max-width: 800px; | |
| height: 600px; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| }} | |
| .chat-header {{ | |
| background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); | |
| color: white; | |
| padding: 20px; | |
| text-align: center; | |
| position: relative; | |
| }} | |
| .chat-header h1 {{ | |
| font-size: 24px; | |
| margin-bottom: 5px; | |
| }} | |
| .chat-header p {{ | |
| opacity: 0.9; | |
| font-size: 14px; | |
| }} | |
| .status-indicator {{ | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| width: 12px; | |
| height: 12px; | |
| background: #4CAF50; | |
| border-radius: 50%; | |
| border: 2px solid white; | |
| animation: pulse 2s infinite; | |
| }} | |
| @keyframes pulse {{ | |
| 0% {{ opacity: 1; }} | |
| 50% {{ opacity: 0.5; }} | |
| 100% {{ opacity: 1; }} | |
| }} | |
| .chat-messages {{ | |
| flex: 1; | |
| padding: 20px; | |
| overflow-y: auto; | |
| background: #f8f9fa; | |
| }} | |
| .message {{ | |
| margin-bottom: 15px; | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 10px; | |
| }} | |
| .message.user {{ | |
| flex-direction: row-reverse; | |
| }} | |
| .message-avatar {{ | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 16px; | |
| flex-shrink: 0; | |
| }} | |
| .message.user .message-avatar {{ | |
| background: #2196F3; | |
| color: white; | |
| }} | |
| .message.assistant .message-avatar {{ | |
| background: #4CAF50; | |
| color: white; | |
| }} | |
| .message-content {{ | |
| max-width: 70%; | |
| padding: 12px 16px; | |
| border-radius: 18px; | |
| line-height: 1.4; | |
| white-space: pre-wrap; | |
| }} | |
| .message.user .message-content {{ | |
| background: #2196F3; | |
| color: white; | |
| border-bottom-right-radius: 4px; | |
| }} | |
| .message.assistant .message-content {{ | |
| background: white; | |
| color: #333; | |
| border: 1px solid #e0e0e0; | |
| border-bottom-left-radius: 4px; | |
| }} | |
| .chat-input {{ | |
| padding: 20px; | |
| background: white; | |
| border-top: 1px solid #e0e0e0; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| }} | |
| .chat-input textarea {{ | |
| flex: 1; | |
| padding: 12px 16px; | |
| border: 2px solid #e0e0e0; | |
| border-radius: 20px; | |
| outline: none; | |
| font-size: 14px; | |
| font-family: inherit; | |
| resize: none; | |
| min-height: 20px; | |
| max-height: 120px; | |
| overflow-y: auto; | |
| line-height: 1.4; | |
| transition: border-color 0.3s; | |
| }} | |
| .chat-input textarea:focus {{ | |
| border-color: #4CAF50; | |
| }} | |
| .chat-input button {{ | |
| width: 40px; | |
| height: 40px; | |
| border: none; | |
| background: #4CAF50; | |
| color: white; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: background-color 0.3s; | |
| }} | |
| .chat-input button:hover {{ | |
| background: #45a049; | |
| }} | |
| .chat-input button:disabled {{ | |
| background: #ccc; | |
| cursor: not-allowed; | |
| }} | |
| .typing-indicator {{ | |
| display: none; | |
| padding: 12px 16px; | |
| background: white; | |
| border: 1px solid #e0e0e0; | |
| border-radius: 18px; | |
| border-bottom-left-radius: 4px; | |
| max-width: 70%; | |
| margin-bottom: 15px; | |
| }} | |
| .typing-dots {{ | |
| display: flex; | |
| gap: 4px; | |
| }} | |
| .typing-dots span {{ | |
| width: 8px; | |
| height: 8px; | |
| background: #999; | |
| border-radius: 50%; | |
| animation: typing 1.4s infinite; | |
| }} | |
| .typing-dots span:nth-child(2) {{ | |
| animation-delay: 0.2s; | |
| }} | |
| .typing-dots span:nth-child(3) {{ | |
| animation-delay: 0.4s; | |
| }} | |
| @keyframes typing {{ | |
| 0%, 60%, 100% {{ | |
| transform: translateY(0); | |
| opacity: 0.4; | |
| }} | |
| 30% {{ | |
| transform: translateY(-10px); | |
| opacity: 1; | |
| }} | |
| }} | |
| .welcome-message {{ | |
| text-align: center; | |
| color: #666; | |
| font-style: italic; | |
| margin: 20px 0; | |
| }} | |
| .quick-actions {{ | |
| display: flex; | |
| gap: 10px; | |
| margin: 10px 0; | |
| flex-wrap: wrap; | |
| }} | |
| .quick-action {{ | |
| background: #f0f0f0; | |
| color: #555; | |
| padding: 8px 12px; | |
| border-radius: 15px; | |
| border: none; | |
| cursor: pointer; | |
| font-size: 12px; | |
| transition: background-color 0.3s; | |
| }} | |
| .quick-action:hover {{ | |
| background: #e0e0e0; | |
| }} | |
| /* STT Quick Action Animation */ | |
| .quick-action.listening {{ | |
| animation: stt-recording-pulse 1.5s infinite; | |
| }} | |
| @keyframes stt-recording-pulse {{ | |
| 0% {{ | |
| transform: scale(1); | |
| box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); | |
| }} | |
| 50% {{ | |
| transform: scale(1.05); | |
| box-shadow: 0 0 0 8px rgba(244, 67, 54, 0.1); | |
| }} | |
| 100% {{ | |
| transform: scale(1); | |
| box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); | |
| }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="chat-container"> | |
| <div class="chat-header"> | |
| <div class="status-indicator"></div> | |
| <h1>🌟 ChatCal.ai</h1> | |
| <p>Your friendly AI calendar assistant</p> | |
| </div> | |
| <div class="chat-messages" id="chatMessages"> | |
| <div class="welcome-message"> | |
| 👋 Welcome! I'm ChatCal, Peter Michael Gits' scheduling assistant.<br> | |
| I can schedule business consultations, project meetings, and advisory sessions.<br> | |
| <strong>📝 To book:</strong> <strong>name</strong>, <strong>topic</strong>, <strong>day</strong>, <strong>time</strong>, <strong>length</strong>, <strong>type</strong>: GoogleMeet (requires your <strong>email</strong>), or call (requires your <strong>phone #</strong>)<br> | |
| <em>(Otherwise I will ask for it and may get the email address wrong unless you spell it out military style.)</em> | |
| </div> | |
| <div class="quick-actions"> | |
| <button id="sttIndicator" class="quick-action muted" title="Click to start/stop voice input">🎙️ Voice Input</button> | |
| <button class="quick-action" onclick="sendQuickMessage('Schedule a Google Meet with Peter')">🎥 Google Meet</button> | |
| <button class="quick-action" onclick="sendQuickMessage('Check Peter\\'s availability tomorrow')">📅 Check availability</button> | |
| <button class="quick-action" onclick="sendQuickMessage('Schedule an in-person meeting')">🤝 In-person meeting</button> | |
| <button class="quick-action" onclick="sendQuickMessage('/help')">❓ Help</button> | |
| </div> | |
| </div> | |
| <div class="typing-indicator" id="typingIndicator"> | |
| <div class="typing-dots"> | |
| <span></span> | |
| <span></span> | |
| <span></span> | |
| </div> | |
| </div> | |
| <div class="chat-input"> | |
| <textarea | |
| id="messageInput" | |
| placeholder="Type your message..." | |
| maxlength="1000" | |
| rows="1" | |
| ></textarea> | |
| <button id="sendButton" type="button"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <!-- Version Footer --> | |
| <div style="text-align: center; margin-top: 10px; padding: 5px; color: #999; font-size: 10px; border-top: 1px solid #f0f0f0;"> | |
| ChatCal.ai v0.3.0 | ⚡ Streaming + Interruption | 📧 Smart Email Verification | |
| </div> | |
| </div> | |
| <!-- Hidden audio element for TTS playback --> | |
| <audio id="ttsAudioElement" style="display: none;"></audio> | |
| <script> | |
| let sessionId = null; | |
| let isLoading = false; | |
| const chatMessages = document.getElementById('chatMessages'); | |
| const messageInput = document.getElementById('messageInput'); | |
| const sendButton = document.getElementById('sendButton'); | |
| const typingIndicator = document.getElementById('typingIndicator'); | |
| const sttIndicator = document.getElementById('sttIndicator'); | |
| // Shared Audio Variables (for both TTS and STT) | |
| let globalAudioContext = null; | |
| let globalMediaStream = null; | |
| let isAudioInitialized = false; | |
| // STT v2 Variables | |
| let sttv2Manager = null; | |
| let silenceTimer = null; | |
| let lastSpeechTime = 0; | |
| let hasReceivedSpeech = false; | |
| // TTS Integration - ChatCal WebRTC TTS Class | |
| class ChatCalTTS {{ | |
| constructor() {{ | |
| this.audioContext = null; | |
| this.audioElement = document.getElementById('ttsAudioElement'); | |
| this.webrtcEnabled = false; | |
| this.initializeTTS(); | |
| }} | |
| async initializeTTS() {{ | |
| try {{ | |
| console.log('🎤 Initializing WebRTC TTS for ChatCal...'); | |
| // Request microphone access to enable WebRTC autoplay policies | |
| const stream = await navigator.mediaDevices.getUserMedia({{ audio: true }}); | |
| // Create AudioContext | |
| this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| // Resume if suspended | |
| if (this.audioContext.state === 'suspended') {{ | |
| await this.audioContext.resume(); | |
| }} | |
| // Stop microphone (we don't need to record) | |
| stream.getTracks().forEach(track => track.stop()); | |
| this.webrtcEnabled = true; | |
| console.log('✅ WebRTC TTS enabled for ChatCal'); | |
| }} catch (error) {{ | |
| console.warn('⚠️ TTS initialization failed, continuing without TTS:', error); | |
| this.webrtcEnabled = false; | |
| }} | |
| }} | |
| async synthesizeAndPlay(text) {{ | |
| if (!this.webrtcEnabled || !text.trim()) {{ | |
| return; | |
| }} | |
| try {{ | |
| console.log('🎵 Synthesizing TTS for:', text.substring(0, 50) + '...'); | |
| // Call TTS proxy server | |
| const response = await fetch('http://localhost:8081/api/synthesize', {{ | |
| method: 'POST', | |
| headers: {{ | |
| 'Content-Type': 'application/json', | |
| }}, | |
| body: JSON.stringify({{ | |
| text: text, | |
| voice: 'expresso/ex03-ex01_happy_001_channel1_334s.wav' | |
| }}) | |
| }}); | |
| if (!response.ok) {{ | |
| throw new Error('TTS service unavailable'); | |
| }} | |
| const data = await response.json(); | |
| if (!data.success || !data.audio_url) {{ | |
| throw new Error('TTS generation failed'); | |
| }} | |
| // Fetch the audio file | |
| const audioResponse = await fetch(`http://localhost:8081${{data.audio_url}}`); | |
| if (!audioResponse.ok) {{ | |
| throw new Error('Audio file not found'); | |
| }} | |
| const audioArrayBuffer = await audioResponse.arrayBuffer(); | |
| const audioBuffer = await this.audioContext.decodeAudioData(audioArrayBuffer); | |
| // Play via WebRTC MediaStream | |
| await this.playAudioWithWebRTC(audioBuffer); | |
| }} catch (error) {{ | |
| console.warn('🔇 TTS failed silently:', error); | |
| // Fail silently as requested - no error handling UI | |
| }} | |
| }} | |
| async playAudioWithWebRTC(audioBuffer) {{ | |
| try {{ | |
| console.log('🔊 Playing TTS audio via WebRTC...'); | |
| // Create MediaStream from audio buffer | |
| const source = this.audioContext.createBufferSource(); | |
| const destination = this.audioContext.createMediaStreamDestination(); | |
| source.buffer = audioBuffer; | |
| source.connect(destination); | |
| // Set the MediaStream to audio element | |
| this.audioElement.srcObject = destination.stream; | |
| // Start the audio source | |
| source.start(0); | |
| // Play - should work due to WebRTC permissions | |
| await this.audioElement.play(); | |
| console.log('🎵 TTS audio playing successfully'); | |
| }} catch (error) {{ | |
| console.warn('🔇 WebRTC audio playback failed:', error); | |
| }} | |
| }} | |
| }} | |
| // Initialize TTS system | |
| const chatCalTTS = new ChatCalTTS(); | |
| // Shared Audio Initialization (for TTS only now, STT v2 handles its own audio) | |
| async function initializeSharedAudio() {{ | |
| if (isAudioInitialized) return; | |
| console.log('🎤 Initializing shared audio for TTS...'); | |
| // Basic audio context for TTS | |
| globalAudioContext = new AudioContext(); | |
| if (globalAudioContext.state === 'suspended') {{ | |
| await globalAudioContext.resume(); | |
| }} | |
| isAudioInitialized = true; | |
| console.log('✅ Shared audio initialized for TTS'); | |
| }} | |
| // STT Visual State Management | |
| function updateSTTVisualState(state) {{ | |
| const sttIndicator = document.getElementById('sttIndicator'); | |
| if (!sttIndicator) return; | |
| // Remove all status classes first | |
| sttIndicator.classList.remove('muted', 'listening'); | |
| switch (state) {{ | |
| case 'ready': | |
| sttIndicator.innerHTML = '🎙️ Start Recording'; | |
| sttIndicator.title = 'Click to start voice recording'; | |
| sttIndicator.classList.add('muted'); | |
| sttIndicator.style.background = '#f0f0f0'; | |
| sttIndicator.style.color = '#555'; | |
| break; | |
| case 'connecting': | |
| sttIndicator.innerHTML = '🔄 Connecting...'; | |
| sttIndicator.title = 'Connecting to voice service...'; | |
| sttIndicator.style.background = '#fff3cd'; | |
| sttIndicator.style.color = '#856404'; | |
| break; | |
| case 'recording': | |
| sttIndicator.innerHTML = '⏹️ Stop & Send'; | |
| sttIndicator.title = 'Click to stop recording and transcribe'; | |
| sttIndicator.classList.add('listening'); | |
| sttIndicator.style.background = '#ffebee'; | |
| sttIndicator.style.color = '#d32f2f'; | |
| sttIndicator.style.fontWeight = 'bold'; | |
| break; | |
| case 'processing': | |
| sttIndicator.innerHTML = '⚡ Transcribing...'; | |
| sttIndicator.title = 'Processing your speech...'; | |
| sttIndicator.style.background = '#d1ecf1'; | |
| sttIndicator.style.color = '#0c5460'; | |
| sttIndicator.style.fontWeight = 'normal'; | |
| break; | |
| case 'error': | |
| sttIndicator.innerHTML = '❌ Try Again'; | |
| sttIndicator.title = 'Click to retry voice recording'; | |
| sttIndicator.style.background = '#f8d7da'; | |
| sttIndicator.style.color = '#721c24'; | |
| sttIndicator.style.fontWeight = 'normal'; | |
| break; | |
| }} | |
| }} | |
| // STT v2 Manager Class (adapted from stt-gpu-service-v2/client-stt/v2-audio-client.js) | |
| class STTv2Manager {{ | |
| constructor() {{ | |
| this.isRecording = false; | |
| this.mediaRecorder = null; | |
| this.audioChunks = []; | |
| this.serverUrl = 'https://pgits-stt-gpu-service-v2.hf.space'; | |
| this.language = 'en'; | |
| this.modelSize = 'base'; | |
| this.recordingTimer = null; | |
| this.maxRecordingTime = 30000; // 30 seconds max | |
| console.log('🎤 STT v2 Manager initialized'); | |
| }} | |
| generateSessionHash() {{ | |
| return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); | |
| }} | |
| async toggleRecording() {{ | |
| if (!this.isRecording) {{ | |
| await this.startRecording(); | |
| }} else {{ | |
| await this.stopRecording(); | |
| }} | |
| }} | |
| async startRecording() {{ | |
| try {{ | |
| console.log('🎤 Starting STT v2 recording...'); | |
| updateSTTVisualState('connecting'); | |
| const stream = await navigator.mediaDevices.getUserMedia({{ | |
| audio: {{ | |
| sampleRate: 44100, | |
| channelCount: 1, | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true | |
| }} | |
| }}); | |
| this.mediaRecorder = new MediaRecorder(stream, {{ | |
| mimeType: 'audio/webm;codecs=opus' | |
| }}); | |
| this.audioChunks = []; | |
| this.mediaRecorder.ondataavailable = (event) => {{ | |
| if (event.data.size > 0) {{ | |
| this.audioChunks.push(event.data); | |
| }} | |
| }}; | |
| this.mediaRecorder.onstop = () => {{ | |
| this.processRecording(); | |
| }}; | |
| this.mediaRecorder.start(); | |
| this.isRecording = true; | |
| updateSTTVisualState('recording'); | |
| sttIndicator.classList.remove('muted'); | |
| sttIndicator.classList.add('listening'); | |
| // Auto-stop after max recording time | |
| this.recordingTimer = setTimeout(() => {{ | |
| if (this.isRecording) {{ | |
| console.log('⏰ Auto-stopping recording after 30 seconds'); | |
| this.stopRecording(); | |
| }} | |
| }}, this.maxRecordingTime); | |
| console.log('✅ STT v2 recording started (auto-stop in 30s)'); | |
| }} catch (error) {{ | |
| console.error('❌ STT v2 recording failed:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| }} | |
| }} | |
| async stopRecording() {{ | |
| if (this.mediaRecorder && this.isRecording) {{ | |
| console.log('🔚 Stopping STT v2 recording...'); | |
| // Clear the auto-stop timer | |
| if (this.recordingTimer) {{ | |
| clearTimeout(this.recordingTimer); | |
| this.recordingTimer = null; | |
| }} | |
| this.mediaRecorder.stop(); | |
| this.isRecording = false; | |
| updateSTTVisualState('processing'); | |
| sttIndicator.classList.remove('listening'); | |
| sttIndicator.classList.add('muted'); | |
| // Stop all tracks | |
| this.mediaRecorder.stream.getTracks().forEach(track => track.stop()); | |
| console.log('✅ STT v2 recording stopped'); | |
| }} | |
| }} | |
| async processRecording() {{ | |
| if (this.audioChunks.length === 0) {{ | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| console.warn('⚠️ No audio recorded'); | |
| return; | |
| }} | |
| try {{ | |
| console.log('🔄 Processing STT v2 recording...'); | |
| // Create blob from chunks | |
| const audioBlob = new Blob(this.audioChunks, {{ type: 'audio/webm;codecs=opus' }}); | |
| console.log(`📦 Audio blob created: ${{audioBlob.size}} bytes`); | |
| // Convert to base64 | |
| const audioBase64 = await this.blobToBase64(audioBlob); | |
| console.log(`🔗 Base64 length: ${{audioBase64.length}} characters`); | |
| // Send to transcription service | |
| await this.transcribeAudio(audioBase64); | |
| }} catch (error) {{ | |
| console.error('❌ STT v2 processing failed:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| }} | |
| }} | |
| async blobToBase64(blob) {{ | |
| return new Promise((resolve, reject) => {{ | |
| const reader = new FileReader(); | |
| reader.onloadend = () => {{ | |
| const result = reader.result; | |
| // Extract base64 part from data URL | |
| const base64 = result.split(',')[1]; | |
| resolve(base64); | |
| }}; | |
| reader.onerror = reject; | |
| reader.readAsDataURL(blob); | |
| }}); | |
| }} | |
| async transcribeAudio(audioBase64) {{ | |
| const sessionHash = this.generateSessionHash(); | |
| const payload = {{ | |
| data: [ | |
| audioBase64, | |
| this.language, | |
| this.modelSize | |
| ], | |
| session_hash: sessionHash | |
| }}; | |
| console.log(`📤 Sending to STT v2 service: ${{this.serverUrl}}/call/gradio_transcribe_memory`); | |
| try {{ | |
| const startTime = Date.now(); | |
| const response = await fetch(`${{this.serverUrl}}/call/gradio_transcribe_memory`, {{ | |
| method: 'POST', | |
| headers: {{ | |
| 'Content-Type': 'application/json', | |
| }}, | |
| body: JSON.stringify(payload) | |
| }}); | |
| if (!response.ok) {{ | |
| throw new Error(`STT v2 request failed: ${{response.status}}`); | |
| }} | |
| const responseData = await response.json(); | |
| console.log('📨 STT v2 queue response:', responseData); | |
| let result; | |
| if (responseData.event_id) {{ | |
| console.log(`🎯 Got queue event_id: ${{responseData.event_id}}`); | |
| result = await this.listenForQueueResult(responseData, startTime, sessionHash); | |
| }} else if (responseData.data && Array.isArray(responseData.data)) {{ | |
| result = responseData.data[0]; | |
| console.log('📥 Got direct response from queue'); | |
| }} else {{ | |
| throw new Error(`Unexpected response format: ${{JSON.stringify(responseData)}}`); | |
| }} | |
| if (result && result.trim()) {{ | |
| const processingTime = (Date.now() - startTime) / 1000; | |
| console.log(`✅ STT v2 transcription successful (${{processingTime.toFixed(2)}}s): "${{result.substring(0, 100)}}"`); | |
| // Add transcription to message input | |
| this.addTranscriptionToInput(result); | |
| updateSTTVisualState('ready'); | |
| }} else {{ | |
| console.warn('⚠️ Empty transcription result'); | |
| updateSTTVisualState('ready'); | |
| }} | |
| }} catch (error) {{ | |
| console.error('❌ STT v2 transcription failed:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| }} | |
| }} | |
| async listenForQueueResult(queueResponse, startTime, sessionHash) {{ | |
| return new Promise((resolve, reject) => {{ | |
| const wsUrl = this.serverUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/queue/data'; | |
| console.log(`🔌 Connecting to STT v2 WebSocket: ${{wsUrl}}`); | |
| const ws = new WebSocket(wsUrl); | |
| const timeout = setTimeout(() => {{ | |
| ws.close(); | |
| reject(new Error('STT v2 queue timeout after 30 seconds')); | |
| }}, 30000); | |
| ws.onopen = () => {{ | |
| console.log('✅ STT v2 WebSocket connected'); | |
| if (queueResponse.event_id) {{ | |
| ws.send(JSON.stringify({{ | |
| event_id: queueResponse.event_id | |
| }})); | |
| console.log(`📤 Sent event_id: ${{queueResponse.event_id}}`); | |
| }} | |
| }}; | |
| ws.onmessage = (event) => {{ | |
| try {{ | |
| const data = JSON.parse(event.data); | |
| console.log('📨 STT v2 queue message:', data); | |
| if (data.msg === 'process_completed' && data.output && data.output.data) {{ | |
| clearTimeout(timeout); | |
| ws.close(); | |
| resolve(data.output.data[0]); | |
| }} else if (data.msg === 'process_starts') {{ | |
| updateSTTVisualState('processing'); | |
| }} | |
| }} catch (e) {{ | |
| console.warn('⚠️ STT v2 WebSocket parse error:', e.message); | |
| }} | |
| }}; | |
| ws.onerror = (error) => {{ | |
| console.error('❌ STT v2 WebSocket error:', error); | |
| clearTimeout(timeout); | |
| // Try polling as fallback | |
| this.pollForResult(queueResponse.event_id, startTime, sessionHash).then(resolve).catch(reject); | |
| }}; | |
| ws.onclose = (event) => {{ | |
| console.log(`🔌 STT v2 WebSocket closed: code=${{event.code}}`); | |
| clearTimeout(timeout); | |
| }}; | |
| }}); | |
| }} | |
| async pollForResult(eventId, startTime, sessionHash) {{ | |
| console.log(`🔄 Starting STT v2 polling for event: ${{eventId}}`); | |
| const maxAttempts = 20; | |
| for (let attempt = 0; attempt < maxAttempts; attempt++) {{ | |
| try {{ | |
| const endpoint = `/queue/data?event_id=${{eventId}}&session_hash=${{sessionHash}}`; | |
| const response = await fetch(`${{this.serverUrl}}${{endpoint}}`); | |
| if (response.ok) {{ | |
| const responseText = await response.text(); | |
| console.log(`📊 STT v2 poll attempt ${{attempt + 1}}: ${{responseText.substring(0, 200)}}`); | |
| if (responseText.includes('data: ')) {{ | |
| const lines = responseText.split('\\n'); | |
| for (const line of lines) {{ | |
| if (line.startsWith('data: ')) {{ | |
| try {{ | |
| const data = JSON.parse(line.substring(6)); | |
| if (data.msg === 'process_completed' && data.output && data.output.data) {{ | |
| return data.output.data[0]; | |
| }} | |
| }} catch (parseError) {{ | |
| console.warn('⚠️ STT v2 SSE parse error:', parseError.message); | |
| }} | |
| }} | |
| }} | |
| }} | |
| }} | |
| }} catch (e) {{ | |
| console.warn(`⚠️ STT v2 poll error attempt ${{attempt + 1}}:`, e.message); | |
| }} | |
| // Progressive delay | |
| const delay = attempt < 5 ? 200 : 500; | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| }} | |
| throw new Error('STT v2 polling timeout - no result after 20 attempts'); | |
| }} | |
| addTranscriptionToInput(transcription) {{ | |
| const currentValue = messageInput.value; | |
| let newText = transcription.trim(); | |
| // SMART EMAIL PARSING: Convert spoken email patterns to proper email format | |
| newText = this.parseSpokenEmail(newText); | |
| // Add transcription to message input | |
| if (currentValue && !currentValue.endsWith(' ')) {{ | |
| messageInput.value = currentValue + ' ' + newText; | |
| }} else {{ | |
| messageInput.value = currentValue + newText; | |
| }} | |
| // Move cursor to end | |
| messageInput.setSelectionRange(messageInput.value.length, messageInput.value.length); | |
| // Auto-resize textarea | |
| autoResizeTextarea(); | |
| // Track speech activity for auto-submission | |
| lastSpeechTime = Date.now(); | |
| hasReceivedSpeech = true; | |
| // UNIFIED TIMER: Always start 2.5 second timer after ANY transcription | |
| console.log('⏱️ Starting 2.5 second timer after transcription...'); | |
| // Clear any existing timer first | |
| if (silenceTimer) {{ | |
| clearTimeout(silenceTimer); | |
| }} | |
| // Check if transcription contains an email address for UI feedback only | |
| const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{{2,}}\b/; | |
| const hasEmail = emailRegex.test(newText); | |
| if (hasEmail) {{ | |
| console.log('📧 Email detected - showing verification notice'); | |
| this.showEmailVerificationNotice(); | |
| this.highlightEmailInInput(); | |
| }} | |
| // Start 2.5 second timer for ALL transcriptions (not just emails) | |
| silenceTimer = setTimeout(() => {{ | |
| if (hasReceivedSpeech && messageInput.value.trim()) {{ | |
| console.log('⏱️ 2.5 seconds completed after transcription, auto-submitting...'); | |
| submitMessage(); | |
| }} | |
| }}, 2500); | |
| }} | |
| showEmailVerificationNotice() {{ | |
| // Create a temporary notification | |
| const notification = document.createElement('div'); | |
| notification.style.cssText = ` | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: #e3f2fd; | |
| color: #1976d2; | |
| padding: 12px 16px; | |
| border-radius: 8px; | |
| border-left: 4px solid #2196f3; | |
| font-size: 14px; | |
| font-weight: 500; | |
| z-index: 1000; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
| max-width: 300px; | |
| animation: slideIn 0.3s ease-out; | |
| `; | |
| notification.innerHTML = ` | |
| <div style="display: flex; align-items: center; gap: 8px;"> | |
| <span>📧</span> | |
| <div> | |
| <div style="font-weight: bold;">Email Detected!</div> | |
| <div style="font-size: 12px; opacity: 0.8;">You have 2.5 seconds to verify/edit before auto-submission</div> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(notification); | |
| // Add CSS animation | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| @keyframes slideIn {{ | |
| from {{ | |
| transform: translateX(100%); | |
| opacity: 0; | |
| }} | |
| to {{ | |
| transform: translateX(0); | |
| opacity: 1; | |
| }} | |
| }} | |
| `; | |
| document.head.appendChild(style); | |
| // Remove notification after 4 seconds | |
| setTimeout(() => {{ | |
| if (notification.parentNode) {{ | |
| notification.style.animation = 'slideIn 0.3s ease-out reverse'; | |
| setTimeout(() => {{ | |
| if (notification.parentNode) {{ | |
| notification.parentNode.removeChild(notification); | |
| }} | |
| }}, 300); | |
| }} | |
| }}, 4000); | |
| }} | |
| highlightEmailInInput() {{ | |
| // Find and select the email address in the input field | |
| const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{{2,}}\b/; | |
| const inputValue = messageInput.value; | |
| const match = inputValue.match(emailRegex); | |
| if (match) {{ | |
| const emailStart = inputValue.indexOf(match[0]); | |
| const emailEnd = emailStart + match[0].length; | |
| // Focus the input field | |
| messageInput.focus(); | |
| // Select the email address | |
| messageInput.setSelectionRange(emailStart, emailEnd); | |
| // Add visual styling to indicate selection | |
| messageInput.style.backgroundColor = '#fff3cd'; | |
| messageInput.style.borderColor = '#ffc107'; | |
| // Remove highlighting after 3 seconds or when user starts typing | |
| const removeHighlight = () => {{ | |
| messageInput.style.backgroundColor = ''; | |
| messageInput.style.borderColor = ''; | |
| }}; | |
| setTimeout(removeHighlight, 3000); | |
| // Remove highlight immediately when user starts typing | |
| const handleInput = () => {{ | |
| removeHighlight(); | |
| messageInput.removeEventListener('input', handleInput); | |
| }}; | |
| messageInput.addEventListener('input', handleInput); | |
| }} | |
| }} | |
| parseSpokenEmail(text) {{ | |
| // Convert common spoken email patterns to proper email format | |
| let processed = text; | |
| // Pattern 1: "pgits at gmail dot com" -> "pgits@gmail.com" | |
| processed = processed.replace(/\b(\w+)\s+at\s+(\w+)\s+dot\s+(\w+)\b/gi, '$1@$2.$3'); | |
| // Pattern 2a: "pgitsatgmail.com" (catch this specific pattern first) | |
| processed = processed.replace(/(\w+)at(\w+)\.com/gi, '$1@$2.com'); | |
| // Pattern 2b: "pgitsatgmail.org" and other domains | |
| processed = processed.replace(/(\w+)at(\w+)\.(\w+)/gi, '$1@$2.$3'); | |
| // Pattern 3: "pgits at gmail.com" -> "pgits@gmail.com" | |
| processed = processed.replace(/\b(\w+)\s+at\s+(\w+\.\w+)\b/gi, '$1@$2'); | |
| // Pattern 4: "pgitsatgmaildotcom" -> "pgits@gmail.com" (everything run together) | |
| processed = processed.replace(/\b(\w+)at(\w+)dot(\w+)\b/gi, '$1@$2.$3'); | |
| // Pattern 5: "petergetsgitusat gmail.com" -> "petergetsgitus@gmail.com" (space before at) | |
| processed = processed.replace(/\b(\w+)\s*at\s+(\w+\.\w+)\b/gi, '$1@$2'); | |
| // Pattern 6: "petergetsgitusat gmail dot com" -> "petergetsgitus@gmail.com" | |
| processed = processed.replace(/\b(\w+)\s*at\s+(\w+)\s+dot\s+(\w+)\b/gi, '$1@$2.$3'); | |
| // Pattern 7: Handle multiple dots - "john at company dot co dot uk" -> "john@company.co.uk" | |
| processed = processed.replace(/\b(\w+)\s+at\s+([\w\s]+?)\s+dot\s+([\w\s]+)\b/gi, (match, username, domain, tld) => {{ | |
| // Replace spaces and 'dot' with actual dots in domain part | |
| const cleanDomain = domain.replace(/\s+dot\s+/g, '.').replace(/\s+/g, ''); | |
| const cleanTld = tld.replace(/\s+dot\s+/g, '.').replace(/\s+/g, ''); | |
| return `${{username}}@${{cleanDomain}}.${{cleanTld}}`; | |
| }}); | |
| // Log the conversion if any changes were made | |
| if (processed !== text) {{ | |
| console.log(`📧 Email pattern converted: "${{text}}" -> "${{processed}}"`); | |
| }} | |
| return processed; | |
| }} | |
| }} | |
| // Auto-submission function | |
| function submitMessage() {{ | |
| const message = messageInput.value.trim(); | |
| if (message && !isLoading) {{ | |
| // Clear the speech tracking | |
| hasReceivedSpeech = false; | |
| if (silenceTimer) {{ | |
| clearTimeout(silenceTimer); | |
| silenceTimer = null; | |
| }} | |
| // Submit the message (using existing sendMessage logic) | |
| sendMessage(message); | |
| }} | |
| }} | |
| // Initialize session | |
| async function initializeSession() {{ | |
| try {{ | |
| const response = await fetch('{base_url}/sessions', {{ | |
| method: 'POST', | |
| headers: {{ | |
| 'Content-Type': 'application/json', | |
| }}, | |
| body: JSON.stringify({{user_data: {{}}}}) | |
| }}); | |
| if (response.ok) {{ | |
| const data = await response.json(); | |
| sessionId = data.session_id; | |
| }} | |
| }} catch (error) {{ | |
| console.error('Failed to initialize session:', error); | |
| }} | |
| }} | |
| async function addMessage(content, isUser = false) {{ | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = `message ${{isUser ? 'user' : 'assistant'}}`; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'message-avatar'; | |
| avatar.textContent = isUser ? '👤' : '🤖'; | |
| const messageContent = document.createElement('div'); | |
| messageContent.className = 'message-content'; | |
| // For assistant messages, start TTS FIRST, then display with delay | |
| if (!isUser && content && content.trim()) {{ | |
| // Create temporary element to extract text content | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = content; | |
| const textContent = tempDiv.textContent || tempDiv.innerText; | |
| if (textContent && textContent.trim()) {{ | |
| // Start TTS synthesis immediately (non-blocking) | |
| chatCalTTS.synthesizeAndPlay(textContent.trim()); | |
| // Wait 500ms before displaying the text | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| }} | |
| // Now render the HTML content | |
| messageContent.innerHTML = content; | |
| }} else {{ | |
| // For user messages, just set text content immediately | |
| messageContent.textContent = content; | |
| }} | |
| messageDiv.appendChild(avatar); | |
| messageDiv.appendChild(messageContent); | |
| // Simply append to chatMessages instead of insertBefore | |
| chatMessages.appendChild(messageDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| }} | |
| function showTyping() {{ | |
| if (typingIndicator) {{ | |
| typingIndicator.style.display = 'block'; | |
| }} | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| }} | |
| function hideTyping() {{ | |
| if (typingIndicator) {{ | |
| typingIndicator.style.display = 'none'; | |
| }} | |
| }} | |
| async function sendMessage(message = null) {{ | |
| const text = message || messageInput.value.trim(); | |
| if (!text || isLoading) {{ | |
| return; | |
| }} | |
| // Ensure we have a session before sending | |
| if (!sessionId) {{ | |
| await initializeSession(); | |
| if (!sessionId) {{ | |
| await addMessage('Sorry, I had trouble connecting. Please try again!'); | |
| return; | |
| }} | |
| }} | |
| // Add user message | |
| await addMessage(text, true); | |
| messageInput.value = ''; | |
| // Show loading state | |
| isLoading = true; | |
| sendButton.disabled = true; | |
| showTyping(); | |
| try {{ | |
| const response = await fetch('{base_url}/chat', {{ | |
| method: 'POST', | |
| headers: {{ | |
| 'Content-Type': 'application/json', | |
| }}, | |
| body: JSON.stringify({{ | |
| message: text, | |
| session_id: sessionId | |
| }}) | |
| }}); | |
| if (response.ok) {{ | |
| const data = await response.json(); | |
| sessionId = data.session_id; // Update session ID | |
| await addMessage(data.response); | |
| // TTS integration - play the response | |
| if (chatCalTTS && chatCalTTS.webrtcEnabled && data.response) {{ | |
| chatCalTTS.synthesizeAndPlay(data.response); | |
| }} | |
| }} else {{ | |
| const error = await response.json(); | |
| await addMessage(`Sorry, I encountered an error: ${{error.message || 'Unknown error'}}`); | |
| }} | |
| }} catch (error) {{ | |
| console.error('Chat error:', error); | |
| await addMessage('Sorry, I had trouble connecting. Please try again!'); | |
| }} finally {{ | |
| isLoading = false; | |
| sendButton.disabled = false; | |
| hideTyping(); | |
| // Clear any pending speech timers to allow fresh voice input | |
| hasReceivedSpeech = false; | |
| if (silenceTimer) {{ | |
| clearTimeout(silenceTimer); | |
| silenceTimer = null; | |
| }} | |
| }} | |
| }} | |
| function sendQuickMessage(message) {{ | |
| messageInput.value = message; | |
| sendMessage(); | |
| }} | |
| // Event listeners | |
| messageInput.addEventListener('keypress', function(e) {{ | |
| if (e.key === 'Enter' && !e.shiftKey) {{ | |
| e.preventDefault(); | |
| sendMessage(); | |
| }} | |
| }}); | |
| // Auto-resize textarea as content grows | |
| function autoResizeTextarea() {{ | |
| messageInput.style.height = 'auto'; | |
| const newHeight = Math.min(messageInput.scrollHeight, 120); // Max height 120px | |
| messageInput.style.height = newHeight + 'px'; | |
| }} | |
| // Enhanced input handling with typing delay for STT | |
| let typingTimer = null; | |
| let lastTypingTime = 0; | |
| let lastMouseMoveTime = 0; | |
| let mouseTimer = null; | |
| messageInput.addEventListener('input', function() {{ | |
| autoResizeTextarea(); | |
| // Track typing activity to delay STT auto-submission | |
| lastTypingTime = Date.now(); | |
| // Mouse movement tracking for editing detection | |
| lastMouseMoveTime = Date.now(); | |
| // If user is typing, clear any existing silence timer to prevent premature submission | |
| if (silenceTimer) {{ | |
| clearTimeout(silenceTimer); | |
| silenceTimer = null; | |
| }} | |
| // Clear existing typing timer | |
| if (typingTimer) {{ | |
| clearTimeout(typingTimer); | |
| }} | |
| // Set new typing timer - if user stops typing for 2.5 seconds, check for STT auto-submission | |
| typingTimer = setTimeout(() => {{ | |
| // Only check for STT auto-submission if user has stopped typing and we have speech input | |
| if (hasReceivedSpeech && messageInput.value.trim() && (Date.now() - lastTypingTime) >= 2500) {{ | |
| console.log('🔇 User stopped typing, checking for STT auto-submission...'); | |
| // Additional delay to ensure user is done typing (2.5 seconds after last keystroke) | |
| silenceTimer = setTimeout(() => {{ | |
| if (hasReceivedSpeech && messageInput.value.trim()) {{ | |
| console.log('🔇 STT auto-submitting after typing pause...'); | |
| submitMessage(); | |
| }} | |
| }}, 2500); | |
| }} | |
| }}, 2500); | |
| }}); | |
| messageInput.addEventListener('paste', () => setTimeout(autoResizeTextarea, 0)); | |
| // Add click listener to send button | |
| if (sendButton) {{ | |
| sendButton.addEventListener('click', function(e) {{ | |
| e.preventDefault(); | |
| sendMessage(); | |
| }}); | |
| }} | |
| // Mouse movement detection for editing detection | |
| document.addEventListener('mousemove', function() {{ | |
| lastMouseMoveTime = Date.now(); | |
| // Reset timer when user moves mouse (indicates they might be editing) | |
| resetAutoSubmitTimer(); | |
| }}); | |
| // Keyboard activity detection for editing detection | |
| messageInput.addEventListener('keydown', function() {{ | |
| // Reset timer when user types (indicates they are editing) | |
| resetAutoSubmitTimer(); | |
| }}); | |
| messageInput.addEventListener('input', function() {{ | |
| // Reset timer when input content changes | |
| resetAutoSubmitTimer(); | |
| }}); | |
| // Function to reset the auto-submit timer | |
| function resetAutoSubmitTimer() {{ | |
| if (silenceTimer && hasReceivedSpeech) {{ | |
| console.log('⌨️ User activity detected, resetting 2.5 second timer...'); | |
| clearTimeout(silenceTimer); | |
| // Restart the 2.5 second timer | |
| silenceTimer = setTimeout(() => {{ | |
| if (hasReceivedSpeech && messageInput.value.trim()) {{ | |
| console.log('⏱️ 2.5 seconds completed after user activity, auto-submitting...'); | |
| submitMessage(); | |
| }} | |
| }}, 2500); | |
| }} | |
| }} | |
| // Initialize when page loads | |
| // STT v2 indicator click to toggle recording | |
| sttIndicator.addEventListener('click', () => {{ | |
| if (sttv2Manager) {{ | |
| sttv2Manager.toggleRecording().catch(error => {{ | |
| console.error('Failed to toggle STT v2 recording:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| alert('Microphone access failed. Please check permissions.'); | |
| }}); | |
| }} | |
| }}); | |
| // Initialize session and STT v2 | |
| async function initAndStartSTT() {{ | |
| await initializeSession(); | |
| // Initialize STT v2 Manager | |
| try {{ | |
| sttv2Manager = new STTv2Manager(); | |
| updateSTTVisualState('ready'); | |
| console.log('✅ STT v2 Manager initialized and ready'); | |
| }} catch (error) {{ | |
| console.warn('STT v2 initialization failed:', error); | |
| updateSTTVisualState('error'); | |
| // STT failure is not critical, user can still type | |
| }} | |
| }} | |
| // Cleanup on page unload | |
| window.addEventListener('beforeunload', () => {{ | |
| if (sttv2Manager && sttv2Manager.isRecording) {{ | |
| sttv2Manager.stopRecording(); | |
| }} | |
| }}); | |
| window.addEventListener('load', initAndStartSTT); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| async def embeddable_widget(): | |
| """Minimal embeddable widget for other websites.""" | |
| return """ | |
| <div id="chatcal-widget" style=" | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| width: 400px; | |
| height: 500px; | |
| background: white; | |
| border-radius: 10px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.2); | |
| z-index: 9999; | |
| display: none; | |
| "> | |
| <iframe | |
| src="/chat-widget" | |
| width="100%" | |
| height="100%" | |
| frameborder="0" | |
| style="border-radius: 10px;"> | |
| </iframe> | |
| </div> | |
| <button id="chatcal-toggle" style=" | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| width: 60px; | |
| height: 60px; | |
| background: #4CAF50; | |
| color: white; | |
| border: none; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| font-size: 24px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.2); | |
| z-index: 10000; | |
| ">💬</button> | |
| <script> | |
| document.getElementById('chatcal-toggle').onclick = function() { | |
| const widget = document.getElementById('chatcal-widget'); | |
| const toggle = document.getElementById('chatcal-toggle'); | |
| if (widget.style.display === 'none') { | |
| widget.style.display = 'block'; | |
| toggle.textContent = '✕'; | |
| } else { | |
| widget.style.display = 'none'; | |
| toggle.textContent = '💬'; | |
| } | |
| }; | |
| </script> | |
| """ |