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