diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ae08cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# .dockerignore + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +.env + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# Git +.git/ +.gitignore + +# Logs +logs/ +*.log + +# Runtime +audio/ +transcript.txt +dialogue.html + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# CI/CD +.github/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51281f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,137 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + lint-and-test: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Python dependencies + run: | + pip install --upgrade pip + pip install ruff black isort + pip install -r requirements.txt + + - name: Lint Python code (Ruff) + run: ruff check . --fix + continue-on-error: true + + - name: Format Python code (Black) + run: black --check . + continue-on-error: true + + - name: Check import sorting (isort) + run: isort --check-only . + continue-on-error: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install frontend dependencies + working-directory: ./frontend + run: npm ci + + - name: Lint frontend code (ESLint) + working-directory: ./frontend + run: npm run lint + continue-on-error: true + + - name: Type check frontend (TypeScript) + working-directory: ./frontend + run: npx tsc --noEmit + continue-on-error: true + + build-backend: + name: Build Backend Docker Image + runs-on: ubuntu-latest + needs: lint-and-test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build backend image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.backend + push: false + tags: ai-stream-backend:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + build-frontend: + name: Build Frontend Docker Image + runs-on: ubuntu-latest + needs: lint-and-test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build frontend image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.frontend + push: false + tags: ai-stream-frontend:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + docker-compose-test: + name: Test Docker Compose + runs-on: ubuntu-latest + needs: [build-backend, build-frontend] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create .env file + run: | + echo "GROQ_API_KEY=test_key" >> .env + echo "ELEVENLABS_API_KEY=test_key" >> .env + + - name: Build services + run: docker compose build + + - name: Start services + run: docker compose up -d + + - name: Wait for services to be healthy + run: | + timeout 60 bash -c 'until docker compose ps | grep healthy; do sleep 2; done' + + - name: Test backend health + run: | + curl -f http://localhost:8000/health || exit 1 + + - name: Test frontend + run: | + curl -f http://localhost:80 || exit 1 + + - name: Stop services + run: docker compose down diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..43a033f --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,8 @@ +[settings] +profile = black +line_length = 100 +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..4b800fa --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,9 @@ +line-length = 100 +target-version = "py312" + +[lint] +select = ["E", "F", "I", "N", "W"] +ignore = ["E501"] # Line too long (handled by black) + +[lint.per-file-ignores] +"__init__.py" = ["F401"] # Unused imports in __init__.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..f7e1715 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,314 @@ +# Deployment Guide + +## Local Development + +### Without Docker + +**Backend:** +```bash +uvicorn backend.main:app --reload +``` + +**Frontend:** +```bash +cd frontend +npm run dev +``` + +Access at: +- Frontend: http://localhost:5173 +- Backend: http://localhost:8000 +- API Docs: http://localhost:8000/docs + +### With Docker + +```bash +docker-compose up --build +``` + +Access at: +- Frontend: http://localhost +- Backend: http://localhost:8000 +- API Docs: http://localhost:8000/docs + +--- + +## Production Deployment + +### Prerequisites + +- Docker & Docker Compose installed +- API keys (Groq, ElevenLabs) +- Domain name (optional) + +### Steps + +#### 1. Clone repository + +```bash +git clone https://github.com/YOUR_USERNAME/ai-avatar-stream.git +cd ai-avatar-stream +``` + +#### 2. Configure environment + +```bash +cp .env.example .env +# Edit .env with production API keys +``` + +Required environment variables: +```env +GROQ_API_KEY=your_groq_api_key +ELEVENLABS_API_KEY=your_elevenlabs_api_key +VOICE_ID_ELENA=21m00Tcm4TlvDq8ikWAM +VOICE_ID_MARCUS=29vD33N1CtxCmqQRPOHJ +``` + +#### 3. Build and deploy + +```bash +docker-compose up -d +``` + +#### 4. Verify deployment + +```bash +# Check running containers +docker-compose ps + +# View logs +docker-compose logs -f + +# Test health endpoints +curl http://localhost:8000/health +curl http://localhost/ +``` + +--- + +## Monitoring + +### View Logs + +```bash +# All services +docker-compose logs -f + +# Backend only +docker-compose logs -f backend + +# Frontend only +docker-compose logs -f frontend +``` + +### Check Health + +```bash +# Backend health +curl http://localhost:8000/health + +# Frontend health +curl http://localhost/ + +# Check container status +docker-compose ps +``` + +### Restart Services + +```bash +# Restart all services +docker-compose restart + +# Restart specific service +docker-compose restart backend +docker-compose restart frontend +``` + +--- + +## Scaling + +### Cloud Deployment Options + +#### AWS (Amazon Web Services) + +1. **Push images to ECR** + ```bash + aws ecr create-repository --repository-name ai-stream-backend + aws ecr create-repository --repository-name ai-stream-frontend + docker tag ai-stream-backend:latest .dkr.ecr..amazonaws.com/ai-stream-backend:latest + docker push .dkr.ecr..amazonaws.com/ai-stream-backend:latest + ``` + +2. **Deploy with ECS** + - Create ECS cluster + - Create task definitions for backend and frontend + - Create services + - Configure Application Load Balancer + +#### GCP (Google Cloud Platform) + +1. **Push images to GCR** + ```bash + gcloud builds submit --tag gcr.io//ai-stream-backend + gcloud builds submit --tag gcr.io//ai-stream-frontend + ``` + +2. **Deploy with Cloud Run** + ```bash + gcloud run deploy ai-stream-backend \ + --image gcr.io//ai-stream-backend \ + --platform managed + ``` + +#### Azure + +1. **Push images to ACR** + ```bash + az acr create --resource-group myResourceGroup --name myregistry --sku Basic + docker tag ai-stream-backend myregistry.azurecr.io/ai-stream-backend + docker push myregistry.azurecr.io/ai-stream-backend + ``` + +2. **Deploy with AKS or Container Instances** + +--- + +## SSL/TLS Setup + +### Using Let's Encrypt with Nginx + +1. **Install Certbot** + ```bash + sudo apt-get install certbot python3-certbot-nginx + ``` + +2. **Obtain certificate** + ```bash + sudo certbot --nginx -d yourdomain.com + ``` + +3. **Update nginx.conf** to use SSL + ```nginx + server { + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; + # ... rest of config + } + ``` + +--- + +## Troubleshooting + +### Backend won't start + +```bash +# Check logs +docker-compose logs backend + +# Common issues: +# - Missing API keys in .env +# - Port 8000 already in use +# - Invalid Python dependencies +``` + +### Frontend won't build + +```bash +# Check logs +docker-compose logs frontend + +# Common issues: +# - Node modules not installed +# - TypeScript errors +# - Missing environment variables +``` + +### WebSocket connection fails + +- Ensure backend is running +- Check CORS settings in backend/main.py +- Verify nginx proxy configuration for /ws + +### Health checks failing + +```bash +# Backend +docker exec ai-stream-backend python -c "import requests; requests.get('http://localhost:8000/health')" + +# Frontend +docker exec ai-stream-frontend wget -O- http://localhost/ +``` + +--- + +## Maintenance + +### Update images + +```bash +git pull origin main +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +### Clear volumes + +```bash +docker-compose down -v +``` + +### Cleanup unused images + +```bash +docker system prune -af +``` + +--- + +## Performance Tuning + +### Backend + +- Increase Uvicorn workers: `CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--workers", "4"]` +- Configure Gunicorn for production workloads +- Enable Redis for caching (future enhancement) + +### Frontend + +- Nginx already includes gzip compression +- Static assets cached for 1 year +- Consider CDN for global distribution + +--- + +## Security + +### Best Practices + +1. **Never commit .env file** +2. **Use secrets management** (AWS Secrets Manager, GCP Secret Manager) +3. **Enable HTTPS** in production +4. **Restrict CORS** to specific domains +5. **Update dependencies** regularly + +### Security Scan + +```bash +# Scan Docker images +docker scan ai-stream-backend:latest +docker scan ai-stream-frontend:latest +``` + +--- + +## Support + +For issues and questions: +- GitHub Issues: https://github.com/YOUR_USERNAME/ai-avatar-stream/issues +- Documentation: README.md diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..39f2167 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,33 @@ +# Dockerfile.backend +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first (for layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY backend/ ./backend/ +COPY core/ ./core/ +COPY utils/ ./utils/ +COPY config.py logger.py ./ + +# Create directories for runtime +RUN mkdir -p logs audio avatars + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/health')" + +# Run the application +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..23a60f2 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,34 @@ +# Dockerfile.frontend + +# Stage 1: Build +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY frontend/package*.json ./ +RUN npm ci + +# Copy source code +COPY frontend/ ./ + +# Build production bundle +RUN npm run build + +# Stage 2: Serve with Nginx +FROM nginx:alpine + +# Copy built files from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 9ebaea3..1688921 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,67 @@ The stream demonstrates: --- +## Docker Deployment + +### Quick Start with Docker + +```bash +# 1. Create .env file with your API keys +cp .env.example .env +# Edit .env and add your GROQ_API_KEY and ELEVENLABS_API_KEY + +# 2. Build and start services +docker-compose up --build + +# 3. Access the application +# Frontend: http://localhost +# Backend API: http://localhost:8000 +# API Docs: http://localhost:8000/docs +``` + +### Production Deployment + +```bash +# Build images +docker-compose build + +# Start in detached mode +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop services +docker-compose down +``` + +### Health Checks + +- Backend: `curl http://localhost:8000/health` +- Frontend: `curl http://localhost/` + +### Architecture + +- **Backend**: FastAPI server with REST API and WebSocket +- **Frontend**: React + TypeScript control panel +- **Nginx**: Production web server with API/WebSocket proxying +- **Docker Compose**: Orchestrates both services with health checks + +--- + +## CI/CD + +GitHub Actions automatically: +- Lints Python (Ruff, Black, isort) +- Lints TypeScript (ESLint) +- Builds Docker images +- Tests with docker-compose +- Runs on every push and PR + +See `.github/workflows/ci.yml` for details. + +--- + ## License MIT \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 761b896..257b389 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,13 +12,14 @@ http://localhost:8000/redoc (ReDoc) """ +import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from backend.routers import stream, config -from backend.websockets import transcript + from backend.models.schemas import HealthResponse +from backend.routers import config, stream +from backend.websockets import transcript from logger import get_logger -import uvicorn logger = get_logger(__name__) @@ -35,9 +36,13 @@ app.add_middleware( CORSMiddleware, allow_origins=[ + "http://localhost", # Nginx proxy (Docker) + "http://localhost:80", # Nginx proxy explicit port "http://localhost:3000", # Create React App default "http://localhost:5173", # Vite default "http://localhost:5174", # Alternative Vite port + "http://127.0.0.1", # Nginx proxy (127.0.0.1) + "http://127.0.0.1:80", # Nginx proxy explicit port "http://127.0.0.1:3000", "http://127.0.0.1:5173", "http://127.0.0.1:5174", diff --git a/backend/models/schemas.py b/backend/models/schemas.py index ee69017..b78fec2 100644 --- a/backend/models/schemas.py +++ b/backend/models/schemas.py @@ -5,8 +5,9 @@ request validation and response serialization. """ +from typing import Dict, List, Optional + from pydantic import BaseModel, Field -from typing import Optional, List, Dict class StreamStatus(BaseModel): diff --git a/backend/routers/config.py b/backend/routers/config.py index f2208ab..26e65f7 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -7,11 +7,13 @@ - Updating stream configuration """ -from fastapi import APIRouter, HTTPException from typing import Dict, List + +from fastapi import APIRouter, HTTPException + +import config as app_config from backend.models.schemas import AgentConfig, ConfigUpdate, ConfigUpdateResponse from logger import get_logger -import config as app_config logger = get_logger(__name__) router = APIRouter(prefix="/api/config", tags=["config"]) diff --git a/backend/routers/stream.py b/backend/routers/stream.py index 3e0c930..d6ae2d4 100644 --- a/backend/routers/stream.py +++ b/backend/routers/stream.py @@ -8,12 +8,13 @@ """ from fastapi import APIRouter, HTTPException + from backend.models.schemas import ( - StreamStatus, StreamStartRequest, StreamStartResponse, + StreamStatus, StreamStopRequest, - StreamStopResponse + StreamStopResponse, ) from backend.services.stream_manager import StreamManager from logger import get_logger diff --git a/backend/services/stream_manager.py b/backend/services/stream_manager.py index aacd334..b4cc6b6 100644 --- a/backend/services/stream_manager.py +++ b/backend/services/stream_manager.py @@ -9,21 +9,22 @@ """ import asyncio +import os +import random import threading import time -import random -import os -from queue import Queue # Thread-safe queue for async/sync communication -from typing import Optional, List, Dict from datetime import datetime +from queue import Queue # Thread-safe queue for async/sync communication +from typing import Dict, List, Optional -from logger import get_logger -from config import AGENTS, TOPICS, AUDIO_DIR, MAX_TURNS, TOPIC_SWITCH_EVERY, PAUSE_BETWEEN_TURNS +from config import AGENTS, AUDIO_DIR, MAX_TURNS, PAUSE_BETWEEN_TURNS, TOPIC_SWITCH_EVERY, TOPICS +from core.avatar import connect as connect_obs +from core.avatar import set_avatar, set_both_idle, start_idle_animation, stop_idle_animation from core.dialogue import generate_response, reset_history -from core.tts import text_to_speech, play_audio from core.overlay import update_overlay from core.transcript import init_transcript, log_message, set_broadcast_callback -from core.avatar import connect as connect_obs, set_avatar, set_both_idle, start_idle_animation, stop_idle_animation +from core.tts import play_audio, text_to_speech +from logger import get_logger logger = get_logger(__name__) diff --git a/backend/websockets/transcript.py b/backend/websockets/transcript.py index 87fa36e..e987c57 100644 --- a/backend/websockets/transcript.py +++ b/backend/websockets/transcript.py @@ -7,11 +7,13 @@ Uses a thread-safe queue to receive messages from the sync stream thread. """ +import asyncio +import json + from fastapi import APIRouter, WebSocket, WebSocketDisconnect + from backend.services.stream_manager import StreamManager from logger import get_logger -import asyncio -import json logger = get_logger(__name__) router = APIRouter() diff --git a/config.py b/config.py index 79b90f5..3fba4eb 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,5 @@ import os + from dotenv import load_dotenv load_dotenv() diff --git a/core/avatar.py b/core/avatar.py index 6c321a0..40fd7f0 100644 --- a/core/avatar.py +++ b/core/avatar.py @@ -12,14 +12,17 @@ - pip install obs-websocket-py """ -from obswebsocket import obsws, requests as obs_requests -from obswebsocket.exceptions import ConnectionFailure -from config import AGENTS -from logger import get_logger import os import threading import time +from obswebsocket import obsws +from obswebsocket import requests as obs_requests +from obswebsocket.exceptions import ConnectionFailure + +from config import AGENTS +from logger import get_logger + logger = get_logger(__name__) # OBS WebSocket connection diff --git a/core/dialogue.py b/core/dialogue.py index 07a88a3..6e85574 100644 --- a/core/dialogue.py +++ b/core/dialogue.py @@ -1,8 +1,8 @@ -from groq import Groq -from groq import RateLimitError, APIError -from config import GROQ_API_KEY, GROQ_MODEL, CONTEXT_WINDOW, AGENTS -from utils.retry import retry_with_backoff +from groq import APIError, Groq, RateLimitError + +from config import AGENTS, CONTEXT_WINDOW, GROQ_API_KEY, GROQ_MODEL from logger import get_logger +from utils.retry import retry_with_backoff logger = get_logger(__name__) client = Groq(api_key=GROQ_API_KEY) diff --git a/core/overlay.py b/core/overlay.py index de23af1..a5e7252 100644 --- a/core/overlay.py +++ b/core/overlay.py @@ -177,6 +177,7 @@ def update_overlay(agent_key: str, text: str, topic: str): from core.avatar import ws if ws is not None: from obswebsocket import requests as obs_requests + # Press the "Refresh cache" button programmatically ws.call(obs_requests.PressInputPropertiesButton( inputName="Dialogue", diff --git a/core/transcript.py b/core/transcript.py index c1f7f7d..401d09a 100644 --- a/core/transcript.py +++ b/core/transcript.py @@ -1,7 +1,8 @@ from datetime import datetime +from typing import Callable, Dict, Optional + from config import TRANSCRIPT_FILE from logger import get_logger -from typing import Optional, Callable, Dict logger = get_logger(__name__) diff --git a/core/tts.py b/core/tts.py index 6f48b9e..8cfa103 100644 --- a/core/tts.py +++ b/core/tts.py @@ -1,13 +1,15 @@ import os import platform -import time import subprocess -from core.avatar import set_avatar +import time + from elevenlabs.client import ElevenLabs from elevenlabs.core import ApiError as ElevenLabsAPIError -from config import ELEVENLABS_API_KEY, AGENTS, AUDIO_DIR, CHARS_PER_SECOND -from utils.retry import retry_with_backoff + +from config import AGENTS, AUDIO_DIR, CHARS_PER_SECOND, ELEVENLABS_API_KEY +from core.avatar import set_avatar from logger import get_logger +from utils.retry import retry_with_backoff logger = get_logger(__name__) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4176fe0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: ai-stream-backend + ports: + - "8000:8000" + environment: + - GROQ_API_KEY=${GROQ_API_KEY} + - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY} + - VOICE_ID_ELENA=${VOICE_ID_ELENA:-21m00Tcm4TlvDq8ikWAM} + - VOICE_ID_MARCUS=${VOICE_ID_MARCUS:-29vD33N1CtxCmqQRPOHJ} + volumes: + - ./logs:/app/logs + - ./audio:/app/audio + - ./avatars:/app/avatars + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + networks: + - ai-stream-network + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + container_name: ai-stream-frontend + ports: + - "80:80" + depends_on: + - backend + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 3s + retries: 3 + restart: unless-stopped + networks: + - ai-stream-network + +networks: + ai-stream-network: + driver: bridge diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1b71b21 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# Frontend Environment Variables + +# API URL - Leave empty for Docker deployment (uses Nginx proxy) +# For local development: http://localhost:8000 +# For Docker: (empty - uses relative URLs) +VITE_API_URL= diff --git a/frontend/README.md b/frontend/README.md index 38d856a..09104c6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,10 +15,18 @@ Open http://localhost:5173 Create `.env`: -``` +**For local development (without Docker):** +```env VITE_API_URL=http://localhost:8000 ``` +**For Docker deployment (with Nginx proxy):** +```env +VITE_API_URL= +``` + +Leave `VITE_API_URL` empty when using Docker Compose. Nginx will proxy `/api` and `/ws` requests to the backend container. + ## Features - 🎮 Start/Stop stream controls diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c3c44b..74bc6a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,13 @@ import { TranscriptViewer } from './components/TranscriptViewer'; import { ConfigPanel } from './components/ConfigPanel'; import { LogsViewer } from './components/LogsViewer'; import { streamAPI, createWebSocket } from './api/client'; -import type { StreamStatus, TranscriptMessage } from './types'; +import type { StreamStatus, TranscriptMessage, ConfigUpdate, LogEntry } from './types'; +import { AxiosError } from 'axios'; function App() { const [status, setStatus] = useState(null); const [transcriptMessages, setTranscriptMessages] = useState([]); - const [logs, setLogs] = useState([]); + const [logs, setLogs] = useState([]); const [isLoading, setIsLoading] = useState(false); const [maxTurns, setMaxTurns] = useState(5); const [pauseBetweenTurns, setPauseBetweenTurns] = useState(1); @@ -30,15 +31,17 @@ function App() { // Setup WebSocket for real-time transcript and status updates useEffect(() => { - const websocket = createWebSocket((data) => { - if (data.type === 'transcript' && data.data) { - setTranscriptMessages((prev) => [...prev, data.data]); - addLog('info', `${data.data.agent_name} spoke`); + const websocket = createWebSocket((message) => { + if (message.type === 'transcript' && message.data) { + const transcriptData = message.data as TranscriptMessage; + setTranscriptMessages((prev) => [...prev, transcriptData]); + addLog('info', `${transcriptData.agent_name} spoke`); } - if (data.type === 'status' && data.data) { - setStatus(data.data); + if (message.type === 'status' && message.data) { + const statusData = message.data as StreamStatus; + setStatus(statusData); } - if (data.type === 'connection') { + if (message.type === 'connection') { addLog('info', 'Connected to transcript stream'); } }); @@ -48,7 +51,7 @@ function App() { }; }, []); - const addLog = (level: string, message: string) => { + const addLog = (level: LogEntry['level'], message: string) => { setLogs((prev) => [ ...prev, { timestamp: new Date().toISOString(), level, message }, @@ -61,8 +64,9 @@ function App() { await streamAPI.start(maxTurns); addLog('info', `Stream started with ${maxTurns} turns`); setTranscriptMessages([]); // Clear previous transcript - } catch (error: any) { - addLog('error', error.response?.data?.detail || 'Failed to start stream'); + } catch (error) { + const axiosError = error as AxiosError<{ detail?: string }>; + addLog('error', axiosError.response?.data?.detail || 'Failed to start stream'); } finally { setIsLoading(false); } @@ -73,28 +77,21 @@ function App() { try { await streamAPI.stop(); addLog('info', 'Stream stopped'); - } catch (error: any) { - addLog('error', error.response?.data?.detail || 'Failed to stop stream'); + } catch (error) { + const axiosError = error as AxiosError<{ detail?: string }>; + addLog('error', axiosError.response?.data?.detail || 'Failed to stop stream'); } finally { setIsLoading(false); } }; - const handleSaveConfig = async (config: any) => { - try { - // Update local state with config values - if (config.max_turns !== undefined) { - setMaxTurns(config.max_turns); - } - if (config.pause_between_turns !== undefined) { - setPauseBetweenTurns(config.pause_between_turns); - } + const handleSaveConfig = async (config: ConfigUpdate) => { + // Update local state with config values + setMaxTurns(config.max_turns); + setPauseBetweenTurns(config.pause_between_turns); - // For MVP, just log - full implementation would call configAPI.updateConfig - addLog('info', 'Config updated (restart stream to apply)'); - } catch (error) { - addLog('error', 'Failed to save config'); - } + // For MVP, just log - full implementation would call configAPI.updateConfig + addLog('info', 'Config updated (restart stream to apply)'); }; return ( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fd84a85..b956431 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import type { ConfigUpdate, WebSocketMessage } from '../types'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; @@ -29,20 +30,27 @@ export const configAPI = { getTopics: () => api.get('/api/config/topics'), - updateConfig: (config: any) => + updateConfig: (config: ConfigUpdate) => api.put('/api/config', config), }; // WebSocket helper -export const createWebSocket = (onMessage: (data: any) => void) => { - const ws = new WebSocket(`ws://localhost:8000/ws/transcript`); +export const createWebSocket = (onMessage: (data: WebSocketMessage) => void) => { + // Determine WebSocket URL based on environment + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsHost = API_BASE_URL + ? API_BASE_URL.replace(/^https?:/, wsProtocol) + : `${wsProtocol}//${window.location.host}`; + const wsUrl = `${wsHost}/ws/transcript`; + + const ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('WebSocket connected'); }; ws.onmessage = (event) => { - const data = JSON.parse(event.data); + const data = JSON.parse(event.data) as WebSocketMessage; onMessage(data); }; diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index efac8ae..074c477 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from 'react'; import { Settings } from 'lucide-react'; +import type { ConfigUpdate } from '../types'; interface Props { - onSave: (config: any) => void; + onSave: (config: ConfigUpdate) => void; disabled: boolean; initialMaxTurns: number; initialPause: number; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 60a363a..6c22dba 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -24,8 +24,18 @@ export interface TranscriptMessage { } export interface WebSocketMessage { - type: 'connection' | 'transcript' | 'topic_change'; + type: 'connection' | 'transcript' | 'topic_change' | 'status'; message?: string; - status?: StreamStatus; - data?: TranscriptMessage; + data?: TranscriptMessage | StreamStatus; +} + +export interface ConfigUpdate { + max_turns: number; + pause_between_turns: number; +} + +export interface LogEntry { + timestamp: string; + level: 'info' | 'warning' | 'error'; + message: string; } diff --git a/main.py b/main.py index bc56862..616b904 100644 --- a/main.py +++ b/main.py @@ -1,21 +1,15 @@ import os -import time import random +import time from datetime import datetime -from config import ( - AGENTS, - TOPICS, - AUDIO_DIR, - MAX_TURNS, - TOPIC_SWITCH_EVERY, - PAUSE_BETWEEN_TURNS, -) +from config import AGENTS, AUDIO_DIR, MAX_TURNS, PAUSE_BETWEEN_TURNS, TOPIC_SWITCH_EVERY, TOPICS +from core.avatar import connect as connect_obs +from core.avatar import set_avatar, set_both_idle from core.dialogue import generate_response, reset_history -from core.tts import text_to_speech, play_audio, estimate_duration from core.overlay import update_overlay from core.transcript import init_transcript, log_message -from core.avatar import connect as connect_obs, set_avatar, set_both_idle +from core.tts import estimate_duration, play_audio, text_to_speech from logger import get_logger logger = get_logger(__name__) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..04081ad --- /dev/null +++ b/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy (if running in same docker-compose network) + location /api { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # WebSocket proxy + location /ws { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/utils/retry.py b/utils/retry.py index e85ad07..235f297 100644 --- a/utils/retry.py +++ b/utils/retry.py @@ -8,9 +8,10 @@ def my_api_call(): pass """ -import time import functools -from typing import Callable, TypeVar, Any +import time +from typing import Any, Callable, TypeVar + from logger import get_logger logger = get_logger(__name__)