voiceCal-ai-v2 / app /api /chat_widget.py
pgits's picture
REBRAND: Update UI from ChatCal.ai to VoiceCal.ai
acbbf62
"""Chat widget HTML interface for ChatCal.ai."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.get("/chat-widget", response_class=HTMLResponse)
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;
}
.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;
}
.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;
}
.quick-actions {
display: flex;
gap: 10px;
margin: 10px 0;
flex-wrap: wrap;
}
.quick-action {
background: #f0f0f0;
color: #555;
padding: 8px 12px;
border-radius: 15px;
border: none;
cursor: pointer;
font-size: 12px;
transition: background-color 0.3s;
}
.quick-action:hover {
background: #e0e0e0;
}
/* STT Quick Action Animation */
.quick-action.listening {
animation: stt-recording-pulse 1.5s infinite;
}
@keyframes stt-recording-pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 8px rgba(244, 67, 54, 0.1);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4);
}
}
/* 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: 20px;
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: relative;
margin: 15px auto 20px auto;
width: fit-content;
background: rgba(76, 175, 80, 0.9);
padding: 15px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
z-index: 1000;
backdrop-filter: blur(10px);
}
.audio-visualizer canvas {
display: block;
border-radius: 8px;
background: rgba(0,0,0,0.1);
}
.visualizer-info {
text-align: center;
color: white;
font-size: 12px;
margin-top: 8px;
font-weight: bold;
}
/* 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: 8px;
padding: 8px 12px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 10;
min-width: 60px;
}
.mute-toggle-btn:hover {
transform: translateY(-50%) scale(1.05);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
/* Unmuted state - Green (ready to mute) */
.mute-toggle-btn.unmuted {
background: linear-gradient(135deg, #4CAF50, #45a049);
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
}
/* Muted state - Red (ready to unmute) */
.mute-toggle-btn.muted {
background: linear-gradient(135deg, #f44336, #d32f2f);
box-shadow: 0 2px 8px rgba(244, 67, 54, 0.3);
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 80px 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:</p>
<p style="margin: 5px 0 2px 0; font-size: 13px;">1) Say your name</p>
<p style="margin: 2px 0; font-size: 13px;">2) The date, time, length, and agenda</p>
<p style="margin: 2px 0; font-size: 13px;">3) GoogleMeet conference or phone call</p>
<p style="margin: 2px 0 0 0; font-size: 13px;">4) Your phone number, in case I need to call you</p>
</div>
<div class="quick-actions">
<button class="quick-action" onclick="sendQuickMessage('Schedule a Google Meet with Peter')">πŸŽ₯ Google Meet</button>
<button class="quick-action" onclick="sendQuickMessage('Check Peter\\'s availability tomorrow')">πŸ“… Check availability</button>
<button class="quick-action" onclick="sendQuickMessage('Schedule an in-person meeting')">🀝 In-person meeting</button>
<button class="quick-action" onclick="sendQuickMessage('/help')">❓ Help</button>
</div>
</div>
<div class="typing-indicator" id="typingIndicator">
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<!-- 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">Mute</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="300" height="60"></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;
// STT v2 Variables
let sttv2Manager = null;
let silenceTimer = null;
let lastSpeechTime = 0;
let hasReceivedSpeech = false;
// TTS Integration - ChatCal WebRTC TTS Class
class ChatCalTTS {
constructor() {
this.audioContext = null;
this.audioElement = document.getElementById('ttsAudioElement');
this.webrtcEnabled = false;
this.initializeTTS();
}
async initializeTTS() {
try {
console.log('🎀 Initializing WebRTC TTS for ChatCal...');
// Request microphone access to enable WebRTC autoplay policies
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Create AudioContext
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Resume if suspended
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
// Stop microphone (we don't need to record)
stream.getTracks().forEach(track => track.stop());
this.webrtcEnabled = true;
this.isPlaying = false; // Track if audio is currently playing
this.audioQueue = []; // Queue for pending TTS requests
console.log('βœ… WebRTC TTS enabled for ChatCal');
} catch (error) {
console.warn('⚠️ TTS initialization failed, continuing without TTS:', error);
this.webrtcEnabled = false;
}
}
async synthesizeAndPlay(text) {
if (!this.webrtcEnabled || !text.trim()) {
return;
}
// Prevent very long TTS (over 500 characters)
if (text.length > 500) {
console.log('πŸ”‡ Skipping very long TTS text:', text.length, 'characters');
return;
}
// Prevent duplicate TTS requests for the same text
if (this.audioQueue.includes(text)) {
console.log('πŸ”„ Skipping duplicate TTS request for:', text.substring(0, 30) + '...');
return;
}
// Add to queue and process (may preempt current audio)
this.audioQueue.push(text);
console.log(`πŸ“ Added to TTS queue (${this.audioQueue.length} items). Playing: ${this.isPlaying}`);
this.processAudioQueue();
}
async processAudioQueue() {
// No longer preempt current audio - let it finish naturally
// If already playing, wait for current audio to complete before starting next item
// If still playing or queue is empty, don't start new playback
if (this.isPlaying || this.audioQueue.length === 0) {
return;
}
// Get next text from queue
const text = this.audioQueue.shift();
this.isPlaying = true;
try {
console.log('🎡 Synthesizing TTS for:', text.substring(0, 50) + '...');
// DON'T stop currently playing audio - let it complete naturally
// Only interrupt if user explicitly presses ESC key
console.log('⏳ TTS processing (current audio will continue)...');
// Call our TTS proxy endpoint (no CORS issues)
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: 'expresso/ex03-ex01_happy_001_channel1_334s.wav'
})
});
if (!response.ok) {
throw new Error(`TTS proxy error: ${response.status}`);
}
const result = await response.json();
const totalTime = performance.now() - startTime;
console.log(`🎡 TTS completed in ${totalTime.toFixed(0)}ms:`, result);
if (!result.success || !result.audio_url) {
throw new Error('TTS generation failed');
}
// Use the audio URL returned by our TTS backend
const fullAudioUrl = result.audio_url;
console.log('πŸ”Š Playing audio:', fullAudioUrl);
// Set up audio element for playback
this.audioElement.src = fullAudioUrl;
this.audioElement.load();
// Add timeout to prevent queue from getting stuck
const timeoutId = setTimeout(() => {
console.warn('⏰ TTS playback timeout - resetting queue');
this.isPlaying = false;
this.processAudioQueue();
}, 30000); // 30 second timeout
// Add event listeners for when audio finishes
const onAudioEnd = () => {
console.log('🎡 TTS audio finished');
clearTimeout(timeoutId);
this.isPlaying = false;
this.audioElement.removeEventListener('ended', onAudioEnd);
this.audioElement.removeEventListener('error', onAudioError);
// Resume microphone after TTS completes
resumeMicrophoneAfterTTS();
// Process next item in queue
this.processAudioQueue();
};
const onAudioError = (error) => {
console.warn('πŸ”‡ TTS audio error:', error);
clearTimeout(timeoutId);
this.isPlaying = false;
this.audioElement.removeEventListener('ended', onAudioEnd);
this.audioElement.removeEventListener('error', onAudioError);
// Resume microphone after TTS error
resumeMicrophoneAfterTTS();
// Process next item in queue
this.processAudioQueue();
};
this.audioElement.addEventListener('ended', onAudioEnd);
this.audioElement.addEventListener('error', onAudioError);
// Setup TTS with speech interrupt capability
setupTTSWithInterrupt(this.audioElement);
await this.audioElement.play();
console.log('🎡 TTS audio started successfully');
} catch (error) {
console.warn('πŸ”‡ TTS failed silently:', error);
this.isPlaying = false;
// Process next item in queue
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);
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
console.log('🎀 STT v2 Manager initialized');
}
generateSessionHash() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
async toggleRecording() {
if (!this.isRecording) {
await this.startRecording();
} else {
await this.stopRecording();
}
}
async startRecording() {
try {
console.log('🎀 Starting STT v2 recording...');
updateSTTVisualState('connecting');
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 44100,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
// 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;
}
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 2.5 second timer after ANY transcription
console.log('⏱️ Starting 2.5 second timer after transcription...');
// Clear any existing timer first
if (silenceTimer) {
clearTimeout(silenceTimer);
}
// Email is provided from landing page, no need to detect it in speech
// Start 2.5 second timer for ALL transcriptions (not just emails)
silenceTimer = setTimeout(() => {
if (hasReceivedSpeech && messageInput.value.trim()) {
console.log('⏱️ 2.5 seconds completed after transcription, auto-submitting...');
submitMessage();
}
}, 2500);
}
// 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) {
// 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) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`;
const avatar = document.createElement('div');
avatar.className = 'message-avatar';
avatar.textContent = isUser ? 'πŸ‘€' : 'πŸ€–';
const messageContent = document.createElement('div');
messageContent.className = 'message-content';
// For assistant messages, start TTS FIRST, then display with delay
if (!isUser && content && content.trim()) {
// Create temporary element to extract text content
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
let textContent = tempDiv.textContent || tempDiv.innerText;
if (textContent && textContent.trim()) {
// Remove "assistant:" prefix if present
textContent = textContent.replace(/^assistant:\s*/i, '').trim();
// Start TTS synthesis immediately (non-blocking)
chatCalTTS.synthesizeAndPlay(textContent);
// Wait 500ms before displaying the text
await new Promise(resolve => setTimeout(resolve, 500));
}
// Now render the HTML content
messageContent.innerHTML = content;
} else {
// For user messages, just set text content immediately
messageContent.textContent = content;
}
messageDiv.appendChild(avatar);
messageDiv.appendChild(messageContent);
// Simply append to chatMessages instead of insertBefore
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function showTyping() {
if (typingIndicator) {
typingIndicator.style.display = 'block';
}
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function hideTyping() {
if (typingIndicator) {
typingIndicator.style.display = 'none';
}
}
async function sendMessage(message = null) {
const text = message || messageInput.value.trim();
if (!text || isLoading) {
return;
}
// Ensure we have a session before sending
if (!sessionId) {
await initializeSession();
if (!sessionId) {
await addMessage('Sorry, I had trouble connecting. Please try again!');
return;
}
}
// Add user message
await addMessage(text, true);
messageInput.value = '';
// Show loading state
isLoading = true;
sendButton.disabled = true;
showTyping();
try {
const response = await fetch('{base_url}/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: text,
session_id: sessionId
})
});
if (response.ok) {
const data = await response.json();
sessionId = data.session_id; // Update session ID
await addMessage(data.response);
// TTS integration - play the response (remove assistant prefix)
if (chatCalTTS && chatCalTTS.webrtcEnabled && data.response) {
let cleanResponse = data.response.replace(/^assistant:\s*/i, '').trim();
chatCalTTS.synthesizeAndPlay(cleanResponse);
}
} else {
const error = await response.json();
await addMessage(`Sorry, I encountered an error: ${error.message || 'Unknown error'}`);
}
} catch (error) {
console.error('Chat error:', error);
await addMessage('Sorry, I had trouble connecting. Please try again!');
} finally {
isLoading = false;
sendButton.disabled = false;
hideTyping();
// Clear any pending speech timers to allow fresh voice input
hasReceivedSpeech = false;
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
}
}
function sendQuickMessage(message) {
messageInput.value = message;
sendMessage();
}
// Event listeners
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Auto-resize textarea as content grows
function autoResizeTextarea() {
messageInput.style.height = 'auto';
const newHeight = Math.min(messageInput.scrollHeight, 120); // Max height 120px
messageInput.style.height = newHeight + 'px';
}
// Enhanced input handling with typing delay for STT
let typingTimer = null;
let lastTypingTime = 0;
let lastMouseMoveTime = 0;
let mouseTimer = null;
messageInput.addEventListener('input', function() {
autoResizeTextarea();
// Track typing activity to delay STT auto-submission
lastTypingTime = Date.now();
// Mouse movement tracking for editing detection
lastMouseMoveTime = Date.now();
// If user is typing, clear any existing silence timer to prevent premature submission
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
// Clear existing typing timer
if (typingTimer) {
clearTimeout(typingTimer);
}
// Set new typing timer - if user stops typing for 2.5 seconds, check for STT auto-submission
typingTimer = setTimeout(() => {
// Only check for STT auto-submission if user has stopped typing and we have speech input
if (hasReceivedSpeech && messageInput.value.trim() && (Date.now() - lastTypingTime) >= 2500) {
console.log('πŸ”‡ User stopped typing, checking for STT auto-submission...');
// Additional delay to ensure user is done typing (2.5 seconds after last keystroke)
silenceTimer = setTimeout(() => {
if (hasReceivedSpeech && messageInput.value.trim()) {
console.log('πŸ”‡ STT auto-submitting after typing pause...');
submitMessage();
}
}, 2500);
}
}, 2500);
});
messageInput.addEventListener('paste', () => setTimeout(autoResizeTextarea, 0));
// Add click listener to send button
if (sendButton) {
sendButton.addEventListener('click', function(e) {
e.preventDefault();
sendMessage();
});
}
// Mouse movement detection for editing detection
document.addEventListener('mousemove', function() {
lastMouseMoveTime = Date.now();
// Reset timer when user moves mouse (indicates they might be editing)
resetAutoSubmitTimer();
});
// Keyboard activity detection for editing detection
messageInput.addEventListener('keydown', function() {
// Reset timer when user types (indicates they are editing)
resetAutoSubmitTimer();
});
messageInput.addEventListener('input', function() {
// Reset timer when input content changes
resetAutoSubmitTimer();
});
// Function to reset the auto-submit timer
function resetAutoSubmitTimer() {
if (silenceTimer && hasReceivedSpeech) {
console.log('⌨️ User activity detected, resetting 2.5 second timer...');
clearTimeout(silenceTimer);
// Restart the 2.5 second timer
silenceTimer = setTimeout(() => {
if (hasReceivedSpeech && messageInput.value.trim()) {
console.log('⏱️ 2.5 seconds completed after user activity, auto-submitting...');
submitMessage();
}
}, 2500);
}
}
// Initialize when page loads
// STT v2 indicator click to toggle recording
// 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();
}
// Hide audio visualizer
const visualizer = document.getElementById('audioVisualizer');
if (visualizer) {
visualizer.style.display = 'none';
}
// 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;
muteToggle.classList.remove('muted', 'unmuted');
if (state === 'muted') {
muteToggle.classList.add('muted');
muteToggle.textContent = 'Unmute';
muteToggle.title = 'Click to UNMUTE microphone';
} else {
muteToggle.classList.add('unmuted');
muteToggle.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
function checkForBookingSuccess() {
const successMarker = document.getElementById('booking-success');
if (successMarker) {
console.log('πŸŽ‰ Booking successful! Starting celebration...');
showFireworksAndRedirect();
return;
}
// Fallback: Check for booking success text patterns
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,
/All set!/i
];
for (const pattern of bookingPatterns) {
if (pattern.test(messageText)) {
console.log('πŸŽ‰ Booking detected via text pattern! Starting celebration...');
showFireworksAndRedirect();
return;
}
}
}
}
function showFireworksAndRedirect() {
// 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(() => {
window.location.href = '/';
}, 3000);
}
// Monitor for booking success and extract user info after each message
const originalAddMessage = addMessage;
addMessage = function(message, isUser = false) {
originalAddMessage(message, isUser);
if (!isUser) {
// Check for booking success after adding assistant message
setTimeout(checkForBookingSuccess, 100);
// Extract user info if present in response
extractUserInfoFromResponse(message);
}
};
// Auto-start recording and show popup notification
async function autoStartRecording() {
try {
// Show recording popup for 2 seconds
showRecordingPopup();
// Auto-start recording after a short delay
setTimeout(async () => {
if (sttv2Manager && !sttv2Manager.isRecording) {
await sttv2Manager.startRecording();
setupAudioVisualization();
}
}, 500);
} 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;
let dataArray = null;
let animationId = null;
async function setupAudioVisualization() {
try {
const visualizer = document.getElementById('audioVisualizer');
const canvas = document.getElementById('audioCanvas');
const ctx = canvas.getContext('2d');
// Show visualizer
visualizer.style.display = 'block';
// Set up audio context for visualization
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Get microphone stream
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioContext.createMediaStreamSource(stream);
// Create analyser
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);
}
}
// Draw waveform visualization
function drawWaveform(canvas, ctx) {
if (!analyser || !dataArray) return;
animationId = requestAnimationFrame(() => drawWaveform(canvas, ctx));
analyser.getByteFrequencyData(dataArray);
// Clear canvas
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw waveform bars
const barWidth = (canvas.width / dataArray.length) * 2.5;
let barHeight;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
barHeight = (dataArray[i] / 255) * canvas.height * 0.8;
// Create gradient for bars
const gradient = ctx.createLinearGradient(0, canvas.height, 0, canvas.height - barHeight);
gradient.addColorStop(0, '#4CAF50');
gradient.addColorStop(1, '#81C784');
ctx.fillStyle = gradient;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
}
// 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.15; // Higher threshold - requires actual speaking (15% of max volume)
let backgroundNoiseLevel = 0.05; // Baseline noise level to ignore
let speechDetectionCount = 0;
let speechDetectionRequired = 5; // More consecutive detections needed (reduces false positives)
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.03); // 3% 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);
</script>
</body>
</html>
"""
return html_content.replace('{base_url}', base_url).replace('{default_email}', default_email)
@router.get("/widget", response_class=HTMLResponse)
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>
"""