diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7567fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Environment variables +.env +# Python cache +__pycache__/ +*.pyc diff --git a/AI_Voice_Agent.md b/AI_Voice_Agent.md new file mode 100644 index 0000000..44c4870 --- /dev/null +++ b/AI_Voice_Agent.md @@ -0,0 +1,80 @@ +# Open Source Hackathon 2026 Project Submission + +## Participant Details + +**Full Name:** +Saloni Sharma + +**GitHub Username:** +Saloni90Sharma + +**Team Name:** + + +**College/University:** +GLA University Mathura + +--- + +## Project Details + +**Project Title:** +AI Voice Agent + +**Project Description:** +AI-powered voice assistant built with FastAPI, Google Gemini 1.5 Flash, and Web Speech APIs, featuring real-time voice interaction, conversation memory, and natural speech responses. + +**Tech Stack Used:** +Frontend: HTML5, CSS3, JavaScript +Backend: Python, FastAPI, Uvicorn +AI Model: Google Gemini 1.5 Flash +Speech Recognition: Web Speech API +Text-to-Speech: SpeechSynthesis API +Deployment: Render, GitHub + +**GitHub Repository Link:** +[](https://github.com/Saloni90sharma/AI_Voice_Agent_Project.git) + +**Live Demo Link:** + + +**Presentation / Demo Video Link:** + + +--- + +## Open Source Readiness + +- [✓] My project is public on GitHub +- [✓] My repository has a proper README.md +- [✓] I have added setup/installation instructions +- [✓] I have added screenshots/demo where possible +- [✓] I have added a license file +- [✓] My project is original and built/updated during the hackathon period + +--- + +## Memori Labs Sponsor Task + +Please complete these before submitting: + +- [✓] I have starred the Memori Labs GitHub repository + https://github.com/MemoriLabs/Memori + +- [✓] I have followed Memori Labs on LinkedIn + https://www.linkedin.com/company/memorilabs/ + +- [✓] I have followed Memori Labs on X + https://x.com/memorilab + +- [✓] I have checked Memori Labs social links + https://linktr.ee/memorilabs + +--- + +## ID Card Verification + +- [✓] I have generated my ID card from https://oshack.xyz +- [✓] If my ID was not verified, I completed the mandatory verification/giveaway form and tried again + +--- diff --git a/README.md b/README.md new file mode 100644 index 0000000..51c3491 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# 🎙️ **AI Voice Agent with Chat History** +🚀 *Day 10 of my AI Voice Agent Challenge* + +> 🗣️ Talk to AI, have a real conversation, and get human-like voice replies — **with memory**! + +--- + +## ✨ **Features** +✅ **Voice Input** — Speak directly to the AI agent +✅ **Conversation Memory** — Remembers past messages in the same session +✅ **Smart Responses** — Powered by Google Gemini LLM +✅ **Natural Voice Output** — Murf AI TTS for lifelike speech +✅ **Lightweight UI** — Plain HTML, CSS, JS (no heavy frameworks) + +--- + +## 🏗 **Architecture** + +🎤 Your Voice +⬇ +📝 AssemblyAI (Speech-to-Text) +⬇ +⚙️ Python FastAPI Server (Stores Chat History) +⬇ +🧠 Google Gemini (LLM) +⬇ +🎙️ Murf AI (Text-to-Speech) +⬇ +🔊 Audio Response + + +--- + +## 🛠 **Tech Stack** +| Layer | Technology | +|----------------|------------| +| **Backend** | Python, FastAPI, Uvicorn | +| **Frontend** | HTML5, CSS3, JavaScript | +| **STT** | AssemblyAI API | +| **LLM** | Google Gemini API | +| **TTS** | Murf AI API | + +--- + +## ⚡ **Quick Start** + +### 1️⃣ Prerequisites +- Python **3.9+** +- API Keys: + - `ASSEMBLYAI_API_KEY` + - `GOOGLE_GEMINI_API_KEY` + - `MURF_API_KEY` + +### 2️⃣ Environment Variables +Create a `.env` file in the project root: + +```env +ASSEMBLYAI_API_KEY=your_assemblyai_api_key +GOOGLE_GEMINI_API_KEY=your_gemini_api_key +MURF_API_KEY=your_murf_api_key + +### 3️⃣ Install Dependencies +pip install -r requirements.txt + +# Backend Framework +fastapi +uvicorn + +# API Calls & Utilities +requests +python-dotenv + +# Audio Handling +pydub +soundfile +numpy + +# STT (Speech-to-Text) +assemblyai + +# LLM (Google Gemini) +google-generativeai + +# TTS (Text-to-Speech - Murf AI via API calls) +# No official Python SDK, handled via requests + +# Optional Dev Tools +black + + +### 4️⃣ Run the API Server +uvicorn main:app --reload + +### 5️⃣ Open the Frontend + +Open frontend.html in your browser with a session_id: +http://127.0.0.1:5500/frontend.html?session_id=abc123 + +🏆 Author + +👨‍💻 Saloni Sharma — AI Developer & Voice Tech Enthusiast +📌 13th Part of my 30 Days of AI Voice Agents series diff --git a/Submission/PROJECT NAME.md b/Submission/PROJECT NAME.md deleted file mode 100644 index 13362da..0000000 --- a/Submission/PROJECT NAME.md +++ /dev/null @@ -1,75 +0,0 @@ -# Open Source Hackathon 2026 Project Submission - -## Participant Details - -**Full Name:** - - -**GitHub Username:** - - -**Team Name:** - - -**College/University:** - - ---- - -## Project Details - -**Project Title:** - - -**Project Description:** - - -**Tech Stack Used:** - - -**GitHub Repository Link:** - - -**Live Demo Link:** - - -**Presentation / Demo Video Link:** - - ---- - -## Open Source Readiness - -- [ ] My project is public on GitHub -- [ ] My repository has a proper README.md -- [ ] I have added setup/installation instructions -- [ ] I have added screenshots/demo where possible -- [ ] I have added a license file -- [ ] My project is original and built/updated during the hackathon period - ---- - -## Memori Labs Sponsor Task - -Please complete these before submitting: - -- [ ] I have starred the Memori Labs GitHub repository - https://github.com/MemoriLabs/Memori - -- [ ] I have followed Memori Labs on LinkedIn - https://www.linkedin.com/company/memorilabs/ - -- [ ] I have followed Memori Labs on X - https://x.com/memorilab - -- [ ] I have checked Memori Labs social links - https://linktr.ee/memorilabs - ---- - -## ID Card Verification - -- [ ] I have generated my ID card from https://oshack.xyz -- [ ] If my ID was not verified, I completed the mandatory verification/giveaway form and tried again - ---- diff --git a/index.html b/index.html new file mode 100644 index 0000000..eea6d38 --- /dev/null +++ b/index.html @@ -0,0 +1,160 @@ + + + + + + Conversational AI Voice Agent + + + + +
+ +
+

🤖 AI Voice Assistant

+

Have natural conversations with advanced AI

+
+ + +
+
+ Session: Loading... +
+
+ Turns: 0 | Messages: + 0 +
+
+ + +
+

🎙️ Voice Conversation

+ + +
+ + +
+ + +
+
+ +
+
+ Click to start recording +
+
+ + +
+
+ + +
+
+ + +
+
🟢
+ Ready to chat +
+ + +
+
+ Auto-record +
+
+
+ OFF +
+
+ + +
+

💬 Conversation History

+
+
+
💭
+

Start a conversation by recording or typing a message

+
+
+
+
+ + +
+

⚙️ Session Management

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + + + +
+
+
+ Checking server... +
+
+ + +
+
Press Space to record
+
Press Esc to dismiss
+
+
+ + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..bd6e7d3 --- /dev/null +++ b/main.py @@ -0,0 +1,1228 @@ +from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Path +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from murf import Murf +from dotenv import load_dotenv +from pathlib import Path as PathLib +from datetime import datetime +from typing import Dict, List +import time +import assemblyai as aai +import os +import uuid +import tempfile +import requests +import json +from typing import Dict, Any, Optional +from fastapi import Body +import httpx +import google.generativeai as genai +import logging +import traceback +from functools import wraps +import asyncio + +# Initialize global variables at module level +client = None +gemini_model = None + +# Configure comprehensive logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +load_dotenv() + +app = FastAPI(title="Robust Echo Bot v2 with Comprehensive Error Handling") + +# Mount static files (HTML, JS, etc.) +static_dir = PathLib("static") +static_dir.mkdir(exist_ok=True) +app.mount("/static", StaticFiles(directory="static"), name="static") + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + +# Enhanced CORS middleware configuration +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, replace with your specific domain + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], + expose_headers=["*"], + max_age=3600, +) + +# Configuration - Get from environment variables with fallback error messages +ASSEMBLYAI_API_KEY = os.getenv("ASSEMBLYAI_API_KEY") +MURF_API_KEY = os.getenv("MURF_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +def initialize_apis(): + """Initialize all API clients with proper error handling""" + global client, gemini_model + + api_status = { + "assemblyai": False, + "murf": False, + "gemini": False, + "errors": [] + } + + # Initialize AssemblyAI + try: + if ASSEMBLYAI_API_KEY: + aai.settings.api_key = ASSEMBLYAI_API_KEY + api_status["assemblyai"] = True + logger.info("AssemblyAI initialized successfully") + else: + api_status["errors"].append("AssemblyAI API key not configured") + logger.error("AssemblyAI API key missing") + except Exception as e: + api_status["errors"].append(f"AssemblyAI initialization failed: {str(e)}") + logger.error(f"AssemblyAI initialization error: {str(e)}") + + # Initialize Murf + try: + if MURF_API_KEY: + client = Murf(api_key=MURF_API_KEY) + api_status["murf"] = True + logger.info("Murf initialized successfully") + else: + client = None + api_status["errors"].append("Murf API key not configured") + logger.error("Murf API key missing") + except Exception as e: + client = None + api_status["errors"].append(f"Murf initialization failed: {str(e)}") + logger.error(f"Murf initialization error: {str(e)}") + + # Initialize Gemini + try: + if GEMINI_API_KEY: + genai.configure(api_key=GEMINI_API_KEY) + gemini_model = genai.GenerativeModel('gemini-1.5-flash') + api_status["gemini"] = True + logger.info("Gemini initialized successfully") + else: + gemini_model = None + api_status["errors"].append("Gemini API key not configured") + logger.error("Gemini API key missing") + except Exception as e: + gemini_model = None + api_status["errors"].append(f"Gemini initialization failed: {str(e)}") + logger.error(f"Gemini initialization error: {str(e)}") + + return api_status + +# UNCOMMENT THESE LINES TO SIMULATE API FAILURES: +#ASSEMBLYAI_API_KEY = None # Simulate STT failure +# MURF_API_KEY = None # Simulate TTS failure +# GEMINI_API_KEY = None # Simulate LLM failure + +# Error messages for different failure scenarios +ERROR_MESSAGES = { + "stt_failure": "I'm having trouble understanding your audio right now. Please try again or type your message instead.", + "llm_failure": "I'm having trouble connecting to my brain right now. Let me try that again.", + "tts_failure": "I can understand you, but I'm having trouble speaking right now. Here's my text response instead.", + "general_failure": "I'm experiencing some technical difficulties. Please try again in a moment.", + "no_input": "I didn't catch that. Could you please try recording again or type your message?", + "timeout": "That took longer than expected. Let me try a different approach.", + "network_error": "I'm having trouble connecting right now. Please check your internet connection and try again." +} + +# Fallback TTS audio URLs (pre-generated error messages) +FALLBACK_AUDIO_URLS = { + "stt_failure": "https://example.com/fallback/stt_error.mp3", + "llm_failure": "https://example.com/fallback/llm_error.mp3", + "tts_failure": "https://example.com/fallback/tts_error.mp3", + "general_failure": "https://example.com/fallback/general_error.mp3" +} + +# ENHANCED: In-memory chat history storage +CHAT_HISTORY: Dict[str, List[Dict]] = {} + +# Error handling decorators +def handle_api_errors(error_type: str): + """Decorator to handle specific API errors with fallback responses""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + logger.error(f"{error_type} error in {func.__name__}: {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + + error_response = { + "error": True, + "error_type": error_type, + "error_message": ERROR_MESSAGES.get(error_type, ERROR_MESSAGES["general_failure"]), + "fallback_audio_url": FALLBACK_AUDIO_URLS.get(error_type), + "status": "error", + "original_error": str(e) + } + + return error_response + return wrapper + return decorator + +def retry_with_fallback(max_retries: int = 3, delay: float = 1.0): + """Decorator to retry operations with exponential backoff""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + last_exception = None + + for attempt in range(max_retries): + try: + logger.info(f"Attempt {attempt + 1}/{max_retries} for {func.__name__}") + return await func(*args, **kwargs) + except Exception as e: + last_exception = e + if attempt < max_retries - 1: + wait_time = delay * (2 ** attempt) # Exponential backoff + logger.warning(f"Attempt {attempt + 1} failed: {str(e)}. Retrying in {wait_time}s...") + await asyncio.sleep(wait_time) + else: + logger.error(f"All {max_retries} attempts failed for {func.__name__}") + raise last_exception + + raise last_exception + return wrapper + return decorator + +# Initialize APIs on startup +try: + api_status = initialize_apis() + logger.info(f"API initialization completed: {api_status}") +except Exception as e: + logger.error(f"Failed to initialize APIs: {str(e)}") + api_status = {"assemblyai": False, "murf": False, "gemini": False, "errors": [str(e)]} + +# Enhanced utility functions with error handling +def get_chat_history(session_id: str) -> List[Dict]: + """Get chat history for a session with error handling""" + try: + return CHAT_HISTORY.get(session_id, []) + except Exception as e: + logger.error(f"Error retrieving chat history for session {session_id}: {str(e)}") + return [] + +def add_to_chat_history(session_id: str, role: str, content: str): + """Add a message to chat history with error handling""" + try: + if session_id not in CHAT_HISTORY: + CHAT_HISTORY[session_id] = [] + + CHAT_HISTORY[session_id].append({ + "role": role, + "content": content, + "timestamp": time.time() + }) + logger.info(f"Added {role} message to session {session_id}") + except Exception as e: + logger.error(f"Error adding message to chat history: {str(e)}") + +def format_chat_for_gemini(session_id: str, new_user_message: str) -> str: + """Format chat history for Gemini API with error handling""" + try: + history = get_chat_history(session_id) + + # Build conversation context + conversation = [] + for msg in history[-10:]: # Keep last 10 messages for context + if msg["role"] == "user": + conversation.append(f"User: {msg['content']}") + else: + conversation.append(f"Assistant: {msg['content']}") + + # Add new user message + conversation.append(f"User: {new_user_message}") + + # Create prompt with context + if len(conversation) == 1: + # First message in conversation + prompt = f"You are a helpful AI assistant. Please respond conversationally (maximum 2500 characters) to: {new_user_message}" + else: + # Continuing conversation + context = "\n".join(conversation[:-1]) + prompt = f"""You are a helpful AI assistant. Here's our conversation so far: + +{context} + +Now the user says: {new_user_message} + +Please respond naturally and conversationally (maximum 2500 characters):""" + + return prompt + except Exception as e: + logger.error(f"Error formatting chat for Gemini: {str(e)}") + return f"Please respond to: {new_user_message}" + +# STT function with error handling +@handle_api_errors("stt_failure") +@retry_with_fallback(max_retries=2) +async def transcribe_audio_with_fallback(audio_bytes: bytes) -> str: + """Transcribe audio with comprehensive error handling""" + if not ASSEMBLYAI_API_KEY: + raise ValueError("AssemblyAI API key not configured") + + try: + logger.info(f"Starting transcription for {len(audio_bytes)} bytes") + transcriber = aai.Transcriber() + + # Add timeout handling + transcript = transcriber.transcribe(audio_bytes) + + if transcript.status == aai.TranscriptStatus.error: + raise ValueError(f"Transcription failed: {transcript.error}") + + if not transcript.text or transcript.text.strip() == "": + raise ValueError("No speech detected in audio") + + logger.info(f"Transcription successful: {len(transcript.text)} characters") + return transcript.text.strip() + + except Exception as e: + logger.error(f"Transcription error: {str(e)}") + raise + +#LLM function with error handling +@handle_api_errors("llm_failure") +@retry_with_fallback(max_retries=2) +async def generate_llm_response_with_fallback(session_id: str, user_input: str) -> str: + """Generate LLM response with comprehensive error handling""" + if not gemini_model: + raise ValueError("Gemini API key not configured") + + try: + formatted_prompt = format_chat_for_gemini(session_id, user_input) + logger.info(f"Querying Gemini for session {session_id}") + + response = gemini_model.generate_content(formatted_prompt) + + if not response.text: + raise ValueError("No response generated from Gemini") + + llm_response = response.text.strip() + logger.info(f"Gemini response generated: {len(llm_response)} characters") + + return llm_response + + except Exception as e: + logger.error(f"LLM generation error: {str(e)}") + raise + +# Enhanced TTS function with error handling +@handle_api_errors("tts_failure") +@retry_with_fallback(max_retries=2) +async def generate_audio_with_fallback(text: str, voice_id: str = "en-US-natalie") -> str: + """Generate TTS audio with comprehensive error handling""" + if not MURF_API_KEY: + raise ValueError("Murf API key not configured") + + try: + logger.info(f"Generating TTS for {len(text)} characters with voice {voice_id}") + + if client: + # Use Murf SDK + audio_res = client.text_to_speech.generate( + text=text, + voice_id=voice_id + ) + return audio_res.audio_file + else: + # Fallback to direct API call + headers = { + "Authorization": f"Bearer {MURF_API_KEY}", + "Content-Type": "application/json" + } + + payload = { + "voiceId": voice_id, + "style": "Conversational", + "text": text, + "rate": 0, + "pitch": 0, + "sampleRate": 22050, + "format": "MP3", + "channelType": "MONO", + "pronunciationDictionary": {}, + "encodeAsBase64": False + } + + async with httpx.AsyncClient(timeout=30.0) as client_http: + response = await client_http.post( + "https://api.murf.ai/v1/speech/generate", + headers=headers, + json=payload + ) + + if response.status_code != 200: + raise ValueError(f"Murf API error: {response.status_code} - {response.text}") + + result = response.json() + return result["audioFile"] + + except Exception as e: + logger.error(f"TTS generation error: {str(e)}") + raise + +# Create upload directory +UPLOAD_DIR = PathLib("uploads") +UPLOAD_DIR.mkdir(exist_ok=True) + +# Pydantic models +class TextRequest(BaseModel): + text: str + +class LLMQueryRequest(BaseModel): + text: str + +# Test endpoints for debugging +@app.get("/test") +async def test_endpoint(): + """Simple test endpoint to verify server is working""" + return {"message": "Server is working!", "timestamp": datetime.now().isoformat()} + +@app.options("/health") +async def health_options(): + """Handle preflight requests for health endpoint""" + return {"message": "OK"} + +@app.get("/static/test") +async def static_test(): + """Test static file serving""" + return {"message": "Static files working"} + +# ENHANCED MAIN CHAT ENDPOINT with comprehensive error handling +@app.post("/agent/chat/{session_id}") +async def agent_chat( + session_id: str = Path(..., description="Session ID for chat history"), + audio_file: UploadFile = File(None), + text: Optional[str] = Form(None), + voiceId: Optional[str] = Form(default="en-US-natalie") +): + """ + Enhanced Chat endpoint with comprehensive error handling and fallback responses + """ + # Add comprehensive logging at the start + logger.info(f"[Session {session_id}] Chat endpoint called") + logger.info(f"[Session {session_id}] Audio file: {audio_file.filename if audio_file else None}") + logger.info(f"[Session {session_id}] Text: {text[:50] if text else None}...") + logger.info(f"[Session {session_id}] Voice ID: {voiceId}") + + try: + input_text = "" + transcription_error = False + llm_error = False + tts_error = False + + # Step 1: Get input text (from audio or direct text) with error handling + if audio_file: + logger.info(f"[Session {session_id}] Processing audio input: {audio_file.filename}") + + try: + audio_bytes = await audio_file.read() + logger.info(f"[Session {session_id}] Audio file size: {len(audio_bytes)} bytes") + + # Attempt transcription with fallback + transcription_result = await transcribe_audio_with_fallback(audio_bytes) + + if isinstance(transcription_result, dict) and transcription_result.get("error"): + # STT failed - return error response with fallback + transcription_error = True + return JSONResponse( + status_code=200, # Don't return 500, let client handle gracefully + content={ + "session_id": session_id, + "error": True, + "error_type": "stt_failure", + "error_message": transcription_result["error_message"], + "fallback_audio_url": transcription_result.get("fallback_audio_url"), + "suggestion": "Please try recording again or type your message instead.", + "status": "error" + } + ) + + input_text = transcription_result + logger.info(f"[Session {session_id}] Transcribed text: {input_text}") + + except Exception as e: + logger.error(f"[Session {session_id}] Audio processing failed: {str(e)}") + return JSONResponse( + status_code=200, + content={ + "session_id": session_id, + "error": True, + "error_type": "stt_failure", + "error_message": ERROR_MESSAGES["stt_failure"], + "fallback_audio_url": FALLBACK_AUDIO_URLS.get("stt_failure"), + "suggestion": "Please try recording again or type your message instead.", + "status": "error" + } + ) + + elif text: + input_text = text.strip() + logger.info(f"[Session {session_id}] Direct text input: {input_text}") + else: + return JSONResponse( + status_code=400, + content={ + "session_id": session_id, + "error": True, + "error_type": "no_input", + "error_message": ERROR_MESSAGES["no_input"], + "status": "error" + } + ) + + if not input_text: + return JSONResponse( + status_code=400, + content={ + "session_id": session_id, + "error": True, + "error_type": "no_input", + "error_message": ERROR_MESSAGES["no_input"], + "status": "error" + } + ) + + # Step 2: Add user message to chat history + add_to_chat_history(session_id, "user", input_text) + logger.info(f"[Session {session_id}] Added user message to history") + + # Step 3: Generate LLM response with error handling + try: + llm_response_result = await generate_llm_response_with_fallback(session_id, input_text) + + if isinstance(llm_response_result, dict) and llm_response_result.get("error"): + # LLM failed - return error response with text-only fallback + llm_error = True + fallback_response = f"I'm having trouble processing that right now. You said: '{input_text}'. Could you please try rephrasing your question?" + + return JSONResponse( + status_code=200, + content={ + "session_id": session_id, + "input": input_text, + "response": fallback_response, + "error": True, + "error_type": "llm_failure", + "error_message": llm_response_result["error_message"], + "fallback_audio_url": llm_response_result.get("fallback_audio_url"), + "status": "error_with_fallback" + } + ) + + llm_response = llm_response_result + logger.info(f"[Session {session_id}] LLM response generated: {len(llm_response)} characters") + + except Exception as e: + logger.error(f"[Session {session_id}] LLM processing failed: {str(e)}") + fallback_response = f"I'm having trouble processing that right now. You said: '{input_text}'. Could you please try rephrasing your question?" + + return JSONResponse( + status_code=200, + content={ + "session_id": session_id, + "input": input_text, + "response": fallback_response, + "error": True, + "error_type": "llm_failure", + "error_message": ERROR_MESSAGES["llm_failure"], + "fallback_audio_url": FALLBACK_AUDIO_URLS.get("llm_failure"), + "status": "error_with_fallback" + } + ) + + # Step 4: Add assistant response to chat history + add_to_chat_history(session_id, "assistant", llm_response) + logger.info(f"[Session {session_id}] Added assistant response to history") + + # Step 5: Generate TTS with error handling + audio_urls = [] + try: + if len(llm_response) <= 3000: + # Single request + audio_result = await generate_audio_with_fallback(llm_response, voiceId) + + if isinstance(audio_result, dict) and audio_result.get("error"): + # TTS failed but we have text response + tts_error = True + logger.warning(f"[Session {session_id}] TTS failed, returning text-only response") + else: + audio_urls.append(audio_result) + else: + # Split into chunks + chunks = split_text_for_murf(llm_response, 2800) + logger.info(f"[Session {session_id}] Split response into {len(chunks)} chunks") + + for i, chunk in enumerate(chunks): + logger.info(f"[Session {session_id}] Processing chunk {i+1}/{len(chunks)}") + try: + audio_result = await generate_audio_with_fallback(chunk, voiceId) + if isinstance(audio_result, dict) and audio_result.get("error"): + tts_error = True + break + audio_urls.append(audio_result) + except Exception as chunk_error: + logger.error(f"[Session {session_id}] Chunk {i+1} TTS failed: {str(chunk_error)}") + tts_error = True + break + + except Exception as e: + logger.error(f"[Session {session_id}] TTS generation failed: {str(e)}") + tts_error = True + + # Step 6: Prepare response + history = get_chat_history(session_id) + conversation_length = len(history) + + response_data = { + "session_id": session_id, + "input": input_text, + "response": llm_response, + "model": "gemini-1.5-flash", + "voice_id": voiceId, + "audio_urls": audio_urls, + "audio_url": audio_urls[0] if audio_urls else None, + "chunks_count": len(audio_urls), + "conversation_length": conversation_length, + "conversation_turns": conversation_length // 2, + "status": "success" + } + + # Add TTS error info if applicable + if tts_error: + response_data.update({ + "tts_error": True, + "tts_error_message": ERROR_MESSAGES["tts_failure"], + "fallback_audio_url": FALLBACK_AUDIO_URLS.get("tts_failure"), + "status": "success_no_audio" + }) + + return response_data + + except Exception as e: + logger.error(f"[Session {session_id}] Unexpected error in agent_chat: {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + + return JSONResponse( + status_code=200, # Return 200 to allow client to handle gracefully + content={ + "session_id": session_id, + "error": True, + "error_type": "general_failure", + "error_message": ERROR_MESSAGES["general_failure"], + "fallback_audio_url": FALLBACK_AUDIO_URLS.get("general_failure"), + "original_error": str(e), + "status": "error" + } + ) + +# Get conversation history endpoint with error handling +@app.get("/agent/history/{session_id}") +async def get_conversation_history(session_id: str = Path(..., description="Session ID")): + """Get conversation history with error handling""" + try: + history = get_chat_history(session_id) + + return { + "session_id": session_id, + "message_count": len(history), + "conversation_turns": len(history) // 2, + "history": history, + "status": "success" + } + + except Exception as e: + logger.error(f"Error retrieving history for session {session_id}: {str(e)}") + return JSONResponse( + status_code=500, + content={ + "error": True, + "error_message": f"Failed to retrieve history: {str(e)}", + "status": "error" + } + ) + +# Clear conversation history with error handling +@app.delete("/agent/history/{session_id}") +async def clear_conversation_history(session_id: str = Path(..., description="Session ID")): + """Clear conversation history with error handling""" + try: + if session_id in CHAT_HISTORY: + del CHAT_HISTORY[session_id] + return { + "session_id": session_id, + "message": "Conversation history cleared", + "status": "success" + } + else: + return { + "session_id": session_id, + "message": "No history found for this session", + "status": "success" + } + + except Exception as e: + logger.error(f"Error clearing history for session {session_id}: {str(e)}") + return JSONResponse( + status_code=500, + content={ + "error": True, + "error_message": f"Failed to clear history: {str(e)}", + "status": "error" + } + ) + +# Enhanced health check endpoint +@app.get("/health") +async def health_check(): + """Comprehensive health check with detailed API status""" + try: + health_status = { + "status": "healthy" if all([ + api_status["assemblyai"], + api_status["murf"], + api_status["gemini"] + ]) else "degraded", + "timestamp": datetime.now().isoformat(), + "apis": { + "assemblyai": { + "configured": bool(ASSEMBLYAI_API_KEY), + "status": "healthy" if api_status["assemblyai"] else "unavailable" + }, + "murf": { + "configured": bool(MURF_API_KEY), + "sdk_available": client is not None, + "status": "healthy" if api_status["murf"] else "unavailable" + }, + "gemini": { + "configured": bool(GEMINI_API_KEY), + "model": "gemini-1.5-flash" if gemini_model else None, + "status": "healthy" if api_status["gemini"] else "unavailable" + } + }, + "chat_sessions": { + "active_sessions": len(CHAT_HISTORY), + "total_messages": sum(len(history) for history in CHAT_HISTORY.values()) + }, + "errors": api_status.get("errors", []) + } + + return health_status + + except Exception as e: + logger.error(f"Health check failed: {str(e)}") + return JSONResponse( + status_code=500, + content={ + "status": "unhealthy", + "error": str(e), + "timestamp": datetime.now().isoformat() + } + ) + +# Enhanced error simulation endpoint for testing +@app.post("/simulate-error/{error_type}") +async def simulate_error(error_type: str): + """Simulate different types of errors for testing""" + try: + if error_type == "stt": + global ASSEMBLYAI_API_KEY + ASSEMBLYAI_API_KEY = None + return {"message": "STT error simulated - AssemblyAI API key removed"} + elif error_type == "llm": + global GEMINI_API_KEY, gemini_model + GEMINI_API_KEY = None + gemini_model = None + return {"message": "LLM error simulated - Gemini API key removed"} + elif error_type == "tts": + global MURF_API_KEY, client + MURF_API_KEY = None + client = None + return {"message": "TTS error simulated - Murf API key removed"} + elif error_type == "reset": + # Reset APIs to original state + api_status = initialize_apis() + return {"message": "APIs reset to original configuration"} + else: + return {"error": "Invalid error type. Use: stt, llm, tts, or reset"} + + except Exception as e: + return {"error": f"Failed to simulate error: {str(e)}"} + +# Helper function to split text for Murf's character limit +def split_text_for_murf(text: str, max_chars: int = 2800) -> list: + """Split text into chunks that fit within Murf's character limit with error handling""" + try: + if len(text) <= max_chars: + return [text] + + chunks = [] + current_chunk = "" + + # Split by sentences first + sentences = text.replace('!', '.').replace('?', '.').split('.') + + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + + sentence += "." # Add period back + + # If adding this sentence exceeds limit, save current chunk and start new one + if len(current_chunk + sentence) > max_chars: + if current_chunk: + chunks.append(current_chunk.strip()) + current_chunk = sentence + else: + # Single sentence is too long, force split by words + words = sentence.split() + temp_chunk = "" + for word in words: + if len(temp_chunk + " " + word) > max_chars: + if temp_chunk: + chunks.append(temp_chunk.strip()) + temp_chunk = word + else: + # Single word too long, force character split + chunks.append(word[:max_chars]) + temp_chunk = word[max_chars:] + else: + temp_chunk += " " + word if temp_chunk else word + + if temp_chunk: + current_chunk = temp_chunk + else: + current_chunk += " " + sentence if current_chunk else sentence + + # Add the last chunk + if current_chunk: + chunks.append(current_chunk.strip()) + + return chunks if chunks else [text] # Fallback to original text if splitting fails + + except Exception as e: + logger.error(f"Error splitting text: {str(e)}") + return [text] # Return original text as single chunk on error + +# Legacy endpoints with enhanced error handling + +@app.post("/generate-audio/") +async def generate_audio(request: TextRequest): + """Legacy endpoint for direct text-to-speech with error handling""" + try: + if not client and not MURF_API_KEY: + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_message": ERROR_MESSAGES["tts_failure"], + "fallback_audio_url": FALLBACK_AUDIO_URLS.get("tts_failure") + } + ) + + audio_url = await generate_audio_with_fallback(request.text, "en-US-terrell") + + if isinstance(audio_url, dict) and audio_url.get("error"): + return JSONResponse( + status_code=200, + content=audio_url + ) + + return {"audio_url": audio_url, "status": "success"} + + except Exception as e: + logger.error(f"Legacy generate_audio error: {str(e)}") + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_message": ERROR_MESSAGES["tts_failure"], + "original_error": str(e) + } + ) + +@app.post("/upload-audio/") +async def upload_audio(file: UploadFile = File(...)): + """Upload audio endpoint with error handling""" + try: + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + unique_id = uuid.uuid4().hex[:6] + extension = file.filename.split(".")[-1] if "." in file.filename else "webm" + new_filename = f"audio_{timestamp}_{unique_id}.{extension}" + + file_path = UPLOAD_DIR / new_filename + contents = await file.read() + + with open(file_path, "wb") as f: + f.write(contents) + + return { + "filename": new_filename, + "content_type": file.content_type, + "size": len(contents), + "file_path": str(file_path), + "status": "success" + } + + except Exception as e: + logger.error(f"File upload error: {str(e)}") + return JSONResponse( + status_code=500, + content={ + "error": True, + "error_message": f"File upload failed: {str(e)}" + } + ) + +@app.post("/transcribe/file") +async def transcribe_file(file: UploadFile = File(...)): + """Transcribe file endpoint with error handling""" + try: + audio_bytes = await file.read() + + transcription_result = await transcribe_audio_with_fallback(audio_bytes) + + if isinstance(transcription_result, dict) and transcription_result.get("error"): + return JSONResponse( + status_code=200, + content=transcription_result + ) + + return { + "transcript": transcription_result, + "status": "success" + } + + except Exception as e: + logger.error(f"File transcription error: {str(e)}") + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_type": "stt_failure", + "error_message": ERROR_MESSAGES["stt_failure"], + "original_error": str(e) + } + ) + +@app.post("/tts/echo") +async def tts_echo( + audio_file: UploadFile = File(...), + voiceId: Optional[str] = Form(default="en-US-natalie") +): + """Enhanced echo endpoint with comprehensive error handling""" + try: + logger.info(f"Processing echo request with voice: {voiceId}") + + # Step 1: Read and transcribe audio + audio_bytes = await audio_file.read() + logger.info(f"Audio file size: {len(audio_bytes)} bytes") + + transcription_result = await transcribe_audio_with_fallback(audio_bytes) + + if isinstance(transcription_result, dict) and transcription_result.get("error"): + return JSONResponse( + status_code=200, + content=transcription_result + ) + + transcribed_text = transcription_result + logger.info(f"Transcription completed: {transcribed_text}") + + if not transcribed_text or transcribed_text.strip() == "": + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_type": "no_input", + "error_message": ERROR_MESSAGES["no_input"] + } + ) + + # Step 2: Generate TTS + audio_result = await generate_audio_with_fallback(transcribed_text, voiceId) + + if isinstance(audio_result, dict) and audio_result.get("error"): + # TTS failed, return text-only response + return JSONResponse( + status_code=200, + content={ + "text": transcribed_text, + "voice_id": voiceId, + "error": True, + "error_type": "tts_failure", + "error_message": audio_result["error_message"], + "fallback_audio_url": audio_result.get("fallback_audio_url"), + "status": "success_no_audio" + } + ) + + return { + "text": transcribed_text, + "audio_url": audio_result, + "voice_id": voiceId, + "status": "success" + } + + except Exception as e: + logger.error(f"Echo endpoint error: {str(e)}") + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_type": "general_failure", + "error_message": ERROR_MESSAGES["general_failure"], + "original_error": str(e) + } + ) + +@app.post("/llm/query") +async def llm_query( + audio_file: UploadFile = File(None), + text: Optional[str] = Form(None), + voiceId: Optional[str] = Form(default="en-US-natalie") +): + """Enhanced LLM Query endpoint with comprehensive error handling""" + try: + logger.info("Processing LLM query request") + + input_text = "" + + # Step 1: Get input text + if audio_file: + logger.info(f"Processing audio input: {audio_file.filename}") + + audio_bytes = await audio_file.read() + logger.info(f"Audio file size: {len(audio_bytes)} bytes") + + transcription_result = await transcribe_audio_with_fallback(audio_bytes) + + if isinstance(transcription_result, dict) and transcription_result.get("error"): + return JSONResponse( + status_code=200, + content=transcription_result + ) + + input_text = transcription_result + logger.info(f"Transcribed text: {input_text}") + + elif text: + input_text = text.strip() + logger.info(f"Direct text input: {input_text}") + else: + return JSONResponse( + status_code=400, + content={ + "error": True, + "error_type": "no_input", + "error_message": ERROR_MESSAGES["no_input"] + } + ) + + if not input_text: + return JSONResponse( + status_code=400, + content={ + "error": True, + "error_type": "no_input", + "error_message": ERROR_MESSAGES["no_input"] + } + ) + + # Step 2: Generate LLM response + prompt = f"Please provide a conversational response (maximum 2500 characters) to: {input_text}" + + try: + if not gemini_model: + raise ValueError("Gemini API key not configured") + + response = gemini_model.generate_content(prompt) + + if not response.text: + raise ValueError("No response generated from Gemini") + + llm_response = response.text.strip() + logger.info(f"Gemini response length: {len(llm_response)} chars") + + except Exception as llm_error: + logger.error(f"LLM processing failed: {str(llm_error)}") + fallback_response = f"I'm having trouble processing that right now. You said: '{input_text}'. Could you please try rephrasing your question?" + + return JSONResponse( + status_code=200, + content={ + "input": input_text, + "response": fallback_response, + "error": True, + "error_type": "llm_failure", + "error_message": ERROR_MESSAGES["llm_failure"], + "fallback_audio_url": FALLBACK_AUDIO_URLS.get("llm_failure"), + "status": "error_with_fallback" + } + ) + + # Step 3: Generate TTS with error handling + audio_urls = [] + tts_error = False + + try: + if len(llm_response) <= 3000: + # Single request + audio_result = await generate_audio_with_fallback(llm_response, voiceId) + + if isinstance(audio_result, dict) and audio_result.get("error"): + tts_error = True + else: + audio_urls.append(audio_result) + else: + # Split into chunks + chunks = split_text_for_murf(llm_response, 2800) + logger.info(f"Split response into {len(chunks)} chunks") + + for i, chunk in enumerate(chunks): + logger.info(f"Processing chunk {i+1}/{len(chunks)}: {len(chunk)} chars") + try: + audio_result = await generate_audio_with_fallback(chunk, voiceId) + if isinstance(audio_result, dict) and audio_result.get("error"): + tts_error = True + break + audio_urls.append(audio_result) + except Exception as chunk_error: + logger.error(f"Chunk {i+1} TTS failed: {str(chunk_error)}") + tts_error = True + break + + except Exception as e: + logger.error(f"TTS generation failed: {str(e)}") + tts_error = True + + response_data = { + "input": input_text, + "response": llm_response, + "model": "gemini-1.5-flash", + "voice_id": voiceId, + "audio_urls": audio_urls, + "audio_url": audio_urls[0] if audio_urls else None, + "chunks_count": len(audio_urls), + "status": "success" if not tts_error else "success_no_audio" + } + + if tts_error: + response_data.update({ + "tts_error": True, + "tts_error_message": ERROR_MESSAGES["tts_failure"], + "fallback_audio_url": FALLBACK_AUDIO_URLS.get("tts_failure") + }) + + return response_data + + except Exception as e: + logger.error(f"Error in llm_query: {str(e)}") + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_type": "general_failure", + "error_message": ERROR_MESSAGES["general_failure"], + "original_error": str(e) + } + ) + +@app.get("/voices") +async def get_available_voices(): + """Get available voices with error handling""" + try: + if not client and not MURF_API_KEY: + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_message": "Murf API key not configured", + "voices": [] + } + ) + + # Try to use SDK method first + try: + if client: + voices = client.voices.list() + return {"voices": voices, "status": "success"} + except Exception as sdk_error: + logger.warning(f"Murf SDK voices list failed: {str(sdk_error)}") + + # Fallback to direct API call + headers = {"Authorization": f"Bearer {MURF_API_KEY}"} + + async with httpx.AsyncClient(timeout=10.0) as client_http: + response = await client_http.get( + "https://api.murf.ai/v1/speech/voices", + headers=headers + ) + + if response.status_code != 200: + raise ValueError(f"Failed to fetch voices: {response.text}") + + result = response.json() + result["status"] = "success" + return result + + except Exception as e: + logger.error(f"Error fetching voices: {str(e)}") + return JSONResponse( + status_code=200, + content={ + "error": True, + "error_message": f"Error fetching voices: {str(e)}", + "voices": [] + } + ) + +@app.get("/agent/sessions") +async def list_active_sessions(): + """List all active sessions with error handling""" + try: + sessions_info = [] + for session_id, history in CHAT_HISTORY.items(): + if history: # Only include sessions with messages + sessions_info.append({ + "session_id": session_id, + "message_count": len(history), + "conversation_turns": len(history) // 2, + "last_message_time": history[-1]["timestamp"] if history else None, + "created_time": history[0]["timestamp"] if history else None + }) + + return { + "active_sessions": len(sessions_info), + "sessions": sessions_info, + "status": "success" + } + + except Exception as e: + logger.error(f"Error listing sessions: {str(e)}") + return JSONResponse( + status_code=500, + content={ + "error": True, + "error_message": f"Failed to list sessions: {str(e)}" + } + ) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..7663b33 --- /dev/null +++ b/script.js @@ -0,0 +1,1304 @@ +// Global variables +let mediaRecorder; +let audioChunks = []; +let recordedBlob; +let currentSessionId = ""; +let autoRecordEnabled = false; +let isWaitingForResponse = false; +let isRecording = false; +let retryCount = 0; +const MAX_RETRIES = 3; + +// Server configuration +const SERVER_BASE_URL = "http://127.0.0.1:8000"; + +// Error handling configuration +const ERROR_CONFIG = { + showNotifications: true, + playErrorSounds: true, + autoRetry: true, + fallbackAudioEnabled: true, +}; + +// Enhanced UI elements mapping +const UI_ELEMENTS = { + currentSession: "current-session", + conversationTurns: "conversation-turns", + totalMessages: "total-messages", + mainRecordButton: "main-record-button", + recordStatusText: "record-status-text", + sendTextButton: "send-text-button", + textInput: "text-input", + voiceSelect: "voice-select", + statusDisplay: "status-display", + autoRecordToggle: "auto-record-toggle", + historyMessages: "history-messages", + newSessionBtn: "new-session-btn", + clearHistoryBtn: "clear-history-btn", + loadSessionBtn: "load-session-btn", + customSessionInput: "custom-session-input", + errorContainer: "error-container", + warningContainer: "warning-container", + serverStatus: "server-status", + responseAudio: "response-audio", +}; + +// Initialize when page loads +document.addEventListener("DOMContentLoaded", () => { + console.log("🚀 Enhanced Voice Chat Client initializing..."); + + initializeSession(); + setupEventListeners(); + setupErrorHandling(); + checkServerHealth(); + enhanceAccessibility(); + handleOfflineMode(); + setupKeyboardShortcuts(); + + console.log("✅ Voice Chat Client loaded successfully!"); +}); + +// Event Listeners Setup +function setupEventListeners() { + console.log("Setting up event listeners..."); + + // Session management + const newSessionBtn = getElementById(UI_ELEMENTS.newSessionBtn); + const clearHistoryBtn = getElementById(UI_ELEMENTS.clearHistoryBtn); + const loadSessionBtn = getElementById(UI_ELEMENTS.loadSessionBtn); + + if (newSessionBtn) newSessionBtn.addEventListener("click", createNewSession); + if (clearHistoryBtn) + clearHistoryBtn.addEventListener("click", clearCurrentSession); + if (loadSessionBtn) + loadSessionBtn.addEventListener("click", loadCustomSession); + + // Main record button (unified start/stop) + const mainRecordButton = getElementById(UI_ELEMENTS.mainRecordButton); + if (mainRecordButton) { + mainRecordButton.addEventListener("click", toggleRecording); + console.log("Record button listener added"); + } else { + console.error("Main record button not found!"); + } + + // Text input and send + const sendTextButton = getElementById(UI_ELEMENTS.sendTextButton); + const textInput = getElementById(UI_ELEMENTS.textInput); + + if (sendTextButton) sendTextButton.addEventListener("click", sendToAI); + if (textInput) { + textInput.addEventListener("keydown", handleTextInputKeydown); + textInput.addEventListener("input", handleTextInputChange); + } + + // Auto-record toggle + const autoRecordToggle = getElementById(UI_ELEMENTS.autoRecordToggle); + if (autoRecordToggle) + autoRecordToggle.addEventListener("click", toggleAutoRecord); + + // Voice selection + const voiceSelect = getElementById(UI_ELEMENTS.voiceSelect); + if (voiceSelect) voiceSelect.addEventListener("change", handleVoiceChange); + + console.log("Event listeners setup complete"); +} + +// Enhanced Recording Toggle +function toggleRecording() { + console.log( + "Toggle recording called. Current state:", + isRecording, + "Waiting for response:", + isWaitingForResponse + ); + + if (isWaitingForResponse) { + showWarning("Please wait for the current response to complete", 3000); + return; + } + + if (!isRecording) { + startRecording(); + } else { + stopRecording(); + } +} + +// Enhanced Recording Start +async function startRecording() { + console.log("Starting recording..."); + + try { + // Check if browser supports getUserMedia + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error("Your browser does not support audio recording"); + } + + // Check for microphone permissions first + const permissions = await navigator.permissions.query({ + name: "microphone", + }); + console.log("Microphone permission state:", permissions.state); + + if (permissions.state === "denied") { + showError( + "Microphone access denied. Please enable microphone permissions in your browser settings." + ); + return; + } + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + channelCount: 1, + sampleRate: 16000, + }, + }); + + console.log("Microphone access granted, creating MediaRecorder..."); + + audioChunks = []; + + // Check supported MIME types + let mimeType = "audio/webm;codecs=opus"; + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = "audio/webm"; + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = "audio/mp4"; + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = ""; // Let browser choose + } + } + } + + console.log("Using MIME type:", mimeType); + + const options = mimeType ? { mimeType } : {}; + mediaRecorder = new MediaRecorder(stream, options); + + mediaRecorder.ondataavailable = (event) => { + console.log("Data available:", event.data.size, "bytes"); + if (event.data.size > 0) { + audioChunks.push(event.data); + } + }; + + mediaRecorder.onstop = async () => { + console.log("MediaRecorder stopped"); + try { + recordedBlob = new Blob(audioChunks, { + type: mimeType || "audio/webm", + }); + console.log("Recorded blob size:", recordedBlob.size); + + if (recordedBlob.size === 0) { + showError("No audio was recorded. Please try again."); + return; + } + + const voiceId = + getElementById(UI_ELEMENTS.voiceSelect)?.value || "en-US-natalie"; + await transcribeAndProcess(recordedBlob, voiceId); + } catch (error) { + console.error("Error processing recording:", error); + showError("Failed to process recording: " + error.message); + } finally { + // Stop all tracks to release microphone + stream.getTracks().forEach((track) => { + track.stop(); + console.log("Track stopped"); + }); + } + }; + + mediaRecorder.onerror = (event) => { + console.error("MediaRecorder error:", event.error); + showError( + "Recording failed: " + (event.error?.message || "Unknown error") + ); + }; + + mediaRecorder.start(1000); // Collect data every second + console.log("MediaRecorder started"); + + updateRecordingUI(true); + updateStatus("🎤 Recording...", "recording"); + + // Add visual feedback + addRecordingVisualFeedback(); + } catch (error) { + console.error("Failed to start recording:", error); + handleRecordingError(error); + } +} + +// Enhanced Recording Stop +function stopRecording() { + console.log("Stopping recording..."); + + try { + if (mediaRecorder && mediaRecorder.state === "recording") { + mediaRecorder.stop(); + console.log("MediaRecorder stop() called"); + updateRecordingUI(false); + updateStatus("⏹️ Processing recording...", "processing"); + removeRecordingVisualFeedback(); + } else { + console.log( + "MediaRecorder not in recording state:", + mediaRecorder?.state + ); + } + } catch (error) { + console.error("Error stopping recording:", error); + showError("Failed to stop recording: " + error.message); + } +} + +// Enhanced UI Update for Recording +function updateRecordingUI(recording) { + console.log("Updating recording UI:", recording); + + const recordButton = getElementById(UI_ELEMENTS.mainRecordButton); + const statusText = getElementById(UI_ELEMENTS.recordStatusText); + + isRecording = recording; + + if (recordButton) { + const icon = recordButton.querySelector(".record-icon"); + + if (recording) { + recordButton.classList.add("recording"); + if (icon) icon.textContent = "⏹️"; + recordButton.title = "Click to stop recording"; + recordButton.setAttribute("aria-label", "Stop recording"); + if (statusText) statusText.textContent = "Recording... Click to stop"; + } else { + recordButton.classList.remove("recording", "processing"); + if (icon) icon.textContent = "🎤"; + recordButton.title = "Click to start recording"; + recordButton.setAttribute("aria-label", "Start recording"); + if (statusText) statusText.textContent = "Click to start recording"; + } + } +} + +// Enhanced Processing UI +function updateProcessingUI(processing) { + const recordButton = getElementById(UI_ELEMENTS.mainRecordButton); + + if (recordButton) { + if (processing) { + recordButton.classList.add("processing"); + recordButton.disabled = true; + } else { + recordButton.classList.remove("processing"); + recordButton.disabled = false; + } + } +} + +// Enhanced Transcription and Processing +async function transcribeAndProcess(audioBlob, voiceId = "en-US-natalie") { + console.log("Starting transcription and processing..."); + + try { + isWaitingForResponse = true; + updateProcessingUI(true); + + updateStatus("🔄 Transcribing and processing...", "processing"); + + const formData = new FormData(); + formData.append("audio_file", audioBlob, "recording.webm"); + formData.append("voiceId", voiceId); + + console.log( + "Sending request to:", + `${SERVER_BASE_URL}/agent/chat/${currentSessionId}` + ); + + const response = await makeRobustAPICall( + `${SERVER_BASE_URL}/agent/chat/${currentSessionId}`, + { + method: "POST", + body: formData, + timeout: 60000, + } + ); + + console.log("API Response received:", response); + await handleAPIResponse(response); + } catch (error) { + console.error("Error in transcribeAndProcess:", error); + handleProcessingError(error); + } finally { + isWaitingForResponse = false; + updateProcessingUI(false); + } +} + +// Enhanced Text Message Sending +async function sendToAI() { + console.log("Sending text message..."); + + try { + if (isWaitingForResponse) { + showWarning("Please wait for the current response to complete", 3000); + return; + } + + const textInput = getElementById(UI_ELEMENTS.textInput); + const text = textInput?.value?.trim(); + + if (!text) { + showError("Please enter some text to send", 3000); + textInput?.focus(); + return; + } + + isWaitingForResponse = true; + updateProcessingUI(true); + + updateStatus("🔄 Processing your message...", "processing"); + + const voiceId = + getElementById(UI_ELEMENTS.voiceSelect)?.value || "en-US-natalie"; + + const formData = new FormData(); + formData.append("text", text); + formData.append("voiceId", voiceId); + + console.log( + "Sending text request to:", + `${SERVER_BASE_URL}/agent/chat/${currentSessionId}` + ); + + const response = await makeRobustAPICall( + `${SERVER_BASE_URL}/agent/chat/${currentSessionId}`, + { + method: "POST", + body: formData, + timeout: 60000, + } + ); + + console.log("Text API Response received:", response); + await handleAPIResponse(response); + + // Clear text input on success + if (textInput) { + textInput.value = ""; + handleTextInputChange(); // Update send button state + } + } catch (error) { + console.error("Error in sendToAI:", error); + handleProcessingError(error); + } finally { + isWaitingForResponse = false; + updateProcessingUI(false); + } +} + +// Enhanced API Response Handling +async function handleAPIResponse(response) { + try { + console.log("Handling API Response:", response); + + if (response.error) { + await handleErrorResponse(response); + return; + } + + if ( + response.status === "success" || + response.status === "success_no_audio" + ) { + await handleSuccessResponse(response); + } else { + showError("Unexpected response format from server"); + } + } catch (error) { + console.error("Error handling API response:", error); + showError("Failed to process server response: " + error.message); + } +} + +// Enhanced Success Response Handling +async function handleSuccessResponse(response) { + console.log("Handling success response..."); + + // Add messages to chat with animation + if (response.input) { + addMessageToChat("user", response.input); + } + + if (response.response) { + addMessageToChat( + "assistant", + response.response, + response.status === "success_no_audio" + ); + } + + // Play audio if available + if (response.audio_urls && response.audio_urls.length > 0) { + await playResponseAudio(response.audio_urls); + } else if (response.audio_url) { + await playResponseAudio([response.audio_url]); + } else if (response.tts_error) { + showWarning( + response.tts_error_message || + "Audio generation failed, but here's the text response", + 5000 + ); + } + + // Update session information + if (response.conversation_turns !== undefined) { + updateSessionInfo( + response.conversation_turns, + response.conversation_length + ); + } + + updateStatus("✅ Response completed!", "success"); + + // Auto-record next message if enabled + if (autoRecordEnabled && !isWaitingForResponse) { + setTimeout(() => { + if (!isWaitingForResponse && !isRecording) { + startRecording(); + } + }, 1500); + } +} + +// Enhanced Error Response Handling +async function handleErrorResponse(response) { + console.log("Handling error response:", response); + + const errorMessage = response.error_message || "An unexpected error occurred"; + showError(errorMessage); + + if (response.response) { + addMessageToChat("assistant", response.response, true); + } + + if (response.session_id) { + await loadSessionHistory(); + } +} + +// Enhanced Audio Playback +async function playResponseAudio(audioUrls) { + try { + console.log("Playing response audio:", audioUrls.length, "chunks"); + updateStatus("🔊 Playing audio response...", "processing"); + + const audio = getElementById(UI_ELEMENTS.responseAudio); + if (!audio) { + console.error("Audio element not found"); + return; + } + + for (let i = 0; i < audioUrls.length; i++) { + const audioUrl = audioUrls[i]; + console.log( + `Playing audio chunk ${i + 1}/${audioUrls.length}: ${audioUrl}` + ); + + audio.src = audioUrl; + audio.volume = 0.8; + + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error("Audio playback timeout")); + }, 30000); + + const cleanup = () => { + clearTimeout(timeoutId); + audio.removeEventListener("ended", onEnded); + audio.removeEventListener("error", onError); + }; + + const onEnded = () => { + cleanup(); + resolve(); + }; + + const onError = (error) => { + cleanup(); + console.error("Audio playback error:", error); + reject(error); + }; + + audio.addEventListener("ended", onEnded, { once: true }); + audio.addEventListener("error", onError, { once: true }); + + audio.play().catch(reject); + }); + } + + updateStatus("🔊 Audio playback completed", "success"); + } catch (error) { + console.error("Audio playback failed:", error); + showWarning( + "Audio playback failed, but you can see the text response above", + 5000 + ); + } +} + +// Enhanced Chat Message Addition +function addMessageToChat(role, content, isError = false) { + const historyContainer = getElementById(UI_ELEMENTS.historyMessages); + if (!historyContainer) { + console.error("History container not found"); + return; + } + + console.log( + "Adding message to chat:", + role, + content.substring(0, 100) + "..." + ); + + // Remove the placeholder if it exists + const emptyChat = historyContainer.querySelector(".empty-chat"); + if (emptyChat) { + emptyChat.remove(); + } + + const messageDiv = document.createElement("div"); + messageDiv.className = `chat-message ${role} ${isError ? "error" : ""}`; + + const time = new Date().toLocaleTimeString(); + const errorIndicator = isError + ? '⚠️' + : ""; + + messageDiv.innerHTML = ` +
${errorIndicator}${escapeHtml(content)}
+
${time}
+ `; + + historyContainer.appendChild(messageDiv); + + // Smooth scroll to bottom + setTimeout(() => { + historyContainer.scrollTo({ + top: historyContainer.scrollHeight, + behavior: "smooth", + }); + }, 100); +} + +// Enhanced Status Update +function updateStatus(message, type = "info") { + const statusElement = getElementById(UI_ELEMENTS.statusDisplay); + if (!statusElement) return; + + console.log("Status update:", message, type); + + const iconElement = statusElement.querySelector(".status-icon"); + const textElement = statusElement.querySelector(".status-text"); + + if (textElement) { + textElement.textContent = message.replace( + /^[🟢🔴🟡⚠️🔄🎤⏹️📤🔊✅❌]+ ?/, + "" + ); + } + + if (iconElement) { + const iconMap = { + info: "🟢", + recording: "🎤", + processing: "🔄", + success: "✅", + error: "❌", + warning: "⚠️", + }; + iconElement.textContent = iconMap[type] || "🟢"; + } + + statusElement.className = `status-display ${type}`; +} + +// Session Management +function initializeSession() { + console.log("Initializing session..."); + + const urlParams = new URLSearchParams(window.location.search); + const sessionFromUrl = urlParams.get("session"); + + if (sessionFromUrl && sessionFromUrl.length > 0) { + currentSessionId = sessionFromUrl; + } else { + currentSessionId = generateSessionId(); + updateURL(); + } + + const currentSessionElement = getElementById(UI_ELEMENTS.currentSession); + if (currentSessionElement) { + currentSessionElement.textContent = currentSessionId; + } + + console.log("Session initialized:", currentSessionId); + loadSessionHistory(); +} + +function generateSessionId() { + const timestamp = Date.now(); + const random = Math.random().toString(36).substr(2, 9); + return `session-${timestamp}-${random}`; +} + +function updateURL() { + const newUrl = `${window.location.pathname}?session=${currentSessionId}`; + window.history.replaceState({}, "", newUrl); +} + +async function loadSessionHistory() { + try { + console.log("Loading session history for:", currentSessionId); + + const data = await makeRobustAPICall( + `${SERVER_BASE_URL}/agent/history/${currentSessionId}`, + { + timeout: 10000, + } + ); + + console.log("Session history loaded:", data); + + if (data.status === "success") { + updateSessionInfo(data.conversation_turns, data.message_count); + displayChatHistory(data.history); + } + } catch (error) { + console.error("Failed to load session history:", error); + showWarning("Could not load conversation history", 5000); + } +} + +function updateSessionInfo(turns, messages) { + const turnsElement = getElementById(UI_ELEMENTS.conversationTurns); + const messagesElement = getElementById(UI_ELEMENTS.totalMessages); + + if (turnsElement) turnsElement.textContent = turns || 0; + if (messagesElement) messagesElement.textContent = messages || 0; +} + +function displayChatHistory(history) { + const historyContainer = getElementById(UI_ELEMENTS.historyMessages); + if (!historyContainer) return; + + historyContainer.innerHTML = ""; + + if (!history || history.length === 0) { + historyContainer.innerHTML = ` +
+
💭
+

Start a conversation by recording or typing a message

+
+ `; + return; + } + + console.log("Displaying chat history:", history.length, "messages"); + + history.forEach((message, index) => { + setTimeout(() => { + const messageDiv = document.createElement("div"); + messageDiv.className = `chat-message ${message.role}`; + + const time = new Date(message.timestamp * 1000).toLocaleTimeString(); + messageDiv.innerHTML = ` +
${escapeHtml(message.content)}
+
${time}
+ `; + + historyContainer.appendChild(messageDiv); + }, index * 50); // Staggered animation + }); + + setTimeout(() => { + historyContainer.scrollTop = historyContainer.scrollHeight; + }, history.length * 50 + 100); +} + +function createNewSession() { + console.log("Creating new session..."); + + currentSessionId = generateSessionId(); + const currentSessionElement = getElementById(UI_ELEMENTS.currentSession); + if (currentSessionElement) { + currentSessionElement.textContent = currentSessionId; + } + + updateURL(); + + updateSessionInfo(0, 0); + const historyContainer = getElementById(UI_ELEMENTS.historyMessages); + if (historyContainer) { + historyContainer.innerHTML = ` +
+
💭
+

Start a conversation by recording or typing a message

+
+ `; + } + updateStatus("🆕 New session created!", "success"); +} + +async function clearCurrentSession() { + try { + console.log("Clearing current session..."); + + const response = await makeRobustAPICall( + `${SERVER_BASE_URL}/agent/history/${currentSessionId}`, + { + method: "DELETE", + timeout: 10000, + } + ); + + console.log("Clear session response:", response); + + if (response.error) { + showError(response.error_message || "Failed to clear session"); + return; + } + + updateSessionInfo(0, 0); + const historyContainer = getElementById(UI_ELEMENTS.historyMessages); + if (historyContainer) { + historyContainer.innerHTML = ` +
+
💭
+

Start a conversation by recording or typing a message

+
+ `; + } + updateStatus("🗑️ Session history cleared!", "success"); + } catch (error) { + console.error("Failed to clear session:", error); + showError("Failed to clear session history: " + error.message); + } +} + +function loadCustomSession() { + const customSessionInput = getElementById(UI_ELEMENTS.customSessionInput); + const sessionId = customSessionInput?.value?.trim(); + + if (!sessionId) { + showError("Please enter a session ID", 3000); + customSessionInput?.focus(); + return; + } + + currentSessionId = sessionId; + const currentSessionElement = getElementById(UI_ELEMENTS.currentSession); + if (currentSessionElement) { + currentSessionElement.textContent = currentSessionId; + } + + updateURL(); + + if (customSessionInput) customSessionInput.value = ""; + loadSessionHistory(); + updateStatus("📂 Session loaded!", "success"); +} + +// Enhanced Auto-Record Toggle +function toggleAutoRecord() { + autoRecordEnabled = !autoRecordEnabled; + const toggle = getElementById(UI_ELEMENTS.autoRecordToggle); + const statusElement = toggle?.querySelector(".toggle-status"); + + if (toggle && statusElement) { + if (autoRecordEnabled) { + toggle.classList.add("active"); + statusElement.textContent = "ON"; + } else { + toggle.classList.remove("active"); + statusElement.textContent = "OFF"; + } + } + + updateStatus( + `🔄 Auto-record ${autoRecordEnabled ? "enabled" : "disabled"}`, + "info" + ); +} + +// Enhanced Event Handlers +function handleTextInputKeydown(e) { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + sendToAI(); + } +} + +function handleTextInputChange() { + const textInput = getElementById(UI_ELEMENTS.textInput); + const sendButton = getElementById(UI_ELEMENTS.sendTextButton); + + if (textInput && sendButton) { + const hasText = textInput.value.trim().length > 0; + sendButton.disabled = !hasText || isWaitingForResponse; + sendButton.style.opacity = hasText && !isWaitingForResponse ? "1" : "0.5"; + } +} + +function handleVoiceChange() { + const voiceSelect = getElementById(UI_ELEMENTS.voiceSelect); + if (voiceSelect) { + const selectedVoice = voiceSelect.options[voiceSelect.selectedIndex].text; + updateStatus(`🎭 Voice changed to ${selectedVoice}`, "info"); + } +} + +// Enhanced Error Handling +function handleRecordingError(error) { + console.error("Recording error:", error); + + if (error.name === "NotAllowedError") { + showError( + "Microphone access denied. Please enable microphone permissions and try again." + ); + } else if (error.name === "NotFoundError") { + showError( + "No microphone found. Please connect a microphone and try again." + ); + } else if (error.name === "NotReadableError") { + showError("Microphone is already in use by another application."); + } else { + showError("Failed to start recording: " + error.message); + } +} + +function handleProcessingError(error) { + console.error("Processing error:", error); + + if (error.message.includes("timeout")) { + showError( + "The request took too long. Please try with a shorter recording or message." + ); + } else if (error.message.includes("No internet") || !navigator.onLine) { + showError( + "No internet connection. Please check your network and try again." + ); + } else if (error.message.includes("Failed to fetch")) { + showError( + "Cannot connect to the server. Please make sure the server is running." + ); + } else { + showError("Failed to process your request: " + error.message); + } +} + +// Enhanced API Call Function +async function makeRobustAPICall(url, options = {}) { + console.log("Making API call to:", url); + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + options.timeout || 30000 + ); + + let attempt = 0; + const maxAttempts = options.maxRetries || 1; + + while (attempt < maxAttempts) { + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + ...options.headers, + }, + }); + + clearTimeout(timeoutId); + + console.log("API response status:", response.status); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + const data = await response.json(); + console.log("API response data:", data); + return data; + } else { + const text = await response.text(); + console.log("API response text:", text.substring(0, 200) + "..."); + return text; + } + } catch (error) { + attempt++; + clearTimeout(timeoutId); + + console.error(`API call attempt ${attempt} failed:`, error); + + if (error.name === "AbortError") { + throw new Error( + "Request timeout - the server is taking too long to respond" + ); + } + + if (!navigator.onLine) { + throw new Error("No internet connection"); + } + + if (attempt >= maxAttempts) { + throw error; + } + + // Wait before retry + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); + } + } +} + +// Enhanced Notification Functions +function showError(message, duration = 8000) { + console.error("Showing error:", message); + updateStatus(`❌ ${message}`, "error"); + + const errorContainer = getElementById(UI_ELEMENTS.errorContainer); + if (errorContainer) { + errorContainer.innerHTML = ` +
+ ⚠️ + ${escapeHtml(message)} + +
+ `; + errorContainer.style.display = "block"; + + // Auto hide after duration + setTimeout(hideError, duration); + } + + // Add to browser console for debugging + console.error("Error:", message); +} + +function showWarning(message, duration = 6000) { + console.warn("Showing warning:", message); + updateStatus(`⚠️ ${message}`, "warning"); + + const warningContainer = getElementById(UI_ELEMENTS.warningContainer); + if (warningContainer) { + warningContainer.innerHTML = ` +
+ ⚠️ + ${escapeHtml(message)} + +
+ `; + warningContainer.style.display = "block"; + + // Auto hide after duration + setTimeout(hideWarning, duration); + } + + console.warn("Warning:", message); +} + +function hideError() { + const errorContainer = getElementById(UI_ELEMENTS.errorContainer); + if (errorContainer) { + errorContainer.style.display = "none"; + errorContainer.innerHTML = ""; + } +} + +function hideWarning() { + const warningContainer = getElementById(UI_ELEMENTS.warningContainer); + if (warningContainer) { + warningContainer.style.display = "none"; + warningContainer.innerHTML = ""; + } +} + +// Server Health Check +async function checkServerHealth() { + try { + console.log("Checking server health..."); + + const response = await makeRobustAPICall(`${SERVER_BASE_URL}/health`, { + method: "GET", + timeout: 5000, + }); + + console.log("Server health response:", response); + displayHealthStatus(response); + + if (response.status === "degraded") { + showWarning( + "Some services are currently unavailable. Functionality may be limited.", + 10000 + ); + } + } catch (error) { + console.error("Server health check failed:", error); + displayHealthStatus({ status: "unhealthy", message: "Server unavailable" }); + showError( + "Cannot connect to the server. Please make sure it is running on " + + SERVER_BASE_URL + ); + } +} + +function displayHealthStatus(health) { + const statusElement = getElementById(UI_ELEMENTS.serverStatus); + if (!statusElement) return; + + const statusIndicator = statusElement.querySelector(".status-indicator"); + const statusContent = statusElement.querySelector( + ".server-status-content span" + ); + + const statusColors = { + healthy: "var(--success-color, #22c55e)", + degraded: "var(--warning-color, #f59e0b)", + unhealthy: "var(--danger-color, #ef4444)", + }; + + if (statusIndicator) { + statusIndicator.style.background = + statusColors[health.status] || statusColors.unhealthy; + statusIndicator.className = `status-indicator ${health.status}`; + } + + if (statusContent) { + if (health.apis) { + statusContent.innerHTML = ` +
Server: ${health.status.toUpperCase()}
+
+ STT: ${health.apis.assemblyai?.status || "Unknown"}
+ LLM: ${health.apis.gemini?.status || "Unknown"}
+ TTS: ${health.apis.murf?.status || "Unknown"} +
+ `; + } else { + statusContent.textContent = health.message || `Server: ${health.status}`; + } + } +} + +// Visual Feedback Functions +function addRecordingVisualFeedback() { + document.body.classList.add("recording-active"); + + // Add pulsing effect to record button + const recordButton = getElementById(UI_ELEMENTS.mainRecordButton); + if (recordButton) { + recordButton.style.boxShadow = "0 0 30px rgba(239, 68, 68, 0.5)"; + } +} + +function removeRecordingVisualFeedback() { + document.body.classList.remove("recording-active"); + + const recordButton = getElementById(UI_ELEMENTS.mainRecordButton); + if (recordButton) { + recordButton.style.boxShadow = ""; + } +} + +// Keyboard Shortcuts +function setupKeyboardShortcuts() { + console.log("Setting up keyboard shortcuts..."); + + document.addEventListener("keydown", (event) => { + // Prevent shortcuts when typing in inputs + if (["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)) { + return; + } + + switch (event.code) { + case "Space": + event.preventDefault(); + toggleRecording(); + break; + + case "Escape": + event.preventDefault(); + hideError(); + hideWarning(); + if (isRecording) { + stopRecording(); + } + break; + + case "KeyN": + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + createNewSession(); + } + break; + + case "KeyC": + if ((event.ctrlKey || event.metaKey) && event.shiftKey) { + event.preventDefault(); + clearCurrentSession(); + } + break; + } + }); + + // Show/hide keyboard hints + let hintsTimeout; + document.addEventListener("keydown", () => { + const hints = document.querySelector(".floating-hints"); + if (hints) { + hints.style.opacity = "1"; + clearTimeout(hintsTimeout); + hintsTimeout = setTimeout(() => { + hints.style.opacity = "0.7"; + }, 3000); + } + }); +} + +// Enhanced Error Handling Setup +function setupErrorHandling() { + console.log("Setting up error handling..."); + + // Global error handler + window.addEventListener("error", (event) => { + console.error("Global error:", event.error); + showError( + "An unexpected error occurred. Please refresh the page if problems persist." + ); + }); + + // Unhandled promise rejection handler + window.addEventListener("unhandledrejection", (event) => { + console.error("Unhandled promise rejection:", event.reason); + showError( + "An unexpected error occurred. Please refresh the page if problems persist." + ); + event.preventDefault(); + }); + + // Online/offline handlers + window.addEventListener("online", () => { + console.log("Connection restored"); + updateStatus("🟢 Connection restored", "success"); + hideError(); + checkServerHealth(); + }); + + window.addEventListener("offline", () => { + console.log("Connection lost"); + showError("No internet connection. Please check your network."); + updateStatus("🔴 No internet connection", "error"); + }); +} + +// Accessibility Enhancements +function enhanceAccessibility() { + console.log("Enhancing accessibility..."); + + const recordButton = getElementById(UI_ELEMENTS.mainRecordButton); + const textInput = getElementById(UI_ELEMENTS.textInput); + + if (recordButton) { + recordButton.setAttribute("aria-label", "Start or stop voice recording"); + recordButton.setAttribute("role", "button"); + recordButton.setAttribute("tabindex", "0"); + } + + if (textInput) { + textInput.setAttribute("aria-label", "Type your message here"); + } + + // Add focus management + document.addEventListener("keydown", (e) => { + if (e.key === "Tab") { + document.body.classList.add("keyboard-navigation"); + } + }); + + document.addEventListener("mousedown", () => { + document.body.classList.remove("keyboard-navigation"); + }); +} + +// Offline Mode Handling +function handleOfflineMode() { + if (!navigator.onLine) { + showError("You are currently offline. Some features may not work."); + } + + // Service worker registration for offline support (if available) + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js").catch((err) => { + console.log("Service worker registration failed:", err); + }); + } +} + +// Utility Functions +function getElementById(id) { + const element = document.getElementById(id); + if (!element) { + console.warn(`Element with ID '${id}' not found`); + } + return element; +} + +function escapeHtml(text) { + const map = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, function (m) { + return map[m]; + }); +} + +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// Initialize text input change handler with debouncing +document.addEventListener("DOMContentLoaded", () => { + const textInput = getElementById(UI_ELEMENTS.textInput); + if (textInput) { + const debouncedHandler = debounce(handleTextInputChange, 300); + textInput.addEventListener("input", debouncedHandler); + } +}); + +// Periodic health check +setInterval(checkServerHealth, 30000); // Check every 30 seconds + +// Export functions for global access (for onclick handlers) +window.hideError = hideError; +window.hideWarning = hideWarning; + +console.log("🎯 Enhanced Voice Chat Client JavaScript loaded successfully!"); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..262b460 --- /dev/null +++ b/styles.css @@ -0,0 +1,1143 @@ +:root { + --primary-color: #000000; + --secondary-color: #000000; + --success-color: #000000; + --danger-color: #ffb20a; + --warning-color: #ffad1f; + --background-dark: #ff9b05; + --background-darker: #ffff8b; + --surface-color: rgb(255, 237, 100); + --text-primary: #725cf3; + --text-secondary: #3d8cfb; + --text-muted: #599eff; + --border-color: rgb(147, 251, 131); + --border-active: rgb(216, 248, 56); + --shadow-base: 0 20px 40px rgb(226, 206, 206); + --shadow-hover: 0 30px 60px rgb(255, 214, 214); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 24px; + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + background: radial-gradient( + ellipse at top, + var(--background-dark) 0%, + var(--background-darker) 50%, + #000000 100% + ); + background-attachment: fixed; + min-height: 100vh; + padding: 20px; + color: var(--text-primary); + position: relative; + overflow-x: hidden; +} + +/* Animated Background */ +body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient( + circle at 20% 20%, + rgb(248, 248, 56) 0%, + transparent 40% + ), + radial-gradient( + circle at 80% 40%, + rgba(223, 247, 85, 0.06) 0%, + transparent 40% + ), + radial-gradient( + circle at 40% 80%, + rgba(181, 197, 34, 0.04) 0%, + transparent 40% + ); + pointer-events: none; + z-index: -1; + animation: floatingOrbs 20s ease-in-out infinite; +} + +@keyframes floatingOrbs { + 0%, + 100% { + opacity: 0.8; + transform: scale(1) rotate(0deg); + } + 50% { + opacity: 1; + transform: scale(1.1) rotate(180deg); + } +} + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; +} + +/* Header */ +.header { + text-align: center; + margin-bottom: 40px; + position: relative; +} + +.header h1 { + font-size: 3.5rem; + font-weight: 800; + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + var(--secondary-color) 35%, + var(--success-color) 70%, + #06b6d4 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 10px; + letter-spacing: -2px; + text-shadow: 0 0 40px rgba(56, 189, 248, 0.3); + animation: titleGlow 3s ease-in-out infinite alternate; +} + +.header p { + color: var(--text-secondary); + font-size: 1.1rem; + font-weight: 400; +} + +@keyframes titleGlow { + 0% { + filter: drop-shadow(0 0 20px rgba(56, 189, 248, 0.3)); + } + 100% { + filter: drop-shadow(0 0 40px rgba(168, 85, 247, 0.4)); + } +} + +/* Section Titles */ +.section-title { + text-align: center; + margin-bottom: 32px; + color: var(--text-primary); + font-size: 1.5rem; + font-weight: 700; +} + +/* Glass Card Effect */ +.glass-card { + background: var(--surface-color); + border-radius: var(--radius-xl); + padding: 32px; + margin-bottom: 32px; + backdrop-filter: blur(16px); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-base), inset 0 1px 0 rgb(255, 255, 255); + position: relative; + transition: var(--transition); +} + +.glass-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + var(--border-active), + transparent + ); + border-radius: var(--radius-xl) var(--radius-xl) 0 0; +} + +.glass-card:hover { + transform: translateY(-8px); + box-shadow: var(--shadow-hover), inset 0 1px 0 rgba(255, 255, 255, 0.88), + 0 0 50px rgba(56, 191, 248, 0.992); + border-color: var(--border-active); +} + +/* Session Info */ +.session-info { + background: linear-gradient( + 135deg, + rgba(56, 191, 248, 0.76) 0%, + rgba(169, 85, 247, 0.859) 100% + ); + padding: 20px; + border-radius: var(--radius-lg); + text-align: center; + font-weight: 600; + margin-bottom: 32px; + border: 1px solid var(--border-active); + backdrop-filter: blur(12px); + animation: sessionPulse 4s ease-in-out infinite; +} + +@keyframes sessionPulse { + 0%, + 100% { + border-color: rgba(56, 189, 248, 0.2); + } + 50% { + border-color: rgba(168, 85, 247, 0.3); + } +} + +.session-id { + font-family: "JetBrains Mono", monospace; + font-size: 0.9rem; + color: var(--primary-color); + margin-bottom: 8px; +} + +.stats { + color: var(--text-secondary); + font-size: 0.9rem; +} + +/* Voice Controls */ +.voice-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin: 24px 0; +} + +.voice-label { + color: var(--text-secondary); + font-weight: 600; + font-size: 0.9rem; +} + +.voice-select { + background: rgba(30, 41, 59, 0.8); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + backdrop-filter: blur(8px); + min-width: 180px; +} + +.voice-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 20px rgba(56, 189, 248, 0.3); +} + +.voice-select option { + background: #1e293b; + color: var(--text-primary); +} + +/* Record Section */ +.record-section { + text-align: center; + margin: 40px 0; +} + +.record-button-container { + position: relative; + display: inline-block; + margin-bottom: 20px; +} + +.record-button { + width: 140px; + height: 140px; + border-radius: 50%; + border: none; + cursor: pointer; + font-size: 2.5rem; + font-weight: 600; + position: relative; + transition: var(--transition); + background: linear-gradient( + 135deg, + var(--success-color) 0%, + #16a34a 50%, + #15803d 100% + ); + box-shadow: 0 20px 40px rgba(34, 197, 94, 0.3), + inset 0 2px 0 rgba(255, 255, 255, 0.2), 0 0 0 0 rgba(34, 197, 94, 0.4); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.record-button:hover:not(:disabled) { + transform: scale(1.05) translateY(-2px); + box-shadow: 0 30px 60px rgba(34, 197, 94, 0.4), + inset 0 2px 0 rgba(255, 255, 255, 0.3), 0 0 0 20px rgba(34, 197, 94, 0.1); + animation: recordHover 0.6s ease-in-out; +} + +@keyframes recordHover { + 0% { + box-shadow: 0 20px 40px rgba(34, 197, 94, 0.3), + inset 0 2px 0 rgba(255, 255, 255, 0.2), 0 0 0 0 rgba(34, 197, 94, 0.4); + } + 50% { + box-shadow: 0 30px 60px rgba(34, 197, 94, 0.4), + inset 0 2px 0 rgba(255, 255, 255, 0.3), 0 0 0 20px rgba(34, 197, 94, 0.2); + } + 100% { + box-shadow: 0 30px 60px rgba(34, 197, 94, 0.4), + inset 0 2px 0 rgba(255, 255, 255, 0.3), 0 0 0 20px rgba(34, 197, 94, 0.1); + } +} + +.record-button.recording { + background: linear-gradient( + 135deg, + var(--danger-color) 0%, + #dc2626 50%, + #b91c1c 100% + ); + box-shadow: 0 20px 40px rgba(239, 68, 68, 0.4), + inset 0 2px 0 rgba(255, 255, 255, 0.2); + animation: recordingPulse 1.5s ease-in-out infinite; +} + +@keyframes recordingPulse { + 0%, + 100% { + transform: scale(1); + box-shadow: 0 20px 40px rgba(239, 68, 68, 0.4), + 0 0 0 0 rgba(239, 68, 68, 0.7); + } + 50% { + transform: scale(1.05); + box-shadow: 0 25px 50px rgba(239, 68, 68, 0.5), + 0 0 0 15px rgba(239, 68, 68, 0.3); + } +} + +.record-button.processing { + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + #2563eb 50%, + #1d4ed8 100% + ); + box-shadow: 0 20px 40px rgba(59, 130, 246, 0.4), + inset 0 2px 0 rgba(255, 255, 255, 0.2); + animation: processingSpin 2s linear infinite; +} + +@keyframes processingSpin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.record-icon { + position: relative; + z-index: 2; + transition: var(--transition); +} + +.record-ripple { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 100%; + border-radius: 50%; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.3) 0%, + transparent 70% + ); + transform: translate(-50%, -50%) scale(0); + transition: transform 0.6s ease-out; + pointer-events: none; +} + +.record-button:active .record-ripple { + transform: translate(-50%, -50%) scale(1.2); +} + +.record-status { + margin-top: 16px; +} + +#record-status-text { + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + transition: var(--transition); +} + +.record-button.recording ~ .record-status #record-status-text { + color: #fca5a5; + animation: statusBlink 1s ease-in-out infinite alternate; +} + +@keyframes statusBlink { + 0% { + opacity: 1; + } + 100% { + opacity: 0.6; + } +} + +/* Text Input Section */ +.text-input-section { + margin: 32px 0; + position: relative; +} + +.input-container { + position: relative; + display: flex; + align-items: stretch; + background: rgba(30, 41, 59, 0.6); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + backdrop-filter: blur(8px); + transition: var(--transition); +} + +.input-container:focus-within { + border-color: var(--primary-color); + background: rgba(30, 41, 59, 0.8); + box-shadow: 0 0 25px rgba(56, 189, 248, 0.2); +} + +.text-input { + flex: 1; + background: transparent; + border: none; + padding: 16px 20px; + color: var(--text-primary); + font-size: 1rem; + font-weight: 400; + resize: none; + min-height: 60px; + font-family: inherit; +} + +.text-input:focus { + outline: none; +} + +.text-input::placeholder { + color: var(--text-muted); + font-style: italic; +} + +.send-button { + background: linear-gradient(135deg, var(--primary-color) 0%, #2563eb 100%); + border: none; + border-radius: var(--radius-md); + padding: 12px 16px; + color: white; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + font-size: 0.9rem; + margin: 8px; + display: flex; + align-items: center; + justify-content: center; + min-width: 60px; +} + +.send-button:hover:not(:disabled) { + transform: scale(1.05); + box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3); + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.send-icon { + font-size: 1rem; +} + +/* Status Display */ +.status-display { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + border-radius: var(--radius-md); + margin: 20px 0; + font-weight: 600; + background: rgba(30, 41, 59, 0.4); + border: 1px solid var(--border-color); + backdrop-filter: blur(8px); + transition: var(--transition); +} + +.status-display.recording { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: #fca5a5; + animation: statusRecording 1s ease-in-out infinite alternate; +} + +@keyframes statusRecording { + 0% { + background: rgba(239, 68, 68, 0.15); + } + 100% { + background: rgba(239, 68, 68, 0.25); + } +} + +.status-display.processing { + background: rgba(59, 130, 246, 0.15); + border-color: rgba(59, 130, 246, 0.3); + color: #93c5fd; +} + +.status-display.success { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.3); + color: #86efac; +} + +.status-display.error { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: #fca5a5; +} + +.status-icon { + font-size: 1.1rem; + animation: statusIconPulse 2s ease-in-out infinite; +} + +@keyframes statusIconPulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +.status-text { + font-size: 0.9rem; +} + +/* Toggle Section */ +.toggle-section { + display: flex; + justify-content: center; + margin: 24px 0; +} + +.auto-record-toggle { + display: flex; + align-items: center; + gap: 16px; + background: rgba(30, 41, 59, 0.6); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 20px; + cursor: pointer; + transition: var(--transition); + font-size: 0.9rem; + font-weight: 600; + backdrop-filter: blur(8px); +} + +.auto-record-toggle:hover { + background: rgba(30, 41, 59, 0.8); + transform: translateY(-2px); +} + +.auto-record-toggle.active { + background: linear-gradient( + 135deg, + rgba(22, 163, 74, 0.2) 0%, + rgba(21, 128, 61, 0.1) 100% + ); + border-color: var(--success-color); + box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); +} + +.toggle-label { + color: var(--text-primary); +} + +.toggle-switch { + position: relative; + width: 50px; + height: 26px; + background: rgba(100, 116, 139, 0.3); + border-radius: 13px; + transition: var(--transition); +} + +.auto-record-toggle.active .toggle-switch { + background: var(--success-color); +} + +.toggle-slider { + position: absolute; + top: 2px; + left: 2px; + width: 22px; + height: 22px; + background: white; + border-radius: 50%; + transition: var(--transition); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.auto-record-toggle.active .toggle-slider { + transform: translateX(24px); +} + +.toggle-status { + color: var(--text-secondary); + font-weight: 500; +} + +.auto-record-toggle.active .toggle-status { + color: var(--success-color); +} + +/* Chat History */ +.chat-history { + background: rgba(15, 23, 42, 0.4); + border-radius: var(--radius-lg); + padding: 24px; + margin: 24px 0; + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--border-color); + backdrop-filter: blur(12px); +} + +.chat-title { + color: var(--text-primary); + text-align: center; + margin-bottom: 20px; + font-size: 1.1rem; + font-weight: 700; + padding-bottom: 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.history-messages { + display: flex; + flex-direction: column; + gap: 16px; +} + +.empty-chat { + text-align: center; + color: var(--text-muted); + font-style: italic; + margin: 40px 0; +} + +.empty-icon { + font-size: 2rem; + margin-bottom: 12px; + opacity: 0.5; +} + +.chat-message { + padding: 16px 20px; + border-radius: var(--radius-lg); + max-width: 85%; + word-wrap: break-word; + line-height: 1.5; + font-size: 0.95rem; + position: relative; + backdrop-filter: blur(8px); + animation: messageSlideIn 0.3s ease-out; +} + +@keyframes messageSlideIn { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.chat-message.user { + background: linear-gradient( + 135deg, + rgba(56, 189, 248, 0.2) 0%, + rgba(59, 130, 246, 0.15) 100% + ); + margin-left: auto; + text-align: right; + border-bottom-right-radius: 4px; + border: 1px solid rgba(56, 189, 248, 0.3); +} + +.chat-message.assistant { + background: linear-gradient( + 135deg, + rgba(168, 85, 247, 0.2) 0%, + rgba(147, 51, 234, 0.15) 100% + ); + margin-right: auto; + text-align: left; + border-bottom-left-radius: 4px; + border: 1px solid rgba(168, 85, 247, 0.3); +} + +.message-time { + font-size: 0.75rem; + opacity: 0.7; + margin-top: 8px; + font-weight: 400; +} + +/* Session Controls */ +.session-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.control-group { + display: flex; + gap: 8px; + align-items: stretch; +} + +.session-input { + flex: 1; + background: rgba(30, 41, 59, 0.6); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-size: 0.9rem; + transition: var(--transition); +} + +.session-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 20px rgba(56, 189, 248, 0.3); +} + +.session-input::placeholder { + color: var(--text-muted); +} + +/* Modern Buttons */ +.btn { + background: linear-gradient(135deg, #1e293b 0%, #334155 100%); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 12px 20px; + border-radius: var(--radius-md); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + position: relative; + overflow: hidden; + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; + justify-content: center; +} + +.btn::before { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.1), + transparent + ); + transition: left 0.6s ease; +} + +.btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); + border-color: var(--border-active); +} + +.btn:hover:not(:disabled)::before { + left: 100%; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, #2563eb 100%); + border-color: var(--primary-color); +} + +.btn-primary:hover:not(:disabled) { + box-shadow: 0 12px 24px rgba(56, 189, 248, 0.3); +} + +.btn-danger { + background: linear-gradient(135deg, var(--danger-color) 0%, #dc2626 100%); + border-color: var(--danger-color); +} + +.btn-danger:hover:not(:disabled) { + box-shadow: 0 12px 24px rgba(239, 68, 68, 0.3); +} + +.btn-secondary { + background: linear-gradient(135deg, var(--text-muted) 0%, #475569 100%); + border-color: var(--text-muted); +} + +.btn-secondary:hover:not(:disabled) { + box-shadow: 0 12px 24px rgba(100, 116, 139, 0.3); +} + +.btn-icon { + font-size: 1rem; +} + +/* Notification Containers */ +.notification-container { + margin: 16px 0; + position: relative; +} + +.notification-container:empty { + display: none; +} + +.error-message, +.warning-message { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md); + padding: 16px 20px; + color: #fca5a5; + font-weight: 600; + display: flex; + align-items: center; + gap: 12px; + backdrop-filter: blur(8px); + animation: notificationSlideIn 0.3s ease-out; +} + +@keyframes notificationSlideIn { + 0% { + opacity: 0; + transform: translateY(-20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.warning-message { + background: rgba(245, 158, 11, 0.15); + border-color: rgba(245, 158, 11, 0.3); + color: #fcd34d; +} + +.close-btn, +.retry-btn { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + transition: var(--transition); + margin-left: auto; +} + +.close-btn:hover, +.retry-btn:hover { + background: rgba(255, 255, 255, 0.1); + transform: scale(1.1); +} + +/* Server Status */ +.server-status { + position: fixed; + bottom: 20px; + right: 20px; + background: rgba(15, 23, 42, 0.9); + border-radius: var(--radius-md); + padding: 12px 16px; + font-size: 0.8rem; + border: 1px solid var(--border-color); + backdrop-filter: blur(12px); + z-index: 1000; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); +} + +.server-status-content { + display: flex; + align-items: center; + gap: 8px; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success-color); + animation: statusIndicatorPulse 2s ease-in-out infinite; +} + +@keyframes statusIndicatorPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.status-indicator.degraded { + background: var(--warning-color); +} + +.status-indicator.unhealthy { + background: var(--danger-color); +} + +/* Floating Hints */ +.floating-hints { + position: fixed; + bottom: 20px; + left: 20px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 1000; +} + +.hint { + background: rgba(15, 23, 42, 0.9); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 8px 12px; + font-size: 0.75rem; + color: var(--text-secondary); + backdrop-filter: blur(8px); + opacity: 0.7; + transition: var(--transition); +} + +.hint:hover { + opacity: 1; + color: var(--text-primary); +} + +/* Loading Animation */ +.loading-dots { + display: inline-flex; + gap: 4px; +} + +.loading-dots span { + width: 6px; + height: 6px; + background: currentColor; + border-radius: 50%; + animation: loadingPulse 1.4s ease-in-out infinite both; +} + +.loading-dots span:nth-child(1) { + animation-delay: -0.32s; +} +.loading-dots span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes loadingPulse { + 0%, + 80%, + 100% { + transform: scale(0.8); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* Scrollbar Styles */ +.chat-history::-webkit-scrollbar { + width: 6px; +} + +.chat-history::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.3); + border-radius: 3px; +} + +.chat-history::-webkit-scrollbar-thumb { + background: var(--text-muted); + border-radius: 3px; + transition: var(--transition); +} + +.chat-history::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .header h1 { + font-size: 2.5rem; + } + + .glass-card { + padding: 24px; + margin-bottom: 24px; + } + + .session-controls { + grid-template-columns: 1fr; + } + + .voice-controls { + flex-direction: column; + gap: 12px; + } + + .record-button { + width: 120px; + height: 120px; + font-size: 2rem; + } + + .control-group { + flex-direction: column; + } + + .server-status, + .floating-hints { + display: none; + } +} + +@media (max-width: 480px) { + .header h1 { + font-size: 2rem; + } + + .glass-card { + padding: 20px; + } + + .record-button { + width: 100px; + height: 100px; + font-size: 1.8rem; + } + + .chat-message { + max-width: 95%; + } + + .session-controls { + gap: 12px; + } +} + +/* Accessibility Improvements */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* Focus Styles for Keyboard Navigation */ +.btn:focus, +.voice-select:focus, +.text-input:focus, +.record-button:focus, +.session-input:focus, +.auto-record-toggle:focus { + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.5); + outline: none; +} + +/* High Contrast Mode Support */ +@media (prefers-contrast: high) { + :root { + --text-primary: #ffffff; + --text-secondary: #cccccc; + --border-color: rgba(255, 255, 255, 0.3); + --border-active: rgba(56, 189, 248, 0.8); + } +} + +/* Dark Mode Enhancements */ +@media (prefers-color-scheme: dark) { + .glass-card { + background: rgba(15, 23, 42, 0.8); + } + + .status-display { + background: rgba(30, 41, 59, 0.6); + } +}