Spaces:
Running
Running
| """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, email: str = None): | |
| """Embeddable chat widget.""" | |
| # Force HTTPS for production HuggingFace deployment | |
| from app.config import settings | |
| if settings.app_env == "production" and "hf.space" in str(request.url.netloc): | |
| base_url = f"https://{request.url.netloc}" | |
| else: | |
| base_url = f"{request.url.scheme}://{request.url.netloc}" | |
| # Pass the email parameter to the frontend | |
| default_email = email or "" | |
| # Debug logging | |
| print(f"🔍 Chat widget called with email parameter: '{email}' -> defaultEmail: '{default_email}'") | |
| html_content = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>VoiceCal.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; | |
| } | |
| /* Live booking-field values shown inline next to each checklist label */ | |
| .bookingValue { | |
| color: #0d47a1; /* distinct, readable navy */ | |
| font-size: 16px; /* ~2 sizes larger than label (13px) */ | |
| font-weight: 700; | |
| margin-left: 8px; | |
| font-family: inherit; | |
| letter-spacing: 0.2px; | |
| /* Detach from parent <p>'s text-decoration so when the label | |
| gets struck through after capture, the value stays clean. */ | |
| display: inline-block; | |
| text-decoration: none !important; | |
| } | |
| .bookingValue:not(:empty)::before { | |
| content: "→ "; | |
| color: #1976d2; | |
| font-weight: 600; | |
| } | |
| /* Booking confirmation card — shown visibly so user can see the | |
| Google Meet link, meeting time, etc. (voice-only mode hides | |
| normal chat messages). */ | |
| .booking-confirmation { | |
| margin: 16px; | |
| padding: 16px 20px; | |
| background: #fff; | |
| border: 2px solid #1976d2; | |
| border-radius: 10px; | |
| font-size: 15px; | |
| line-height: 1.5; | |
| color: #0d47a1; | |
| box-shadow: 0 2px 8px rgba(13,71,161,0.12); | |
| } | |
| .booking-confirmation a { | |
| color: #1976d2; | |
| font-weight: 700; | |
| word-break: break-all; | |
| } | |
| .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 12px 60px; /* Extra left padding for microphone button */ | |
| 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; | |
| } | |
| /* Voice-Only Mode - Hide keyboard elements */ | |
| #messageInput { | |
| display: none !important; | |
| } | |
| /* Hide sttIndicator initially, but allow it to be shown after greeting */ | |
| #sttIndicator { | |
| display: none; | |
| } | |
| #sendButton { | |
| display: none !important; | |
| } | |
| /* Audio visualizer visible in voice-only mode to show mic activity */ | |
| /* Adjust chat-input container for voice-only mode */ | |
| .chat-input { | |
| justify-content: center; | |
| padding: 15px 20px; | |
| } | |
| .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; | |
| } | |
| .user-info-display { | |
| background: #f8f9fa; | |
| border: 1px solid #e9ecef; | |
| border-radius: 6px; | |
| padding: 8px 12px; | |
| margin: 10px 0; | |
| font-size: 12px; | |
| color: #495057; | |
| display: none; /* Hidden by default */ | |
| } | |
| .user-info-display.visible { | |
| display: block; | |
| } | |
| .user-info-display strong { | |
| color: #212529; | |
| } | |
| /* Header user info styling - UPDATED */ | |
| .user-info-header { | |
| background: linear-gradient(135deg, #e8f5e9, #f1f8e9); | |
| border: 2px solid #4CAF50; | |
| border-radius: 8px; | |
| padding: 12px 20px; | |
| margin: 10px 0 0 0; | |
| font-size: 14px; | |
| color: #2e7d32; | |
| text-align: center; | |
| display: block; /* ALWAYS VISIBLE FOR TESTING */ | |
| box-shadow: 0 3px 6px rgba(0,0,0,0.15); | |
| font-weight: bold; | |
| } | |
| .user-info-header.visible { | |
| display: block; | |
| } | |
| .user-info-header strong { | |
| color: #1976d2; | |
| } | |
| @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); | |
| } | |
| } | |
| /* Position chat input area for button placement */ | |
| .chat-input { | |
| position: relative; | |
| } | |
| /* Floating Record Button - positioned inside text input area */ | |
| .floating-record-btn { | |
| position: absolute; | |
| bottom: 12px; | |
| left: 12px; | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 50%; | |
| background: #4CAF50; | |
| color: white; | |
| border: none; | |
| font-size: 16px; | |
| cursor: pointer; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); | |
| transition: all 0.3s ease; | |
| z-index: 1000; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .floating-record-btn:hover { | |
| background: #45a049; | |
| transform: scale(1.1); | |
| box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); | |
| } | |
| .floating-record-btn.listening { | |
| background: #f44336; | |
| animation: float-recording-pulse 1.5s infinite; | |
| } | |
| .floating-record-btn.connecting { | |
| background: #ff9800; | |
| } | |
| .floating-record-btn.error { | |
| background: #ff5722; | |
| } | |
| @keyframes float-recording-pulse { | |
| 0% { | |
| transform: scale(1); | |
| box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4); | |
| } | |
| 50% { | |
| transform: scale(1.1); | |
| box-shadow: 0 6px 20px rgba(244, 67, 54, 0.6); | |
| } | |
| 100% { | |
| transform: scale(1); | |
| box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4); | |
| } | |
| } | |
| /* Recording notification popup */ | |
| .recording-popup { | |
| position: fixed; | |
| top: 300px; /* Moved down to avoid covering menu items */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| color: white; | |
| padding: 20px; | |
| border-radius: 12px; | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.3); | |
| z-index: 10000; | |
| text-align: center; | |
| animation: popupSlideIn 0.3s ease-out; | |
| } | |
| .popup-content .recording-icon { | |
| font-size: 32px; | |
| margin-bottom: 10px; | |
| animation: pulse 1.5s ease-in-out infinite; | |
| } | |
| .popup-content p { | |
| margin: 5px 0; | |
| font-weight: bold; | |
| font-size: 16px; | |
| } | |
| .popup-content .popup-subtitle { | |
| font-size: 14px; | |
| opacity: 0.9; | |
| font-weight: normal; | |
| } | |
| @keyframes popupSlideIn { | |
| from { | |
| opacity: 0; | |
| transform: translateX(-50%) translateY(-20px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateX(-50%) translateY(0); | |
| } | |
| } | |
| /* Audio visualizer */ | |
| .audio-visualizer { | |
| position: fixed; | |
| bottom: 140px; /* Position above text input */ | |
| right: 20px; /* Position in bottom-right corner */ | |
| width: fit-content; | |
| background: transparent; | |
| padding: 20px; | |
| border-radius: 50%; | |
| z-index: 100; /* Lower z-index so it doesn't cover menu items */ | |
| } | |
| .audio-visualizer canvas { | |
| display: block; | |
| border-radius: 50%; | |
| background: transparent; | |
| } | |
| .visualizer-info { | |
| text-align: center; | |
| color: rgba(0, 255, 220, 0.9); | |
| font-size: 11px; | |
| margin-top: 10px; | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| } | |
| /* Recording button states - CLEAR MESSAGING */ | |
| /* NOT RECORDING - Green (Ready to record) */ | |
| .floating-record-btn.ready-to-record { | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| color: white; | |
| box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); | |
| border: 2px solid #4CAF50; | |
| } | |
| .floating-record-btn.ready-to-record:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 6px 16px rgba(76, 175, 80, 0.5); | |
| } | |
| /* CURRENTLY RECORDING - Red (Click to stop) */ | |
| .floating-record-btn.currently-recording { | |
| background: linear-gradient(135deg, #f44336, #d32f2f); | |
| color: white; | |
| animation: recordingPulse 1.5s ease-in-out infinite; | |
| box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4); | |
| border: 2px solid #f44336; | |
| } | |
| @keyframes recordingPulse { | |
| 0%, 100% { transform: scale(1); opacity: 1; } | |
| 50% { transform: scale(1.08); opacity: 0.9; } | |
| } | |
| /* TTS playing state for microphone button */ | |
| .floating-record-btn.tts-playing { | |
| background: linear-gradient(135deg, #FF9800, #F57C00); | |
| color: white; | |
| animation: ttsPause 1s ease-in-out infinite; | |
| box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4); | |
| } | |
| /* TTS playing with interrupt capability */ | |
| .floating-record-btn.tts-playing-interruptible { | |
| background: linear-gradient(135deg, #2196F3, #1976D2); | |
| color: white; | |
| animation: ttsInterruptible 2s ease-in-out infinite; | |
| box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4); | |
| } | |
| @keyframes ttsInterruptible { | |
| 0%, 100% { | |
| transform: scale(1); | |
| opacity: 1; | |
| box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4); | |
| } | |
| 50% { | |
| transform: scale(1.05); | |
| opacity: 0.9; | |
| box-shadow: 0 6px 16px rgba(33, 150, 243, 0.6); | |
| } | |
| } | |
| @keyframes ttsPause { | |
| 0%, 100% { opacity: 0.7; } | |
| 50% { opacity: 1; } | |
| } | |
| /* Mute/Unmute Toggle Button */ | |
| .mute-toggle-btn { | |
| position: absolute; | |
| right: 80px; /* Position 20px further left from send button */ | |
| top: 50%; | |
| transform: translateY(-50%); | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| color: white; | |
| border: none; | |
| border-radius: 50%; /* Circular like a microphone */ | |
| width: 60px; | |
| height: 60px; | |
| font-size: 10px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.2); | |
| z-index: 10; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 2px; | |
| } | |
| .mute-toggle-btn:hover { | |
| transform: translateY(-50%) scale(1.1); | |
| box-shadow: 0 6px 16px rgba(0,0,0,0.3); | |
| } | |
| .mute-toggle-btn svg { | |
| width: 24px; | |
| height: 24px; | |
| fill: white; | |
| } | |
| .mute-toggle-btn .btn-label { | |
| font-size: 9px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| /* Unmuted state - Green (ready to mute) */ | |
| .mute-toggle-btn.unmuted { | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4); | |
| } | |
| /* Muted state - Red (ready to unmute) */ | |
| .mute-toggle-btn.muted { | |
| background: linear-gradient(135deg, #f44336, #d32f2f); | |
| box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4); | |
| animation: mutedPulse 2s ease-in-out infinite; | |
| } | |
| @keyframes mutedPulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="chat-container"> | |
| <div class="chat-header"> | |
| <div class="status-indicator"></div> | |
| <h1>🌟 VoiceCal.ai</h1> | |
| <p>Your friendly AI calendar assistant</p> | |
| <!-- User Info Display - UPDATED v1.3.0 - Email Only --> | |
| <div id="userInfoDisplay" class="user-info-header"> | |
| <strong>📧 Your email address:</strong> <span id="userEmailDisplay">Loading...</span> | |
| </div> | |
| </div> | |
| <div class="chat-messages" id="chatMessages"> | |
| <div class="welcome-message"> | |
| 👋 Welcome! I'm VoiceCal, Peter Michael Gits' scheduling assistant.<br> | |
| Just speak into your microphone. | |
| </div> | |
| <div style="text-align: left; background: #e8f5e9; padding: 10px 15px; margin: 15px 15px 180px 15px; border-radius: 8px; border-left: 4px solid #4caf50; font-size: 14px;"> | |
| <p style="margin: 0; font-weight: bold; font-size: 15px;">To book a meeting, tell me:</p> | |
| <p id="menuItem1" style="margin: 5px 0 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">1) Your name <span class="bookingValue" id="bookingValueName"></span></p> | |
| <p id="menuItem2" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">2) The date <span class="bookingValue" id="bookingValueDate"></span></p> | |
| <p id="menuItem3" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">3) The time <span class="bookingValue" id="bookingValueTime"></span></p> | |
| <p id="menuItem4" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">4) The length (duration) <span class="bookingValue" id="bookingValueDuration"></span></p> | |
| <p id="menuItem5" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">5) The agenda (meeting topic) <span class="bookingValue" id="bookingValueTopic"></span></p> | |
| <p id="menuItem6" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">6) GoogleMeet or phone call <span class="bookingValue" id="bookingValueFormat"></span></p> | |
| <p id="menuItem7" style="margin: 2px 0 0 0; font-size: 13px; color: #4caf50; font-weight: bold;">7) Your phone number <span class="bookingValue" id="bookingValuePhone"></span></p> | |
| </div> | |
| </div> | |
| <div class="typing-indicator" id="typingIndicator"> | |
| <div class="typing-dots"> | |
| <span></span> | |
| <span></span> | |
| <span></span> | |
| </div> | |
| </div> | |
| <!-- Recording notification popup --> | |
| <div id="recordingPopup" class="recording-popup" style="display: none;"> | |
| <div class="popup-content"> | |
| <div class="recording-icon">🎙️</div> | |
| <p>You are being recorded</p> | |
| <p class="popup-subtitle">Please follow the instructions</p> | |
| </div> | |
| </div> | |
| <div class="chat-input"> | |
| <!-- Record Button positioned relative to input --> | |
| <button id="sttIndicator" class="floating-record-btn ready-to-record" title="Microphone initializing...">🎙️</button> | |
| <textarea | |
| id="messageInput" | |
| placeholder="Type your message..." | |
| maxlength="1000" | |
| rows="1" | |
| ></textarea> | |
| <!-- Mute/Unmute Toggle Button --> | |
| <button id="muteToggle" class="mute-toggle-btn unmuted" title="Click to MUTE microphone"> | |
| <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/> | |
| <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/> | |
| </svg> | |
| <span class="btn-label">MUTE</span> | |
| </button> | |
| <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> | |
| <!-- Audio visualization (moved below text input) --> | |
| <div id="audioVisualizer" class="audio-visualizer" style="display: none;"> | |
| <canvas id="audioCanvas" width="200" height="200"></canvas> | |
| <div class="visualizer-info">🎤 Listening...</div> | |
| </div> | |
| <!-- Version Footer --> | |
| <div style="text-align: center; margin-top: 10px; padding: 5px; color: #999; font-size: 10px; border-top: 1px solid #f0f0f0;"> | |
| VoiceCal.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; | |
| // Default email from landing page (if provided) | |
| const defaultEmail = '{default_email}'; | |
| console.log('🏠 Default email from landing page:', defaultEmail || 'None provided'); | |
| console.log('🔍 defaultEmail variable type:', typeof defaultEmail); | |
| console.log('🔍 defaultEmail length:', defaultEmail.length); | |
| console.log('🔍 defaultEmail value (raw):', JSON.stringify(defaultEmail)); | |
| console.log('🔍 URL params for debugging:', window.location.search); | |
| // Additional debug - check if email is being passed correctly | |
| if (defaultEmail && defaultEmail.trim() && defaultEmail !== 'None' && defaultEmail !== '') { | |
| console.log('✅ Email successfully passed from landing page:', defaultEmail); | |
| } else { | |
| console.log('❌ Email NOT passed from landing page or is empty'); | |
| console.log('🔍 Possible reasons: 1) User bypassed landing page, 2) Email not in URL params, 3) Template substitution failed'); | |
| } | |
| // Check if user came through landing page with email | |
| if (!defaultEmail || defaultEmail.trim() === '') { | |
| console.log('⚠️ No email provided from landing page - user bypassed email form'); | |
| // Add helpful message about providing email | |
| const emailReminder = document.createElement('div'); | |
| emailReminder.innerHTML = ` | |
| <div style="background: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 10px; margin: 10px 0; border-radius: 6px; font-size: 14px;"> | |
| 💡 <strong>Tip:</strong> For faster booking, please provide your email address during our conversation so I can send you calendar invitations. | |
| </div> | |
| `; | |
| document.getElementById('chatMessages').appendChild(emailReminder); | |
| } | |
| 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'); | |
| const muteToggle = document.getElementById('muteToggle'); | |
| // Shared Audio Variables (for both TTS and STT) | |
| let globalAudioContext = null; | |
| let globalMediaStream = null; | |
| let isAudioInitialized = false; | |
| let audioUnlocked = false; // iOS requires audio unlock on first user gesture | |
| // Unlock audio playback on first user interaction (critical for iOS Safari). | |
| // Must happen in the same synchronous call stack as the gesture. | |
| function unlockAudio() { | |
| if (audioUnlocked) return; | |
| audioUnlocked = true; | |
| console.log('🔓 Unlocking audio on user gesture...'); | |
| // Create shared AudioContext during gesture | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| // Resume must start in the gesture call stack | |
| audioContext.resume().then(() => { | |
| console.log('🔓 AudioContext resumed:', audioContext.state); | |
| }); | |
| // Play a tiny silent buffer — this permanently unlocks the context on iOS | |
| try { | |
| const silentBuffer = audioContext.createBuffer(1, 1, 22050); | |
| const src = audioContext.createBufferSource(); | |
| src.buffer = silentBuffer; | |
| src.connect(audioContext.destination); | |
| src.start(0); | |
| console.log('🔓 Audio unlocked successfully'); | |
| } catch (e) { | |
| console.warn('🔓 Audio unlock failed:', e); | |
| } | |
| document.removeEventListener('touchend', unlockAudio, true); | |
| document.removeEventListener('click', unlockAudio, true); | |
| } | |
| document.addEventListener('touchend', unlockAudio, true); | |
| document.addEventListener('click', unlockAudio, true); | |
| // Greeting state - prevents visualizer from showing during initial greeting | |
| let greetingComplete = 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 TTS for ChatCal...'); | |
| // TTS does not require microphone permission. | |
| // Enable immediately; AudioContext will be created/resumed on first | |
| // user gesture (mic click or send button) to satisfy browser autoplay policy. | |
| this.webrtcEnabled = true; | |
| this.isPlaying = false; | |
| this.audioQueue = []; | |
| console.log('✅ TTS enabled for ChatCal'); | |
| } catch (error) { | |
| console.warn('⚠️ TTS initialization failed, continuing without TTS:', error); | |
| this.webrtcEnabled = false; | |
| } | |
| } | |
| async ensureAudioContext() { | |
| // Use the shared global audioContext — the same one that | |
| // setupTTSVisualization routes the audio element through via | |
| // createMediaElementSource. If that context is suspended, | |
| // audio is routed into it but never output → silence. | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| if (audioContext.state === 'suspended') { | |
| await audioContext.resume(); | |
| } | |
| // Keep this.audioContext pointing to the same shared instance | |
| this.audioContext = audioContext; | |
| } | |
| async synthesizeAndPlay(text) { | |
| if (!this.webrtcEnabled || !text.trim()) { | |
| return; | |
| } | |
| // Prevent very long TTS (over 500 characters) | |
| if (text.length > 500) { | |
| console.log('🔇 Skipping very long TTS text:', text.length, 'characters'); | |
| return; | |
| } | |
| // Filter text for TTS (remove URLs, shorten meeting IDs) | |
| const ttsText = this.filterTextForTTS(text); | |
| console.log('🔇 Original text length:', text.length, '| Filtered for TTS:', ttsText.length); | |
| // Prevent duplicate TTS requests for the same text | |
| if (this.audioQueue.includes(ttsText)) { | |
| console.log('🔄 Skipping duplicate TTS request for:', ttsText.substring(0, 30) + '...'); | |
| return; | |
| } | |
| // Add to queue and process (may preempt current audio) | |
| this.audioQueue.push(ttsText); | |
| console.log(`📝 Added to TTS queue (${this.audioQueue.length} items). Playing: ${this.isPlaying}`); | |
| this.processAudioQueue(); | |
| } | |
| filterTextForTTS(text) { | |
| let filtered = text; | |
| // Remove URLs (http/https links) | |
| const urlRegex = /https?:\/\/[^\s<>"]+/gi; | |
| const urls = filtered.match(urlRegex); | |
| if (urls) { | |
| console.log('🔇 Filtering', urls.length, 'URL(s) from TTS:', urls); | |
| filtered = filtered.replace(urlRegex, 'link in your email'); | |
| } | |
| // Shorten meeting IDs (format: MMDD-HHMM-DURm) to last 5 characters | |
| const meetingIdRegex = /\b\d{4}-\d{4}-\d+m\b/g; | |
| const meetingIds = filtered.match(meetingIdRegex); | |
| if (meetingIds) { | |
| console.log('🔇 Shortening', meetingIds.length, 'meeting ID(s) for TTS'); | |
| filtered = filtered.replace(meetingIdRegex, (match) => { | |
| const last5 = match.slice(-5); | |
| console.log(`🔇 Meeting ID ${match} → ending in ${last5}`); | |
| return `ending in ${last5}`; | |
| }); | |
| } | |
| // Remove HTML tags for cleaner TTS | |
| filtered = filtered.replace(/<[^>]*>/g, ''); | |
| // Clean up multiple spaces and normalize | |
| filtered = filtered.replace(/\s+/g, ' ').trim(); | |
| return filtered; | |
| } | |
| async processAudioQueue() { | |
| if (this.isPlaying || this.audioQueue.length === 0) { | |
| return; | |
| } | |
| const text = this.audioQueue.shift(); | |
| this.isPlaying = true; | |
| try { | |
| console.log('🎵 Synthesizing TTS for:', text.substring(0, 50) + '...'); | |
| // Step 1: Call TTS proxy | |
| const startTime = performance.now(); | |
| const response = await fetch('{base_url}/tts/synthesize', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text: text, voice: 'hannah' }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`TTS proxy error: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| console.log(`🎵 TTS synthesis done in ${(performance.now() - startTime).toFixed(0)}ms`); | |
| if (!result.success || !result.audio_url) { | |
| throw new Error('TTS generation failed'); | |
| } | |
| // Step 2: Fetch the WAV as raw bytes (bypass HTMLAudioElement entirely) | |
| console.log('🔊 Fetching audio:', result.audio_url); | |
| const audioResponse = await fetch(result.audio_url); | |
| const arrayBuffer = await audioResponse.arrayBuffer(); | |
| console.log('🔊 Audio fetched:', arrayBuffer.byteLength, 'bytes'); | |
| // Step 3: Ensure AudioContext is running (unlocked on first tap) | |
| await this.ensureAudioContext(); | |
| console.log('🔊 AudioContext state:', audioContext.state); | |
| // Step 4: Decode and play via Web Audio API AudioBufferSourceNode. | |
| // This bypasses HTMLAudioElement autoplay restrictions on iOS Safari. | |
| const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0)); | |
| const source = audioContext.createBufferSource(); | |
| source.buffer = audioBuffer; | |
| source.connect(audioContext.destination); | |
| // Notify TTS-aware mic control | |
| if (typeof setupTTSWithInterrupt === 'function') { | |
| setupTTSWithInterrupt(null); | |
| } | |
| // Timeout guard | |
| const timeoutId = setTimeout(() => { | |
| console.warn('⏰ TTS playback timeout'); | |
| this.isPlaying = false; | |
| resumeMicrophoneAfterTTS(); | |
| this.processAudioQueue(); | |
| }, 30000); | |
| source.onended = () => { | |
| console.log('🎵 TTS audio finished'); | |
| clearTimeout(timeoutId); | |
| this.isPlaying = false; | |
| resumeMicrophoneAfterTTS(); | |
| this.processAudioQueue(); | |
| }; | |
| source.start(0); | |
| console.log('🎵 TTS playing via Web Audio API'); | |
| } catch (error) { | |
| console.warn('🔇 TTS failed:', error.message || error); | |
| this.isPlaying = false; | |
| this.processAudioQueue(); | |
| } | |
| } | |
| async waitForTTSResult(eventId) { | |
| try { | |
| console.log('⏳ Waiting for TTS queue result:', eventId); | |
| // Poll for the result | |
| const maxAttempts = 20; | |
| for (let attempt = 0; attempt < maxAttempts; attempt++) { | |
| await new Promise(resolve => setTimeout(resolve, 500)); // Wait 500ms | |
| const response = await fetch(`https://pgits-kyutai-tts-service-v3.hf.space/queue/data?event_id=${eventId}`); | |
| if (response.ok) { | |
| const text = await response.text(); | |
| const lines = text.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) { | |
| console.log('✅ TTS queue completed'); | |
| return data.output.data[0]; | |
| } | |
| } catch (e) { | |
| // Continue polling | |
| } | |
| } | |
| } | |
| } | |
| } | |
| throw new Error('TTS queue timeout'); | |
| } catch (error) { | |
| console.warn('TTS queue polling failed:', error); | |
| return null; | |
| } | |
| } | |
| async playAudioDirectly(audioUrl) { | |
| try { | |
| console.log('🔊 Playing TTS audio directly...'); | |
| // Simple HTML5 audio playback | |
| // Setup TTS with speech interrupt capability | |
| setupTTSWithInterrupt(this.audioElement); | |
| // Setup TTS audio visualization (outer circle) - only runs once | |
| setupTTSVisualization(this.audioElement); | |
| this.audioElement.src = audioUrl; | |
| this.audioElement.load(); | |
| await this.audioElement.play(); | |
| console.log('🎵 TTS audio playing successfully'); | |
| } catch (error) { | |
| console.warn('🔇 Audio playback failed:', error); | |
| } | |
| } | |
| } | |
| // Initialize TTS system | |
| const chatCalTTS = new ChatCalTTS(); | |
| // TTS interrupt functionality disabled per user request | |
| // No keyboard interrupts allowed for voice stream continuity | |
| /* | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Escape') { | |
| if (chatCalTTS.isPlaying) { | |
| console.log('🛑 ESC key pressed - interrupting TTS playback'); | |
| chatCalTTS.audioElement.pause(); | |
| chatCalTTS.audioElement.currentTime = 0; | |
| chatCalTTS.isPlaying = false; | |
| chatCalTTS.audioQueue = []; // Clear any pending audio | |
| console.log('✅ TTS interrupted and queue cleared'); | |
| } | |
| } | |
| }); | |
| */ | |
| // 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('listening', 'connecting', 'error'); | |
| switch (state) { | |
| case 'ready': | |
| sttIndicator.innerHTML = '🎙️'; | |
| sttIndicator.title = 'Click to start voice recording'; | |
| break; | |
| case 'connecting': | |
| sttIndicator.innerHTML = '🔄'; | |
| sttIndicator.title = 'Connecting to voice service...'; | |
| sttIndicator.classList.add('connecting'); | |
| break; | |
| case 'recording': | |
| sttIndicator.innerHTML = '⏹️'; | |
| sttIndicator.title = 'Click to stop recording and transcribe'; | |
| sttIndicator.classList.add('listening'); | |
| break; | |
| case 'processing': | |
| sttIndicator.innerHTML = '⚡'; | |
| sttIndicator.title = 'Processing your speech...'; | |
| sttIndicator.classList.add('connecting'); // Use orange/connecting style | |
| break; | |
| case 'error': | |
| sttIndicator.innerHTML = '❌'; | |
| sttIndicator.title = 'Click to retry voice recording'; | |
| sttIndicator.classList.add('error'); | |
| 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 | |
| // Background noise filtering - tuned for faster response | |
| this.audioContext = null; | |
| this.audioAnalyser = null; | |
| this.noiseThreshold = -45; // dB threshold - ignore audio below this level (less strict) | |
| this.minVolumeForSpeech = 0.015; // Minimum volume level (0-1 scale, lowered for sensitivity) | |
| console.log('🎤 STT v2 Manager initialized with noise filtering'); | |
| } | |
| generateSessionHash() { | |
| return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); | |
| } | |
| // Get current audio level (0-1 scale) for noise detection | |
| getAudioLevel() { | |
| if (!this.audioAnalyser) return 0; | |
| const bufferLength = this.audioAnalyser.frequencyBinCount; | |
| const dataArray = new Uint8Array(bufferLength); | |
| this.audioAnalyser.getByteTimeDomainData(dataArray); | |
| // Calculate RMS (Root Mean Square) for audio level | |
| let sum = 0; | |
| for (let i = 0; i < bufferLength; i++) { | |
| const normalized = (dataArray[i] - 128) / 128; // Normalize to -1 to 1 | |
| sum += normalized * normalized; | |
| } | |
| const rms = Math.sqrt(sum / bufferLength); | |
| return rms; | |
| } | |
| // Check if current audio contains actual speech (vs background noise) | |
| hasSignificantAudio() { | |
| const level = this.getAudioLevel(); | |
| const isSpeech = level > this.minVolumeForSpeech; | |
| if (!isSpeech) { | |
| console.log('🔇 Audio level too low (' + level.toFixed(4) + '), ignoring background noise'); | |
| } | |
| return isSpeech; | |
| } | |
| 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, // Browser-level noise suppression | |
| autoGainControl: true | |
| } | |
| }); | |
| // Re-enabled: Audio analyzer for better VAD (Voice Activity Detection) | |
| if (!this.audioContext) { | |
| this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| const source = this.audioContext.createMediaStreamSource(stream); | |
| this.audioAnalyser = this.audioContext.createAnalyser(); | |
| this.audioAnalyser.fftSize = 2048; | |
| this.audioAnalyser.smoothingTimeConstant = 0.3; // Fast response to audio changes | |
| source.connect(this.audioAnalyser); | |
| console.log('🔊 Audio level monitoring enabled (threshold: ' + this.minVolumeForSpeech + ')'); | |
| // Try different formats based on browser support and Groq compatibility | |
| let mimeType; | |
| if (MediaRecorder.isTypeSupported('audio/wav')) { | |
| mimeType = 'audio/wav'; | |
| } else if (MediaRecorder.isTypeSupported('audio/mp4;codecs=aac')) { | |
| mimeType = 'audio/mp4;codecs=aac'; // MP4 with AAC codec (Groq compatible) | |
| } else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) { | |
| mimeType = 'audio/ogg;codecs=opus'; // OGG with Opus (Groq supports ogg) | |
| } else if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { | |
| mimeType = 'audio/webm;codecs=opus'; // WebM fallback | |
| } else { | |
| console.warn('⚠️ No preferred audio format supported, using default'); | |
| mimeType = undefined; // Let MediaRecorder choose | |
| } | |
| console.log(`🎤 Using audio format: ${mimeType || 'default'}`); | |
| this.mediaRecorder = new MediaRecorder(stream, { | |
| mimeType: mimeType | |
| }); | |
| 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'); | |
| updateMicrophoneButtonState('recording'); | |
| // 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'); | |
| updateMicrophoneButtonState('ready'); | |
| // 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; | |
| } | |
| // TEMPORARILY DISABLED: Noise filtering was blocking valid speech | |
| // TODO: Re-implement with proper threshold calibration | |
| // if (!this.hasSignificantAudio()) { | |
| // console.log('🔇 Skipping transcription - audio below noise threshold'); | |
| // updateSTTVisualState('ready'); | |
| // return; | |
| // } | |
| try { | |
| console.log('🔄 Processing STT v2 recording...'); | |
| // Create blob from chunks using the same MIME type as recording | |
| let blobType = 'audio/webm;codecs=opus'; // fallback | |
| if (this.mediaRecorder && this.mediaRecorder.mimeType) { | |
| blobType = this.mediaRecorder.mimeType; | |
| } | |
| const audioBlob = new Blob(this.audioChunks, { type: blobType }); | |
| console.log(`📦 Audio blob created: ${audioBlob.size} bytes, type: ${blobType}`); | |
| // Send audio blob directly to transcription service (skip base64 conversion) | |
| await this.transcribeAudio(audioBlob, blobType); | |
| } 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); | |
| }); | |
| } | |
| base64ToBlob(base64, mimeType = 'audio/webm') { | |
| // Decode base64 to binary | |
| const byteCharacters = atob(base64); | |
| const byteNumbers = new Array(byteCharacters.length); | |
| for (let i = 0; i < byteCharacters.length; i++) { | |
| byteNumbers[i] = byteCharacters.charCodeAt(i); | |
| } | |
| const byteArray = new Uint8Array(byteNumbers); | |
| // Create blob | |
| return new Blob([byteArray], { type: mimeType }); | |
| } | |
| async transcribeAudio(audioBlob, blobType = 'audio/webm;codecs=opus') { | |
| console.log(`📤 Sending to Groq STT service: /api/stt/transcribe`); | |
| try { | |
| const startTime = Date.now(); | |
| // Determine appropriate filename based on MIME type | |
| let filename = 'audio.webm'; // default | |
| if (blobType.includes('wav')) { | |
| filename = 'audio.wav'; | |
| } else if (blobType.includes('mp4')) { | |
| filename = 'audio.mp4'; | |
| } else if (blobType.includes('ogg')) { | |
| filename = 'audio.ogg'; | |
| } else if (blobType.includes('mp3')) { | |
| filename = 'audio.mp3'; | |
| } else if (blobType.includes('opus') && !blobType.includes('ogg')) { | |
| filename = 'audio.opus'; // Only for pure opus, not ogg+opus | |
| } | |
| console.log(`🎤 Using filename: ${filename} for MIME type: ${blobType}`); | |
| const formData = new FormData(); | |
| formData.append('file', audioBlob, filename); | |
| const response = await fetch('/api/stt/transcribe', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Groq STT request failed: ${response.status}`); | |
| } | |
| const responseData = await response.json(); | |
| console.log('📨 Groq STT response:', responseData); | |
| const result = responseData.text; | |
| if (result && result.trim()) { | |
| const processingTime = (Date.now() - startTime) / 1000; | |
| console.log(`✅ Groq STT 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('❌ Groq STT transcription failed:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| } | |
| } | |
| 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 1.0 second timer after ANY transcription | |
| console.log('⏱️ Starting 1.0 second timer after transcription...'); | |
| // Clear any existing timer first | |
| if (silenceTimer) { | |
| clearTimeout(silenceTimer); | |
| } | |
| // Email is provided from landing page, no need to detect it in speech | |
| // Start 1.0 second timer for ALL transcriptions (reduced from 2.0s for faster response) | |
| silenceTimer = setTimeout(() => { | |
| if (hasReceivedSpeech && messageInput.value.trim()) { | |
| console.log('⏱️ 1.0 second completed after transcription, auto-submitting...'); | |
| submitMessage(); | |
| } | |
| }, 1000); | |
| } | |
| // Email verification methods removed - email now provided from landing page | |
| 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) { | |
| // Check if booking was just completed and user is saying goodbye/thanks | |
| // If so, immediately redirect instead of processing the message | |
| if (bookingDetected) { | |
| console.log('🛑 Booking complete — blocking input, triggering celebration'); | |
| messageInput.value = ''; | |
| if (!waitingForTTSCompletion) { | |
| showFireworksAndRedirect(); | |
| } | |
| return; // Don't send any message after booking | |
| } | |
| // 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 sessionCreatePayload = { | |
| user_data: { | |
| email: defaultEmail || null | |
| } | |
| }; | |
| console.log('📧 About to create session with payload:', JSON.stringify(sessionCreatePayload, null, 2)); | |
| const response = await fetch('{base_url}/sessions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(sessionCreatePayload) | |
| }); | |
| console.log('📧 Session creation response status:', response.status); | |
| console.log('📧 Creating session with email:', defaultEmail || 'null'); | |
| 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) { | |
| // VOICE-ONLY MODE: Normal assistant messages are hidden — TTS only. | |
| // EXCEPTION: booking confirmations are rendered visibly so the user | |
| // can see the Google Meet link, meeting time, IDs, etc. | |
| if (!isUser && content && content.trim()) { | |
| // Create hidden element for booking success detection FIRST | |
| // This must be added to DOM before checkForBookingSuccess() runs | |
| const hiddenDiv = document.createElement('div'); | |
| hiddenDiv.innerHTML = content; | |
| hiddenDiv.style.display = 'none'; | |
| chatMessages.appendChild(hiddenDiv); | |
| // Render visibly if this looks like a booking confirmation | |
| // (has Meet link, meeting ID, or success marker). | |
| const isBookingConfirmation = | |
| /booking-success/i.test(content) || | |
| /meet\.google\.com/i.test(content) || | |
| /Meeting confirmed/i.test(content) || | |
| /Meeting ID:/i.test(content) || | |
| /Google Calendar ID:/i.test(content); | |
| if (isBookingConfirmation) { | |
| const card = document.createElement('div'); | |
| card.className = 'booking-confirmation'; | |
| // Strip the hidden marker so its raw text never shows | |
| let visibleHtml = content.replace( | |
| /<div[^>]*id=["']booking-success["'][^>]*>[\s\S]*?<\/div>/gi, | |
| '' | |
| ); | |
| card.innerHTML = visibleHtml; | |
| chatMessages.appendChild(card); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| // Create temporary element to extract text content for TTS | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = content; | |
| let textContent = tempDiv.textContent || tempDiv.innerText; | |
| if (textContent && textContent.trim()) { | |
| // Strip "assistant:" prefix | |
| textContent = textContent.replace(/^assistant:\s*/i, '').trim(); | |
| // Scrub the hidden booking marker so TTS doesn't say | |
| // "BOOKING COMPLETE" out loud after every booking. | |
| textContent = textContent.replace(/BOOKING_COMPLETE/gi, '').trim(); | |
| // Scrub long meeting IDs/URLs from TTS (they're shown on screen). | |
| textContent = textContent.replace(/https?:\/\/meet\.google\.com\/\S+/gi, ''); | |
| textContent = textContent.replace(/Meeting ID:\s*\S+/gi, ''); | |
| textContent = textContent.replace(/Google Calendar ID:\s*\S+/gi, ''); | |
| textContent = textContent.replace(/\s+/g, ' ').trim(); | |
| if (textContent) { | |
| chatCalTTS.synthesizeAndPlay(textContent); | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| } | |
| } | |
| } | |
| // User messages: completely skip (no display, no processing) | |
| // In voice-only mode, user doesn't need to see their transcribed text | |
| } | |
| function showTyping() { | |
| if (typingIndicator) { | |
| typingIndicator.style.display = 'block'; | |
| } | |
| // Removed auto-scroll - let user control scroll position | |
| // chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| // Live booking-field panel updater. | |
| // Backend sends ChatResponse.user_info each turn. We paste each non-null | |
| // value next to its checklist label so the user sees what was captured | |
| // without TTS having to read it back. | |
| function updateBookingValues(info) { | |
| if (!info) return; | |
| const fmtPhone = (p) => { | |
| if (!p) return ''; | |
| const d = String(p).replace(/\D/g, ''); | |
| if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`; | |
| if (d.length === 11 && d[0] === '1') return `+1 (${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`; | |
| return p; | |
| }; | |
| const fmtDur = (d) => d ? `${d} min` : ''; | |
| const set = (id, val) => { | |
| const el = document.getElementById(id); | |
| if (el && val) el.textContent = val; | |
| }; | |
| set('bookingValueName', info.name); | |
| set('bookingValueDate', info.date); | |
| set('bookingValueTime', info.time); | |
| set('bookingValueDuration', fmtDur(info.duration_minutes)); | |
| set('bookingValueTopic', info.topic); | |
| set('bookingValuePhone', fmtPhone(info.phone)); | |
| // bookingValueFormat is filled by existing phone/google-meet detection | |
| // elsewhere in this widget; leave it alone here. | |
| } | |
| function hideTyping() { | |
| if (typingIndicator) { | |
| typingIndicator.style.display = 'none'; | |
| } | |
| } | |
| async function sendMessage(message = null) { | |
| // Block ALL messages after booking — one meeting per session | |
| if (bookingDetected) { | |
| console.log('🛑 Booking already completed — ignoring new message'); | |
| return; | |
| } | |
| 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 is handled inside addMessage() - no need to call here | |
| // Live booking-field panel: paste captured values inline | |
| if (data.user_info) { updateBookingValues(data.user_info); } | |
| // FORCE update menu colors after response | |
| console.log('🔄 Forcing menu color update after chat response...'); | |
| setTimeout(updateMenuItemColors, 1000); | |
| } 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; | |
| } | |
| } | |
| } | |
| // 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.0 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) >= 1000) { | |
| console.log('🔇 User stopped typing, checking for STT auto-submission...'); | |
| // Additional delay to ensure user is done typing (1.0 second after last keystroke) | |
| silenceTimer = setTimeout(() => { | |
| if (hasReceivedSpeech && messageInput.value.trim()) { | |
| console.log('🔇 STT auto-submitting after typing pause...'); | |
| submitMessage(); | |
| } | |
| }, 1000); | |
| } | |
| }, 2000); | |
| }); | |
| 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 1.0 second timer...'); | |
| clearTimeout(silenceTimer); | |
| // Restart the 1.0 second timer | |
| silenceTimer = setTimeout(() => { | |
| if (hasReceivedSpeech && messageInput.value.trim()) { | |
| console.log('⏱️ 1.0 second completed after user activity, auto-submitting...'); | |
| submitMessage(); | |
| } | |
| }, 1000); | |
| } | |
| } | |
| // Initialize when page loads | |
| // STT v2 indicator click to toggle recording | |
| // Transcribe/Submit button - stops recording and processes speech | |
| sttIndicator.addEventListener('click', () => { | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| console.log('🎯 User clicked to TRANSCRIBE current recording'); | |
| // Stop recording and process the current audio | |
| sttv2Manager.stopRecording().catch(error => { | |
| console.error('Failed to transcribe recording:', error); | |
| updateMicrophoneButtonState('error'); | |
| }); | |
| } else { | |
| console.log('ℹ️ Microphone button is for visual indication only - use Mute/Unmute to control recording'); | |
| } | |
| }); | |
| // Mute/Unmute toggle functionality | |
| if (muteToggle) { | |
| muteToggle.addEventListener('click', () => { | |
| toggleMicrophone(); | |
| }); | |
| } | |
| function toggleMicrophone() { | |
| if (isMicrophoneMuted) { | |
| // Currently muted -> UNMUTE | |
| unmuteMicrophone(); | |
| } else { | |
| // Currently unmuted -> MUTE | |
| muteMicrophone(); | |
| } | |
| } | |
| function muteMicrophone() { | |
| console.log('🔇 User clicked MUTE - stopping recording'); | |
| isMicrophoneMuted = true; | |
| // Stop current recording | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| sttv2Manager.stopRecording(); | |
| } | |
| // Keep visualizer visible (TTS outer circle can still show when assistant speaks) | |
| // Don't hide it - just stop recording the microphone | |
| console.log('🎨 Keeping visualizer visible for TTS outer circle'); | |
| // Update mute button | |
| updateMuteButtonState('muted'); | |
| // Update microphone indicator | |
| updateMicrophoneButtonState('ready'); | |
| } | |
| function unmuteMicrophone() { | |
| console.log('🎙️ User clicked UNMUTE - starting recording'); | |
| isMicrophoneMuted = false; | |
| // Start recording if not currently playing TTS | |
| if (sttv2Manager && !isTTSPlaying && !recordingPausedForTTS) { | |
| sttv2Manager.startRecording().then(() => { | |
| setupAudioVisualization(); | |
| }); | |
| } | |
| // Update mute button | |
| updateMuteButtonState('unmuted'); | |
| // Update microphone indicator | |
| updateMicrophoneButtonState('recording'); | |
| } | |
| function updateMuteButtonState(state) { | |
| if (!muteToggle) return; | |
| const btnLabel = muteToggle.querySelector('.btn-label'); | |
| if (!btnLabel) return; | |
| muteToggle.classList.remove('muted', 'unmuted'); | |
| if (state === 'muted') { | |
| muteToggle.classList.add('muted'); | |
| btnLabel.textContent = 'UNMUTE'; | |
| muteToggle.title = 'Click to UNMUTE microphone'; | |
| } else { | |
| muteToggle.classList.add('unmuted'); | |
| btnLabel.textContent = 'MUTE'; | |
| muteToggle.title = 'Click to MUTE microphone'; | |
| } | |
| } | |
| // Clear microphone button state management | |
| function updateMicrophoneButtonState(state) { | |
| const sttIndicator = document.getElementById('sttIndicator'); | |
| if (!sttIndicator) return; | |
| // Clear all state classes | |
| sttIndicator.classList.remove('ready-to-record', 'currently-recording', 'tts-playing', 'tts-playing-interruptible', 'connecting', 'error'); | |
| switch (state) { | |
| case 'ready': | |
| // Green - Ready state (visual indicator) | |
| sttIndicator.classList.add('ready-to-record'); | |
| sttIndicator.innerHTML = '🎙️'; | |
| sttIndicator.title = 'Microphone ready (use Mute/Unmute button to control)'; | |
| break; | |
| case 'recording': | |
| // Red pulsing - Currently recording (visual + transcribe) | |
| sttIndicator.classList.add('currently-recording'); | |
| sttIndicator.innerHTML = '🎙️'; | |
| sttIndicator.title = 'RECORDING - Click to transcribe current speech'; | |
| break; | |
| case 'tts-playing': | |
| // Orange - Paused for TTS | |
| sttIndicator.classList.add('tts-playing'); | |
| sttIndicator.innerHTML = '🔇'; | |
| sttIndicator.title = 'Microphone paused - AI is speaking'; | |
| break; | |
| case 'tts-playing-interruptible': | |
| // Blue - Recording during TTS (interruptible) | |
| sttIndicator.classList.add('tts-playing-interruptible'); | |
| sttIndicator.innerHTML = '🎤'; | |
| sttIndicator.title = 'Listening during TTS - Speak to interrupt'; | |
| break; | |
| case 'connecting': | |
| // Blue - Connecting | |
| sttIndicator.classList.add('connecting'); | |
| sttIndicator.innerHTML = '🔄'; | |
| sttIndicator.title = 'Connecting to microphone...'; | |
| break; | |
| case 'error': | |
| // Red - Error state | |
| sttIndicator.classList.add('error'); | |
| sttIndicator.innerHTML = '❌'; | |
| sttIndicator.title = 'Microphone error - Click to retry'; | |
| break; | |
| } | |
| } | |
| // Update user info display | |
| function updateUserInfoDisplay(name, email) { | |
| const userInfoDisplay = document.getElementById('userInfoDisplay'); | |
| const userEmailDisplay = document.getElementById('userEmailDisplay'); | |
| console.log('📧 Updating user email display:', { email }); | |
| if (email && email !== "Not provided" && email !== null && email !== undefined && email.trim() !== "") { | |
| userEmailDisplay.textContent = email; | |
| userInfoDisplay.classList.add('visible'); | |
| console.log('📧 Email display made visible with:', email); | |
| } else { | |
| console.log('📧 No valid email to display, hiding panel'); | |
| userInfoDisplay.classList.remove('visible'); | |
| } | |
| } | |
| // Extract user info from agent responses (if present in system messages) | |
| function extractUserInfoFromResponse(response) { | |
| // Look for user info pattern in responses | |
| const nameMatch = response.match(/Name:\s*([^•|*]+)/i); | |
| const emailMatch = response.match(/Email:\s*([^•|*]+)/i); | |
| if (nameMatch || emailMatch) { | |
| const name = nameMatch ? nameMatch[1].trim() : null; | |
| const email = emailMatch ? emailMatch[1].trim() : null; | |
| updateUserInfoDisplay(name, email); | |
| } | |
| } | |
| // Check session for existing user info and display it | |
| async function checkAndDisplaySessionUserInfo() { | |
| try { | |
| // First, check URL parameters for email | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const emailFromURL = urlParams.get('email'); | |
| if (emailFromURL) { | |
| console.log('📧 Found email in URL parameters:', emailFromURL); | |
| updateUserInfoDisplay(null, emailFromURL); | |
| return; | |
| } | |
| // Then check session data | |
| const response = await fetch('/api/session-info', { | |
| method: 'GET', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| credentials: 'include' | |
| }); | |
| if (response.ok) { | |
| const sessionData = await response.json(); | |
| console.log('📧 Session data received:', sessionData); | |
| // Try multiple possible locations for email in session data | |
| let email = null; | |
| if (sessionData.user_data) { | |
| // Check direct email field | |
| email = sessionData.user_data.email || | |
| sessionData.user_data.userEmail || | |
| (sessionData.user_data.user_info && sessionData.user_data.user_info.email); | |
| } | |
| if (email) { | |
| console.log('📧 Found email in session data:', email); | |
| updateUserInfoDisplay(null, email); | |
| } else { | |
| console.log('📧 No email found in session data'); | |
| } | |
| } | |
| } catch (error) { | |
| console.log('Session user info check failed:', error); | |
| } | |
| } | |
| // Initialize session and STT v2 | |
| async function initAndStartSTT() { | |
| await initializeSession(); | |
| // Force update email display for testing | |
| setTimeout(() => { | |
| const emailDisplay = document.getElementById('userEmailDisplay'); | |
| if (emailDisplay && emailDisplay.textContent === 'Loading...') { | |
| emailDisplay.textContent = 'Email provided from landing page'; | |
| console.log('📧 Email display updated - using landing page email'); | |
| } | |
| }, 1000); | |
| await checkAndDisplaySessionUserInfo(); | |
| // 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(); | |
| } | |
| }); | |
| // Booking success celebration and redirect | |
| let bookingDetected = false; | |
| let waitingForTTSCompletion = false; | |
| function checkForBookingSuccess() { | |
| const successMarker = document.getElementById('booking-success'); | |
| let isBooking = false; | |
| if (successMarker) { | |
| console.log('🎉 Booking successful detected via marker!'); | |
| isBooking = true; | |
| } | |
| // Fallback: Check for booking success text patterns | |
| if (!isBooking) { | |
| const messages = document.querySelectorAll('.message.assistant'); | |
| const lastMessage = messages[messages.length - 1]; | |
| if (lastMessage) { | |
| const messageText = lastMessage.textContent || lastMessage.innerHTML; | |
| // Look for booking confirmation patterns | |
| const bookingPatterns = [ | |
| /Meeting confirmed/i, | |
| /✅.*Meeting/i, | |
| /Meeting ID:/i, | |
| /Google Calendar ID:/i, | |
| /Meeting booked/i, | |
| /MEETING BOOKED SUCCESSFULLY/i, | |
| /All set!/i | |
| ]; | |
| for (const pattern of bookingPatterns) { | |
| if (pattern.test(messageText)) { | |
| console.log('🎉 Booking detected via text pattern!'); | |
| isBooking = true; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| // If booking detected, check TTS status | |
| if (isBooking && !bookingDetected) { | |
| bookingDetected = true; | |
| // Stop STT immediately to prevent new input after booking | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| console.log('🛑 Stopping STT immediately after booking detection'); | |
| sttv2Manager.stopRecording(); | |
| } | |
| if (silenceTimer) { | |
| clearTimeout(silenceTimer); | |
| silenceTimer = null; | |
| } | |
| messageInput.value = ''; | |
| // Check if TTS is currently playing | |
| if (chatCalTTS && chatCalTTS.isPlaying) { | |
| console.log('🎵 TTS is playing confirmation... waiting for completion before celebration'); | |
| waitingForTTSCompletion = true; | |
| // Set up listener for TTS completion | |
| const checkTTSComplete = setInterval(() => { | |
| if (!chatCalTTS.isPlaying && chatCalTTS.audioQueue.length === 0) { | |
| console.log('✅ TTS completed! Now showing celebration...'); | |
| clearInterval(checkTTSComplete); | |
| waitingForTTSCompletion = false; | |
| showFireworksAndRedirect(); | |
| } else { | |
| console.log('⏳ Still waiting for TTS... Playing:', chatCalTTS.isPlaying, 'Queue:', chatCalTTS.audioQueue.length); | |
| } | |
| }, 500); // Check every 500ms | |
| // Safety timeout: show celebration after 15 seconds max | |
| setTimeout(() => { | |
| if (waitingForTTSCompletion) { | |
| console.log('⚠️ TTS timeout - showing celebration anyway'); | |
| clearInterval(checkTTSComplete); | |
| waitingForTTSCompletion = false; | |
| showFireworksAndRedirect(); | |
| } | |
| }, 15000); | |
| } else { | |
| // TTS not playing, show celebration immediately | |
| console.log('🎉 No TTS playing, showing celebration now!'); | |
| showFireworksAndRedirect(); | |
| } | |
| } | |
| } | |
| async function showFireworksAndRedirect() { | |
| console.log('🎆 Starting celebration and session reset...'); | |
| // IMPORTANT: Stop STT recording immediately so user's "thanks" doesn't start new session | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| console.log('🛑 Stopping STT recording to prevent additional input...'); | |
| try { | |
| await sttv2Manager.stopRecording(); | |
| } catch (error) { | |
| console.error('❌ Error stopping STT:', error); | |
| } | |
| } | |
| // Reset session before showing celebration | |
| try { | |
| const resetResponse = await fetch('/api/session/reset', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ session_id: currentSessionId }) | |
| }); | |
| if (resetResponse.ok) { | |
| console.log('✅ Session reset successful'); | |
| } else { | |
| console.warn('⚠️ Session reset failed, but continuing with celebration'); | |
| } | |
| } catch (error) { | |
| console.error('❌ Session reset error:', error); | |
| } | |
| // Create fireworks overlay | |
| const fireworksOverlay = document.createElement('div'); | |
| fireworksOverlay.innerHTML = ` | |
| <div style=" | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(45deg, #1e3c72, #2a5298); | |
| z-index: 10000; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| animation: fadeIn 0.5s ease-in; | |
| "> | |
| <div style=" | |
| text-align: center; | |
| color: white; | |
| font-size: 48px; | |
| margin-bottom: 20px; | |
| animation: bounce 1s infinite; | |
| "> | |
| 🎉 Meeting Booked! 🎉 | |
| </div> | |
| <div style=" | |
| text-align: center; | |
| color: white; | |
| font-size: 24px; | |
| margin-bottom: 40px; | |
| "> | |
| Your appointment with Peter is confirmed! | |
| </div> | |
| <div style="font-size: 80px; animation: fireworks 2s ease-in-out infinite;"> | |
| ✨ 🎆 ✨ 🎇 ✨ 🎆 ✨ | |
| </div> | |
| <div style=" | |
| text-align: center; | |
| color: #ccc; | |
| font-size: 16px; | |
| margin-top: 40px; | |
| "> | |
| Redirecting to home page in 3 seconds... | |
| </div> | |
| </div> | |
| `; | |
| // Add CSS animations | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| @keyframes bounce { | |
| 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } | |
| 40% { transform: translateY(-20px); } | |
| 60% { transform: translateY(-10px); } | |
| } | |
| @keyframes fireworks { | |
| 0% { transform: scale(1) rotate(0deg); } | |
| 50% { transform: scale(1.1) rotate(180deg); } | |
| 100% { transform: scale(1) rotate(360deg); } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| document.body.appendChild(fireworksOverlay); | |
| // Redirect after 3 seconds | |
| setTimeout(() => { | |
| console.log('🔄 Redirecting to home page...'); | |
| window.location.href = '/'; | |
| }, 3000); | |
| } | |
| // Monitor for booking success and extract user info after each message | |
| const originalAddMessage = addMessage; | |
| addMessage = async function(message, isUser = false) { | |
| console.log('📬 addMessage called:', { isUser, messageLength: message?.length }); | |
| await originalAddMessage(message, isUser); | |
| if (!isUser) { | |
| console.log('📨 Assistant message received, checking for updates...'); | |
| // Check for booking success after adding assistant message | |
| setTimeout(checkForBookingSuccess, 100); | |
| // Extract user info if present in response | |
| extractUserInfoFromResponse(message); | |
| // Update menu item colors to show collection progress | |
| // Increased delay to ensure backend has saved session data | |
| console.log('⏱️ Scheduling color update in 500ms...'); | |
| setTimeout(updateMenuItemColors, 500); | |
| } else { | |
| console.log('👤 User message, skipping color update'); | |
| } | |
| }; | |
| // Auto-start recording and show popup notification | |
| async function autoStartRecording() { | |
| try { | |
| // Show recording popup for 2 seconds | |
| showRecordingPopup(); | |
| // Play welcome greeting via TTS | |
| const greetingMessage = "[cheerful] Hello! This is Jessica. Peter Gits's friendly, and [whisper] <chuckle> may I say dynamic [cheerful], Personal Voice Calendar assistant. [excited] I can book a GoogleMeet conference call! Or just schedule a callback. [professional] What day and time works for you?"; | |
| console.log('🎙️ Playing welcome greeting...'); | |
| chatCalTTS.synthesizeAndPlay(greetingMessage); | |
| // Auto-start recording AFTER greeting completes | |
| // Greeting is ~8 seconds, wait 9 seconds to ensure it finishes | |
| // This prevents the visualizer from showing during TTS playback | |
| setTimeout(async () => { | |
| if (sttv2Manager && !sttv2Manager.isRecording) { | |
| console.log('🎙️ Greeting complete, starting microphone...'); | |
| // Mark greeting as complete | |
| greetingComplete = true; | |
| // Show the visualizer and mic button now that greeting is done | |
| const visualizer = document.getElementById('audioVisualizer'); | |
| const sttButton = document.getElementById('sttIndicator'); | |
| if (visualizer) { | |
| visualizer.style.display = 'block'; | |
| console.log('👁️ Showing audio visualizer after greeting'); | |
| } | |
| if (sttButton) { | |
| sttButton.style.display = 'block'; | |
| console.log('👁️ Showing microphone button after greeting'); | |
| } | |
| await sttv2Manager.startRecording(); | |
| setupAudioVisualization(); | |
| console.log('🎤 Microphone enabled - ready for your request'); | |
| } | |
| }, 9000); | |
| } catch (error) { | |
| console.error('Auto-start recording failed:', error); | |
| } | |
| } | |
| // Show recording notification popup | |
| function showRecordingPopup() { | |
| const popup = document.getElementById('recordingPopup'); | |
| popup.style.display = 'block'; | |
| // Hide popup after 2 seconds | |
| setTimeout(() => { | |
| popup.style.display = 'none'; | |
| }, 2000); | |
| } | |
| // Audio visualization setup | |
| let audioContext = null; | |
| let analyser = null; // User microphone analyser | |
| let dataArray = null; // User audio data | |
| let ttsAnalyser = null; // TTS audio analyser (outer circle) | |
| let ttsDataArray = null; // TTS audio data | |
| let animationId = null; | |
| async function setupAudioVisualization() { | |
| try { | |
| const visualizer = document.getElementById('audioVisualizer'); | |
| const canvas = document.getElementById('audioCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // Only show visualizer if greeting is complete | |
| if (greetingComplete) { | |
| visualizer.style.display = 'block'; | |
| } else { | |
| console.log('🎤 Greeting in progress - keeping visualizer hidden'); | |
| } | |
| // Set up audio context for visualization | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| // Get microphone stream (inner circle) | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| // Create analyser for user microphone | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 256; | |
| source.connect(analyser); | |
| const bufferLength = analyser.frequencyBinCount; | |
| dataArray = new Uint8Array(bufferLength); | |
| // Start visualization | |
| drawWaveform(canvas, ctx); | |
| } catch (error) { | |
| console.error('Audio visualization setup failed:', error); | |
| } | |
| } | |
| // Setup TTS audio visualization (outer circle) | |
| // NOTE: Can only be called ONCE per audio element (Web Audio API limitation) | |
| let ttsVisualizationSetup = false; | |
| function setupTTSVisualization(audioElement) { | |
| // Show visualizer for TTS playback | |
| const visualizer = document.getElementById('audioVisualizer'); | |
| const canvas = document.getElementById('audioCanvas'); | |
| const ctx = canvas ? canvas.getContext('2d') : null; | |
| // Only show visualizer if greeting is complete | |
| if (visualizer && greetingComplete) { | |
| visualizer.style.display = 'block'; | |
| console.log('🎨 Visualizer shown for TTS playback'); | |
| } else if (visualizer) { | |
| console.log('🎤 Greeting in progress - keeping visualizer hidden during TTS'); | |
| } | |
| if (ttsVisualizationSetup) { | |
| console.log('🎨 TTS visualization already set up'); | |
| // Animation loop should already be running | |
| return; | |
| } | |
| try { | |
| console.log('🎨 Setting up TTS audio visualization (outer circle)'); | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| // Create analyser for TTS audio (only once) | |
| ttsAnalyser = audioContext.createAnalyser(); | |
| ttsAnalyser.fftSize = 256; | |
| const bufferLength = ttsAnalyser.frequencyBinCount; | |
| ttsDataArray = new Uint8Array(bufferLength); | |
| // Connect TTS audio element to analyser (can only be done once!) | |
| const ttsSource = audioContext.createMediaElementSource(audioElement); | |
| ttsSource.connect(ttsAnalyser); | |
| ttsSource.connect(audioContext.destination); // Also connect to speakers | |
| ttsVisualizationSetup = true; | |
| console.log('✅ TTS visualization connected (outer circle will show when TTS plays)'); | |
| // Start animation loop if not already running | |
| if (!animationId && canvas && ctx) { | |
| console.log('🎬 Starting animation loop for TTS visualization'); | |
| drawWaveform(canvas, ctx); | |
| } | |
| } catch (error) { | |
| console.error('❌ TTS visualization setup failed:', error); | |
| console.error('Error details:', error.message); | |
| } | |
| } | |
| // Draw circular waveform visualization (inspired by unmute.sh) | |
| // Inner circle: User microphone (cyan) | |
| // Outer circle: TTS assistant voice (white/blue) | |
| let rotationAngle = 0; | |
| function drawWaveform(canvas, ctx) { | |
| // Continue if at least one analyser is active (mic OR tts) | |
| if ((!analyser || !dataArray) && (!ttsAnalyser || !ttsDataArray)) { | |
| return; // Exit only if both are unavailable | |
| } | |
| animationId = requestAnimationFrame(() => drawWaveform(canvas, ctx)); | |
| // Get audio data for both sources (if available) | |
| if (analyser && dataArray) { | |
| analyser.getByteFrequencyData(dataArray); | |
| } | |
| if (ttsAnalyser && ttsDataArray) { | |
| ttsAnalyser.getByteFrequencyData(ttsDataArray); | |
| } | |
| // Clear canvas with transparent background | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Circle parameters | |
| const centerX = canvas.width / 2; | |
| const centerY = canvas.height / 2; | |
| const innerRadius = Math.min(canvas.width, canvas.height) / 2 - 70; // User mic (smaller) | |
| const outerRadius = Math.min(canvas.width, canvas.height) / 2 - 30; // TTS (larger) | |
| // OUTER CIRCLE: TTS Assistant Voice (white/light blue) | |
| if (ttsAnalyser && ttsDataArray) { | |
| // Draw outer base circle | |
| ctx.strokeStyle = 'rgba(220, 230, 255, 0.7)'; // Light blue/white | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(centerX, centerY, outerRadius, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| // Draw TTS waveform radiating outward | |
| const ttsSliceAngle = (Math.PI * 2) / ttsDataArray.length; | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; // White for TTS | |
| for (let i = 0; i < ttsDataArray.length; i++) { | |
| const amplitude = ttsDataArray[i] / 255; | |
| const waveHeight = amplitude * 25; // Spike length | |
| // Calculate angle (with rotation) | |
| const angle = ttsSliceAngle * i + rotationAngle; | |
| // Start point on outer circle edge | |
| const x1 = centerX + Math.cos(angle) * outerRadius; | |
| const y1 = centerY + Math.sin(angle) * outerRadius; | |
| // End point extending outward | |
| const x2 = centerX + Math.cos(angle) * (outerRadius + waveHeight); | |
| const y2 = centerY + Math.sin(angle) * (outerRadius + waveHeight); | |
| // Draw waveform line | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, y1); | |
| ctx.lineTo(x2, y2); | |
| ctx.stroke(); | |
| } | |
| } | |
| // INNER CIRCLE: User Microphone (cyan) | |
| // Only draw if microphone analyser is active | |
| if (analyser && dataArray) { | |
| // Draw inner base circle | |
| ctx.strokeStyle = 'rgba(0, 230, 200, 0.8)'; // Cyan/turquoise | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| // Draw user waveform radiating outward | |
| const userSliceAngle = (Math.PI * 2) / dataArray.length; | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)'; // Bright cyan for user | |
| for (let i = 0; i < dataArray.length; i++) { | |
| const amplitude = dataArray[i] / 255; | |
| const waveHeight = amplitude * 25; // Spike length | |
| // Calculate angle (with rotation) | |
| const angle = userSliceAngle * i + rotationAngle; | |
| // Start point on inner circle edge | |
| const x1 = centerX + Math.cos(angle) * innerRadius; | |
| const y1 = centerY + Math.sin(angle) * innerRadius; | |
| // End point extending outward | |
| const x2 = centerX + Math.cos(angle) * (innerRadius + waveHeight); | |
| const y2 = centerY + Math.sin(angle) * (innerRadius + waveHeight); | |
| // Draw waveform line | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, y1); | |
| ctx.lineTo(x2, y2); | |
| ctx.stroke(); | |
| } | |
| } | |
| // Rotate slowly for dynamic effect | |
| rotationAngle += 0.01; | |
| } | |
| // Enhanced STT initialization with auto-start continuous recording | |
| async function initAndStartSTTWithAutoStart() { | |
| // Initialize STT as before | |
| await initAndStartSTT(); | |
| // Enable continuous listening | |
| enableContinuousListening(); | |
| // Auto-start recording after initialization | |
| setTimeout(() => { | |
| autoStartRecording(); | |
| }, 1000); | |
| } | |
| // Continuous listening - restart recording after processing | |
| function enableContinuousListening() { | |
| if (sttv2Manager) { | |
| // Override the original stopRecording to restart automatically | |
| const originalStopRecording = sttv2Manager.stopRecording.bind(sttv2Manager); | |
| sttv2Manager.stopRecording = async function() { | |
| await originalStopRecording(); | |
| // Auto-restart recording for continuous listening (core functionality) | |
| // But not if TTS is playing OR microphone is muted by user | |
| setTimeout(async () => { | |
| if (!this.isRecording && !isTTSPlaying && !recordingPausedForTTS && !isMicrophoneMuted) { | |
| console.log('🔄 Auto-restarting recording for continuous listening'); | |
| await this.startRecording(); | |
| setupAudioVisualization(); | |
| } | |
| }, 1000); | |
| }; | |
| } | |
| } | |
| // TTS-aware microphone control with interrupt capability | |
| let isTTSPlaying = false; | |
| let recordingPausedForTTS = false; | |
| let ttsInterruptible = false; | |
| let currentTTSAudio = null; | |
| // Enhanced speech activity detection during TTS | |
| let speechDetectionThreshold = 0.25; // Higher threshold - requires actual speaking (25% of max volume) | |
| let backgroundNoiseLevel = 0.05; // Baseline noise level to ignore | |
| let speechDetectionCount = 0; | |
| let speechDetectionRequired = 7; // More consecutive detections needed (reduces false positives from breathing) | |
| let recentAudioLevels = []; // Track recent audio levels for noise filtering | |
| const audioLevelHistorySize = 10; // Keep last 10 measurements | |
| // Mute state management | |
| let isMicrophoneMuted = false; | |
| function setupTTSWithInterrupt(audioElement) { | |
| console.log('🎤 Setting up TTS with speech interruption capability'); | |
| isTTSPlaying = true; | |
| ttsInterruptible = true; | |
| currentTTSAudio = audioElement; | |
| speechDetectionCount = 0; | |
| recentAudioLevels = []; // Clear audio history for fresh detection | |
| backgroundNoiseLevel = 0.05; // Reset to default noise level | |
| // Keep microphone recording during TTS for interrupt detection | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| console.log('🎤 Continuing recording during TTS for interrupt detection'); | |
| // Set up speech activity monitoring | |
| setupSpeechInterruptDetection(); | |
| } else if (sttv2Manager && !isMicrophoneMuted) { | |
| // Start recording if not already active | |
| sttv2Manager.startRecording().then(() => { | |
| setupSpeechInterruptDetection(); | |
| setupAudioVisualization(); | |
| }); | |
| } | |
| updateMicrophoneButtonState('tts-playing-interruptible'); | |
| } | |
| function setupSpeechInterruptDetection() { | |
| console.log('🗣️ Setting up speech interrupt detection during TTS'); | |
| if (!audioContext || !analyser) { | |
| console.log('⚠️ Audio context not available for interrupt detection'); | |
| return; | |
| } | |
| const dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| function detectSpeechActivity() { | |
| if (!ttsInterruptible || !isTTSPlaying) { | |
| return; // Stop monitoring when TTS ends or is interrupted | |
| } | |
| analyser.getByteFrequencyData(dataArray); | |
| // Calculate average volume | |
| let sum = 0; | |
| for (let i = 0; i < dataArray.length; i++) { | |
| sum += dataArray[i]; | |
| } | |
| const average = sum / dataArray.length / 255; // Normalize to 0-1 | |
| // Track recent audio levels for adaptive noise filtering | |
| recentAudioLevels.push(average); | |
| if (recentAudioLevels.length > audioLevelHistorySize) { | |
| recentAudioLevels.shift(); | |
| } | |
| // Calculate dynamic background noise level | |
| if (recentAudioLevels.length >= 5) { | |
| const sortedLevels = [...recentAudioLevels].sort((a, b) => a - b); | |
| const medianLevel = sortedLevels[Math.floor(sortedLevels.length / 2)]; | |
| backgroundNoiseLevel = Math.max(0.02, medianLevel * 1.5); // Dynamic noise floor | |
| } | |
| // Enhanced speech detection with multiple criteria | |
| const isAboveBaselineThreshold = average > speechDetectionThreshold; | |
| const isAboveNoiseFloor = average > (backgroundNoiseLevel + 0.05); // 5% above noise floor | |
| const isPeakDetection = average > (Math.max(...recentAudioLevels.slice(-3)) * 0.8); // Recent peak detection | |
| if (isAboveBaselineThreshold && isAboveNoiseFloor && isPeakDetection) { | |
| speechDetectionCount++; | |
| console.log(`🗣️ Strong speech detected during TTS (${speechDetectionCount}/${speechDetectionRequired}) - level: ${average.toFixed(3)}, noise: ${backgroundNoiseLevel.toFixed(3)}`); | |
| if (speechDetectionCount >= speechDetectionRequired) { | |
| console.log('🛑 Confirmed user speech detected - interrupting TTS!'); | |
| interruptTTS(); | |
| return; | |
| } | |
| } else { | |
| // More aggressive decay to prevent false positives from sustained background noise | |
| speechDetectionCount = Math.max(0, speechDetectionCount - 2); | |
| // Debug log for borderline cases | |
| if (average > 0.05) { | |
| console.log(`🔍 Audio detected but not speech - level: ${average.toFixed(3)}, noise: ${backgroundNoiseLevel.toFixed(3)}, above_threshold: ${isAboveBaselineThreshold}, above_noise: ${isAboveNoiseFloor}, is_peak: ${isPeakDetection}`); | |
| } | |
| } | |
| // Continue monitoring | |
| requestAnimationFrame(detectSpeechActivity); | |
| } | |
| // Start monitoring | |
| requestAnimationFrame(detectSpeechActivity); | |
| } | |
| function interruptTTS() { | |
| console.log('🛑 Interrupting TTS due to user speech'); | |
| ttsInterruptible = false; | |
| speechDetectionCount = 0; | |
| // Stop current TTS audio | |
| if (currentTTSAudio) { | |
| currentTTSAudio.pause(); | |
| currentTTSAudio.currentTime = 0; | |
| console.log('🔇 TTS audio stopped'); | |
| } | |
| // Clear TTS queue if using WebRTC TTS | |
| if (typeof chatCalTTS !== 'undefined' && chatCalTTS) { | |
| chatCalTTS.stop(); | |
| console.log('🔇 WebRTC TTS stopped'); | |
| } | |
| resumeMicrophoneAfterTTS(); | |
| } | |
| function resumeMicrophoneAfterTTS() { | |
| console.log('🎙️ Resuming normal microphone operation after TTS'); | |
| isTTSPlaying = false; | |
| ttsInterruptible = false; | |
| currentTTSAudio = null; | |
| speechDetectionCount = 0; | |
| recordingPausedForTTS = false; | |
| // Update microphone button to show ready state | |
| updateMicrophoneButtonState('ready'); | |
| // Ensure recording continues (should already be active if interrupt feature worked) | |
| if (sttv2Manager && !sttv2Manager.isRecording && !isMicrophoneMuted) { | |
| setTimeout(async () => { | |
| await sttv2Manager.startRecording(); | |
| setupAudioVisualization(); | |
| }, 100); | |
| } | |
| // Show audio visualizer again | |
| const visualizer = document.getElementById('audioVisualizer'); | |
| if (visualizer && !isMicrophoneMuted) { | |
| visualizer.style.display = 'block'; | |
| } | |
| } | |
| // Replace the original load event listener | |
| window.removeEventListener('load', initAndStartSTT); | |
| window.addEventListener('load', initAndStartSTTWithAutoStart); | |
| // Update menu items based on collected user information | |
| // GREEN = still needed | BLACK = already captured | |
| async function updateMenuItemColors() { | |
| try { | |
| if (!sessionId) { | |
| console.warn('⚠️ No session ID available for color update'); | |
| return; | |
| } | |
| console.log('🔍 Fetching session-info for color update... Session:', sessionId); | |
| const response = await fetch(`/api/session-info?session_id=${sessionId}`); | |
| if (!response.ok) { | |
| console.warn('❌ Failed to fetch session-info:', response.status); | |
| return; | |
| } | |
| const data = await response.json(); | |
| console.log('📦 Full session data:', JSON.stringify(data, null, 2)); | |
| const userInfo = data.user_data?.user_info || {}; | |
| console.log('🎨 Updating menu colors with user info:', JSON.stringify(userInfo, null, 2)); | |
| // Menu item 1: Name | |
| const menuItem1 = document.getElementById('menuItem1'); | |
| if (menuItem1) { | |
| if (userInfo.name) { | |
| // Already captured - Black with strikethrough | |
| menuItem1.style.color = '#333'; | |
| menuItem1.style.textDecoration = 'line-through'; | |
| menuItem1.style.opacity = '0.6'; | |
| } else { | |
| // Still needed - Green and bold | |
| menuItem1.style.color = '#4caf50'; | |
| menuItem1.style.textDecoration = 'none'; | |
| menuItem1.style.opacity = '1'; | |
| menuItem1.style.fontWeight = 'bold'; | |
| } | |
| } | |
| // Menu item 2: Date | |
| const menuItem2 = document.getElementById('menuItem2'); | |
| if (menuItem2) { | |
| if (userInfo.date_string) { | |
| menuItem2.style.color = '#333'; | |
| menuItem2.style.textDecoration = 'line-through'; | |
| menuItem2.style.opacity = '0.6'; | |
| } else { | |
| menuItem2.style.color = '#4caf50'; | |
| menuItem2.style.textDecoration = 'none'; | |
| menuItem2.style.opacity = '1'; | |
| menuItem2.style.fontWeight = 'bold'; | |
| } | |
| } | |
| // Menu item 3: Time | |
| const menuItem3 = document.getElementById('menuItem3'); | |
| if (menuItem3) { | |
| if (userInfo.time_string) { | |
| menuItem3.style.color = '#333'; | |
| menuItem3.style.textDecoration = 'line-through'; | |
| menuItem3.style.opacity = '0.6'; | |
| } else { | |
| menuItem3.style.color = '#4caf50'; | |
| menuItem3.style.textDecoration = 'none'; | |
| menuItem3.style.opacity = '1'; | |
| menuItem3.style.fontWeight = 'bold'; | |
| } | |
| } | |
| // Menu item 4: Length (duration) | |
| const menuItem4 = document.getElementById('menuItem4'); | |
| if (menuItem4) { | |
| if (userInfo.duration_minutes) { | |
| menuItem4.style.color = '#333'; | |
| menuItem4.style.textDecoration = 'line-through'; | |
| menuItem4.style.opacity = '0.6'; | |
| } else { | |
| menuItem4.style.color = '#4caf50'; | |
| menuItem4.style.textDecoration = 'none'; | |
| menuItem4.style.opacity = '1'; | |
| menuItem4.style.fontWeight = 'bold'; | |
| } | |
| } | |
| // Menu item 5: Agenda (topic) | |
| const menuItem5 = document.getElementById('menuItem5'); | |
| if (menuItem5) { | |
| if (userInfo.topic) { | |
| menuItem5.style.color = '#333'; | |
| menuItem5.style.textDecoration = 'line-through'; | |
| menuItem5.style.opacity = '0.6'; | |
| } else { | |
| menuItem5.style.color = '#4caf50'; | |
| menuItem5.style.textDecoration = 'none'; | |
| menuItem5.style.opacity = '1'; | |
| menuItem5.style.fontWeight = 'bold'; | |
| } | |
| } | |
| // Menu item 6: GoogleMeet or phone call | |
| const menuItem6 = document.getElementById('menuItem6'); | |
| if (menuItem6) { | |
| if (userInfo.preferences?.google_meet || userInfo.preferences?.phone_call) { | |
| menuItem6.style.color = '#333'; | |
| menuItem6.style.textDecoration = 'line-through'; | |
| menuItem6.style.opacity = '0.6'; | |
| } else { | |
| menuItem6.style.color = '#4caf50'; | |
| menuItem6.style.textDecoration = 'none'; | |
| menuItem6.style.opacity = '1'; | |
| menuItem6.style.fontWeight = 'bold'; | |
| } | |
| } | |
| // Menu item 7: Phone number | |
| const menuItem7 = document.getElementById('menuItem7'); | |
| if (menuItem7) { | |
| if (userInfo.phone) { | |
| menuItem7.style.color = '#333'; | |
| menuItem7.style.textDecoration = 'line-through'; | |
| menuItem7.style.opacity = '0.6'; | |
| } else { | |
| menuItem7.style.color = '#4caf50'; | |
| menuItem7.style.textDecoration = 'none'; | |
| menuItem7.style.opacity = '1'; | |
| menuItem7.style.fontWeight = 'bold'; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error updating menu colors:', error); | |
| } | |
| } | |
| // Initial update on page load | |
| window.addEventListener('load', () => { | |
| setTimeout(updateMenuItemColors, 500); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| return html_content.replace('{base_url}', base_url).replace('{default_email}', default_email) | |
| 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> | |
| """ |